Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3295583591 | ||
|
|
47aeaba9c8 | ||
|
|
d31173fa5e | ||
|
|
05885bfff1 | ||
|
|
168d10bf87 | ||
|
|
2dea198bcc | ||
|
|
50da075c15 | ||
|
|
23980182b0 | ||
|
|
85bec34d7d | ||
|
|
ab5fda658e | ||
|
|
4e488625fe | ||
|
|
9799843cd2 | ||
|
|
8b39bfaf10 | ||
|
|
8e66d9e7bc | ||
|
|
4bc8637a74 |
@@ -22,3 +22,9 @@ dist/
|
||||
.pytest_cache/
|
||||
.ruff_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
|
||||
|
||||
Ausgabe und Verwaltung der Lizenzen fuer TESM: Kunde -> Ticket -> Lizenz,
|
||||
Aktivierung online und offline, Kundenportal, Selbstbedienung, Versand ueber
|
||||
Ausgabe und Verwaltung der Lizenzen für TESM: Kunde -> Ticket -> Lizenz,
|
||||
Aktivierung online und offline, Kundenportal, Selbstbedienung, Versand über
|
||||
MS Graph und eine Hierarchie aus Unter-Lizenzservern.
|
||||
|
||||
**Dieses Repository enthaelt genau das, was zum Installieren und Betreiben des
|
||||
Lizenzservers gebraucht wird** -- nichts darueber hinaus:
|
||||
**Dieses Repository enthält genau das, was zum Installieren und Betreiben des
|
||||
Lizenzservers gebraucht wird** -- nichts darüber hinaus:
|
||||
|
||||
```
|
||||
apps/tesm-license die Anwendung
|
||||
packages/tesm-core gemeinsamer Kern (Anmeldung, Rechte, Sicherung, nginx, ...)
|
||||
packages/tesm-licensing Lizenzprotokoll, gemeinsame Haelfte
|
||||
packages/tesm-licensing-server Lizenzprotokoll, Serverhaelfte: ausstellen, erneuern, antworten
|
||||
packages/tesm-licensing Lizenzprotokoll, gemeinsame Hälfte
|
||||
packages/tesm-licensing-server Lizenzprotokoll, Serverhälfte: ausstellen, erneuern, antworten
|
||||
deploy/ install.sh, update.sh, release.sh, systemd, privilegierter Helfer
|
||||
docs/ Betrieb und Sicherheit
|
||||
```
|
||||
@@ -28,7 +28,7 @@ Die verwaltete Anwendung liegt hier **nicht** mit drin.
|
||||
| Zweig | Inhalt |
|
||||
|---|---|
|
||||
| `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
|
||||
```
|
||||
|
||||
**`init-key` erzeugt den Signaturschluessel dieses Servers. Sichern Sie ihn
|
||||
sofort.** Ohne ihn laesst sich keine ausgestellte Lizenz mehr erneuern, und
|
||||
Kunden muessten einen Ausstellerwechsel bestaetigen.
|
||||
**`init-key` erzeugt den Signaturschlüssel dieses Servers. Sichern Sie ihn
|
||||
sofort.** Ohne ihn lässt sich keine ausgestellte Lizenz mehr erneuern, und
|
||||
Kunden müssten einen Ausstellerwechsel bestätigen.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
Der Lizenzserver darf neben TESM auf demselben Host laufen; nginx unterscheidet
|
||||
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
|
||||
|
||||
@@ -60,7 +60,7 @@ fuer nginx-Site, logrotate, Zertifikate und Sitzungscookie.
|
||||
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.
|
||||
|
||||
---
|
||||
@@ -69,16 +69,16 @@ Health-Check mit automatischem Rollback.
|
||||
|
||||
```bash
|
||||
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 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
|
||||
lesend**, uebernimmt dessen Signaturschluessel und stellt die Lizenzen mit ihrer
|
||||
restlichen Laufzeit neu aus. Wird der Schluessel nicht uebernommen, muessen
|
||||
Kunden den Ausstellerwechsel einmal bestaetigen.
|
||||
lesend**, übernimmt dessen Signaturschlüssel und stellt die Lizenzen mit ihrer
|
||||
restlichen Laufzeit neu aus. Wird der Schlüssel nicht übernommen, müssen
|
||||
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
|
||||
anderen nicht verwendbar.
|
||||
* 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.
|
||||
* Aussteller wird beim ersten Kontakt gemerkt (TOFU); ein Wechsel muss
|
||||
ausdruecklich bestaetigt werden.
|
||||
* Der Fingerabdruck der Maschine enthaelt **keinen** Hostnamen -- Umbenennen
|
||||
brach im Vorgaenger die Bindung.
|
||||
* Offline-Aktivierung ueber einen kurzen Code in beide Richtungen; die Lizenz
|
||||
bleibt auf dem Server als "Aktivierung offen" markiert, bis sie zurueckkommt.
|
||||
ausdrücklich bestätigt werden.
|
||||
* Der Fingerabdruck der Maschine enthält **keinen** Hostnamen -- Umbenennen
|
||||
brach im Vorgänger die Bindung.
|
||||
* Offline-Aktivierung über einen kurzen Code in beide Richtungen; die Lizenz
|
||||
bleibt auf dem Server als "Aktivierung offen" markiert, bis sie zurückkommt.
|
||||
* Keine Lizenz ohne `master_endpoint`: eine Ausstellung ohne Endpunkt wird
|
||||
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
|
||||
installieren -- statt zweier Dateien, die byte-identisch bleiben sollen.
|
||||
* **Kein root fuer die Anwendung.** Ein einziger allowlisted Helfer mit
|
||||
Argumentpruefung.
|
||||
* **CSRF-Schutz ueberhaupt.** Der Vorgaenger hatte keinen.
|
||||
* **Rechte werden serverseitig geprueft**, nicht nur im Template ausgeblendet.
|
||||
* **Kein root für die Anwendung.** Ein einziger allowlisted Helfer mit
|
||||
Argumentprüfung.
|
||||
* **CSRF-Schutz überhaupt.** Der Vorgänger hatte keinen.
|
||||
* **Rechte werden serverseitig geprüft**, nicht nur im Template ausgeblendet.
|
||||
* **Verschachtelte Datenbankverbindungen sind ein Fehler**, kein Deadlock.
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "tesm-license"
|
||||
version = "2.0.0"
|
||||
version = "2.0.7"
|
||||
description = "TESM-Lizenzserver -- Kunden, Tickets, Lizenzen, Aktivierung"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -23,12 +23,29 @@ from .permissions import PERMISSIONS, ROLE_PRESETS
|
||||
from .schema import LICENSE_MIGRATIONS
|
||||
from .settings_spec import LICENSE_SERVER_SETTINGS
|
||||
|
||||
__version__ = "2.0.0"
|
||||
#: Version aus den Metadaten des installierten Pakets -- die einzige Quelle
|
||||
#: ist ``pyproject.toml``. Vorher stand hier eine feste Zahl, und die
|
||||
#: Fusszeile zeigte vier Releases lang 2.0.2.
|
||||
#:
|
||||
#: Im Quellbaum ohne ``pip install`` gibt es keine Metadaten; dann steht dort
|
||||
#: "unbekannt" statt einer erfundenen Nummer. In einem editierbaren Install
|
||||
#: zeigt sie den Stand des letzten ``pip install -e`` -- also die Version, die
|
||||
#: tatsaechlich installiert ist.
|
||||
try: # pragma: no cover - haengt an der Installationsart
|
||||
from importlib.metadata import PackageNotFoundError, version as _paketversion
|
||||
|
||||
__version__ = _paketversion("tesm-license")
|
||||
except (ImportError, PackageNotFoundError): # pragma: no cover
|
||||
__version__ = "unbekannt"
|
||||
|
||||
APP_KEY = "tesm-license"
|
||||
APP_NAME = "TESM-Lizenzserver"
|
||||
APP_SHORT = "TL"
|
||||
APP_EDITION = "Lizenzverwaltung"
|
||||
|
||||
#: Zeile unter der Wortmarke auf den Anmeldeseiten, in derselben Schrift wie
|
||||
#: bei TESM. Ein Wort statt einer Liste: der Lizenzserver tut genau eine Sache.
|
||||
AUTH_TAGLINE = "LIZENZIEREN"
|
||||
ENV_PREFIX = "TESM_LICENSE_"
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -56,6 +73,7 @@ def create_app(**overrides: Any) -> Flask:
|
||||
import_name="tesm_license",
|
||||
app_short=APP_SHORT,
|
||||
app_edition=APP_EDITION,
|
||||
auth_tagline=AUTH_TAGLINE,
|
||||
app_version=__version__,
|
||||
enable_license=True,
|
||||
)
|
||||
|
||||
@@ -336,6 +336,28 @@ def cmd_migrate_legacy(args: argparse.Namespace) -> int:
|
||||
key = master_key.load(key_path)
|
||||
|
||||
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 = {
|
||||
"name": str(extension.settings.get(conn, "vendor_name") or "Eigenbetrieb"),
|
||||
"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 "")
|
||||
if not endpoint and not args.dry_run:
|
||||
print(
|
||||
"Es ist kein Endpunkt hinterlegt (Einstellungen -> Anbieter). Ohne ihn "
|
||||
"koennen uebernommene Lizenzen sich spaeter nicht aktivieren.",
|
||||
"Es ist kein Endpunkt hinterlegt, und die alte Installation nennt "
|
||||
"auch keinen. Bitte unter Einstellungen -> Anbieter eintragen: ohne "
|
||||
"ihn koennen uebernommene Lizenzen sich spaeter nicht aktivieren.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
|
||||
@@ -86,7 +86,7 @@ NAVIGATION = NavTree(
|
||||
),
|
||||
NavItem(
|
||||
key="logs_audit",
|
||||
label="Aenderungen",
|
||||
label="Audit",
|
||||
icon="history",
|
||||
endpoint="logs.audit_view",
|
||||
permissions=("logs_audit.view", "logs_audit.export"),
|
||||
|
||||
@@ -25,6 +25,8 @@ angefasst, nicht angehalten und nicht veraendert.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import json
|
||||
import sqlite3
|
||||
from dataclasses import dataclass, field
|
||||
@@ -110,6 +112,39 @@ def open_legacy(root: Path) -> sqlite3.Connection:
|
||||
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]:
|
||||
"""Liest den alten Signaturschluessel. Rueckgabe: ``(privat, oeffentlich)``."""
|
||||
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:
|
||||
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 "")
|
||||
public = str(data.get("public") or data.get("public_key") or "")
|
||||
if not private or not public:
|
||||
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(
|
||||
"Im alten Signaturschluessel passen privater und oeffentlicher Teil nicht "
|
||||
"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))
|
||||
|
||||
|
||||
|
||||
#: 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(
|
||||
legacy: sqlite3.Connection,
|
||||
conn: sqlite3.Connection,
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
{% extends "tesm_core/base.html" %}
|
||||
{% from "tesm_core/macros.html" import field, alert, pill %}
|
||||
|
||||
{% block page_title %}Sicherung und Uebertragung{% endblock %}
|
||||
{% block page_sub %}Verschluesselter Export · zweistufiger Import{% endblock %}
|
||||
{% block page_title %}Sicherung und Übertragung{% endblock %}
|
||||
{% block page_sub %}Verschlüsselter Export · zweistufiger Import{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{{ alert('Der Export wird mit Argon2id aus Ihrer Passphrase verschluesselt (AES-256-GCM). Ohne
|
||||
diese Passphrase ist die Datei nicht wiederherstellbar -- es gibt keine Hintertuer.', 'warning',
|
||||
{{ 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 Hintertür.', 'warning',
|
||||
'Passphrase') }}
|
||||
|
||||
{{ alert('Der Signaturschluessel (' ~ signing_key_path ~ ') ist bewusst
|
||||
nicht Teil dieser Sicherung. Er ist der Kern des Vertrauens und gehoert in eine
|
||||
{{ alert('Der Signaturschlüssel (' ~ signing_key_path ~ ') ist bewusst
|
||||
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
|
||||
ist nach einer Wiederherstellung keine ausgestellte Lizenz mehr pruefbar.', 'danger',
|
||||
'Wichtig: Schluessel getrennt sichern') }}
|
||||
ist nach einer Wiederherstellung keine ausgestellte Lizenz mehr prüfbar.', 'danger',
|
||||
'Wichtig: Schlüssel getrennt sichern') }}
|
||||
|
||||
<div class="grid grid--split">
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<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>
|
||||
<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 %}
|
||||
<div class="fs-xs faint">{{ category.hint }}</div>
|
||||
{% 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 %}
|
||||
</span>
|
||||
</label>
|
||||
@@ -66,8 +66,8 @@ ist nach einer Wiederherstellung keine ausgestellte Lizenz mehr pruefbar.', 'dan
|
||||
<section class="card card--accent">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('upload', size=17) }} Geoeffnete Sicherung</div>
|
||||
<div class="card__sub">Gueltig fuer {{ preview_ttl }} Minuten</div>
|
||||
<div class="card__title">{{ icon('upload', size=17) }} Geöffnete Sicherung</div>
|
||||
<div class="card__sub">Gültig für {{ preview_ttl }} Minuten</div>
|
||||
</div>
|
||||
</div>
|
||||
<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="table-wrap">
|
||||
<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>
|
||||
{% for entry in preview.summary %}
|
||||
<tr>
|
||||
@@ -89,17 +89,17 @@ ist nach einer Wiederherstellung keine ausgestellte Lizenz mehr pruefbar.', 'dan
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{ alert('Bereits vorhandene Lizenzen werden nie ueberschrieben -- ihr Inhalt ist
|
||||
kryptographisch festgelegt. Kunden werden ueber ihren Namen zusammengefuehrt.',
|
||||
{{ alert('Bereits vorhandene Lizenzen werden nie überschrieben -- ihr Inhalt ist
|
||||
kryptographisch festgelegt. Kunden werden über ihren Namen zusammengeführt.',
|
||||
'info') }}
|
||||
</div>
|
||||
<div class="card__footer modal__footer--split">
|
||||
<button class="btn btn--sm btn--ghost" type="submit"
|
||||
formaction="{{ url_for('backup.import_discard') }}">Verwerfen</button>
|
||||
<button class="btn btn--primary" type="submit"
|
||||
data-confirm="Ausgewaehlte Kategorien jetzt uebernehmen?"
|
||||
data-confirm-title="Sicherung uebernehmen" data-confirm-ok="Uebernehmen"
|
||||
data-confirm-style="primary">Uebernehmen</button>
|
||||
data-confirm="Ausgewählte Kategorien jetzt übernehmen?"
|
||||
data-confirm-title="Sicherung übernehmen" data-confirm-ok="Übernehmen"
|
||||
data-confirm-style="primary">Übernehmen</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
@@ -108,7 +108,7 @@ ist nach einer Wiederherstellung keine ausgestellte Lizenz mehr pruefbar.', 'dan
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<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>
|
||||
<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') }}
|
||||
</div>
|
||||
<div class="card__footer">
|
||||
<button class="btn btn--primary" type="submit" data-busy-label="Oeffne ...">
|
||||
Datei oeffnen
|
||||
<button class="btn btn--primary" type="submit" data-busy-label="Öffne ...">
|
||||
Datei öffnen
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
{% if licenses %}
|
||||
<div class="table-wrap">
|
||||
<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>
|
||||
{% for entry in licenses %}
|
||||
<tr>
|
||||
@@ -119,7 +119,7 @@
|
||||
</div>
|
||||
<div class="card__body stack">
|
||||
{{ 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 %}
|
||||
<details class="card">
|
||||
<summary class="card__header" style="cursor:pointer">
|
||||
@@ -193,7 +193,7 @@
|
||||
<div class="divider"></div>
|
||||
<dl class="kv fs-xs">
|
||||
<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>
|
||||
<dd><span class="mono break-all">{{ portal_url }}</span>
|
||||
<button class="btn btn--sm btn--ghost" type="button" data-copy="{{ portal_url }}">
|
||||
@@ -205,9 +205,9 @@
|
||||
{% if current_user.has('customers.delete') %}
|
||||
<button class="btn btn--sm btn--ghost danger-text" type="submit"
|
||||
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-title="Kunde loeschen" data-confirm-ok="Loeschen">
|
||||
Loeschen
|
||||
data-confirm="Kunde {{ customer.name }} löschen? Das ist nur möglich, solange kein Ticket mehr an ihm hängt."
|
||||
data-confirm-title="Kunde löschen" data-confirm-ok="Löschen">
|
||||
Löschen
|
||||
</button>
|
||||
{% else %}<span></span>{% endif %}
|
||||
<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('contact_email', 'E-Mail', value=customer.contact_email if customer else '',
|
||||
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_phone', 'Telefon', value=customer.contact_phone if customer else '') }}
|
||||
{{ field('street', 'Strasse', value=customer.street if customer else '') }}
|
||||
@@ -72,7 +72,7 @@
|
||||
|
||||
{% block dialogs %}
|
||||
{% 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') }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<div class="modal__body">{{ customer_form() }}</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{% extends "tesm_core/base.html" %}
|
||||
{% from "tesm_core/macros.html" import pill, empty_state, alert, stat %}
|
||||
|
||||
{% block page_title %}Uebersicht{% endblock %}
|
||||
{% block page_title %}Übersicht{% endblock %}
|
||||
{% block page_sub %}
|
||||
{% if summary %}
|
||||
{{ summary.counts.customers }} Kunden · {{ summary.counts.licenses_active }} aktive Lizenzen
|
||||
@@ -24,13 +24,13 @@
|
||||
</div>
|
||||
|
||||
{% 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') }}
|
||||
{% endif %}
|
||||
|
||||
{% for key, label, kind, hint in [
|
||||
('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')] %}
|
||||
{% set entries = summary.buckets[key] %}
|
||||
{% if entries %}
|
||||
|
||||
@@ -26,13 +26,13 @@
|
||||
{% block content %}
|
||||
{% if state.revision_pending %}
|
||||
{{ 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
|
||||
Verbindung braucht dafuer den Offline-Weg -- der Kunde findet ihn in seinem Portal.',
|
||||
Eine erreichbare Installation holt sie beim nächsten Heartbeat; eine Installation ohne
|
||||
Verbindung braucht dafür den Offline-Weg -- der Kunde findet ihn in seinem Portal.',
|
||||
'warning', 'Revision ausstehend') }}
|
||||
{% endif %}
|
||||
|
||||
{% 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
|
||||
license.revoke_reason else ''), 'danger', 'Widerrufen') }}
|
||||
{% endif %}
|
||||
@@ -72,7 +72,7 @@
|
||||
{% else %}<span class="faint">keine</span>{% endif %}
|
||||
</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>
|
||||
{% if license.lifetime %}{{ pill('unbefristet', 'online') }}
|
||||
{% else %}
|
||||
@@ -105,9 +105,9 @@
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<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">
|
||||
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.
|
||||
</div>
|
||||
</div>
|
||||
@@ -126,9 +126,9 @@
|
||||
</div>
|
||||
</fieldset>
|
||||
{% endif %}
|
||||
{{ field('extend_days', 'Laufzeit verlaengern (Tage)', type='number',
|
||||
{{ field('extend_days', 'Laufzeit verlängern (Tage)', type='number',
|
||||
placeholder='0',
|
||||
hint_text='Wird auf das aktuelle Ablaufdatum aufgeschlagen.') }}
|
||||
hint_text='Wird auf das aktülle Ablaufdatum aufgeschlagen.') }}
|
||||
</div>
|
||||
<div class="card__footer">
|
||||
<button class="btn btn--primary" type="submit">Neue Revision signieren</button>
|
||||
@@ -141,13 +141,13 @@
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<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>
|
||||
{% if deliveries %}
|
||||
<div class="table-wrap">
|
||||
<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>
|
||||
{% for entry in deliveries %}
|
||||
<tr>
|
||||
@@ -170,7 +170,7 @@
|
||||
<summary class="card__header" style="cursor:pointer">
|
||||
<div class="flex-1">
|
||||
<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>
|
||||
{{ icon('chevron-down', size=16) }}
|
||||
</summary>
|
||||
@@ -185,7 +185,7 @@
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('mail', size=17) }} Per E-Mail versenden</div>
|
||||
<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>
|
||||
@@ -193,7 +193,7 @@
|
||||
data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<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) }}
|
||||
</div>
|
||||
<div class="card__footer">
|
||||
@@ -215,7 +215,7 @@
|
||||
data-dialog-open="revoke-dialog">Widerrufen</button>
|
||||
<p class="fs-xs faint">
|
||||
Das Ticket bleibt offen -- eine neue Lizenz kann sofort ausgestellt werden. Genau so
|
||||
laeuft ein Systemwechsel.
|
||||
läuft ein Systemwechsel.
|
||||
</p>
|
||||
{% endif %}
|
||||
{% if current_user.has('licenses.delete') %}
|
||||
@@ -225,9 +225,9 @@
|
||||
<button class="btn btn--sm btn--ghost danger-text btn--block" type="submit"
|
||||
{% if license.status not in ('revoked', 'issued') %}disabled
|
||||
title="Eine aktive Lizenz muss erst widerrufen werden"{% endif %}
|
||||
data-confirm="Lizenz endgueltig aus der Datenbank entfernen? Die Historie geht verloren."
|
||||
data-confirm-title="Lizenz loeschen" data-confirm-ok="Loeschen">
|
||||
Endgueltig loeschen
|
||||
data-confirm="Lizenz endgültig aus der Datenbank entfernen? Die Historie geht verloren."
|
||||
data-confirm-title="Lizenz löschen" data-confirm-ok="Löschen">
|
||||
Endgültig löschen
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
@@ -244,7 +244,7 @@
|
||||
<form method="post" action="{{ url_for('licenses.revoke', license_id=license.license_id) }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<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') }}
|
||||
{{ field('reason', 'Grund (erscheint im Protokoll)', placeholder='Systemwechsel') }}
|
||||
</div>
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
{% from "tesm_core/macros.html" import alert %}
|
||||
|
||||
{% 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 %}
|
||||
{{ alert('Beide Codes sind signierte Protokollobjekte -- inhaltlich identisch mit dem
|
||||
Online-Weg, nur als kopierbarer Text. Die Pruefungen (Signatur, Frische, Nonce, Fingerprint)
|
||||
laufen exakt gleich; es gibt hier keine Abkuerzung.', 'info') }}
|
||||
Online-Weg, nur als kopierbarer Text. Die Prüfungen (Signatur, Frische, Nonce, Fingerprint)
|
||||
laufen exakt gleich; es gibt hier keine Abkürzung.', 'info') }}
|
||||
|
||||
<div class="grid grid--split">
|
||||
<section class="card">
|
||||
@@ -20,8 +20,8 @@ laufen exakt gleich; es gibt hier keine Abkuerzung.', 'info') }}
|
||||
<label class="field">
|
||||
<span class="field__label">Anfragecode</span>
|
||||
<textarea class="textarea input--mono" name="code" rows="8" required
|
||||
placeholder="Vom Kunden erhaltenen Code einfuegen ..."></textarea>
|
||||
<span class="field__hint">Umbrueche und Leerzeichen sind erlaubt.</span>
|
||||
placeholder="Vom Kunden erhaltenen Code einfügen ..."></textarea>
|
||||
<span class="field__hint">Umbrüche und Leerzeichen sind erlaubt.</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="card__footer">
|
||||
|
||||
@@ -5,14 +5,14 @@
|
||||
{% block page_sub %}
|
||||
{% if state and state.valid and state.activated %}
|
||||
aktiv · Rolle {{ role_labels.get(state.role, state.role) }}
|
||||
{% elif state and state.valid %}gueltig, nicht aktiviert
|
||||
{% elif state and state.present %}ungueltig
|
||||
{% elif state and state.valid %}gültig, nicht aktiviert
|
||||
{% elif state and state.present %}ungültig
|
||||
{% else %}keine Lizenz installiert{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block topbar_extra %}
|
||||
<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>
|
||||
{% endblock %}
|
||||
|
||||
@@ -23,14 +23,14 @@
|
||||
{% if state and state.error %}{{ alert(state.error, 'danger') }}{% endif %}
|
||||
|
||||
{% if state and state.role == 'master' %}
|
||||
{{ alert('Diese Instanz ist die Wurzelinstanz. Nur sie darf Lizenzen fuer
|
||||
weitere Lizenzserver ausstellen -- diese Grenze haengt an der signierten Rolle dieser
|
||||
{{ alert('Diese Instanz ist die Wurzelinstanz. Nur sie darf Lizenzen für
|
||||
weitere Lizenzserver ausstellen -- diese Grenze hängt an der signierten Rolle dieser
|
||||
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' %}
|
||||
{{ alert('Diese Instanz ist ein Sub-Lizenzserver. Sie darf eigene Kunden,
|
||||
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') }}
|
||||
{% endif %}
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
<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>Revision</dt><dd>{{ state.status.seq }}</dd>
|
||||
<dt>Laeuft ab</dt>
|
||||
<dt>Läuft ab</dt>
|
||||
<dd>
|
||||
{% if state.status.lifetime %}{{ pill('unbefristet', 'online') }}
|
||||
{% else %}
|
||||
@@ -66,7 +66,7 @@
|
||||
{{ pill('abgelaufen', 'danger') }}
|
||||
{% if state.status.grace_active %}
|
||||
<div class="fs-xs warning-text">
|
||||
Kulanzfrist laeuft ({{ grace_days }} Tage).
|
||||
Kulanzfrist läuft ({{ grace_days }} Tage).
|
||||
</div>
|
||||
{% endif %}
|
||||
{% elif state.status.expiring_soon %}
|
||||
@@ -76,7 +76,7 @@
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</dd>
|
||||
<dt>Uebergeordneter Server</dt>
|
||||
<dt>Übergeordneter Server</dt>
|
||||
<dd class="break-all">
|
||||
{{ state.status.master_endpoint or 'keiner (Wurzelinstanz oder Offline-Betrieb)' }}
|
||||
</dd>
|
||||
@@ -116,7 +116,7 @@
|
||||
{% endif %}
|
||||
</section>
|
||||
{% 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"
|
||||
die eigene Lizenz der Wurzelinstanz.', 'key') }}
|
||||
{% endif %}
|
||||
@@ -133,12 +133,12 @@
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<div class="card__body">
|
||||
<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>
|
||||
</label>
|
||||
</div>
|
||||
<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>
|
||||
</form>
|
||||
</section>
|
||||
@@ -150,7 +150,7 @@
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('upload', size=17) }} Lizenzdatei einspielen</div>
|
||||
<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>
|
||||
@@ -160,7 +160,7 @@
|
||||
<div class="card__body stack">
|
||||
<input class="input" type="file" name="license_file" accept=".json,.lic" required>
|
||||
{% 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 %}
|
||||
</div>
|
||||
<div class="card__footer">
|
||||
@@ -182,7 +182,7 @@
|
||||
<dl class="kv fs-sm">
|
||||
<dt>Hostname</dt><dd class="mono">{{ hostname }}</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>
|
||||
</dl>
|
||||
</div>
|
||||
@@ -191,8 +191,8 @@
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('shield', size=17) }} Vertrauenswuerdige Aussteller</div>
|
||||
<div class="card__sub">{{ trusted_keys | length }} gepinnte Schluessel</div>
|
||||
<div class="card__title">{{ icon('shield', size=17) }} Vertrauenswürdige Aussteller</div>
|
||||
<div class="card__sub">{{ trusted_keys | length }} gepinnte Schlüssel</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card__body">
|
||||
@@ -200,7 +200,7 @@
|
||||
<dl class="kv fs-sm">
|
||||
{% for key in trusted_keys %}
|
||||
<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 %}
|
||||
</dl>
|
||||
{% else %}
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<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 class="card__body stack stack--tight">
|
||||
@@ -68,7 +68,7 @@
|
||||
{{ field('contact_person', 'Ansprechpartner', value=customer.contact_person) }}
|
||||
{{ field('contact_phone', 'Telefon', value=customer.contact_phone) }}
|
||||
{{ 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('house_number', 'Hausnummer', value=customer.house_number) }}
|
||||
{{ field('postal_code', 'PLZ', value=customer.postal_code) }}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
{% block content %}
|
||||
<p class="fs-sm muted center">
|
||||
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>
|
||||
|
||||
<form method="post" class="stack" data-no-guard>
|
||||
@@ -22,7 +22,7 @@
|
||||
</form>
|
||||
|
||||
<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.
|
||||
</p>
|
||||
{% endblock %}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
{% block content %}
|
||||
{{ 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 %}
|
||||
<div class="stack">
|
||||
@@ -71,7 +71,7 @@ werden bei jedem Bericht vollstaendig ersetzt.', 'info') }}
|
||||
</div>
|
||||
{% else %}
|
||||
{{ 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') }}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
|
||||
{% block content %}
|
||||
{{ alert('Ein Sub-Lizenzserver meldet seinen kompletten Bestand -- huckepack auf dem
|
||||
' ~ heartbeat_hours ~ '-Stunden-Heartbeat und zusaetzlich sofort nach jeder Aenderung. Jeder
|
||||
Bericht ist ein vollstaendiger Schnappschuss: Geloeschtes verschwindet automatisch auch hier.',
|
||||
' ~ heartbeat_hours ~ '-Stunden-Heartbeat und zusätzlich sofort nach jeder Änderung. Jeder
|
||||
Bericht ist ein vollständiger Schnappschuss: Gelöschtes verschwindet automatisch auch hier.',
|
||||
'info') }}
|
||||
|
||||
{% if sources %}
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">
|
||||
{{ icon('shield', size=17) }} Aktuelle Lizenz
|
||||
{{ icon('shield', size=17) }} Aktülle Lizenz
|
||||
{% if current.status == 'active' %}{{ pill('aktiv', 'online') }}
|
||||
{% else %}{{ pill('bereit zum Einspielen', 'info') }}{% endif %}
|
||||
</div>
|
||||
@@ -34,7 +34,7 @@
|
||||
</div>
|
||||
<div class="card__body stack">
|
||||
<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>
|
||||
{% if current.hostname %}<dt>Installiert auf</dt><dd>{{ current.hostname }}</dd>{% endif %}
|
||||
<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) }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<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">
|
||||
Lizenz freigeben (Systemwechsel)
|
||||
</button>
|
||||
@@ -81,7 +81,7 @@
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<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 class="card__body">
|
||||
@@ -101,8 +101,8 @@
|
||||
<details class="card">
|
||||
<summary class="card__header" style="cursor:pointer">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('history', size=17) }} Frueher ausgestellt</div>
|
||||
<div class="card__sub">{{ history | length }} Eintraege</div>
|
||||
<div class="card__title">{{ icon('history', size=17) }} Früher ausgestellt</div>
|
||||
<div class="card__sub">{{ history | length }} Einträge</div>
|
||||
</div>
|
||||
{{ icon('chevron-down', size=16) }}
|
||||
</summary>
|
||||
@@ -115,7 +115,7 @@
|
||||
<td class="fs-sm">{{ entry.issued_at | date }}</td>
|
||||
<td class="fs-sm">
|
||||
{% if entry.status == 'revoked' %}freigegeben
|
||||
{% elif entry.status == 'deactivated' %}abgeloest
|
||||
{% elif entry.status == 'deactivated' %}abgelöst
|
||||
{% else %}{{ entry.status }}{% endif %}
|
||||
</td>
|
||||
<td class="fs-sm">{{ entry.hostname or '–' | safe }}</td>
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
<section class="card">
|
||||
<div class="card__body stack">
|
||||
{{ alert('In Ihrer TESM-Installation unter Verwaltung -- Lizenz auf "Lizenz aktivieren"
|
||||
klicken. Erscheint dort ein Anfragecode, fuegen Sie ihn hier ein. Sie bekommen einen
|
||||
Antwortcode zurueck, den Sie in TESM eintragen. Beide Codes sind signiert und lassen
|
||||
sich unterwegs nicht veraendern.', 'info', 'So geht es') }}
|
||||
klicken. Erscheint dort ein Anfragecode, fügen Sie ihn hier ein. Sie bekommen einen
|
||||
Antwortcode zurück, den Sie in TESM eintragen. Beide Codes sind signiert und lassen
|
||||
sich unterwegs nicht verändern.', 'info', 'So geht es') }}
|
||||
|
||||
{% if response_code %}
|
||||
<div class="field">
|
||||
@@ -35,7 +35,7 @@
|
||||
<label class="field">
|
||||
<span class="field__label">Anfragecode aus Ihrer TESM-Installation</span>
|
||||
<textarea class="textarea input--mono" name="code" rows="6" required
|
||||
placeholder="Code einfuegen ..."></textarea>
|
||||
placeholder="Code einfügen ..."></textarea>
|
||||
</label>
|
||||
<div class="row row--end mt-3">
|
||||
<button class="btn btn--primary" type="submit">Antwortcode erzeugen</button>
|
||||
@@ -44,7 +44,7 @@
|
||||
{% endif %}
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
{% block page_title %}E-Mail-Versand{% endblock %}
|
||||
{% block page_sub %}
|
||||
Microsoft Graph (App-only) ·
|
||||
{% if ready %}einsatzbereit{% else %}nicht vollstaendig konfiguriert{% endif %}
|
||||
{% if ready %}einsatzbereit{% else %}nicht vollständig konfiguriert{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block topbar_extra %}
|
||||
@@ -19,8 +19,8 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{{ alert('Der Versand laeuft ueber den Client-Credentials-Flow von Microsoft Graph -- kein SMTP,
|
||||
kein zusaetzliches Paket. Die Anwendung braucht in Azure die Anwendungsberechtigung
|
||||
{{ alert('Der Versand läuft über den Client-Credentials-Flow von Microsoft Graph -- kein SMTP,
|
||||
kein zusätzliches Paket. Die Anwendung braucht in Azure die Anwendungsberechtigung
|
||||
Mail.Send. Der Verbindungstest holt nur ein Token und verschickt
|
||||
bewusst keine echte Nachricht.', 'info') }}
|
||||
|
||||
@@ -46,7 +46,7 @@ bewusst keine echte Nachricht.', 'info') }}
|
||||
</div>
|
||||
<fieldset class="fieldset">
|
||||
<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) }}
|
||||
{% if not can_secrets %}
|
||||
<p class="fs-xs faint mt-2">
|
||||
@@ -63,17 +63,17 @@ bewusst keine echte Nachricht.', 'info') }}
|
||||
|
||||
<section class="card">
|
||||
<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 class="card__body">
|
||||
<ul class="stack stack--tight fs-sm muted">
|
||||
<li>Lizenzdatei an den Kunden senden (mit Anhang).</li>
|
||||
<li>Zugangslink fuer das Kundenportal.</li>
|
||||
<li>Zugangslink für das Kundenportal.</li>
|
||||
</ul>
|
||||
<div class="divider"></div>
|
||||
<p class="fs-sm muted">
|
||||
Das Secret liegt AES-256-GCM-verschluesselt in der Datenbank, gebunden an seinen
|
||||
Verwendungszweck. Ohne die Schluesseldatei dieser Installation ist es nicht lesbar.
|
||||
Das Secret liegt AES-256-GCM-verschlüsselt in der Datenbank, gebunden an seinen
|
||||
Verwendungszweck. Ohne die Schlüsseldatei dieser Installation ist es nicht lesbar.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
|
||||
{% block content %}
|
||||
{% if not sysops_available %}
|
||||
{{ alert('Systemhelfer nicht verfuegbar -- Hostname und Zeitzone koennen hier nur angezeigt,
|
||||
nicht geaendert werden.', 'info') }}
|
||||
{{ alert('Systemhelfer nicht verfügbar -- Hostname und Zeitzone können hier nur angezeigt,
|
||||
nicht geändert werden.', 'info') }}
|
||||
{% endif %}
|
||||
|
||||
<div class="grid grid--split">
|
||||
@@ -16,7 +16,7 @@
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<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>
|
||||
<form method="post" action="{{ url_for('settings.system_save') }}">
|
||||
@@ -24,10 +24,10 @@
|
||||
<div class="card__body stack">
|
||||
{{ checkbox('portal_enabled', 'Kundenportal aktiv', checked=values.portal_enabled) }}
|
||||
<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',
|
||||
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',
|
||||
hint_text=specs['portal_session_minutes'].hint) }}
|
||||
</div>
|
||||
@@ -35,7 +35,7 @@
|
||||
{{ checkbox('self_service_enabled', 'Self-Service-Link je Ticket aktiv',
|
||||
checked=values.self_service_enabled,
|
||||
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,
|
||||
hint_text=specs['allow_customer_revoke'].hint) }}
|
||||
</div>
|
||||
@@ -60,6 +60,9 @@
|
||||
selected=values.log_rotate_interval) }}
|
||||
{{ field('log_rotate_keep', 'Aufbewahrte Rotationen', value=values.log_rotate_keep,
|
||||
type='number') }}
|
||||
{{ field('session_idle_minutes', 'Automatische Abmeldung (Minuten)',
|
||||
value=values.session_idle_minutes, type='number',
|
||||
hint_text=specs['session_idle_minutes'].hint) }}
|
||||
{{ field('trash_retention_days', 'Papierkorb-Aufbewahrung (Tage)',
|
||||
value=values.trash_retention_days, type='number') }}
|
||||
</div>
|
||||
@@ -105,7 +108,7 @@
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<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>
|
||||
<form method="post" action="{{ url_for('admin.nav_order') }}">
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
{% block content %}
|
||||
{{ 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') }}
|
||||
|
||||
<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')) }}
|
||||
</div>
|
||||
{{ 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',
|
||||
disabled=not current_user.has('settings_vendor.edit'),
|
||||
hint_text='Basis-URL, unter der Kundeninstanzen diesen Server erreichen. Wird in
|
||||
jede Lizenz eingebettet und fuer alle kundengerichteten Links verwendet -- statt
|
||||
des Host-Headers der aktuellen Anfrage, der bei internen Aufrufen falsch waere.',
|
||||
jede Lizenz eingebettet und für alle kundengerichteten Links verwendet -- statt
|
||||
des Host-Headers der aktüllen Anfrage, der bei internen Aufrufen falsch wäre.',
|
||||
extra_class='field--full') }}
|
||||
<fieldset class="fieldset">
|
||||
<legend>Logo (maximal {{ max_logo_kb }} KB)</legend>
|
||||
<div class="stack stack--tight">
|
||||
{% 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)">
|
||||
{% else %}
|
||||
<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"
|
||||
{% if not current_user.has('settings_vendor.edit') %}disabled{% endif %}>
|
||||
<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>
|
||||
</div>
|
||||
</fieldset>
|
||||
@@ -75,7 +75,7 @@ welche Basis-URL in kundengerichteten Links steht.', 'info') }}
|
||||
<div class="divider"></div>
|
||||
<p class="fs-sm muted">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,34 +1,34 @@
|
||||
{% extends "tesm_core/base.html" %}
|
||||
{% from "tesm_core/macros.html" import alert %}
|
||||
{% block page_title %}Signaturschluessel{% endblock %}
|
||||
{% block page_sub %}Schluessel-ID {{ key_id }}{% endblock %}
|
||||
{% block page_title %}Signaturschlüssel{% endblock %}
|
||||
{% block page_sub %}Schlüssel-ID {{ key_id }}{% endblock %}
|
||||
{% block 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>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
{{ alert('Der oeffentliche 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
|
||||
vertrauenswuerdig hinterlegt.', 'info') }}
|
||||
{{ alert('Der öffentliche Teil ist kein Geheimnis -- er reist in jeder ausgestellten Lizenz mit.
|
||||
Ihn hier anzuzeigen erlaubt es, ihn unabhängig zu vergleichen, bevor ein Kunde ihn als
|
||||
vertrauenswürdig hinterlegt.', 'info') }}
|
||||
{% if health.problems %}
|
||||
{% for problem in health.problems %}{{ alert(problem, 'danger') }}{% endfor %}
|
||||
{% endif %}
|
||||
<section class="card" style="max-width:760px">
|
||||
<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>
|
||||
<button class="btn btn--sm" type="button" data-copy="#pubkey">{{ icon('copy', size=14) }} Kopieren</button>
|
||||
</div>
|
||||
<div class="card__body stack">
|
||||
<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>
|
||||
</dl>
|
||||
<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
|
||||
Deployment-Abgleich ausgeschlossen. Ginge er verloren, waere jede
|
||||
ausgestellte Lizenz nicht mehr pruefbar -- bitte getrennt sichern.', 'warning') }}
|
||||
Deployment-Abgleich ausgeschlossen. Ginge er verloren, wäre jede
|
||||
ausgestellte Lizenz nicht mehr prüfbar -- bitte getrennt sichern.', 'warning') }}
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
@@ -17,11 +17,11 @@
|
||||
|
||||
{% block content %}
|
||||
{% if endpoint_missing %}
|
||||
{{ alert(endpoint_missing, 'danger', 'Keine Ausstellung moeglich') }}
|
||||
{{ alert(endpoint_missing, 'danger', 'Keine Ausstellung möglich') }}
|
||||
{% endif %}
|
||||
{% if ticket.status == 'blocked' %}
|
||||
{{ 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') }}
|
||||
{% endif %}
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
<input type="hidden" name="as_trial" value="1">
|
||||
<button class="btn" type="submit"
|
||||
{% 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-style="primary">
|
||||
Als Testlizenz ({{ trial_days }} Tage, alle Module)
|
||||
@@ -81,7 +81,7 @@
|
||||
{% if licenses %}
|
||||
<div class="table-wrap">
|
||||
<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>
|
||||
{% for entry in licenses %}
|
||||
<tr>
|
||||
@@ -115,7 +115,7 @@
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<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">
|
||||
Ohne Anmeldung nutzbar – die Berechtigung ist der Besitz dieses Links
|
||||
</div>
|
||||
@@ -128,7 +128,7 @@
|
||||
</div>
|
||||
<p class="fs-xs faint">
|
||||
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.
|
||||
</p>
|
||||
<div class="divider"></div>
|
||||
@@ -143,7 +143,7 @@
|
||||
</div>
|
||||
</section>
|
||||
{% 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') }}
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -158,8 +158,8 @@
|
||||
<form method="post" action="{{ url_for('tickets.update', ticket_id=ticket.id) }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<div class="card__body stack">
|
||||
{{ alert('Aenderungen wirken auf die naechste Ausstellung. Kunde und Rolle sind
|
||||
unveraenderlich.', 'info') }}
|
||||
{{ alert('Änderungen wirken auf die nächste Ausstellung. Kunde und Rolle sind
|
||||
unveränderlich.', 'info') }}
|
||||
<label class="field">
|
||||
<span class="field__label">Lizenztyp</span>
|
||||
<select class="select" name="type" id="ticket-type"
|
||||
@@ -206,7 +206,7 @@
|
||||
{% if current_user.has('tickets.edit') or current_user.has('tickets.delete') %}
|
||||
<section class="card">
|
||||
<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 class="card__body stack stack--tight">
|
||||
{% if current_user.has('tickets.edit') %}
|
||||
@@ -216,7 +216,7 @@
|
||||
<button class="btn btn--sm btn--block {{ 'btn--danger' if ticket.status != 'blocked' }}"
|
||||
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-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' }}">
|
||||
{{ 'Entsperren' if ticket.status == 'blocked' else 'Sperren' }}
|
||||
</button>
|
||||
@@ -228,9 +228,9 @@
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<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 %}
|
||||
data-confirm="Ticket und seine gesamte Lizenzhistorie loeschen? Das laesst sich nicht rueckgaengig machen."
|
||||
data-confirm-title="Ticket loeschen" data-confirm-ok="Loeschen">
|
||||
Ticket loeschen
|
||||
data-confirm="Ticket und seine gesamte Lizenzhistorie löschen? Das lässt sich nicht rückgängig machen."
|
||||
data-confirm-title="Ticket löschen" data-confirm-ok="Löschen">
|
||||
Ticket löschen
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
<label class="field">
|
||||
<span class="field__label">Kunde <span class="req">*</span></span>
|
||||
<select class="select" name="customer_id" required>
|
||||
<option value="">bitte waehlen</option>
|
||||
<option value="">bitte wählen</option>
|
||||
{% for customer in customers %}
|
||||
<option value="{{ customer.id }}"
|
||||
{% if preselected_customer == customer.id %}selected{% endif %}>
|
||||
@@ -32,7 +32,7 @@
|
||||
{% endfor %}
|
||||
</select>
|
||||
<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.
|
||||
</span>
|
||||
</label>
|
||||
@@ -47,13 +47,13 @@
|
||||
{% endfor %}
|
||||
</select>
|
||||
<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.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<fieldset class="fieldset" id="module-box">
|
||||
<legend>Module (nur bei „Individuell“)</legend>
|
||||
<legend>Module (nur bei „Individüll“)</legend>
|
||||
<div class="stack stack--tight">
|
||||
{% for module in modules %}
|
||||
{{ checkbox('modules', module_labels[module], value=module) }}
|
||||
@@ -65,7 +65,7 @@
|
||||
{{ field('valid_days', 'Laufzeit (Tage)', value=default_valid_days, type='number') }}
|
||||
</div>
|
||||
{{ 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.') }}
|
||||
{{ textarea_field('notes', 'Interne Notiz', rows=2) }}
|
||||
</div>
|
||||
@@ -77,15 +77,15 @@
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('share', size=17) }} Lizenzserver-Lizenz</div>
|
||||
<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 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
|
||||
Bestand automatisch hierher zurueck. Module sind fuer diese Rolle bedeutungslos und
|
||||
werden ignoriert. Self-Service und Kundenportal sind fuer solche Tickets gesperrt.',
|
||||
Bestand automatisch hierher zurück. Module sind für diese Rolle bedeutungslos und
|
||||
werden ignoriert. Self-Service und Kundenportal sind für solche Tickets gesperrt.',
|
||||
'warning') }}
|
||||
<label class="check">
|
||||
<input type="checkbox" name="role" value="license_server">
|
||||
|
||||
@@ -17,11 +17,11 @@
|
||||
|
||||
{% block content %}
|
||||
{% if endpoint_missing %}
|
||||
{{ alert(endpoint_missing, 'danger', 'Keine Ausstellung moeglich') }}
|
||||
{{ alert(endpoint_missing, 'danger', 'Keine Ausstellung möglich') }}
|
||||
{% endif %}
|
||||
{{ alert('Ein Ticket beschreibt den Anspruch (Typ, Module, Laufzeit); die Lizenz ist die daraus
|
||||
erzeugte, signierte Datei. Aenderungen am Ticket wirken auf die naechste
|
||||
Ausstellung -- bereits ausgestellte Lizenzen bleiben unveraendert.', 'info') }}
|
||||
erzeugte, signierte Datei. Änderungen am Ticket wirken auf die nächste
|
||||
Ausstellung -- bereits ausgestellte Lizenzen bleiben unverändert.', 'info') }}
|
||||
|
||||
<section class="card">
|
||||
<div class="table-wrap">
|
||||
@@ -85,7 +85,7 @@ Ausstellung -- bereits ausgestellte Lizenzen bleiben unveraendert.', 'info') }}
|
||||
</table>
|
||||
</div>
|
||||
{% 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 %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
+52
-1
@@ -112,6 +112,21 @@ MONITOR_UNIT="$NAME-monitor.service"
|
||||
NGINX_SITE="/etc/nginx/sites-available/$NAME"
|
||||
ENV_PREFIX="TESM_"
|
||||
[[ "$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
|
||||
PORT=5000
|
||||
[[ "$APP" == "tesm-license" ]] && PORT=5001
|
||||
@@ -134,7 +149,7 @@ fi
|
||||
# Browser sendet das Cookie dann weiter nur ueber HTTPS, die Anwendung setzt es
|
||||
# aber ohne das Flag neu, und beide reden aneinander vorbei.
|
||||
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
|
||||
fi
|
||||
[[ $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"
|
||||
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 ------------------------------------------------------------------
|
||||
step "Dateirechte"
|
||||
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
|
||||
MemoryDenyWriteExecute=no
|
||||
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
|
||||
# Bewusst **kein** einschraenkendes CapabilityBoundingSet: sudo braucht
|
||||
# CAP_SETUID/CAP_SETGID, und der Helfer dahinter braucht Root. Was hier fehlt,
|
||||
|
||||
@@ -258,6 +258,15 @@ case "$verb" in
|
||||
# Kommandozeile (Prozessliste) und nie ueber die Umgebung
|
||||
# (/proc/<pid>/environ).
|
||||
read_stdin_limited > "$creds"
|
||||
# Eingebunden wird im Mount-Namensraum des aufrufenden Dienstes -- absichtlich:
|
||||
# nur dieser Dienst liest die Dateien, und mit ihm verschwinden die
|
||||
# Einbindungen wieder. Damit der Syscall durchkommt, braucht die Unit
|
||||
# "@mount" im SystemCallFilter. Ohne das scheitert es mit
|
||||
# "mount error(1): Operation not permitted"; gemessen war der Syscall-Filter
|
||||
# die Ursache, nicht der Namensraum. Ein Umweg ueber nsenter in den
|
||||
# Namensraum des Wirts ist keine Option -- "RestrictNamespaces=yes"
|
||||
# verbietet setns, und das Recht dafuer wieder aufzugeben waere teurer als
|
||||
# der Gewinn.
|
||||
mkdir -p "$target"
|
||||
if 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"
|
||||
@@ -271,6 +280,10 @@ case "$verb" in
|
||||
|
||||
umount)
|
||||
check_mount_target "${1:?Ziel fehlt}"
|
||||
# Gegenstueck zu mount-cifs, und deshalb im selben Namensraum: loesen kann
|
||||
# nur, wer die Einbindung auch sieht. Ein Aufruf aus einem anderen Dienst
|
||||
# greift ins Leere -- genau deshalb laeuft die Aufraeumaufgabe fuer
|
||||
# abgelaufene Einbindungen im Webprozess und nicht im Ueberwachungsdienst.
|
||||
umount -l "$1" || true
|
||||
rmdir "$1" 2>/dev/null || true
|
||||
;;
|
||||
@@ -280,9 +293,27 @@ case "$verb" in
|
||||
for package in "$@"; do
|
||||
check_in_list "$package" "${PACKAGES_ALLOWED[@]}" || fail "Paket nicht erlaubt: $package"
|
||||
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
|
||||
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 install -y -qq "$@"
|
||||
fi
|
||||
;;
|
||||
|
||||
certbot-issue)
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Live-Banner fuer die SSH-Anmeldung (MOTD).
|
||||
|
||||
Eingebunden ueber ``/etc/update-motd.d/50-<name>``, das ``install.sh``
|
||||
erzeugt. Aufruf mit ``--app`` und ``--root``; alles andere steht in der
|
||||
Datenbank der Installation.
|
||||
|
||||
**Nur Standardbibliothek, kein Flask, kein venv.** Der Vorgaenger importierte
|
||||
dafuer die ganze Anwendung -- bei jeder SSH-Anmeldung. Das ist teuer, und es
|
||||
faellt aus, sobald das venv nicht mehr passt: nach dem Umstieg auf diese
|
||||
Fassung zeigte das alte Banner monatelang eingefrorene Zahlen aus einer
|
||||
Datenbank, die niemand mehr fuellte. Hier wird gelesen, was auf der Platte
|
||||
liegt, und zwar **nur lesend** (``mode=ro``).
|
||||
|
||||
Ein Banner darf eine Anmeldung niemals stoeren: jede Auskunft, die nicht zu
|
||||
holen ist, wird zu einem kurzen Hinweis, und am Ende faengt ``main`` alles ab.
|
||||
Der Aufrufer setzt zusaetzlich ein Zeitlimit.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sqlite3
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
AMBER = "\033[38;2;226;166;60m"
|
||||
DIM = "\033[2m"
|
||||
BOLD = "\033[1m"
|
||||
RESET = "\033[0m"
|
||||
|
||||
#: Kea schreibt seine Leases als CSV; die letzte Zeile je Adresse gilt.
|
||||
#: Dieselbe Datei liest die Anwendung (``services/kea.py``: ``LEASE_FILE``).
|
||||
LEASE_FILE = "/var/lib/kea/kea-leases4.csv"
|
||||
|
||||
#: Zeile unter der Wortmarke. Wortlaut wie in der jeweiligen Anwendung
|
||||
#: (``AUTH_TAGLINE`` in deren ``__init__.py``) -- ein Hygienetest haelt beides
|
||||
#: zusammen.
|
||||
TAGLINE = {
|
||||
"tesm": "ÜBERWACHEN · BOOTEN · ANBINDEN",
|
||||
"tesm-license": "LIZENZIEREN",
|
||||
}
|
||||
|
||||
TITEL = {"tesm": "TimEShepManager", "tesm-license": "Lizenzserver"}
|
||||
|
||||
#: Wortgleich mit ``services/monitor.py``: ``DASHBOARD_COUNT_SQL``. Die
|
||||
#: Statusuebersicht und dieses Banner sollen nicht zwei Wahrheiten haben; ein
|
||||
#: Hygienetest vergleicht die beiden Zeichenketten.
|
||||
DASHBOARD_COUNT_SQL = """
|
||||
SELECT
|
||||
COUNT(*) AS total,
|
||||
SUM(CASE WHEN d.is_active=0 THEN 1 ELSE 0 END) AS disabled,
|
||||
SUM(CASE WHEN d.is_active=1 AND COALESCE(st.state,'unknown')='online' THEN 1 ELSE 0 END) AS online,
|
||||
SUM(CASE WHEN d.is_active=1 AND COALESCE(st.state,'unknown')='offline' THEN 1 ELSE 0 END) AS offline,
|
||||
SUM(CASE WHEN d.is_active=1 AND COALESCE(st.state,'unknown')='unknown' THEN 1 ELSE 0 END) AS unknown,
|
||||
SUM(CASE WHEN d.is_active=1 AND st.state='restarting' THEN 1 ELSE 0 END) AS restarting
|
||||
FROM devices d LEFT JOIN device_status st ON st.device_id = d.id
|
||||
WHERE d.deleted_at IS NULL
|
||||
"""
|
||||
|
||||
|
||||
def _zahl(anzahl: int, singular: str, plural: str) -> str:
|
||||
"""Zahl mit passendem Wort. "1 Clients" liest man jeden Tag einmal zu oft."""
|
||||
return f"{BOLD}{anzahl}{RESET} {singular if anzahl == 1 else plural}"
|
||||
|
||||
|
||||
def _oeffnen(root: Path) -> sqlite3.Connection:
|
||||
pfad = root / "data" / "app.db"
|
||||
conn = sqlite3.connect(f"file:{pfad.as_posix()}?mode=ro", uri=True, timeout=1.0)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
def _flotte(conn: sqlite3.Connection) -> str:
|
||||
row = conn.execute(DASHBOARD_COUNT_SQL).fetchone()
|
||||
online = int(row["online"] or 0)
|
||||
offline = int(row["offline"] or 0)
|
||||
teile = [
|
||||
_zahl(online, "Client online", "Clients online"),
|
||||
f"{BOLD}{offline}{RESET} nicht erreichbar",
|
||||
]
|
||||
# Nur nennen, wenn es sie gibt: eine Null bei "startet neu" ist keine
|
||||
# Auskunft, sondern Fuellmaterial.
|
||||
restarting = int(row["restarting"] or 0)
|
||||
if restarting:
|
||||
teile.append(_zahl(restarting, "startet neu", "starten neu"))
|
||||
unbekannt = int(row["unknown"] or 0)
|
||||
if unbekannt:
|
||||
teile.append(f"{DIM}{unbekannt} ohne Pruefung{RESET}")
|
||||
return " · ".join(teile)
|
||||
|
||||
|
||||
def _dhcp() -> str:
|
||||
"""Aktive Leases aus der Kea-CSV. Laeuft als root, braucht keinen Helfer."""
|
||||
datei = Path(LEASE_FILE)
|
||||
if not datei.is_file():
|
||||
return "keine Lease-Datei"
|
||||
try:
|
||||
zeilen = datei.read_text(encoding="utf-8", errors="replace").splitlines()
|
||||
except OSError:
|
||||
return "Lease-Datei nicht lesbar"
|
||||
if len(zeilen) < 2:
|
||||
return "0 Leases aktiv"
|
||||
kopf = [name.strip().strip('"') for name in zeilen[0].split(",")]
|
||||
try:
|
||||
i_adresse = kopf.index("address")
|
||||
i_ablauf = kopf.index("expire")
|
||||
i_zustand = kopf.index("state")
|
||||
except ValueError:
|
||||
return "Lease-Datei mit unerwarteten Spalten"
|
||||
|
||||
jetzt = int(time.time())
|
||||
letzte: dict[str, tuple[int, str]] = {}
|
||||
for zeile in zeilen[1:]:
|
||||
felder = [feld.strip().strip('"') for feld in zeile.split(",")]
|
||||
if len(felder) < len(kopf):
|
||||
continue
|
||||
try:
|
||||
letzte[felder[i_adresse]] = (int(felder[i_ablauf] or 0), felder[i_zustand] or "0")
|
||||
except ValueError:
|
||||
continue
|
||||
aktiv = sum(1 for ablauf, zustand in letzte.values() if zustand == "0" and ablauf > jetzt)
|
||||
return f"{aktiv} {'Lease' if aktiv == 1 else 'Leases'} aktiv"
|
||||
|
||||
|
||||
def _lizenzen(conn: sqlite3.Connection) -> str:
|
||||
kunden = int(conn.execute("SELECT COUNT(*) FROM license_customers").fetchone()[0] or 0)
|
||||
lizenzen = int(
|
||||
conn.execute(
|
||||
"SELECT COUNT(*) FROM licenses WHERE status IN ('issued','active')"
|
||||
).fetchone()[0]
|
||||
or 0
|
||||
)
|
||||
return (
|
||||
_zahl(kunden, "Kunde", "Kunden")
|
||||
+ " · "
|
||||
+ _zahl(lizenzen, "Lizenz ausgestellt", "Lizenzen ausgestellt")
|
||||
)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(prog="tesm-motd", description="Banner fuer die SSH-Anmeldung")
|
||||
parser.add_argument("--app", required=True, choices=sorted(TITEL))
|
||||
parser.add_argument("--root", required=True, help="Verzeichnis der Installation")
|
||||
parser.add_argument("--no-color", action="store_true")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if args.no_color:
|
||||
globals().update(AMBER="", DIM="", BOLD="", RESET="")
|
||||
|
||||
root = Path(args.root)
|
||||
# Kein isatty()-Test: dieses Skript schreibt beim Login nicht auf ein
|
||||
# Terminal, sondern ueber run-parts in eine Datei. Der Betrachter ist
|
||||
# trotzdem ein echtes Terminal -- deshalb immer Farbe.
|
||||
print(f"{BOLD}{AMBER}TESM{RESET} · {TITEL[args.app]}")
|
||||
if TAGLINE[args.app]:
|
||||
print(f"{DIM}{TAGLINE[args.app]}{RESET}")
|
||||
print()
|
||||
try:
|
||||
conn = _oeffnen(root)
|
||||
except sqlite3.Error:
|
||||
print(f"{DIM}Datenbank nicht lesbar: {root}{RESET}")
|
||||
print()
|
||||
return 0
|
||||
try:
|
||||
if args.app == "tesm":
|
||||
print(f"{_flotte(conn)} · DHCP: {_dhcp()}")
|
||||
else:
|
||||
print(_lizenzen(conn))
|
||||
except sqlite3.Error as fehler:
|
||||
print(f"{DIM}Zustand nicht verfuegbar ({fehler}){RESET}")
|
||||
finally:
|
||||
conn.close()
|
||||
print()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except SystemExit:
|
||||
raise
|
||||
except Exception: # noqa: BLE001 - ein Banner darf keinen Login stoeren
|
||||
sys.exit(0)
|
||||
+41
-8
@@ -12,17 +12,47 @@
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
# Ein Wagenruecklauf im Paket macht jedes Shell-Skript auf dem Ziel
|
||||
# unbrauchbar ("bad interpreter"). Die Pruefung darf dabei nicht selbst
|
||||
# von einem Zusatzpaket abhaengen: mit "file" -- das auf einem schlanken
|
||||
# Server fehlt -- endete sie in "command not found", grep bekam nichts zu
|
||||
# sehen, und das Skript machte weiter, als waere alles in Ordnung.
|
||||
hat_crlf() {
|
||||
local cr
|
||||
cr="$(printf '\r')"
|
||||
# -U (binaer lesen) ist unter Linux ohne Wirkung, auf einer Windows-Buildmaschine
|
||||
# aber entscheidend: dort entfernt grep den Wagenruecklauf beim Lesen und
|
||||
# findet deshalb nie einen.
|
||||
LC_ALL=C grep -qU "$cr" "$1"
|
||||
}
|
||||
|
||||
APP="${1:-}"
|
||||
TAG="${2:-latest}"
|
||||
BASE="${TESM_RELEASE_BASE:-https://gitea.example.invalid}"
|
||||
OWNER="${TESM_RELEASE_OWNER:-wis}"
|
||||
REPO="${TESM_RELEASE_REPO:-tesm}"
|
||||
|
||||
# Alles nach dem Tag geht unveraendert an install.sh weiter. Ohne das liesse
|
||||
# sich eine benannte Instanz nicht aktualisieren: install.sh leitet den Namen
|
||||
# aus "--instance" ab, und ohne den Schalter entstuende neben "tesm-opus" eine
|
||||
# zweite Installation namens "tesm" -- mit eigener Datenbank, eigener
|
||||
# nginx-Site und demselben Port.
|
||||
shift $(( $# > 2 ? 2 : $# ))
|
||||
EXTRA=("$@")
|
||||
# Bezugsquelle. Die Vorgaben zeigen dorthin, wo die Releases tatsaechlich
|
||||
# liegen -- ein Platzhalter wie "gitea.example.invalid" macht das Skript auf
|
||||
# jedem Host unbrauchbar, und genau das war hier der Fall.
|
||||
#
|
||||
# Das Repository heisst wie die Anwendung; getrennte Repositorien, getrennte
|
||||
# Releases. Der Anhang heisst "<repo>-<tag>.tar.gz", siehe unten.
|
||||
BASE="${TESM_RELEASE_BASE:-https://gitea.int.eertmoed.net}"
|
||||
OWNER="${TESM_RELEASE_OWNER:-alientim}"
|
||||
|
||||
[[ "$APP" == "tesm" || "$APP" == "tesm-license" ]] || {
|
||||
echo "Verwendung: update.sh <tesm|tesm-license> [tag]" >&2
|
||||
echo "Verwendung: update.sh <tesm|tesm-license> [tag] [install.sh-Argumente ...]" >&2
|
||||
echo "Beispiel : update.sh tesm latest --instance opus" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
REPO="${TESM_RELEASE_REPO:-$APP}"
|
||||
|
||||
WORK="$(mktemp -d /tmp/tesm-update-XXXXXX)"
|
||||
trap 'rm -rf "$WORK"' EXIT # anders als im Vorgaenger wird aufgeraeumt
|
||||
|
||||
@@ -39,18 +69,21 @@ curl -fsSL -o "$WORK/$ASSET" "$BASE/$OWNER/$REPO/releases/download/$TAG/$ASSET"
|
||||
|
||||
echo "==> Entpacke ..."
|
||||
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; }
|
||||
SRC="$(cd "$SRC" && pwd)"
|
||||
|
||||
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 "'git -c core.autocrlf=false archive'." >&2
|
||||
exit 1
|
||||
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
|
||||
|
||||
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,
|
||||
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 |
|
||||
|---|---|
|
||||
| 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>/` |
|
||||
| Umgebungsdatei | `/etc/tesm/<name>.env` |
|
||||
| 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` |
|
||||
| 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
|
||||
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
|
||||
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.
|
||||
|
||||
---
|
||||
@@ -43,10 +43,10 @@ Wichtige Optionen:
|
||||
| Option | Wirkung |
|
||||
|---|---|
|
||||
| `--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. |
|
||||
| `--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. |
|
||||
| `--instance <name>` | Zusaetzliche Instanz neben der Hauptinstallation. |
|
||||
| `--port` / `--http-port` / `--https-port` | Abweichende Ports (nur fuer Testinstanzen sinnvoll). |
|
||||
| `--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 bestätigen. |
|
||||
| `--instance <name>` | Zusätzliche Instanz neben der Hauptinstallation. |
|
||||
| `--port` / `--http-port` / `--https-port` | Abweichende Ports (nur für Testinstanzen sinnvoll). |
|
||||
| `--skip-packages` | Keine Systempakete installieren. |
|
||||
|
||||
Danach:
|
||||
@@ -55,17 +55,17 @@ Danach:
|
||||
sudo -u tesm tesm-admin create-admin
|
||||
```
|
||||
|
||||
Beim Lizenzserver zusaetzlich, **in dieser Reihenfolge**:
|
||||
Beim Lizenzserver zusätzlich, **in dieser Reihenfolge**:
|
||||
|
||||
```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 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
|
||||
> pruefbar. Sichern Sie ihn getrennt vom Server.
|
||||
> prüfbar. Sichern Sie ihn getrennt vom Server.
|
||||
|
||||
### 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
|
||||
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.
|
||||
|
||||
---
|
||||
@@ -90,19 +90,115 @@ sudo bash deploy/install.sh --app tesm
|
||||
```
|
||||
|
||||
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>` --
|
||||
unabhaengig davon, ob sich das Schema geaendert hat.
|
||||
1. **Vollständige Sicherung** nach `/srv/<name>-backup-<zeitstempel>` --
|
||||
unabhängig davon, ob sich das Schema geändert hat.
|
||||
2. Dateiabgleich; `data/` bleibt unangetastet.
|
||||
3. Schema-Migrationen beim Start (versioniert, mit Pruefsumme).
|
||||
4. **Gesundheitspruefung.** Antwortet die Anwendung nicht, wird der alte Stand
|
||||
automatisch zurueckgeholt; die fehlgeschlagene Version bleibt unter
|
||||
3. Schema-Migrationen beim Start (versioniert, mit Prüfsumme).
|
||||
4. **Gesundheitsprüfung.** Antwortet die Anwendung nicht, wird der alte Stand
|
||||
automatisch zurückgeholt; die fehlgeschlagene Version bleibt unter
|
||||
`/srv/<name>-failed-<zeitstempel>` zur Analyse liegen.
|
||||
|
||||
Eine bereits eingerichtete HTTPS-Konfiguration bleibt erhalten, auch ohne
|
||||
`--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
|
||||
@@ -114,7 +210,7 @@ Drei Wege dorthin:
|
||||
|
||||
**a) Bei der Installation** -- siehe oben, `--https`.
|
||||
|
||||
**b) Nachtraeglich ueber die Kommandozeile:**
|
||||
**b) Nachträglich über die Kommandozeile:**
|
||||
|
||||
```bash
|
||||
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.
|
||||
|
||||
**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
|
||||
Wege erledigen das selbst) und der Dienst neu gestartet werden.
|
||||
|
||||
### Die Site-Datei wird immer neu erzeugt
|
||||
|
||||
Sie ist **kein** Ort fuer Handarbeit. Jede Uebernahme schreibt sie vollstaendig
|
||||
aus der Vorlage neu. Was frueher von Hand hineingeschrieben wurde -- Domain,
|
||||
Sie ist **kein** Ort für Handarbeit. Jede Übernahme schreibt sie vollständig
|
||||
aus der Vorlage neu. Was früher von Hand hineingeschrieben wurde -- Domain,
|
||||
Ports, Zertifikatspfade, HSTS, Upload-Grenze -- ist heute Einstellung in der
|
||||
Anwendung.
|
||||
|
||||
Der Grund steht im Vorgaengerprojekt: dort wurde die Datei nur angelegt, wenn
|
||||
sie fehlte. Ein einmal falscher `alias`-Pfad ueberlebte dadurch jahrelang jedes
|
||||
Update, und die Oberflaeche kam die ganze Zeit ohne Design.
|
||||
Der Grund steht im Vorgängerprojekt: dort wurde die Datei nur angelegt, wenn
|
||||
sie fehlte. Ein einmal falscher `alias`-Pfad überlebte dadurch jahrelang jedes
|
||||
Update, und die Oberfläche kam die ganze Zeit ohne Design.
|
||||
|
||||
### 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.
|
||||
* **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
|
||||
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`
|
||||
(bewusst nicht ueber das nginx-Plugin, das die erzeugte Site umschreiben
|
||||
wuerde). Der ACME-Pfad bleibt auch bei aktiver Umleitung erreichbar.
|
||||
Die Erneuerung von Let's Encrypt läuft über `certbot certonly --webroot`
|
||||
(bewusst nicht über das nginx-Plugin, das die erzeugte Site umschreiben
|
||||
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
|
||||
|
||||
```bash
|
||||
systemctl status tesm.service # Weboberflaeche (gunicorn)
|
||||
systemctl status tesm-monitor.service # Ueberwachungsschleife (nur TESM)
|
||||
systemctl status tesm.service # Weboberfläche (gunicorn)
|
||||
systemctl status tesm-monitor.service # Überwachungsschleife (nur TESM)
|
||||
systemctl status tesm-license.service
|
||||
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]
|
||||
```
|
||||
|
||||
Der Helfer gehoert `root`, ist per sudoers ausschliesslich fuer genau diesen
|
||||
Pfad mit `NOPASSWD` freigegeben und prueft jedes Argument gegen eine
|
||||
Der Helfer gehört `root`, ist per sudoers ausschliesslich für genau diesen
|
||||
Pfad mit `NOPASSWD` freigegeben und prüft jedes Argument gegen eine
|
||||
Positivliste. Er reicht nie etwas an eine Shell weiter (kein `eval`, kein
|
||||
`sh -c`).
|
||||
|
||||
@@ -191,16 +364,16 @@ sudo /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
|
||||
im Helfer vorkommt und dass jede Pfadpruefung `..` abweist.
|
||||
im Helfer vorkommt und dass jede Pfadprüfung `..` abweist.
|
||||
|
||||
---
|
||||
|
||||
## 7. Sicherung und Wiederherstellung
|
||||
|
||||
In der Oberflaeche unter *Sicherung* -- verschluesselte Ausfuhr (Argon2id +
|
||||
AES-256-GCM) mit einem selbst gewaehlten Kennwort. Der Import zeigt zuerst eine
|
||||
In der Oberfläche unter *Sicherung* -- verschlüsselte Ausfuhr (Argon2id +
|
||||
AES-256-GCM) mit einem selbst gewählten Kennwort. Der Import zeigt zuerst eine
|
||||
Vorschau, bevor irgendetwas geschrieben wird.
|
||||
|
||||
Auf dem Host reicht:
|
||||
@@ -214,10 +387,10 @@ sudo systemctl start tesm
|
||||
Unverzichtbar:
|
||||
|
||||
* `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/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.
|
||||
|
||||
**nginx laesst sich nicht neu laden.**
|
||||
**nginx lässt sich nicht neu laden.**
|
||||
```bash
|
||||
sudo nginx -t
|
||||
```
|
||||
Die Uebernahme aus der Anwendung nimmt eine abgelehnte Konfiguration selbst
|
||||
zurueck. Eine von Hand bearbeitete Datei tut das nicht -- in dem Fall hilft
|
||||
Die Übernahme aus der Anwendung nimmt eine abgelehnte Konfiguration selbst
|
||||
zurück. Eine von Hand bearbeitete Datei tut das nicht -- in dem Fall hilft
|
||||
`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,
|
||||
das er ueber HTTP nie zuruecksendet.
|
||||
das er über HTTP nie zurücksendet.
|
||||
```bash
|
||||
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
|
||||
den Port.
|
||||
|
||||
**Statische Dateien fehlen, die Oberflaeche ist unformatiert.**
|
||||
**Statische Dateien fehlen, die Oberfläche ist unformatiert.**
|
||||
```bash
|
||||
grep alias /etc/nginx/sites-available/tesm
|
||||
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
|
||||
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
|
||||
Schluessel akzeptieren. Die Freigabe erfolgt von Hand, in zwei Schritten, auf
|
||||
der Detailseite des Geraets (*Clients -> Geraet -> SSH-Host-Schluessel*):
|
||||
erst **Auslesen**, dann den Fingerprint mit einer unabhaengigen Quelle
|
||||
vergleichen, dann **Freigeben**. Die Wartungsuebersicht zeigt in einer eigenen
|
||||
Spalte, fuer welche Geraete das noch offen ist. Fuer Switche liegt dieselbe
|
||||
Schlüssel akzeptieren. Die Freigabe erfolgt von Hand, in zwei Schritten, auf
|
||||
der Detailseite des Geräts (*Clients -> Gerät -> SSH-Host-Schlüssel*):
|
||||
erst **Auslesen**, dann den Fingerprint mit einer unabhängigen Quelle
|
||||
vergleichen, dann **Freigeben**. Die Wartungsübersicht zeigt in einer eigenen
|
||||
Spalte, für welche Geräte das noch offen ist. Für Switche liegt dieselbe
|
||||
Freigabe auf der Switch-Detailseite.
|
||||
|
||||
Der Schluessel wird nach **IP und Port** gespeichert, nicht nach Name -- TESM
|
||||
verbindet sich immer ueber die hinterlegte IP-Adresse. Aendert sich die IP
|
||||
eines Geraets, ist der Schluessel erneut freizugeben.
|
||||
Der Schlüssel wird nach **IP und Port** gespeichert, nicht nach Name -- TESM
|
||||
verbindet sich immer über die hinterlegte IP-Adresse. Ändert sich die IP
|
||||
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,
|
||||
dass jemand direkt in die Datenbank geschrieben hat. Der Eintrag, ab dem es
|
||||
nicht mehr stimmt, wird genannt.
|
||||
|
||||
---
|
||||
|
||||
## 9. Zurueck auf eine aeltere Version
|
||||
## 9. Zurück auf eine ältere Version
|
||||
|
||||
```bash
|
||||
sudo systemctl stop tesm
|
||||
@@ -290,6 +463,6 @@ sudo cp -a /srv/tesm-backup-<zeitstempel> /srv/tesm
|
||||
sudo systemctl start tesm
|
||||
```
|
||||
|
||||
Schema-Migrationen laufen nur vorwaerts. Ein Ruecksprung ueber eine
|
||||
Schema-Aenderung hinweg braucht deshalb auch die Datenbank aus der Sicherung --
|
||||
Schema-Migrationen laufen nur vorwärts. Ein Rücksprung über eine
|
||||
Schema-Änderung hinweg braucht deshalb auch die Datenbank aus der Sicherung --
|
||||
genau das liegt im Backup-Verzeichnis.
|
||||
|
||||
+56
-56
@@ -1,6 +1,6 @@
|
||||
# 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) |
|
||||
| Altbestand | bcrypt wird noch geprueft und bei der naechsten erfolgreichen Anmeldung stillschweigend auf Argon2id umgestellt |
|
||||
| Zweiter Faktor | TOTP, optional erzwingbar fuer Administratoren |
|
||||
| Altbestand | bcrypt wird noch geprüft und bei der nächsten erfolgreichen Anmeldung stillschweigend auf Argon2id umgestellt |
|
||||
| Zweiter Faktor | TOTP, optional erzwingbar für Administratoren |
|
||||
| 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 |
|
||||
| Verzeichnisdienst | LDAP nur ueber TLS, Zertifikat wird geprueft. Ohne gueltiges Zertifikat keine Anmeldung -- kein Schalter, der das abschaltet |
|
||||
| Sperre je Adresse | zusätzlich `LOGIN_MAX_ATTEMPTS_PER_IP` -- sonst lässt sich die Kontosperre umgehen, indem man viele Konten je einmal probiert |
|
||||
| Verzeichnisdienst | LDAP nur über TLS, Zertifikat wird geprüft. Ohne gültiges Zertifikat keine Anmeldung -- kein Schalter, der das abschaltet |
|
||||
|
||||
Nach einer Anmeldung wird die Sitzungskennung neu vergeben (gegen Session
|
||||
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,
|
||||
Ausfuhr, Herunterladen eines Lizenzbundles, Schluesselrotation,
|
||||
2FA-Aenderungen, Deaktivierung einer Lizenz.
|
||||
Ausfuhr, Herunterladen eines Lizenzbundles, Schlüsselrotation,
|
||||
2FA-Änderungen, Deaktivierung einer Lizenz.
|
||||
|
||||
Ein uebernommenes Sitzungscookie allein reicht dafuer nicht.
|
||||
Ein übernommenes Sitzungscookie allein reicht dafür nicht.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
* `HttpOnly`, `SameSite=Lax`, `Secure` sobald HTTPS eingerichtet ist
|
||||
* Leerlaufzeit **und** absolute Hoechstdauer
|
||||
* Der Cookiename enthaelt den `SITE_KEY` -- zwei Installationen auf einem Host
|
||||
koennen sich die Sitzung nicht gegenseitig ueberschreiben (Cookies
|
||||
* Leerlaufzeit **und** absolute Höchstdauer
|
||||
* Der Cookiename enthält den `SITE_KEY` -- zwei Installationen auf einem Host
|
||||
können sich die Sitzung nicht gegenseitig überschreiben (Cookies
|
||||
unterscheiden keine Ports)
|
||||
* Aktive Sitzungen sind einsehbar und einzeln beendbar
|
||||
|
||||
@@ -45,22 +45,22 @@ Zufallskennung.
|
||||
|
||||
## 3. CSRF
|
||||
|
||||
Drei voneinander unabhaengige Schichten:
|
||||
Drei voneinander unabhängige Schichten:
|
||||
|
||||
1. `SameSite=Lax` am Sitzungscookie.
|
||||
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
|
||||
Standardports -- **nicht** aber beliebige Ports: ein anderer Dienst auf
|
||||
demselben Rechner ist ein anderer Ursprung.
|
||||
|
||||
Der Vorgaenger hatte gar keinen CSRF-Schutz. Eine praeparierte Seite konnte im
|
||||
Namen eines angemeldeten Administrators Geraete loeschen oder Benutzer anlegen.
|
||||
Der Vorgänger hatte gar keinen CSRF-Schutz. Eine präparierte Seite konnte im
|
||||
Namen eines angemeldeten Administrators Geräte löschen oder Benutzer anlegen.
|
||||
|
||||
Ausnahmen (`@csrf.exempt`) gibt es nur fuer Maschinenschnittstellen, die sich
|
||||
**nicht** ueber Cookies autorisieren. Eine cookie-autorisierte Route ohne
|
||||
CSRF-Schutz waere eine Luecke.
|
||||
Ausnahmen (`@csrf.exempt`) gibt es nur für Maschinenschnittstellen, die sich
|
||||
**nicht** über Cookies autorisieren. Eine cookie-autorisierte Route ohne
|
||||
CSRF-Schutz wäre eine Lücke.
|
||||
|
||||
---
|
||||
|
||||
@@ -69,75 +69,75 @@ CSRF-Schutz waere eine Luecke.
|
||||
Bei jeder Antwort:
|
||||
|
||||
* `Content-Security-Policy` mit einem Nonce je Anfrage. **Kein**
|
||||
`unsafe-inline` fuer Skripte -- deshalb gibt es im ganzen Projekt kein
|
||||
Inline-Script ohne Nonce, und JavaScript haengt ueber
|
||||
`unsafe-inline` für Skripte -- deshalb gibt es im ganzen Projekt kein
|
||||
Inline-Script ohne Nonce, und JavaScript hängt über
|
||||
`data-behavior`-Attribute am DOM. `style-src` erlaubt `unsafe-inline`, weil
|
||||
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-Frame-Options: DENY`, `frame-ancestors 'none'`
|
||||
* `Referrer-Policy: same-origin`
|
||||
* `Permissions-Policy` -- Kamera, Mikrofon, Ort, Zahlung, USB abgeschaltet
|
||||
* `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
|
||||
> merkt sich die Vorgabe monatelang; die Warnung laesst sich danach nicht mehr
|
||||
> HSTS gehört **nicht** zu einem selbstsignierten Zertifikat. Der Browser
|
||||
> 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.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
* Der Verwendungszweck geht als AAD in die Verschluesselung ein. Ein Chiffrat
|
||||
aus einem Kontext laesst sich in einem anderen nicht entschluesseln.
|
||||
* Beim Start wird geprueft, ob die Rechte auf Schluessel- und Lizenzdateien
|
||||
* Der Verwendungszweck geht als AAD in die Verschlüsselung ein. Ein Chiffrat
|
||||
aus einem Kontext lässt sich in einem anderen nicht entschlüsseln.
|
||||
* Beim Start wird geprüft, ob die Rechte auf Schlüssel- und Lizenzdateien
|
||||
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)
|
||||
und nie ueber die Umgebung (`/proc/<pid>/environ`).
|
||||
und nie über die Umgebung (`/proc/<pid>/environ`).
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
sudoers ausschliesslich fuer diesen Pfad freigegeben ist.
|
||||
sudoers ausschliesslich für diesen Pfad freigegeben ist.
|
||||
|
||||
Regeln, die im Repository durch Tests erzwungen werden:
|
||||
|
||||
* Jedes Verb, das die Anwendung kennt, existiert im Helfer.
|
||||
* 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 zulaessigen Basisnamen `x`).
|
||||
den zulässigen Basisnamen `x`).
|
||||
* Der Instanzname ist auf `tesm[-<instanz>]` begrenzt; er landet in Pfaden.
|
||||
* Keine sudoers-Regel ausser der einen. `NOPASSWD: ALL` gibt es nicht.
|
||||
|
||||
Die systemd-Units haben `NoNewPrivileges=yes`, `ProtectSystem=strict`,
|
||||
`PrivateTmp=yes` und eine enge `RestrictAddressFamilies`-Liste.
|
||||
|
||||
Der Vorgaenger liess die komplette Flask-Anwendung als root laufen. Eine
|
||||
einzige Luecke in irgendeinem Pfad haette den Host bedeutet.
|
||||
Der Vorgänger liess die komplette Flask-Anwendung als root laufen. Eine
|
||||
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
|
||||
nachtraegliches Aendern oder Loeschen faellt auf und wird unter *Diagnose*
|
||||
Verkettet gehasht: jeder Eintrag enthält den Hash seines Vorgängers. Ein
|
||||
nachträgliches Ändern oder Löschen fällt auf und wird unter *Diagnose*
|
||||
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`).
|
||||
|
||||
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:
|
||||
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.
|
||||
|
||||
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
|
||||
Passwortbestaetigung, `Cache-Control: no-store`, Eintrag im Zustell- und im
|
||||
Aenderungsprotokoll.
|
||||
Passwortbestätigung, `Cache-Control: no-store`, Eintrag im Zustell- und im
|
||||
Änderungsprotokoll.
|
||||
|
||||
---
|
||||
|
||||
## 9. Ein- und Ausfuhr
|
||||
|
||||
Verschluesselte Umschlaege (Argon2id-Ableitung + AES-256-GCM) mit einem selbst
|
||||
gewaehlten Kennwort. Der Import zeigt eine Vorschau, bevor etwas geschrieben
|
||||
Verschlüsselte Umschläge (Argon2id-Ableitung + AES-256-GCM) mit einem selbst
|
||||
gewählten Kennwort. Der Import zeigt eine Vorschau, bevor etwas geschrieben
|
||||
wird; die Vorschau liegt in der Datenbank, nicht im Prozessspeicher -- deshalb
|
||||
braucht der Dienst kein `--workers 1` mehr.
|
||||
|
||||
@@ -166,11 +166,11 @@ braucht der Dienst kein `--workers 1` mehr.
|
||||
|
||||
## 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.
|
||||
* **Kein CSRF-Freibrief fuer cookie-autorisierte Routen.**
|
||||
* **Kein `unsafe-inline` fuer Skripte**, auch nicht "voruebergehend".
|
||||
* **Keine Rechteausweitung**, auch nicht fuer Administratoren untereinander.
|
||||
* **Kein CSRF-Freibrief für cookie-autorisierte Routen.**
|
||||
* **Kein `unsafe-inline` für Skripte**, auch nicht "vorübergehend".
|
||||
* **Keine Rechteausweitung**, auch nicht für Administratoren untereinander.
|
||||
* **Kein Hostname im Lizenz-Fingerabdruck** -- Umbenennen darf die Bindung
|
||||
nicht brechen.
|
||||
* **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
|
||||
|
||||
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.
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "tesm-core"
|
||||
version = "2.0.0"
|
||||
version = "2.0.7"
|
||||
description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -8,7 +8,20 @@ Design-System.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
__version__ = "2.0.0"
|
||||
#: Version aus den Metadaten des installierten Pakets -- die einzige Quelle
|
||||
#: ist ``pyproject.toml``. Vorher stand hier eine feste Zahl, und die
|
||||
#: Fusszeile zeigte vier Releases lang 2.0.2.
|
||||
#:
|
||||
#: Im Quellbaum ohne ``pip install`` gibt es keine Metadaten; dann steht dort
|
||||
#: "unbekannt" statt einer erfundenen Nummer. In einem editierbaren Install
|
||||
#: zeigt sie den Stand des letzten ``pip install -e`` -- also die Version, die
|
||||
#: tatsaechlich installiert ist.
|
||||
try: # pragma: no cover - haengt an der Installationsart
|
||||
from importlib.metadata import PackageNotFoundError, version as _paketversion
|
||||
|
||||
__version__ = _paketversion("tesm-core")
|
||||
except (ImportError, PackageNotFoundError): # pragma: no cover
|
||||
__version__ = "unbekannt"
|
||||
|
||||
from .app_factory import create_base_app
|
||||
from .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 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, ...] = (
|
||||
SettingSpec("nav_order", [], "json", "Reihenfolge der Navigation", section="intern"),
|
||||
SettingSpec("nav_child_order", {}, "json", "Reihenfolge der Unterpunkte", section="intern"),
|
||||
@@ -56,6 +61,18 @@ CORE_SETTINGS: tuple[SettingSpec, ...] = (
|
||||
minimum=1,
|
||||
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("hostname", "", "str", "Hostname", section="system"),
|
||||
SettingSpec(
|
||||
@@ -113,11 +130,11 @@ def create_base_app(
|
||||
import_name: str = "tesm_core",
|
||||
app_short: str = "TESM",
|
||||
app_edition: str = "",
|
||||
#: Zeile unter der Wortmarke auf den Anmeldeseiten. Leer = kein
|
||||
#: Seitenhintergrund. Der Lizenzserver setzt sie bewusst nicht: die
|
||||
#: Funktionsliste von TESM waere dort schlicht falsch.
|
||||
#: Zeile unter der Wortmarke auf den Anmeldeseiten. Leer = nur die
|
||||
#: Wortmarke, ohne Zeile darunter; die Marke selbst zeigen beide
|
||||
#: Anwendungen.
|
||||
auth_tagline: str = "",
|
||||
app_version: str = "2.0.0",
|
||||
app_version: str = "2.0.7",
|
||||
built_in_trusted_keys: tuple[str, ...] = (),
|
||||
enable_license: bool = True,
|
||||
template_folder: str | None = None,
|
||||
@@ -192,11 +209,25 @@ def create_base_app(
|
||||
)
|
||||
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(
|
||||
database,
|
||||
cookie_name=app.config["SESSION_COOKIE_NAME"],
|
||||
idle_minutes=config.security.session_idle_minutes,
|
||||
absolute_hours=config.security.session_absolute_hours,
|
||||
idle_lookup=_leerlauf,
|
||||
)
|
||||
|
||||
# -- Schema und Startzustand ------------------------------------------ #
|
||||
@@ -228,6 +259,17 @@ def create_base_app(
|
||||
|
||||
@app.after_request
|
||||
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 = ""
|
||||
if config.security.hsts_enabled:
|
||||
hsts = f"max-age={config.security.hsts_max_age}; includeSubDomains"
|
||||
@@ -264,6 +306,7 @@ def create_base_app(
|
||||
"auth_tagline": auth_tagline,
|
||||
"app_version": app_version,
|
||||
"current_year": datetime.now(timezone.utc).year,
|
||||
"copyright_notice": COPYRIGHT_NOTICE,
|
||||
"current_user": current_user,
|
||||
"nav_items": nav_items,
|
||||
"palette_entries": palette,
|
||||
|
||||
@@ -42,7 +42,7 @@ AAD_LDAP_BIND = "service_accounts.ldap_bind"
|
||||
|
||||
#: userAccountControl-Bit fuer "Konto deaktiviert".
|
||||
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"
|
||||
|
||||
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, ...]:
|
||||
"""Rekursive Gruppenauflösung ueber LDAP_MATCHING_RULE_IN_CHAIN."""
|
||||
"""Rekursive Gruppenaufloesung ueber LDAP_MATCHING_RULE_IN_CHAIN."""
|
||||
try:
|
||||
connection.search(
|
||||
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)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("Gruppenauflösung fehlgeschlagen: %s", exc)
|
||||
logger.warning("Gruppenaufloesung fehlgeschlagen: %s", exc)
|
||||
return ()
|
||||
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ from flask import abort, flash, g, redirect, request, session, url_for
|
||||
from werkzeug.local import LocalProxy
|
||||
|
||||
from ..rbac.model import Action, PermissionTree
|
||||
from ..security.sessions import is_automatic
|
||||
|
||||
AUTH_SOURCES = ("local", "ldap")
|
||||
|
||||
@@ -189,9 +190,22 @@ def _deny(message: str) -> Any:
|
||||
if not current_user.is_authenticated:
|
||||
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
|
||||
# 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:
|
||||
abort(401, description="Anmeldung erforderlich.")
|
||||
flash("Bitte melden Sie sich an.", "info")
|
||||
# "Bitte melden Sie sich an." stand auf einer Seite, die aus einem
|
||||
# Anmeldefeld, einem Passwortfeld und einem Knopf "Anmelden" besteht --
|
||||
# eine Meldung, die nichts hinzufuegt. Wer dagegen gerade noch
|
||||
# angemeldet war, soll erfahren, warum er es nicht mehr ist.
|
||||
if getattr(g, "session_expired", False):
|
||||
flash("Ihre Sitzung ist abgelaufen. Bitte melden Sie sich erneut an.", "info")
|
||||
return redirect(url_for("auth.login"))
|
||||
abort(403, description=message)
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ class SecurityConfig:
|
||||
cookie_secure: bool = True
|
||||
cookie_samesite: str = "Lax"
|
||||
#: Leerlauf-Zeitfenster einer Sitzung.
|
||||
session_idle_minutes: int = 60
|
||||
session_idle_minutes: int = 30
|
||||
#: Harte Obergrenze unabhaengig von Aktivitaet.
|
||||
session_absolute_hours: int = 12
|
||||
#: Wie lange eine erneute Passwortabfrage ("sudo-Modus") gilt.
|
||||
@@ -147,7 +147,7 @@ def load_core_config(
|
||||
security = SecurityConfig(
|
||||
cookie_secure=_env_bool(env_prefix, "COOKIE_SECURE", True),
|
||||
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),
|
||||
reauth_valid_minutes=_env_int(env_prefix, "REAUTH_VALID_MINUTES", 15),
|
||||
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_LAST_HEARTBEAT = "license_last_heartbeat_at"
|
||||
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"
|
||||
|
||||
STATUS_INACTIVE = "inactive"
|
||||
@@ -596,6 +600,14 @@ class LicenseManager:
|
||||
message = "Verbindung zum Lizenzserver bestaetigt."
|
||||
|
||||
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(
|
||||
conn,
|
||||
actor=actor,
|
||||
@@ -704,6 +716,19 @@ class LicenseManager:
|
||||
except LicenseError as exc:
|
||||
with self.database.transaction(conn):
|
||||
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)}
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
@@ -761,6 +786,17 @@ class LicenseManager:
|
||||
"dot_class": "status-pill__dot--pulse",
|
||||
"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:
|
||||
try:
|
||||
last = lic.parse_iso(state.last_heartbeat_at)
|
||||
|
||||
@@ -26,7 +26,8 @@ import sqlite3
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
from flask import Flask, Request, Response
|
||||
from flask import Flask, Request, Response, g
|
||||
from flask import request as aktuelle_anfrage
|
||||
from flask.sessions import SessionInterface, SessionMixin
|
||||
from werkzeug.datastructures import CallbackDict
|
||||
|
||||
@@ -34,6 +35,79 @@ from ..db import Database
|
||||
|
||||
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:
|
||||
return datetime.now(timezone.utc)
|
||||
@@ -88,11 +162,24 @@ class SqliteSessionInterface(SessionInterface):
|
||||
cookie_name: str,
|
||||
idle_minutes: int,
|
||||
absolute_hours: int,
|
||||
idle_lookup: Any = None,
|
||||
) -> None:
|
||||
self.database = database
|
||||
self.cookie_name = cookie_name
|
||||
self.idle_minutes = idle_minutes
|
||||
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 ------------------------------------------- #
|
||||
|
||||
@@ -122,6 +209,11 @@ class SqliteSessionInterface(SessionInterface):
|
||||
"WHERE id=?",
|
||||
(_iso(now), sid),
|
||||
)
|
||||
# Fuer die Anmeldeseite vermerken, **warum** sie erscheint. Ohne
|
||||
# das steht dort entweder nichts -- dann wundert sich, wer
|
||||
# gerade noch angemeldet war -- oder eine Meldung, die jeder
|
||||
# Besucher zu sehen bekommt und die deshalb nichts sagt.
|
||||
g.session_expired = True
|
||||
return self.session_class(new=True)
|
||||
|
||||
try:
|
||||
@@ -141,12 +233,21 @@ class SqliteSessionInterface(SessionInterface):
|
||||
}
|
||||
# Leerlauffenster verlaengern, aber hoechstens einmal pro Minute
|
||||
# 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"))
|
||||
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):
|
||||
conn.execute(
|
||||
"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
|
||||
|
||||
@@ -190,7 +291,7 @@ class SqliteSessionInterface(SessionInterface):
|
||||
session.get("user_id"),
|
||||
_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)),
|
||||
session.get("_ip", ""),
|
||||
session.get("_ua", ""),
|
||||
@@ -198,6 +299,31 @@ class SqliteSessionInterface(SessionInterface):
|
||||
),
|
||||
)
|
||||
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:
|
||||
conn.execute(
|
||||
"UPDATE user_sessions SET user_id=?, data=?, last_seen_at=?, "
|
||||
@@ -206,7 +332,11 @@ class SqliteSessionInterface(SessionInterface):
|
||||
session.get("user_id"),
|
||||
payload,
|
||||
_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.sid,
|
||||
),
|
||||
|
||||
@@ -161,6 +161,40 @@
|
||||
border-color: var(--accent);
|
||||
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] {
|
||||
background: var(--surface-inset);
|
||||
color: var(--text-muted);
|
||||
@@ -374,6 +408,18 @@
|
||||
background: var(--danger-soft);
|
||||
color: var(--danger);
|
||||
}
|
||||
/* Gefuellt statt getoent: "kritisch" muss sich von "Warnung" unterscheiden,
|
||||
und beide sind rot. */
|
||||
.pill--critical {
|
||||
background: var(--danger);
|
||||
/* Weiss wie bei .btn--danger. Vorher stand hier ein Token, das es nicht
|
||||
gibt -- die Deklaration war damit unwirksam, und die Schrift blieb in
|
||||
der Textfarbe: dunkelgrau auf Rot. */
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
}
|
||||
.pill--critical::before { background: currentColor; }
|
||||
|
||||
.pill--warning, .pill--unknown, .pill--orange {
|
||||
background: var(--warning-soft);
|
||||
color: var(--warning);
|
||||
@@ -402,6 +448,22 @@
|
||||
background: var(--success);
|
||||
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--danger { border-color: color-mix(in srgb, var(--danger) 45%, var(--border)); color: var(--danger); }
|
||||
.status-pill--danger .status-pill__dot { background: var(--danger); }
|
||||
@@ -621,6 +683,39 @@
|
||||
}
|
||||
.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; }
|
||||
/* 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;
|
||||
/* Die Vorschau zeigt fremde Dokumente; ein neutraler Grund gehoert
|
||||
darunter. --surface-0 gibt es nicht, der Rahmen blieb durchsichtig. */
|
||||
background: var(--surface-1);
|
||||
}
|
||||
/* Der Dateiname ist ein Knopf (er oeffnet die Vorschau), soll aber wie ein
|
||||
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 {
|
||||
padding: var(--sp-5);
|
||||
overflow-y: auto;
|
||||
@@ -759,7 +854,10 @@
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
border-radius: 5px;
|
||||
background: var(--surface-0);
|
||||
/* Dunkler als die Karte -- genau das war die Absicht: "dunkler heisst,
|
||||
es gibt das Recht nicht". Mit dem nicht existierenden --surface-0 war
|
||||
das Feld durchsichtig und damit so hell wie die Karte. */
|
||||
background: var(--canvas);
|
||||
border: 1px solid var(--border-subtle);
|
||||
box-shadow: inset 0 1px 2px rgb(0 0 0 / 28%);
|
||||
vertical-align: -3px;
|
||||
@@ -853,9 +951,15 @@
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: var(--sp-6);
|
||||
/* Zwei warme Kreise, Geometrie und Staerke aus der Fassung 1.x
|
||||
uebernommen: oben links deutlich, unten rechts angedeutet. Die vorige
|
||||
Fassung setzte den zweiten Kreis auf --info und 10 % -- ein kaltes
|
||||
Blau, das man auf dunklem Grund nicht sah und das nicht zur Marke
|
||||
gehoert. Ueber color-mix statt festem rgba, damit es im hellen Thema
|
||||
nicht schmutzig wird. */
|
||||
background:
|
||||
radial-gradient(1100px 520px at 12% -10%, color-mix(in srgb, var(--accent) 12%, transparent), transparent 70%),
|
||||
radial-gradient(900px 480px at 100% 110%, color-mix(in srgb, var(--info) 10%, transparent), transparent 70%),
|
||||
radial-gradient(circle at 15% 15%, color-mix(in srgb, var(--accent) 14%, transparent), transparent 45%),
|
||||
radial-gradient(circle at 85% 85%, color-mix(in srgb, var(--accent) 8%, transparent), transparent 40%),
|
||||
var(--canvas);
|
||||
}
|
||||
/* Die Wortmarke liegt hinter allem (z-index 0), die Anmeldebox davor.
|
||||
@@ -936,24 +1040,35 @@
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
.auth-page { position: relative; overflow: hidden; align-content: center; }
|
||||
/* Marke und Karte stehen uebereinander im normalen Fluss. Vorher lag die
|
||||
Marke absolut positioniert hinter der Karte -- auf einem flachen Fenster
|
||||
schnitt die Karte mitten durch den Schriftzug und die Funktionszeile. Ein
|
||||
Stapel kann nicht kollidieren, und die Seite darf notfalls scrollen. */
|
||||
.auth-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--sp-8);
|
||||
width: 100%;
|
||||
}
|
||||
.auth-backdrop {
|
||||
position: absolute;
|
||||
top: 4vh;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: min(72vw, 880px);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
opacity: 0.5;
|
||||
/* Volle Deckung: solange die Marke hinter der Karte lag, war sie ein
|
||||
Wasserzeichen und musste zurueckhaltend sein. Jetzt steht sie ueber der
|
||||
Karte und ist das Erste, was man sieht -- halbtransparent sah sie dort
|
||||
nur blass aus. Die Farben kommen ohnehin aus zurueckhaltenden Token. */
|
||||
opacity: 1;
|
||||
}
|
||||
.auth-backdrop svg { width: 100%; height: auto; display: block; }
|
||||
@media (max-width: 640px) {
|
||||
.auth-backdrop { width: min(90vw, 460px); opacity: 0.32; }
|
||||
.auth-backdrop { width: min(90vw, 460px); }
|
||||
}
|
||||
/* Wer wenig Bewegung/Reize moechte, bekommt das Motiv dezenter. */
|
||||
/* Wer weniger Reize moechte, bekommt die Marke ruhiger -- aber nicht
|
||||
unsichtbar: sie ist die Ueberschrift der Seite. */
|
||||
@media (prefers-contrast: more) {
|
||||
.auth-backdrop { opacity: 0.18; }
|
||||
.auth-backdrop { opacity: 0.75; }
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
@@ -1001,6 +1116,16 @@
|
||||
.warning-text { color: var(--warning); }
|
||||
.accent-text { color: var(--accent); }
|
||||
.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; }
|
||||
.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
|
||||
.break-all { overflow-wrap: anywhere; }
|
||||
|
||||
@@ -28,7 +28,7 @@ function store(key, value) {
|
||||
if (value === null) localStorage.removeItem(key);
|
||||
else localStorage.setItem(key, value);
|
||||
} 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 || "";
|
||||
}
|
||||
|
||||
/** 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 = {}) {
|
||||
const opts = { credentials: "same-origin", ...options };
|
||||
const { auto = false, ...rest } = options;
|
||||
const opts = { credentials: "same-origin", ...rest };
|
||||
opts.headers = {
|
||||
"X-Requested-With": "fetch",
|
||||
"X-CSRF-Token": csrfToken(),
|
||||
...(auto ? { "X-Auto-Refresh": "1" } : {}),
|
||||
...(options.headers || {}),
|
||||
};
|
||||
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 payload = type.includes("application/json") ? await response.json() : await response.text();
|
||||
if (!response.ok) {
|
||||
const message =
|
||||
(payload && typeof payload === "object" && (payload.error || payload.message)) ||
|
||||
`Anfrage fehlgeschlagen (HTTP ${response.status})`;
|
||||
throw new Error(message);
|
||||
const error = new Error(message);
|
||||
error.status = response.status;
|
||||
throw error;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
@@ -212,13 +235,21 @@ function initDialogs() {
|
||||
event.preventDefault();
|
||||
const dialog = document.getElementById(opener.dataset.dialogOpen);
|
||||
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]) => {
|
||||
if (!key.startsWith("fill")) return;
|
||||
const name = key.slice(4);
|
||||
const target = dialog.querySelector(`[data-fill-target="${name}"]`);
|
||||
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;
|
||||
});
|
||||
openDialog(dialog);
|
||||
@@ -548,7 +579,10 @@ function initAutoRefresh() {
|
||||
const tick = async () => {
|
||||
if (stopped || document.hidden) return schedule();
|
||||
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
|
||||
// URL versehentlich auf eine ganze Seite, waere das Ergebnis eine
|
||||
// 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
|
||||
// deshalb sprang ein laufendes Auftragsprotokoll bei jeder
|
||||
// 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 position = before.scrollTop;
|
||||
// "Am Ende" grosszuegig auslegen: wer mitliest, soll mitgezogen
|
||||
@@ -579,7 +614,16 @@ function initAutoRefresh() {
|
||||
initRelativeTimes(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. */
|
||||
}
|
||||
schedule();
|
||||
@@ -814,6 +858,25 @@ function initTileFilters() {
|
||||
|
||||
/* -- 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() {
|
||||
initTheme();
|
||||
initSidebar();
|
||||
@@ -830,6 +893,7 @@ function boot() {
|
||||
initPalette();
|
||||
initSubmitGuards();
|
||||
initTileFilters();
|
||||
initLogTails();
|
||||
unsavedGuard.init();
|
||||
}
|
||||
|
||||
|
||||
@@ -166,6 +166,13 @@ class SysOps:
|
||||
input=stdin,
|
||||
capture_output=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,
|
||||
check=False,
|
||||
)
|
||||
@@ -197,6 +204,8 @@ class SysOps:
|
||||
[binary, *command[1:]],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=timeout,
|
||||
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
|
||||
Kommas und Leerzeichen und ein Tippfehler faellt erst bei der naechsten
|
||||
Der DN einer AD-Gruppe ist nichts, was man abtippt: er ist lang, enthält
|
||||
Kommas und Leerzeichen und ein Tippfehler fällt erst bei der nächsten
|
||||
Anmeldung auf -- als stille Nicht-Zuordnung. Deshalb gesucht statt getippt.
|
||||
|
||||
Das Suchergebnis kommt aus dem Verzeichnis selbst; die Felder werden dabei
|
||||
beide gefuellt (Anzeigename und DN). Getippt werden darf trotzdem: bei einer
|
||||
Umgebung ohne Dienstkonto ist die Suche nicht moeglich, und dann ist ein
|
||||
eingefuegter DN besser als gar keine Zuordnung.
|
||||
beide gefüllt (Anzeigename und DN). Getippt werden darf trotzdem: bei einer
|
||||
Umgebung ohne Dienstkonto ist die Suche nicht möglich, und dann ist ein
|
||||
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.
|
||||
#}
|
||||
{% macro group_picker(name_field, dn_field, scope, name_value='', dn_value='',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{% if lines %}
|
||||
<pre class="log-view">{% for line in lines %}{{ line }}
|
||||
<pre class="log-view" data-tail>{% for line in lines %}{{ line }}
|
||||
{% endfor %}</pre>
|
||||
{% else %}
|
||||
<div class="empty"><div class="empty__title">Keine Zeilen</div>
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
<div class="card__body">
|
||||
{% if current_user.is_directory_account %}
|
||||
{{ alert('Ihre Stammdaten kommen aus dem Verzeichnisdienst und werden bei jeder
|
||||
Anmeldung uebernommen.', 'info') }}
|
||||
Anmeldung übernommen.', 'info') }}
|
||||
{% endif %}
|
||||
<div class="form-grid">
|
||||
{{ field('first_name', 'Vorname', value=profile.first_name,
|
||||
@@ -48,7 +48,7 @@
|
||||
</div>
|
||||
<div class="card__body">
|
||||
{% 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 %}
|
||||
<div class="row row--tight">
|
||||
{% for permission in permissions %}
|
||||
|
||||
@@ -35,26 +35,26 @@
|
||||
<div class="stack">
|
||||
<section class="card">
|
||||
<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>
|
||||
{% if current_user.is_directory_account %}
|
||||
<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>
|
||||
{% else %}
|
||||
<form method="post" action="{{ url_for('account.change_password') }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<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') }}
|
||||
{{ field('new_password', 'Neues Passwort', type='password', required=True,
|
||||
autocomplete='new-password',
|
||||
hint_text='Mindestens ' ~ policy.min_length ~ ' Zeichen und zwei verschiedene Zeichenarten.') }}
|
||||
{{ field('repeat_password', 'Neues Passwort wiederholen', type='password', required=True,
|
||||
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 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>
|
||||
{% endif %}
|
||||
</section>
|
||||
@@ -70,7 +70,7 @@
|
||||
</div>
|
||||
<div class="card__body stack">
|
||||
{% 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">
|
||||
<dt>Geheimnis</dt>
|
||||
<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 class="input input--mono" type="text" name="code" required maxlength="6"
|
||||
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>
|
||||
{% elif totp_enabled %}
|
||||
<dl class="kv fs-sm">
|
||||
@@ -100,7 +100,7 @@
|
||||
<form method="post" action="{{ url_for('account.recovery_codes') }}" data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<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">
|
||||
Codes erneuern</button>
|
||||
</form>
|
||||
@@ -114,10 +114,10 @@
|
||||
</div>
|
||||
{% else %}
|
||||
{% 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 %}
|
||||
<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>
|
||||
<form method="post" action="{{ url_for('account.totp_start') }}" data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{# ==========================================================================
|
||||
Grundgeruest aller angemeldeten Seiten.
|
||||
Bloecke: extra_head, page_title, page_sub, breadcrumb, topbar_extra,
|
||||
Grundgerüst aller angemeldeten Seiten.
|
||||
Blöcke: extra_head, page_title, page_sub, breadcrumb, topbar_extra,
|
||||
content, dialogs, scripts
|
||||
========================================================================== #}
|
||||
<!doctype html>
|
||||
@@ -23,9 +23,9 @@
|
||||
|
||||
<aside class="sidebar" aria-label="Hauptnavigation">
|
||||
<div class="sidebar__brand">
|
||||
{# Die Wortmarke traegt den Namen selbst -- ein zweiter Textblock daneben
|
||||
waere eine Doppelung. Eine einzige Zeichnung, deren Farben aus den
|
||||
Themen-Token kommen: zwei per Media Query versteckte SVG waeren bei
|
||||
{# Die Wortmarke trägt den Namen selbst -- ein zweiter Textblock daneben
|
||||
wäre eine Doppelung. Eine einzige Zeichnung, deren Farben aus den
|
||||
Themen-Token kommen: zwei per Media Query versteckte SVG wären bei
|
||||
"System" und beim Umschalten von Hand falsch. #}
|
||||
<span class="sidebar__logo" role="img" aria-label="{{ app_name }}">
|
||||
<svg viewBox="0 0 312 112" xmlns="http://www.w3.org/2000/svg">
|
||||
@@ -126,9 +126,25 @@
|
||||
<div class="topbar__right">
|
||||
{% block topbar_extra %}{% endblock %}
|
||||
{% 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 %}>
|
||||
{% if pill.dot %}<span class="status-pill__dot {{ pill.dot_class }}"></span>{% endif %}
|
||||
{{ 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>
|
||||
{% endfor %}
|
||||
<button class="btn btn--icon btn--ghost hide-sm" type="button" data-behavior="open-palette"
|
||||
@@ -141,7 +157,7 @@
|
||||
</main>
|
||||
|
||||
<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">
|
||||
{% if migration_warning %}
|
||||
<a class="danger-text" href="{{ url_for('diagnostics.overview') }}">Schemawarnung</a>
|
||||
@@ -163,17 +179,17 @@
|
||||
aria-label="Suchbegriff" autocomplete="off">
|
||||
<div class="palette__list"></div>
|
||||
<div class="palette__hint">
|
||||
<span><kbd>↑</kbd><kbd>↓</kbd> waehlen</span>
|
||||
<span><kbd>Enter</kbd> oeffnen</span>
|
||||
<span><kbd>↑</kbd><kbd>↓</kbd> wählen</span>
|
||||
<span><kbd>Enter</kbd> öffnen</span>
|
||||
<span><kbd>Esc</kbd> schliessen</span>
|
||||
</div>
|
||||
</dialog>
|
||||
<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">
|
||||
<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 class="modal__body"><p data-confirm-text></p></div>
|
||||
<div class="modal__footer">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
{% from "tesm_core/macros.html" import alert, pill, stat %}
|
||||
|
||||
{% 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 %}
|
||||
<div class="grid grid--4">
|
||||
@@ -17,17 +17,17 @@
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<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 %}
|
||||
</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 class="card__body">
|
||||
{% if chain.ok %}
|
||||
<p class="muted fs-sm">
|
||||
Jede Zeile bindet den Hash ihrer Vorgaengerin ein. Eine nachtraeglich entfernte oder
|
||||
geaenderte Zeile wuerde hier auffallen.
|
||||
Jede Zeile bindet den Hash ihrer Vorgängerin ein. Eine nachträglich entfernte oder
|
||||
geänderte Zeile würde hier auffallen.
|
||||
</p>
|
||||
{% else %}
|
||||
{{ alert(chain.reason ~ ' Erster Bruch bei Eintrag ' ~ chain.broken_at ~ '.', 'danger',
|
||||
@@ -40,17 +40,17 @@
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<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 %}
|
||||
</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>
|
||||
{% if current_user.is_admin %}
|
||||
<form method="post" action="{{ url_for('diagnostics.rotate_keys') }}" data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<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-title="Schluessel rotieren" data-confirm-ok="Rotieren" data-confirm-style="primary">
|
||||
data-confirm="Neuen Datenschlüssel anlegen und gespeicherte Geheimnisse neu verschlüsseln? Der alte Schlüssel bleibt zum Lesen erhalten."
|
||||
data-confirm-title="Schlüssel rotieren" data-confirm-ok="Rotieren" data-confirm-style="primary">
|
||||
{{ icon('refresh', size=14) }} Rotieren
|
||||
</button>
|
||||
</form>
|
||||
@@ -63,8 +63,8 @@
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="muted fs-sm">
|
||||
Alle Schluesseldateien liegen mit Modus 0600. Feldverschluesselung ist AES-256-GCM mit
|
||||
rotierbarem Schluesselbund; jedes Chiffrat ist an seinen Verwendungszweck gebunden.
|
||||
Alle Schlüsseldateien liegen mit Modus 0600. Feldverschlüsselung ist AES-256-GCM mit
|
||||
rotierbarem Schlüsselbund; jedes Chiffrat ist an seinen Verwendungszweck gebunden.
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -87,7 +87,7 @@
|
||||
<th class="sortable">Komponente</th>
|
||||
<th class="sortable numeric">Version</th>
|
||||
<th class="sortable">Migration</th>
|
||||
<th>Pruefsumme</th>
|
||||
<th>Prüfsumme</th>
|
||||
<th class="sortable">Angewendet</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -112,7 +112,7 @@
|
||||
<div class="flex-1">
|
||||
<div class="card__title">
|
||||
{{ 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') }}
|
||||
{% else %}{{ pill('Helfer antwortet nicht', 'danger') }}{% endif %}
|
||||
</div>
|
||||
@@ -121,7 +121,7 @@
|
||||
</div>
|
||||
<div class="card__body">
|
||||
<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.
|
||||
</p>
|
||||
{% if not sysops_available %}
|
||||
@@ -129,10 +129,10 @@
|
||||
Alles andere funktioniert normal -- das ist der erwartete Zustand in einer
|
||||
Entwicklungsumgebung.', 'info') }}
|
||||
{% elif sysops_selftest and not sysops_selftest.ok %}
|
||||
{# Geprueft wird ein echter Aufruf, nicht die blosse Existenz der Datei.
|
||||
Genau daran lag es einmal: die Seite meldete "erreichbar", waehrend
|
||||
{# Geprüft wird ein echter Aufruf, nicht die blosse Existenz der Datei.
|
||||
Genau daran lag es einmal: die Seite meldete "erreichbar", während
|
||||
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') }}
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -144,7 +144,7 @@
|
||||
{% if current_user.is_admin %}
|
||||
<form method="post" action="{{ url_for('diagnostics.housekeeping') }}" data-no-guard>
|
||||
<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>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -167,14 +167,14 @@
|
||||
{{ icon('key', size=17) }} Lizenzzustand
|
||||
{% if license_state.valid and license_state.activated %}{{ pill('aktiv', 'online') }}
|
||||
{% elif license_state.valid %}{{ pill('nicht aktiviert', 'warning') }}
|
||||
{% else %}{{ pill('ungueltig', 'danger') }}{% endif %}
|
||||
{% else %}{{ pill('ungültig', 'danger') }}{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card__body">
|
||||
<dl class="kv fs-sm">
|
||||
<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>Rolle</dt><dd>{{ license_state.role }}</dd>
|
||||
<dt>Module</dt><dd>{{ license_state.modules | sort | join(', ') or '–' | safe }}</dd>
|
||||
|
||||
@@ -7,11 +7,11 @@
|
||||
<div class="alert__body">{{ description }}</div>
|
||||
</div>
|
||||
{% 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 %}
|
||||
<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 }}">
|
||||
{{ '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>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -20,8 +20,8 @@
|
||||
{{ icon('lock', size=18) }}
|
||||
<div class="alert__body">
|
||||
<div class="alert__title">Systemgruppe</div>
|
||||
Name und Rechte sind gesperrt. Anders als im Vorgaenger werden die Rechte
|
||||
<strong>nicht</strong> bei jedem Neustart auf die Vorlage zurueckgesetzt –
|
||||
Name und Rechte sind gesperrt. Anders als im Vorgänger werden die Rechte
|
||||
<strong>nicht</strong> bei jedem Neustart auf die Vorlage zurückgesetzt –
|
||||
Anpassungen bleiben erhalten.
|
||||
{% 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) }}">
|
||||
@@ -49,8 +49,8 @@
|
||||
{% if group.preset_key and current_user.is_admin %}
|
||||
<button class="btn btn--sm" type="submit"
|
||||
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-title="Vorlage anwenden" data-confirm-ok="Zuruecksetzen" data-confirm-style="primary">
|
||||
data-confirm="Rechte dieser Gruppe auf die Vorlage zurücksetzen? Bestehende Anpassungen gehen verloren."
|
||||
data-confirm-title="Vorlage anwenden" data-confirm-ok="Zurücksetzen" data-confirm-style="primary">
|
||||
{{ icon('refresh', size=14) }} Vorlage anwenden
|
||||
</button>
|
||||
{% endif %}
|
||||
@@ -58,7 +58,7 @@
|
||||
<div class="card__body">
|
||||
<div class="form-grid">
|
||||
{{ 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,
|
||||
extra_class='field--full') }}
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
{% 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_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 %}
|
||||
{% if current_user.has('groups.create') %}
|
||||
@@ -13,9 +13,9 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{{ alert('Wer selbst ein Recht nicht besitzt, kann es auch nicht vergeben. Diese Regel gilt fuer
|
||||
alle ausser echten Administratoren und verhindert, dass sich ein Gruppenverwalter ueber eine
|
||||
neue Gruppe selbst hoeherstuft.', 'info', 'Eskalationsschutz') }}
|
||||
{{ 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 über eine
|
||||
neue Gruppe selbst höherstuft.', 'info', 'Eskalationsschutz') }}
|
||||
|
||||
<div class="grid grid--2">
|
||||
{% 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>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<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-title="Gruppe loeschen" data-confirm-ok="Loeschen"
|
||||
aria-label="Loeschen">{{ icon('trash', size=15) }}</button>
|
||||
data-confirm="Gruppe {{ group.name }} löschen? Sie landet im Papierkorb; Mitglieder verlieren die zugehörigen Rechte."
|
||||
data-confirm-title="Gruppe löschen" data-confirm-ok="Löschen"
|
||||
aria-label="Löschen">{{ icon('trash', size=15) }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -67,7 +67,7 @@ neue Gruppe selbst hoeherstuft.', 'info', 'Eskalationsschutz') }}
|
||||
|
||||
{% block dialogs %}
|
||||
{% 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') }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<div class="modal__body">
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
{% extends "tesm_core/standalone.html" %}
|
||||
{# Die Ueberschrift bleibt fuer die Titelzeile des Browsers -- sichtbar ist sie
|
||||
hier nicht: der leere ``brand``-Block laesst Kachel, Ueberschrift und
|
||||
Unterzeile weg. Die Wortmarke darueber sagt schon, wo man ist. #}
|
||||
{% block heading %}Anmelden{% endblock %}
|
||||
{% block subheading %}{{ app_name }} – {{ app_edition }}{% endblock %}
|
||||
{% block brand %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<form method="post" class="stack" data-no-guard autocomplete="on">
|
||||
@@ -24,6 +27,6 @@
|
||||
</p>
|
||||
{% endif %}
|
||||
{% 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 %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{% extends "tesm_core/base.html" %}
|
||||
{% block page_title %}Archiv {{ day }}{% endblock %}
|
||||
{% block page_sub %}{{ rows | length }} archivierte Aenderungen{% endblock %}
|
||||
{% block page_sub %}{{ rows | length }} archivierte Änderungen{% endblock %}
|
||||
{% block breadcrumb %}
|
||||
<nav class="breadcrumb"><span><a href="{{ url_for('logs.history') }}">Verlauf</a></span><span>{{ day }}</span></nav>
|
||||
{% endblock %}
|
||||
@@ -19,7 +19,7 @@
|
||||
<td class="fs-sm">{{ row.actor }}</td>
|
||||
<td class="mono fs-xs">{{ row.action }}</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>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{% extends "tesm_core/base.html" %}
|
||||
{% from "tesm_core/macros.html" import pill, empty_state, alert %}
|
||||
|
||||
{% block page_title %}Aenderungsprotokoll{% endblock %}
|
||||
{% block page_sub %}{{ total }} Eintraege · Hash-Kette {{ 'unversehrt' if chain.ok else 'gebrochen' }}{% endblock %}
|
||||
{% block page_title %}Änderungsprotokoll{% endblock %}
|
||||
{% block page_sub %}{{ total }} Einträge · Hash-Kette {{ 'unversehrt' if chain.ok else 'gebrochen' }}{% endblock %}
|
||||
|
||||
{% block topbar_extra %}
|
||||
{% if current_user.has('logs_audit.export') %}
|
||||
@@ -13,10 +13,10 @@
|
||||
{% block content %}
|
||||
{% if not chain.ok %}
|
||||
{{ alert(chain.reason ~ ' Erster Bruch bei Eintrag ' ~ chain.broken_at ~ '.', 'danger',
|
||||
'Das Protokoll wurde veraendert') }}
|
||||
'Das Protokoll wurde verändert') }}
|
||||
{% else %}
|
||||
{{ alert('Jeder Eintrag bindet den Hash seines Vorgaengers ein. Eine nachtraeglich entfernte
|
||||
oder geaenderte Zeile faellt bei der Pruefung auf -- ' ~ chain.checked ~ ' Zeilen geprueft.',
|
||||
{{ alert('Jeder Eintrag bindet den Hash seines Vorgängers ein. Eine nachträglich entfernte
|
||||
oder geänderte Zeile fällt bei der Prüfung auf -- ' ~ chain.checked ~ ' Zeilen geprüft.',
|
||||
'success') }}
|
||||
{% endif %}
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
<input class="input" type="text" name="benutzer" value="{{ filters.benutzer }}"
|
||||
placeholder="Benutzer" style="width:150px">
|
||||
<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>
|
||||
|
||||
<div class="table-wrap">
|
||||
@@ -64,12 +64,17 @@
|
||||
<td class="fs-sm fw-600">{{ row.actor }}</td>
|
||||
<td class="mono fs-xs">{{ row.action }}</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>
|
||||
{% if row.severity == 'critical' %}{{ pill('kritisch', 'danger') }}
|
||||
{% elif row.severity == 'warning' %}{{ pill('Warnung', 'warning') }}
|
||||
{% elif row.severity == 'notice' %}{{ pill('Hinweis', 'info') }}
|
||||
{% else %}{{ pill('Info', '') }}{% endif %}
|
||||
{# Ampel: Info grün, Hinweis gelb, Warnung rot. Vorher war Info
|
||||
grau und Hinweis blau -- dabei war nicht zu sehen, was harmlos
|
||||
ist und was Aufmerksamkeit braucht. "kritisch" bekommt eine
|
||||
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 class="mono fs-xs faint">{{ row.ip or '–' | safe }}</td>
|
||||
</tr>
|
||||
@@ -77,13 +82,13 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</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">
|
||||
<span class="fs-sm muted">Seite {{ page }}</span>
|
||||
<span class="row row--tight">
|
||||
{% 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 %}
|
||||
{% if rows | length == per_page %}
|
||||
<a class="btn btn--sm" href="{{ url_for('logs.audit_view', seite=page+1, **filters) }}">Weiter</a>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
{% block content %}
|
||||
{{ 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">
|
||||
<section class="card">
|
||||
@@ -19,7 +19,7 @@
|
||||
{% if rotated %}
|
||||
<div class="table-wrap">
|
||||
<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>
|
||||
{% for entry in rotated %}
|
||||
<tr>
|
||||
@@ -39,7 +39,7 @@
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<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>
|
||||
{% if archived and current_user.has('logs_history.export') %}
|
||||
@@ -52,7 +52,7 @@
|
||||
{% if archived %}
|
||||
<div class="table-wrap">
|
||||
<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>
|
||||
{% for entry in archived %}
|
||||
<tr>
|
||||
@@ -69,8 +69,8 @@
|
||||
<form method="post" action="{{ url_for('logs.history_purge') }}" data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<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-title="Archiv loeschen" data-confirm-ok="Loeschen">
|
||||
data-confirm="Alle {{ archived | length }} Archivdateien endgültig vom Server löschen? Bitte vorher als ZIP sichern."
|
||||
data-confirm-title="Archiv löschen" data-confirm-ok="Löschen">
|
||||
Archiv vom Server entfernen
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@@ -51,8 +51,22 @@
|
||||
</label>
|
||||
{%- 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) -%}
|
||||
<label class="check {% if disabled %}check--disabled{% endif %}">
|
||||
{{ _present(name, disabled) }}
|
||||
<input type="checkbox" name="{{ name }}" value="{{ value }}"
|
||||
{% if checked %}checked{% endif %} {% if disabled %}disabled{% endif %}>
|
||||
<span>{{ label }} {{ hint(hint_text) }}</span>
|
||||
@@ -61,6 +75,7 @@
|
||||
|
||||
{% macro switch(name, label, checked=False, value='1', disabled=False) -%}
|
||||
<label class="switch">
|
||||
{{ _present(name, disabled) }}
|
||||
<input type="checkbox" name="{{ name }}" value="{{ value }}"
|
||||
{% if checked %}checked{% endif %} {% if disabled %}disabled{% endif %}>
|
||||
<span>{{ label }}</span>
|
||||
@@ -196,12 +211,12 @@
|
||||
<span class="sr-only">{{ resource.label }} {{ action_meta[action][1] }}</span>
|
||||
</label>
|
||||
{% 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
|
||||
sich anhaken, hatte aber kein name-Attribut -- es sah erteilt
|
||||
aus und war nach dem Speichern weg. Was es nicht gibt, ist
|
||||
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>
|
||||
{% endif %}
|
||||
</td>
|
||||
@@ -216,7 +231,7 @@
|
||||
{% for action, meta in action_meta.items() %}
|
||||
<span><b>{{ meta[0] }}</b> {{ meta[1] }} – {{ meta[2] }}</span>
|
||||
{% endfor %}
|
||||
<span><span class="matrix__na"></span> Fuer diese Zeile nicht vorgesehen –
|
||||
die Spalte gehoert zu einer anderen Zeile desselben Bereichs.</span>
|
||||
<span><span class="matrix__na"></span> Für diese Zeile nicht vorgesehen –
|
||||
die Spalte gehört zu einer anderen Zeile desselben Bereichs.</span>
|
||||
</div>
|
||||
{%- endmacro %}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
{% 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 content %}
|
||||
<div class="alert alert--info">
|
||||
{{ icon('info', size=18) }}
|
||||
<div class="alert__body">
|
||||
Sie greifen auf Geheimnisse oder einen Datenexport zu. Ein uebernommenes
|
||||
Sitzungscookie allein reicht dafuer nicht aus – die Bestaetigung gilt
|
||||
Sie greifen auf Geheimnisse oder einen Datenexport zu. Ein übernommenes
|
||||
Sitzungscookie allein reicht dafür nicht aus – die Bestätigung gilt
|
||||
danach {{ reauth_minutes }} Minuten.
|
||||
</div>
|
||||
</div>
|
||||
@@ -19,7 +19,7 @@
|
||||
</label>
|
||||
<div class="row row--end">
|
||||
<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>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
@@ -11,10 +11,10 @@
|
||||
inputmode="numeric" autocomplete="one-time-code" maxlength="14"
|
||||
placeholder="123456" style="letter-spacing:.22em;text-align:center;font-size:1.15rem">
|
||||
<span class="field__hint">
|
||||
Alternativ koennen Sie einen Ihrer Wiederherstellungscodes eingeben.
|
||||
Alternativ können Sie einen Ihrer Wiederherstellungscodes eingeben.
|
||||
</span>
|
||||
</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>
|
||||
<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 %}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
{% block content %}
|
||||
{{ 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">
|
||||
<div class="table-wrap">
|
||||
@@ -17,8 +17,8 @@ ungueltig -- auch wenn das Cookie noch existiert.', 'info') }}
|
||||
<tr>
|
||||
<th class="sortable">Benutzer</th>
|
||||
<th class="sortable">Angemeldet</th>
|
||||
<th class="sortable">Letzte Aktivitaet</th>
|
||||
<th class="sortable">Laeuft ab</th>
|
||||
<th class="sortable">Letzte Aktivität</th>
|
||||
<th class="sortable">Läuft ab</th>
|
||||
<th class="sortable">IP</th>
|
||||
<th>Client</th>
|
||||
<th class="actions">Aktion</th>
|
||||
|
||||
@@ -12,16 +12,16 @@
|
||||
{% if license_hint %}{{ alert(license_hint, 'warning') }}{% endif %}
|
||||
|
||||
{{ 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') }}
|
||||
|
||||
{% if values.ldap_security == 'none' %}
|
||||
{{ alert('Die Verbindung ist unverschluesselt. Das Passwort geht dabei im Klartext ueber das
|
||||
Netz -- nur fuer Testumgebungen vertretbar.', 'danger', 'Unverschluesselt') }}
|
||||
{{ alert('Die Verbindung ist unverschlüsselt. Das Passwort geht dabei im Klartext über das
|
||||
Netz -- nur für Testumgebungen vertretbar.', 'danger', 'Unverschlüsselt') }}
|
||||
{% 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',
|
||||
'Zertifikat ungeprueft') }}
|
||||
'Zertifikat ungeprüft') }}
|
||||
{% endif %}
|
||||
|
||||
<div class="grid grid--split">
|
||||
@@ -48,8 +48,8 @@ niemals hier gespeichert.', 'info') }}
|
||||
{{ field('ldap_port', 'Port', value=values.ldap_port, type='number',
|
||||
disabled=bool(license_hint), attrs='id="ldap-port"') }}
|
||||
{# Der Transport setzt den Port mit. Wer umstellt und den Port stehen
|
||||
laesst, bekommt eine Verbindung, die auf gut Glueck scheitert --
|
||||
und die Meldung ("Zeitlimit") sagt nichts ueber die Ursache. Ein
|
||||
lässt, bekommt eine Verbindung, die auf gut Glück scheitert --
|
||||
und die Meldung ("Zeitlimit") sagt nichts über die Ursache. Ein
|
||||
absichtlich abweichender Port bleibt erhalten. #}
|
||||
{{ select_field('ldap_security', 'Transport', specs['ldap_security'].choices,
|
||||
selected=values.ldap_security, disabled=bool(license_hint),
|
||||
@@ -68,21 +68,21 @@ niemals hier gespeichert.', 'info') }}
|
||||
disabled=bool(license_hint)) }}
|
||||
</div>
|
||||
<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)) }}
|
||||
{{ 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)) }}
|
||||
{{ 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),
|
||||
hint_text=specs['ldap_allow_insecure'].hint) }}
|
||||
</div>
|
||||
|
||||
<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">
|
||||
{{ field('bind_username', 'Benutzer', value=bind_username, autocomplete='off',
|
||||
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',
|
||||
disabled=not current_user.has('settings_directory.secrets')) }}
|
||||
</div>
|
||||
@@ -125,7 +125,7 @@ niemals hier gespeichert.', 'info') }}
|
||||
action="{{ url_for('hostsettings.directory_mapping_delete', mapping_id=entry.id) }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<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">
|
||||
{{ icon('trash', size=14) }}</button>
|
||||
</form>
|
||||
|
||||
@@ -5,30 +5,30 @@
|
||||
{% block page_sub %}Backend: {{ backend }} · Sicherheitsnetz {{ revert_seconds }} s{% endblock %}
|
||||
|
||||
{% 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
|
||||
zurueckgerollt. So sperrt eine falsche Eingabe den Host nicht aus.', 'warning',
|
||||
'Automatischer Rueckfall') }}
|
||||
zurückgerollt. So sperrt eine falsche Eingabe den Host nicht aus.', 'warning',
|
||||
'Automatischer Rückfall') }}
|
||||
|
||||
{% if pending %}
|
||||
<section class="card card--danger">
|
||||
<div class="card__header">
|
||||
<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>
|
||||
</div>
|
||||
<div class="card__body">
|
||||
<form method="post" action="{{ url_for('hostsettings.network_confirm') }}" data-no-guard>
|
||||
<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>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% 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 %}
|
||||
|
||||
<section class="card">
|
||||
@@ -54,7 +54,7 @@ zurueckgerollt. So sperrt eine falsche Eingabe den Host nicht aus.', 'warning',
|
||||
</table>
|
||||
</div>
|
||||
{% 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 %}
|
||||
</section>
|
||||
|
||||
@@ -62,11 +62,11 @@ zurueckgerollt. So sperrt eine falsche Eingabe den Host nicht aus.', 'warning',
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<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">
|
||||
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
|
||||
unberuehrt.
|
||||
unberührt.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -88,18 +88,18 @@ zurueckgerollt. So sperrt eine falsche Eingabe den Host nicht aus.', 'warning',
|
||||
</select>
|
||||
</label>
|
||||
{{ 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('dns', 'DNS-Server', placeholder='192.168.1.1, 9.9.9.9') }}
|
||||
</div>
|
||||
{{ alert('Das Gateway muss im angegebenen Netz liegen -- sonst waere der Host nach der
|
||||
Aenderung nicht mehr erreichbar. Das wird vor dem Schreiben geprueft.', 'info') }}
|
||||
{{ alert('Das Gateway muss im angegebenen Netz liegen -- sonst wäre der Host nach der
|
||||
Änderung nicht mehr erreichbar. Das wird vor dem Schreiben geprüft.', 'info') }}
|
||||
</div>
|
||||
<div class="card__footer">
|
||||
<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-title="Netzwerk aendern" data-confirm-ok="Uebernehmen">
|
||||
Uebernehmen
|
||||
data-confirm="Netzwerkeinstellungen jetzt übernehmen? Die Verbindung kann kurz abbrechen. Ohne Bestätigung wird nach {{ revert_seconds }} Sekunden zurückgerollt."
|
||||
data-confirm-title="Netzwerk ändern" data-confirm-ok="Übernehmen">
|
||||
Übernehmen
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -10,14 +10,14 @@
|
||||
{% block content %}
|
||||
{% if license_hint %}{{ alert(license_hint, 'warning') }}{% endif %}
|
||||
{% 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') }}
|
||||
{% endif %}
|
||||
|
||||
{{ alert('Die Site-Datei wird bei jeder Uebernahme vollstaendig neu geschrieben.
|
||||
Alles, was ein Administrator sonst per Hand hineinschreiben wuerde, ist hier Einstellung. Der
|
||||
Vorgaenger 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',
|
||||
{{ alert('Die Site-Datei wird bei jeder Übernahme vollständig neu geschrieben.
|
||||
Alles, was ein Administrator sonst per Hand hineinschreiben würde, ist hier Einstellung. Der
|
||||
Vorgänger schrieb die Datei nur, wenn sie noch nicht existierte -- ein einmal falscher Pfad
|
||||
blieb dadurch über Jahre bestehen und lieferte alle statischen Dateien als 404.', 'info',
|
||||
'Warum die Datei immer neu geschrieben wird') }}
|
||||
|
||||
<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">
|
||||
{{ field('web_domain', 'Domain', value=config.domain,
|
||||
placeholder='tesm.firma.de', disabled=bool(license_hint),
|
||||
hint_text='Leer = Zugriff ueber IP-Adresse.') }}
|
||||
{{ field('web_acme_email', 'E-Mail fuer Let\'s Encrypt', value=acme_email, type='email',
|
||||
hint_text='Leer = Zugriff über IP-Adresse.') }}
|
||||
{{ field('web_acme_email', 'E-Mail für Let\'s Encrypt', value=acme_email, type='email',
|
||||
disabled=bool(license_hint)) }}
|
||||
{{ field('web_cert_path', 'Zertifikat (Pfad)', value=config.cert_path,
|
||||
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') }}
|
||||
{{ field('web_acme_webroot', 'ACME-Webroot', value=config.acme_webroot,
|
||||
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)) }}
|
||||
{{ checkbox('web_redirect_http', 'HTTP (Port 80) auf HTTPS umleiten',
|
||||
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.') }}
|
||||
{{ checkbox('web_hsts_enabled', 'HSTS-Header senden',
|
||||
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">
|
||||
<div class="flex-1">
|
||||
<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>
|
||||
{{ icon('chevron-down', size=16) }}
|
||||
</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') %}
|
||||
<dl class="kv fs-sm">
|
||||
<dt>Inhaber</dt><dd class="break-all">{{ certificate.subject }}</dd>
|
||||
<dt>Aussteller</dt><dd class="break-all">{{ certificate.issuer }}</dd>
|
||||
<dt>Gueltig bis</dt><dd>{{ certificate.not_valid_after | dt }}</dd>
|
||||
<dt>Aussteller</dt><dd class="break-all">{{ certificate.issür }}</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>
|
||||
</dl>
|
||||
{% 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"
|
||||
{% if license_hint or not certbot_available or not config.domain %}disabled{% endif %}
|
||||
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-style="primary">
|
||||
{{ 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>
|
||||
{% elif not config.domain %}
|
||||
<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.
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<hr>
|
||||
<p class="muted fs-sm">
|
||||
Kein oeffentlicher Name? Dann ein eigenes Zertifikat erzeugen. Browser
|
||||
zeigen dafuer eine Warnung, die Verbindung ist aber verschluesselt --
|
||||
Kein öffentlicher Name? Dann ein eigenes Zertifikat erzeugen. Browser
|
||||
zeigen dafür eine Warnung, die Verbindung ist aber verschlüsselt --
|
||||
und das Sitzungscookie darf endlich <span class="mono">Secure</span> tragen.
|
||||
</p>
|
||||
{% if local_names %}
|
||||
@@ -133,7 +133,7 @@ blieb dadurch ueber Jahre bestehen und lieferte alle statischen Dateien als 404.
|
||||
Wird ausgestellt auf:
|
||||
<span class="mono">{{ (config.domain or local_names[0]) }}</span>
|
||||
{% if local_names|length > 1 %}
|
||||
(zusaetzlich {{ local_names[1:] | join(', ') }})
|
||||
(zusätzlich {{ local_names[1:] | join(', ') }})
|
||||
{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
@@ -156,7 +156,7 @@ blieb dadurch ueber Jahre bestehen und lieferte alle statischen Dateien als 404.
|
||||
<section class="card card--accent">
|
||||
<div class="card__header">
|
||||
<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>
|
||||
</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() }}">
|
||||
<button class="btn btn--primary btn--block" type="submit"
|
||||
{% if license_hint or not sysops_available %}disabled{% endif %}
|
||||
data-busy-label="Uebernehme ..."
|
||||
data-confirm="Konfiguration schreiben, pruefen und nginx neu laden? Eine fehlerhafte Konfiguration wird vorher abgewiesen."
|
||||
data-confirm-title="nginx uebernehmen" data-confirm-ok="Uebernehmen"
|
||||
data-busy-label="Übernehme ..."
|
||||
data-confirm="Konfiguration schreiben, prüfen und nginx neu laden? Eine fehlerhafte Konfiguration wird vorher abgewiesen."
|
||||
data-confirm-title="nginx übernehmen" data-confirm-ok="Übernehmen"
|
||||
data-confirm-style="primary">
|
||||
Konfiguration uebernehmen
|
||||
Konfiguration übernehmen
|
||||
</button>
|
||||
</form>
|
||||
</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.
|
||||
|
||||
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
|
||||
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>
|
||||
<html lang="de">
|
||||
@@ -22,37 +22,51 @@
|
||||
</head>
|
||||
<body data-csrf="{{ csrf_token() }}">
|
||||
<div class="auth-page">
|
||||
{% if auth_tagline %}
|
||||
{# Wortmarke mit Funktionszeile als tatsaechlicher Seitenhintergrund --
|
||||
uebernommen vom Vorgaenger. Bewusst inline und mit Themen-Tokens statt
|
||||
zweier Dateien fuer hell und dunkel: bei der Einstellung "System" und
|
||||
beim Umschalten von Hand waere die versteckte Variante die falsche.
|
||||
``aria-hidden``, weil der Name schon in der Ueberschrift steht. #}
|
||||
<div class="auth-stack">
|
||||
{# Wortmarke als Seitenhintergrund -- fuer beide Anwendungen. Bewusst inline
|
||||
und mit Themen-Tokens statt zweier Dateien fuer hell und dunkel: bei der
|
||||
Einstellung "System" und beim Umschalten von Hand waere die versteckte
|
||||
Variante die falsche. ``aria-hidden``, weil der Name in der Titelzeile
|
||||
steht.
|
||||
|
||||
Die Geometrie ist symmetrisch um die Mitte (x=260) aufgebaut, damit die
|
||||
Marke ohne Nachrechnen mittig sitzt: der Schriftzug haengt an
|
||||
``text-anchor="middle"``, die beiden Zacken liegen spiegelbildlich, und
|
||||
die Linie beginnt links so weit vor der Mitte, wie der Punkt rechts nach
|
||||
ihr endet. Wer die Linie verlaengert, muss beide Enden anfassen.
|
||||
|
||||
Ohne Funktionszeile (Lizenzserver) ist der Kasten kuerzer -- sonst
|
||||
stuende unter der Marke ein leerer Streifen. #}
|
||||
<div class="auth-backdrop" aria-hidden="true">
|
||||
<svg viewBox="0 0 620 190" xmlns="http://www.w3.org/2000/svg">
|
||||
<g transform="translate(150,15)">
|
||||
<circle cx="298" cy="70" r="15" fill="var(--logo-mark)" opacity="0.22"/>
|
||||
<polyline points="0,70 90,70 100,70 108,20 116,120 124,70 134,70 200,70 210,70
|
||||
218,35 226,105 234,70 244,70 300,70"
|
||||
<svg viewBox="0 0 520 {{ 150 if auth_tagline else 116 }}" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="452" cy="62" r="15" fill="var(--logo-mark)" opacity="0.22"/>
|
||||
<polyline points="60,62 163,62 171,12 179,112 187,62 333,62 341,12 349,112 357,62 452,62"
|
||||
fill="none" stroke="var(--logo-mark)" stroke-width="3.2"
|
||||
stroke-linecap="round" stroke-linejoin="round" opacity="0.85"/>
|
||||
<text x="14" y="98" font-family="var(--font-mono)" font-weight="800"
|
||||
font-size="82" letter-spacing="2" fill="var(--logo-text)">TESM</text>
|
||||
<circle cx="298" cy="70" r="8" fill="var(--logo-mark)"/>
|
||||
</g>
|
||||
<text x="310" y="178" text-anchor="middle" font-family="var(--font-sans)"
|
||||
<text x="260" y="90" text-anchor="middle" font-family="var(--font-mono)"
|
||||
font-weight="800" font-size="82" letter-spacing="2"
|
||||
fill="var(--logo-text)">TESM</text>
|
||||
<circle cx="452" cy="62" r="8" fill="var(--logo-mark)"/>
|
||||
{% if auth_tagline %}
|
||||
<text x="260" y="140" text-anchor="middle" font-family="var(--font-sans)"
|
||||
font-weight="600" font-size="13" letter-spacing="1.5"
|
||||
fill="var(--logo-mark)">{{ auth_tagline }}</text>
|
||||
{% endif %}
|
||||
</svg>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="auth-card {% block card_class %}{% endblock %}">
|
||||
{# Die Anmeldeseite laesst diesen Kopf weg: die Wortmarke darueber sagt
|
||||
schon, wo man ist, und "Anmelden" steht auf dem Knopf. Die uebrigen
|
||||
Standalone-Seiten behalten ihn -- "Zweiter Faktor" oder der Kundenname
|
||||
im Portal sind dort die Auskunft, um die es geht. #}
|
||||
{% block brand %}
|
||||
<div class="auth-brand">
|
||||
<span class="auth-brand__mark" aria-hidden="true">{{ app_short }}</span>
|
||||
<h1>{% block heading %}{{ app_name }}{% endblock %}</h1>
|
||||
{% set sub %}{% block subheading %}{% endblock %}{% endset %}
|
||||
{% if sub | trim %}<p>{{ sub }}</p>{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
@@ -71,7 +85,8 @@
|
||||
|
||||
{% 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>
|
||||
<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 %}
|
||||
|
||||
{% 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 %}
|
||||
{{ alert('Ansehen ist immer moeglich. Wiederherstellen und endgueltiges Loeschen erfordern eine
|
||||
gueltige, aktivierte Lizenz. Abgelaufene Eintraege werden beim Aufruf dieser Seite entfernt.',
|
||||
{{ alert('Ansehen ist immer möglich. Wiederherstellen und endgültiges Löschen erfordern eine
|
||||
gültige, aktivierte Lizenz. Abgelaufene Einträge werden beim Aufruf dieser Seite entfernt.',
|
||||
'info') }}
|
||||
|
||||
{% 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="kind" value="{{ section.key }}">
|
||||
<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-title="Endgueltig loeschen" data-confirm-ok="Endgueltig loeschen">
|
||||
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="Endgültig löschen" data-confirm-ok="Endgültig löschen">
|
||||
Bereich leeren
|
||||
</button>
|
||||
</form>
|
||||
@@ -33,7 +33,7 @@ gueltige, aktivierte Lizenz. Abgelaufene Eintraege werden beim Aufruf dieser Sei
|
||||
{% if section.entries %}
|
||||
<div class="table-wrap">
|
||||
<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>
|
||||
{% for entry in section.entries %}
|
||||
<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) }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<button class="btn btn--sm btn--ghost danger-text" type="submit"
|
||||
data-confirm="{{ entry.label }} endgueltig loeschen?"
|
||||
data-confirm-title="Endgueltig loeschen" data-confirm-ok="Loeschen">
|
||||
Endgueltig
|
||||
data-confirm="{{ entry.label }} endgültig löschen?"
|
||||
data-confirm-title="Endgültig löschen" data-confirm-ok="Löschen">
|
||||
Endgültig
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
<span class="table-toolbar__spacer"></span>
|
||||
{% if directory_enabled and current_user.has('users.create') %}
|
||||
<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>
|
||||
{% endif %}
|
||||
{% if current_user.is_admin %}
|
||||
@@ -90,9 +90,9 @@
|
||||
style="display:inline" data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<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-title="Konto loeschen" data-confirm-ok="Loeschen"
|
||||
aria-label="Loeschen">{{ icon('trash', size=15) }}</button>
|
||||
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 löschen" data-confirm-ok="Löschen"
|
||||
aria-label="Löschen">{{ icon('trash', size=15) }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
@@ -106,8 +106,8 @@
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
{{ alert('Rechte werden ausschliesslich ueber Gruppen vergeben. Das Admin-Kennzeichen ist davon
|
||||
unabhaengig und kann nur von Administratoren gesetzt werden; mindestens ein entsperrtes
|
||||
{{ alert('Rechte werden ausschliesslich über Gruppen vergeben. Das Admin-Kennzeichen ist davon
|
||||
unabhängig und kann nur von Administratoren gesetzt werden; mindestens ein entsperrtes
|
||||
Administratorkonto muss immer bestehen bleiben.', 'info') }}
|
||||
{% endblock %}
|
||||
|
||||
@@ -137,7 +137,7 @@ Administratorkonto muss immer bestehen bleiben.', 'info') }}
|
||||
</div>
|
||||
</fieldset>
|
||||
{% 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 %}
|
||||
{{ checkbox('must_change_password', 'Passwortwechsel bei der ersten Anmeldung erzwingen', checked=True) }}
|
||||
</div>
|
||||
@@ -149,7 +149,7 @@ Administratorkonto muss immer bestehen bleiben.', 'info') }}
|
||||
{% endcall %}
|
||||
|
||||
{% 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') %}
|
||||
<form method="post" action="{{ url_for('admin.directory_adopt') }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
@@ -169,7 +169,7 @@ Administratorkonto muss immer bestehen bleiben.', 'info') }}
|
||||
</div>
|
||||
</fieldset>
|
||||
{{ alert('Name und E-Mail werden bei der ersten echten Anmeldung aus dem Verzeichnis
|
||||
uebernommen und ueberschrieben.', 'info') }}
|
||||
übernommen und überschrieben.', 'info') }}
|
||||
</div>
|
||||
<div class="modal__footer">
|
||||
<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('new_password', 'Neues Passwort setzen (optional)', type='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 %}
|
||||
<fieldset class="fieldset">
|
||||
<legend>Gruppen</legend>
|
||||
|
||||
@@ -8,12 +8,13 @@ nichts, was die CSP aufweichen muesste.
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from flask import Flask, url_for
|
||||
from markupsafe import Markup
|
||||
from markupsafe import Markup, escape
|
||||
|
||||
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"/>',
|
||||
@@ -126,6 +127,37 @@ def format_date(value: Any) -> str:
|
||||
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:
|
||||
try:
|
||||
total = int(seconds)
|
||||
@@ -154,6 +186,32 @@ def yes_no(value: Any) -> str:
|
||||
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:
|
||||
"""Parst einen JSON-String aus der Datenbank -- fuer Modul-/Rechtelisten.
|
||||
|
||||
@@ -206,6 +264,89 @@ def asset_url(app: Flask) -> Any:
|
||||
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:
|
||||
app.jinja_env.globals.update(
|
||||
icon=icon,
|
||||
@@ -230,9 +371,13 @@ def init_app(app: Flask) -> None:
|
||||
dt=format_datetime,
|
||||
date=format_date,
|
||||
duration=format_duration,
|
||||
millis=format_millis,
|
||||
number=format_number,
|
||||
yes_no=yes_no,
|
||||
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.lstrip_blocks = True
|
||||
|
||||
@@ -24,6 +24,7 @@ from .. import audit
|
||||
from ..auth.models import current_user, permission_required
|
||||
from ..auth.service import client_ip
|
||||
from ..extension import core, db, transaction
|
||||
from ..security.sessions import fragment
|
||||
|
||||
bp = Blueprint("logs", __name__, url_prefix="/protokolle")
|
||||
|
||||
@@ -53,6 +54,7 @@ def live():
|
||||
|
||||
@bp.get("/laufend/inhalt")
|
||||
@permission_required("logs_live.view")
|
||||
@fragment
|
||||
def live_fragment():
|
||||
lines = min(int(request.args.get("lines") or 400), MAX_TAIL_LINES)
|
||||
needle = (request.args.get("q") or "").strip()
|
||||
|
||||
@@ -4,10 +4,10 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "tesm-licensing-server"
|
||||
version = "2.0.0"
|
||||
version = "2.0.7"
|
||||
description = "Serverhaelfte des TESM-Lizenzprotokolls: ausstellen, erneuern, antworten"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = ["tesm-licensing==2.0.0", "cryptography>=41"]
|
||||
dependencies = ["tesm-licensing==2.0.7", "cryptography>=41"]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "tesm-licensing"
|
||||
version = "2.0.0"
|
||||
version = "2.0.7"
|
||||
description = "Ed25519-basiertes Lizenz- und Aktivierungsprotokoll fuer TESM und TESM-Lizenzserver"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = ["cryptography>=42"]
|
||||
|
||||
@@ -62,7 +62,7 @@ from .protocol import (
|
||||
verify_master_response,
|
||||
)
|
||||
|
||||
__version__ = "2.0.0"
|
||||
__version__ = "2.0.2"
|
||||
|
||||
__all__ = [
|
||||
"ACTIONS",
|
||||
|
||||
Binary file not shown.
Reference in New Issue
Block a user