Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b984e22074 | ||
|
|
1d4e22b082 | ||
|
|
23c091e99f | ||
|
|
617fe10d8d | ||
|
|
7fd5ba8b54 | ||
|
|
a60cc07ab8 | ||
|
|
0c9e346b66 | ||
|
|
cf5cf8622b | ||
|
|
4c1d6387c0 | ||
|
|
91711a51fc | ||
|
|
1c90251b0f | ||
|
|
4c9ed20da4 | ||
|
|
b266bac055 | ||
|
|
051d3bc649 | ||
|
|
4041156a19 | ||
|
|
ec5cbb2e17 | ||
|
|
f551ce0ee6 | ||
|
|
0c47e1d645 | ||
|
|
db3558dcbd | ||
|
|
9d819aaa3a | ||
|
|
48ce56fe7d | ||
|
|
294d47684b | ||
|
|
e589b4aadb | ||
|
|
f6b0bbaad1 | ||
|
|
c3c6eae72d | ||
|
|
a1987aeb93 | ||
|
|
1d67c36ed2 | ||
|
|
e68f22fd88 | ||
|
|
2d234571d2 | ||
|
|
fafda84bc7 | ||
|
|
cdc3b2cd6e | ||
|
|
35247c895b | ||
|
|
e5ae10814f | ||
|
|
37a200e983 | ||
|
|
de9c0d4fbd | ||
|
|
724052ae28 | ||
|
|
e16236fa65 | ||
|
|
c52b3626b2 | ||
|
|
1ac5dc1637 | ||
|
|
f2d5324449 | ||
|
|
342abb0206 | ||
|
|
8dd8df9e9f | ||
|
|
0de90ab6d4 | ||
|
|
e60a93120c | ||
|
|
e7ad00a5e5 | ||
|
|
e70c6e27c1 | ||
|
|
e96e4837f1 | ||
|
|
119293e5e3 | ||
|
|
36d9cb248e | ||
|
|
cb056b4872 | ||
|
|
ed84e8343e | ||
|
|
157a244900 | ||
|
|
fd333f60ff | ||
|
|
b9342d384c | ||
|
|
f062314a0a | ||
|
|
3295583591 | ||
|
|
47aeaba9c8 | ||
|
|
d31173fa5e | ||
|
|
05885bfff1 | ||
|
|
168d10bf87 | ||
|
|
2dea198bcc | ||
|
|
50da075c15 | ||
|
|
23980182b0 | ||
|
|
85bec34d7d | ||
|
|
ab5fda658e | ||
|
|
4e488625fe | ||
|
|
9799843cd2 | ||
|
|
8b39bfaf10 | ||
|
|
8e66d9e7bc | ||
|
|
4bc8637a74 |
No files matched your search
@@ -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,24 @@
|
||||
# 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:
|
||||
> **Diese Ausgabe ist für Sub-Lizenzserver.** Ein Sub-Lizenzserver bekommt
|
||||
> seine eigene Lizenz von der Wurzelinstanz und stellt danach selbst
|
||||
> Kundenlizenzen aus. Was er nicht kann: sich selbst die Rolle `master` geben --
|
||||
> der Code dafür liegt nicht in diesem Paket, sondern nur in der Master-Ausgabe
|
||||
> (privates Repository `tesm-license-master`). Das ist keine Einstellung, die
|
||||
> sich umlegen lässt, sondern ein fehlendes Verzeichnis.
|
||||
|
||||
**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
|
||||
```
|
||||
@@ -21,6 +28,7 @@ Die verwaltete Anwendung liegt hier **nicht** mit drin.
|
||||
| Wo sonst noch | |
|
||||
|---|---|
|
||||
| [alientim/tesm](https://gitea.int.eertmoed.net/alientim/tesm) | TESM selbst |
|
||||
| [alientim/tesm-license-master](https://gitea.int.eertmoed.net/alientim/tesm-license-master) | Master-Ausgabe (privat): die Wurzelinstanz |
|
||||
| [alientim/TESM-DEV](https://gitea.int.eertmoed.net/alientim/TESM-DEV) | Entwicklung: beide Anwendungen, Tests, Architekturdoku |
|
||||
|
||||
## Zweige
|
||||
@@ -28,39 +36,47 @@ 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 |
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
Eine einzelne Datei genügt -- sie lädt das Release selbst:
|
||||
|
||||
```bash
|
||||
sudo bash deploy/install.sh --app tesm-license --https --domain lizenz.firma.de
|
||||
sudo -u tesm-license tesm-license-admin init-key
|
||||
sudo -u tesm-license tesm-license-admin bootstrap
|
||||
curl -fsSLO https://gitea.int.eertmoed.net/alientim/tesm-license/raw/branch/main/install.sh
|
||||
sudo bash install.sh --app tesm-license --https --domain lizenz.firma.de
|
||||
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.
|
||||
Aus einem entpackten Paket ist es derselbe Aufruf ohne `curl`.
|
||||
|
||||
Den **Signaturschlüssel** legt der Installer selbst an -- mit ihm signiert
|
||||
dieser Server seine Kundenlizenzen. **Sichern Sie ihn sofort**
|
||||
(`data/master_signing_key.json`): ohne ihn ist keine ausgestellte Lizenz mehr
|
||||
prüfbar, und Kunden müssten einen Ausstellerwechsel bestätigen. Eine
|
||||
Aktualisierung fasst ihn nie an.
|
||||
|
||||
Die **eigene Lizenz** dieses Servers kommt von der Wurzelinstanz: dort eine
|
||||
Lizenz für diesen Server anfordern und unter "Eigene Lizenz" einspielen.
|
||||
|
||||
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
|
||||
|
||||
```bash
|
||||
sudo bash deploy/update.sh --app tesm-license
|
||||
sudo bash /srv/tesm-license/deploy/update.sh 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 +85,15 @@ 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 bootstrap # Grunddaten
|
||||
sudo -u tesm-license tesm-license-admin init-key # Signaturschlüssel
|
||||
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 +116,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 +145,24 @@ 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.
|
||||
|
||||
---
|
||||
|
||||
## Von einer Installation der Fassung 1.x
|
||||
|
||||
Eine einzelne Datei genügt -- sie lädt das Release selbst:
|
||||
|
||||
```bash
|
||||
curl -fsSLO https://gitea.int.eertmoed.net/alientim/tesm-license/raw/branch/main/deploy/upgrade.sh
|
||||
sudo bash upgrade.sh --app tesm-license
|
||||
```
|
||||
|
||||
Sichert die alte Installation, schaltet sie ab, installiert diese Fassung und
|
||||
übernimmt die Daten. Einzelheiten und was **nicht** mitkommt:
|
||||
[docs/BETRIEB.md](docs/BETRIEB.md).
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "tesm-license"
|
||||
version = "2.0.0"
|
||||
version = "2.8.11"
|
||||
description = "TESM-Lizenzserver -- Kunden, Tickets, Lizenzen, Aktivierung"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -8,6 +8,7 @@ musste (und ``licensing.py`` byte-identisch gehalten werden *musste*).
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -23,17 +24,46 @@ 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__)
|
||||
|
||||
|
||||
def is_master_edition() -> bool:
|
||||
"""Ist das hier die Master-Ausgabe?
|
||||
|
||||
Entscheidet die **Anwesenheit** des Teilpakets ``tesm_license.master``, nicht
|
||||
eine Einstellung: eine Einstellung koennte der lokale Administrator eines
|
||||
Sub-Lizenzservers umstellen, eine fehlende Datei nicht. Das Release fuer
|
||||
Sub-Lizenzserver bringt das Teilpaket nicht mit -- dort gibt es also
|
||||
ueberhaupt keinen Code, der die Rolle ``master`` erzeugen koennte.
|
||||
"""
|
||||
return importlib.util.find_spec("tesm_license.master") is not None
|
||||
|
||||
|
||||
def master_key_path(config: Any) -> Path:
|
||||
return Path(config.data_dir) / "master_signing_key.json"
|
||||
|
||||
@@ -56,6 +86,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,
|
||||
)
|
||||
@@ -149,7 +180,7 @@ def _reencrypt_secrets(conn: Any, keystore: Any) -> int:
|
||||
|
||||
|
||||
def _status_pills(user: Any) -> list[dict[str, str]]:
|
||||
if not user.is_authenticated or not user.has_any(("licenses.view",)):
|
||||
if not user.is_authenticated or not user.has_any(("licenses.read",)):
|
||||
return []
|
||||
from tesm_core.extension import db
|
||||
|
||||
@@ -173,7 +204,7 @@ def _status_pills(user: Any) -> list[dict[str, str]]:
|
||||
def _startup_tasks(app: Flask) -> None:
|
||||
extension = core(app)
|
||||
if not extension.config.web_process:
|
||||
logger.info("Startaufgaben uebersprungen -- kein Webprozess.")
|
||||
logger.info("Startaufgaben übersprungen – kein Webprozess.")
|
||||
return
|
||||
|
||||
from .services import activation
|
||||
|
||||
@@ -59,7 +59,7 @@ def _handle(action: str):
|
||||
)
|
||||
|
||||
if request.content_length and request.content_length > MAX_BODY_BYTES:
|
||||
return jsonify({"error": "too_large", "message": "Anfrage zu gross."}), 413
|
||||
return jsonify({"error": "too_large", "message": "Anfrage zu groß."}), 413
|
||||
|
||||
payload = request.get_json(silent=True)
|
||||
if not isinstance(payload, dict):
|
||||
|
||||
@@ -13,7 +13,7 @@ from tesm_core.extension import core, db, transaction
|
||||
from ..services import transfer
|
||||
from .common import log_event
|
||||
|
||||
bp = Blueprint("backup", __name__, url_prefix="/sicherung")
|
||||
bp = Blueprint("backup", __name__, url_prefix="/administration/backup")
|
||||
|
||||
MAX_IMPORT_BYTES = 64 * 1024 * 1024
|
||||
PREVIEW_SESSION_KEY = "_import_token"
|
||||
@@ -24,14 +24,14 @@ def _allowed_categories() -> list[transfer.Category]:
|
||||
for category in transfer.CATEGORIES:
|
||||
if category.admin_only and not current_user.is_admin:
|
||||
continue
|
||||
if category.sensitive and not current_user.has("backup.secrets"):
|
||||
if category.sensitive and not current_user.has("backup_export.secrets"):
|
||||
continue
|
||||
allowed.append(category)
|
||||
return allowed
|
||||
|
||||
|
||||
@bp.get("/")
|
||||
@permission_required("backup.view", "backup.export", "backup.execute", mode="any")
|
||||
@permission_required("backup_export.read", "backup_import.read", mode="any")
|
||||
def overview():
|
||||
conn = db()
|
||||
preview = None
|
||||
@@ -50,25 +50,25 @@ def overview():
|
||||
preview=preview,
|
||||
min_passphrase=transfer.MIN_PASSPHRASE_LENGTH,
|
||||
preview_ttl=transfer.PREVIEW_TTL_MINUTES,
|
||||
can_secrets=current_user.has("backup.secrets"),
|
||||
can_secrets=current_user.has("backup_export.secrets"),
|
||||
signing_key_path=str(core().registry["master_key_path"]),
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/export")
|
||||
@permission_required("backup.export")
|
||||
@reauth_required
|
||||
@permission_required("backup_export.export")
|
||||
# Die frische Bestaetigung verlangt das Recht selbst (SENSITIVE_ACTIONS).
|
||||
def export():
|
||||
conn = db()
|
||||
allowed = {category.key for category in _allowed_categories()}
|
||||
chosen = [key for key in request.form.getlist("categories") if key in allowed]
|
||||
if not chosen:
|
||||
flash("Es wurde keine Kategorie ausgewaehlt.", "warning")
|
||||
flash("Es wurde keine Kategorie ausgewählt.", "warning")
|
||||
return redirect(url_for("backup.overview"))
|
||||
|
||||
passphrase = request.form.get("passphrase") or ""
|
||||
if passphrase != (request.form.get("passphrase_repeat") or ""):
|
||||
flash("Die beiden Passphrasen stimmen nicht ueberein.", "danger")
|
||||
flash("Die beiden Passphrasen stimmen nicht überein.", "danger")
|
||||
return redirect(url_for("backup.overview"))
|
||||
|
||||
try:
|
||||
@@ -94,18 +94,18 @@ def export():
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/import/vorschau")
|
||||
@permission_required("backup.execute")
|
||||
@bp.post("/import/preview")
|
||||
@permission_required("backup_import.execute")
|
||||
@reauth_required
|
||||
def import_preview():
|
||||
conn = db()
|
||||
upload = request.files.get("backup_file")
|
||||
if upload is None or not upload.filename:
|
||||
flash("Keine Datei ausgewaehlt.", "danger")
|
||||
flash("Keine Datei ausgewählt.", "danger")
|
||||
return redirect(url_for("backup.overview"))
|
||||
raw = upload.stream.read(MAX_IMPORT_BYTES + 1)
|
||||
if len(raw) > MAX_IMPORT_BYTES:
|
||||
flash("Die Datei ist zu gross.", "danger")
|
||||
flash("Die Datei ist zu groß.", "danger")
|
||||
return redirect(url_for("backup.overview"))
|
||||
|
||||
try:
|
||||
@@ -124,28 +124,28 @@ def import_preview():
|
||||
scope="sicherung",
|
||||
)
|
||||
flash(
|
||||
f"Datei geoeffnet. Sie haben {transfer.PREVIEW_TTL_MINUTES} Minuten Zeit, die "
|
||||
"Uebernahme zu bestaetigen.",
|
||||
f"Datei geöffnet. Sie haben {transfer.PREVIEW_TTL_MINUTES} Minuten Zeit, die "
|
||||
"Übernahme zu bestätigen.",
|
||||
"success",
|
||||
)
|
||||
return redirect(url_for("backup.overview"))
|
||||
|
||||
|
||||
@bp.post("/import/uebernehmen")
|
||||
@permission_required("backup.execute")
|
||||
@bp.post("/import/apply")
|
||||
@permission_required("backup_import.execute")
|
||||
@reauth_required
|
||||
def import_apply():
|
||||
extension = core()
|
||||
conn = db()
|
||||
token = session.get(PREVIEW_SESSION_KEY, "")
|
||||
if not token:
|
||||
flash("Es liegt keine geoeffnete Sicherung vor.", "danger")
|
||||
flash("Es liegt keine geöffnete Sicherung vor.", "danger")
|
||||
return redirect(url_for("backup.overview"))
|
||||
|
||||
allowed = {category.key for category in _allowed_categories()}
|
||||
chosen = [key for key in request.form.getlist("categories") if key in allowed]
|
||||
if not chosen:
|
||||
flash("Es wurde keine Kategorie ausgewaehlt.", "warning")
|
||||
flash("Es wurde keine Kategorie ausgewählt.", "warning")
|
||||
return redirect(url_for("backup.overview"))
|
||||
|
||||
try:
|
||||
@@ -172,7 +172,7 @@ def import_apply():
|
||||
action="backup.imported",
|
||||
detail="; ".join(
|
||||
f"{key}: {counts['created']} neu, {counts['updated']} aktualisiert, "
|
||||
f"{counts['skipped']} uebersprungen"
|
||||
f"{counts['skipped']} übersprungen"
|
||||
for key, counts in report.items()
|
||||
),
|
||||
ip=client_ip(),
|
||||
@@ -182,16 +182,16 @@ def import_apply():
|
||||
lines = [
|
||||
f"{transfer.CATEGORY_BY_KEY[key].label}: {counts['created']} neu, "
|
||||
f"{counts['updated']} aktualisiert"
|
||||
+ (f", {counts['skipped']} uebersprungen" if counts["skipped"] else "")
|
||||
+ (f", {counts['skipped']} übersprungen" if counts["skipped"] else "")
|
||||
for key, counts in report.items()
|
||||
if key in transfer.CATEGORY_BY_KEY
|
||||
]
|
||||
flash("Uebernahme abgeschlossen. " + " | ".join(lines), "success")
|
||||
flash("Übernahme abgeschlossen. " + " – ".join(lines), "success")
|
||||
return redirect(url_for("backup.overview"))
|
||||
|
||||
|
||||
@bp.post("/import/verwerfen")
|
||||
@permission_required("backup.execute")
|
||||
@bp.post("/import/discard")
|
||||
@permission_required("backup_import.execute")
|
||||
def import_discard():
|
||||
token = session.pop(PREVIEW_SESSION_KEY, "")
|
||||
if token:
|
||||
|
||||
@@ -9,7 +9,7 @@ from tesm_core.extension import db, transaction
|
||||
from ..services import issuance, reporting
|
||||
from .common import customer_facing_url, log_event
|
||||
|
||||
bp = Blueprint("customers", __name__, url_prefix="/kunden")
|
||||
bp = Blueprint("customers", __name__, url_prefix="/customers")
|
||||
|
||||
FIELDS = (
|
||||
"name",
|
||||
@@ -29,13 +29,13 @@ def _form() -> dict[str, str]:
|
||||
|
||||
|
||||
@bp.get("/")
|
||||
@permission_required("customers.view")
|
||||
@permission_required("customers.read")
|
||||
def overview():
|
||||
return render_template("license/customers.html", customers=issuance.list_customers(db()))
|
||||
|
||||
|
||||
@bp.get("/<int:customer_id>")
|
||||
@permission_required("customers.view")
|
||||
@permission_required("customers.read")
|
||||
def detail(customer_id: int):
|
||||
conn = db()
|
||||
customer = issuance.get_customer(conn, customer_id)
|
||||
@@ -48,7 +48,7 @@ def detail(customer_id: int):
|
||||
]
|
||||
reported = (
|
||||
reporting.reported_for_customer(conn, customer_id)
|
||||
if current_user.has("reports.view")
|
||||
if current_user.has("reports.read")
|
||||
else {"customers": [], "source_license_id": ""}
|
||||
)
|
||||
return render_template(
|
||||
@@ -61,7 +61,7 @@ def detail(customer_id: int):
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/neu")
|
||||
@bp.post("/new")
|
||||
@permission_required("customers.create")
|
||||
def create():
|
||||
try:
|
||||
@@ -83,7 +83,7 @@ def create():
|
||||
|
||||
|
||||
@bp.post("/<int:customer_id>")
|
||||
@permission_required("customers.edit")
|
||||
@permission_required("customers.write")
|
||||
def update(customer_id: int):
|
||||
try:
|
||||
with transaction() as conn:
|
||||
@@ -96,14 +96,14 @@ def update(customer_id: int):
|
||||
severity="notice",
|
||||
scope="kunde",
|
||||
)
|
||||
flash("Aenderungen gespeichert." if changes else "Keine Aenderungen.",
|
||||
flash("Änderungen gespeichert." if changes else "Keine Änderungen.",
|
||||
"success" if changes else "info")
|
||||
except issuance.IssuanceError as exc:
|
||||
flash(str(exc), "danger")
|
||||
return redirect(url_for("customers.detail", customer_id=customer_id))
|
||||
|
||||
|
||||
@bp.post("/<int:customer_id>/loeschen")
|
||||
@bp.post("/<int:customer_id>/delete")
|
||||
@permission_required("customers.delete")
|
||||
def delete(customer_id: int):
|
||||
conn = db()
|
||||
@@ -119,7 +119,7 @@ def delete(customer_id: int):
|
||||
severity="warning",
|
||||
scope="kunde",
|
||||
)
|
||||
flash("Kunde geloescht.", "success")
|
||||
flash("Kunde gelöscht.", "success")
|
||||
return redirect(url_for("customers.overview"))
|
||||
except issuance.IssuanceError as exc:
|
||||
flash(str(exc), "danger")
|
||||
|
||||
@@ -16,12 +16,12 @@ from ..services import issuance, reporting
|
||||
bp = Blueprint("dashboard", __name__)
|
||||
|
||||
|
||||
@bp.get("/uebersicht")
|
||||
@bp.get("/overview")
|
||||
@login_required
|
||||
def index():
|
||||
conn = db()
|
||||
extension = core()
|
||||
if not current_user.has("licenses.view"):
|
||||
if not current_user.has("licenses.read"):
|
||||
# Leere Huelle statt 403: die Startseite soll fuer jeden erreichbar
|
||||
# bleiben, auch fuer ein Konto ohne Lizenzrechte.
|
||||
return render_template("license/dashboard.html", summary=None, sources=[])
|
||||
@@ -30,7 +30,7 @@ def index():
|
||||
return render_template(
|
||||
"license/dashboard.html",
|
||||
summary=summary,
|
||||
sources=reporting.reporting_sources(conn) if current_user.has("reports.view") else [],
|
||||
sources=reporting.reporting_sources(conn) if current_user.has("reports.read") else [],
|
||||
own_role=(extension.license.state.role if extension.license else "master"),
|
||||
type_labels=dict(),
|
||||
)
|
||||
|
||||
@@ -38,18 +38,18 @@ from .common import (
|
||||
vendor_info,
|
||||
)
|
||||
|
||||
bp = Blueprint("licenses", __name__, url_prefix="/lizenzen")
|
||||
bp = Blueprint("licenses", __name__, url_prefix="/licenses")
|
||||
|
||||
|
||||
@bp.get("/")
|
||||
@permission_required("licenses.view")
|
||||
@permission_required("licenses.read")
|
||||
def overview():
|
||||
conn = db()
|
||||
rows = [
|
||||
{**row, **issuance.display_state(row)}
|
||||
for row in issuance.list_licenses(
|
||||
conn,
|
||||
customer_id=request.args.get("kunde", type=int),
|
||||
customer_id=request.args.get("customer", type=int),
|
||||
ticket_id=request.args.get("ticket"),
|
||||
status=request.args.get("status"),
|
||||
)
|
||||
@@ -65,7 +65,7 @@ def overview():
|
||||
|
||||
|
||||
@bp.get("/<license_id>")
|
||||
@permission_required("licenses.view")
|
||||
@permission_required("licenses.read")
|
||||
def detail(license_id: str):
|
||||
conn = db()
|
||||
row = issuance.get_license(conn, license_id)
|
||||
@@ -99,8 +99,8 @@ def detail(license_id: str):
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@bp.post("/aus-ticket/<ticket_id>")
|
||||
@permission_required("licenses.create")
|
||||
@bp.post("/from-ticket/<ticket_id>")
|
||||
@permission_required("license_issue.execute")
|
||||
def issue(ticket_id: str):
|
||||
conn = db()
|
||||
as_trial = request.form.get("as_trial") == "1"
|
||||
@@ -124,7 +124,7 @@ def issue(ticket_id: str):
|
||||
|
||||
if result.already_existed:
|
||||
flash(
|
||||
"Zu diesem Ticket war bereits eine Lizenz offen -- sie wird angezeigt statt eine "
|
||||
"Zu diesem Ticket war bereits eine Lizenz offen – sie wird angezeigt statt eine "
|
||||
"zweite auszustellen.",
|
||||
"info",
|
||||
)
|
||||
@@ -140,9 +140,9 @@ def issue(ticket_id: str):
|
||||
return redirect(url_for("licenses.detail", license_id=result.license_id))
|
||||
|
||||
|
||||
@bp.get("/<license_id>/datei")
|
||||
@bp.get("/<license_id>/file")
|
||||
@permission_required("licenses.export")
|
||||
@reauth_required
|
||||
# Die frische Bestaetigung verlangt das Recht selbst (SENSITIVE_ACTIONS).
|
||||
def download(license_id: str):
|
||||
"""Laedt das vollstaendige Auslieferungs-Bundle herunter.
|
||||
|
||||
@@ -166,7 +166,7 @@ def download(license_id: str):
|
||||
channel="download",
|
||||
recipient=current_user.username,
|
||||
actor=current_user.username,
|
||||
detail="Vollstaendiges Bundle" if "client_key" in bundle else "Nur Lizenzdokument",
|
||||
detail="Vollständiges Bundle" if "client_key" in bundle else "Nur Lizenzdokument",
|
||||
)
|
||||
log_event("license.downloaded", target=license_id[:12], severity="notice")
|
||||
return Response(
|
||||
@@ -181,8 +181,8 @@ def download(license_id: str):
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/<license_id>/neu-signieren")
|
||||
@permission_required("licenses.edit")
|
||||
@bp.post("/<license_id>/resign")
|
||||
@permission_required("license_renew.execute")
|
||||
@reauth_required
|
||||
def resign(license_id: str):
|
||||
"""Signiert eine neue Revision -- z. B. nach einer Modulerweiterung.
|
||||
@@ -202,7 +202,7 @@ def resign(license_id: str):
|
||||
try:
|
||||
days = int(request.form["extend_days"])
|
||||
except ValueError:
|
||||
flash("Die Verlaengerung muss eine Zahl sein.", "danger")
|
||||
flash("Die Verlängerung muss eine Zahl sein.", "danger")
|
||||
return redirect(url_for("licenses.detail", license_id=license_id))
|
||||
if days > 0:
|
||||
document = json.loads(str(row["license_document"]))
|
||||
@@ -212,7 +212,7 @@ def resign(license_id: str):
|
||||
changes["expires_at"] = lic.iso(base + timedelta(days=days))
|
||||
|
||||
if not changes:
|
||||
flash("Es wurde keine Aenderung angegeben.", "info")
|
||||
flash("Es wurde keine Änderung angegeben.", "info")
|
||||
return redirect(url_for("licenses.detail", license_id=license_id))
|
||||
|
||||
key = signing_key()
|
||||
@@ -233,15 +233,15 @@ def resign(license_id: str):
|
||||
)
|
||||
reporting.report_soon(core())
|
||||
flash(
|
||||
f"Neue Revision {updated['seq']} signiert. Der Client erhaelt sie beim naechsten "
|
||||
f"Neue Revision {updated['seq']} signiert. Der Client erhält sie beim nächsten "
|
||||
"Heartbeat.",
|
||||
"success",
|
||||
)
|
||||
return redirect(url_for("licenses.detail", license_id=license_id))
|
||||
|
||||
|
||||
@bp.post("/<license_id>/widerrufen")
|
||||
@permission_required("licenses.edit")
|
||||
@bp.post("/<license_id>/revoke")
|
||||
@permission_required("license_revoke.execute")
|
||||
def revoke(license_id: str):
|
||||
conn = db()
|
||||
try:
|
||||
@@ -258,14 +258,14 @@ def revoke(license_id: str):
|
||||
)
|
||||
reporting.report_soon(core())
|
||||
flash(
|
||||
"Lizenz widerrufen. Das Ticket bleibt offen -- eine neue Lizenz kann sofort "
|
||||
"Lizenz widerrufen. Das Ticket bleibt offen – eine neue Lizenz kann sofort "
|
||||
"ausgestellt werden.",
|
||||
"success",
|
||||
)
|
||||
return redirect(url_for("licenses.detail", license_id=license_id))
|
||||
|
||||
|
||||
@bp.post("/<license_id>/loeschen")
|
||||
@bp.post("/<license_id>/delete")
|
||||
@permission_required("licenses.delete")
|
||||
def delete(license_id: str):
|
||||
conn = db()
|
||||
@@ -277,7 +277,7 @@ def delete(license_id: str):
|
||||
return redirect(url_for("licenses.detail", license_id=license_id))
|
||||
log_event("license.deleted", target=license_id[:12], severity="warning")
|
||||
reporting.report_soon(core())
|
||||
flash("Lizenz geloescht.", "success")
|
||||
flash("Lizenz gelöscht.", "success")
|
||||
return redirect(url_for("licenses.overview"))
|
||||
|
||||
|
||||
@@ -286,7 +286,7 @@ def delete(license_id: str):
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@bp.post("/<license_id>/versenden")
|
||||
@bp.post("/<license_id>/send")
|
||||
@permission_required("licenses.export")
|
||||
def send_mail(license_id: str):
|
||||
conn = db()
|
||||
@@ -295,7 +295,7 @@ def send_mail(license_id: str):
|
||||
abort(404)
|
||||
recipient = (request.form.get("recipient") or str(row["contact_email"] or "")).strip()
|
||||
if not recipient:
|
||||
flash("Es ist keine Empfaengeradresse hinterlegt.", "danger")
|
||||
flash("Es ist keine Empfängeradresse hinterlegt.", "danger")
|
||||
return redirect(url_for("licenses.detail", license_id=license_id))
|
||||
|
||||
try:
|
||||
@@ -315,7 +315,7 @@ def send_mail(license_id: str):
|
||||
"",
|
||||
f"Lizenz-ID: {license_id}",
|
||||
f"Typ: {type_labels().get(str(row['type']), str(row['type']))}",
|
||||
f"Gueltig bis: {row['expires_at']}"
|
||||
f"Gültig bis: {row['expires_at']}"
|
||||
+ (" (unbefristet)" if row["lifetime"] else ""),
|
||||
]
|
||||
if is_customer and row["ticket_id"]:
|
||||
@@ -329,7 +329,7 @@ def send_mail(license_id: str):
|
||||
"Ihre Stammdaten pflegen:",
|
||||
customer_facing_url("portal.request_link"),
|
||||
]
|
||||
lines += ["", "Freundliche Gruesse", vendor.get("name") or "Ihr Lizenzgeber"]
|
||||
lines += ["", "Freundliche Grüße", vendor.get("name") or "Ihr Lizenzgeber"]
|
||||
if vendor.get("phone"):
|
||||
lines.append(f"Telefon: {vendor['phone']}")
|
||||
if vendor.get("email"):
|
||||
@@ -369,7 +369,7 @@ def send_mail(license_id: str):
|
||||
|
||||
|
||||
@bp.route("/offline-code", methods=["GET", "POST"])
|
||||
@permission_required("licenses.execute")
|
||||
@permission_required("license_activation.execute")
|
||||
def manual_code():
|
||||
"""Verarbeitet einen Offline-Code des Kunden.
|
||||
|
||||
@@ -403,7 +403,7 @@ def manual_code():
|
||||
if outcome.ok:
|
||||
response_code = lic.encode_code(outcome.response)
|
||||
ok = True
|
||||
message = "Antwortcode erzeugt. Bitte an den Kunden zuruecksenden."
|
||||
message = "Antwortcode erzeugt. Bitte an den Kunden zurücksenden."
|
||||
log_event(
|
||||
f"license.offline_{payload.get('action')}",
|
||||
target=str(payload.get("license_id") or "")[:12],
|
||||
@@ -428,8 +428,8 @@ def manual_code():
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@bp.get("/sub-lizenzserver")
|
||||
@permission_required("reports.view")
|
||||
@bp.get("/sub-servers")
|
||||
@permission_required("reports.read")
|
||||
def reports():
|
||||
conn = db()
|
||||
sources = reporting.reporting_sources(conn)
|
||||
@@ -440,8 +440,8 @@ def reports():
|
||||
)
|
||||
|
||||
|
||||
@bp.get("/sub-lizenzserver/<license_id>")
|
||||
@permission_required("reports.view")
|
||||
@bp.get("/sub-servers/<license_id>")
|
||||
@permission_required("reports.read")
|
||||
def report_detail(license_id: str):
|
||||
conn = db()
|
||||
row = issuance.get_license(conn, license_id)
|
||||
|
||||
@@ -32,16 +32,17 @@ from tesm_core.licensing_client import LICENSED_FEATURES, LicenseError
|
||||
|
||||
import tesm_licensing as lic
|
||||
|
||||
from .. import is_master_edition
|
||||
from ..services import master_key
|
||||
from .common import can_issue_license_server_licenses, log_event, signing_key
|
||||
|
||||
bp = Blueprint("own_license", __name__, url_prefix="/eigene-lizenz")
|
||||
bp = Blueprint("own_license", __name__, url_prefix="/own-license")
|
||||
|
||||
MAX_UPLOAD_BYTES = 256 * 1024
|
||||
|
||||
|
||||
@bp.get("/")
|
||||
@permission_required("settings_license.view")
|
||||
@permission_required("settings_license.read")
|
||||
def overview():
|
||||
extension = core()
|
||||
conn = db()
|
||||
@@ -62,12 +63,13 @@ def overview():
|
||||
),
|
||||
signing_key_health=master_key.health(extension.registry["master_key_path"]),
|
||||
may_issue_license_server=can_issue_license_server_licenses(),
|
||||
master_edition=is_master_edition(),
|
||||
grace_days=lic.GRACE_PERIOD_DAYS,
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/einspielen")
|
||||
@permission_required("settings_license.edit")
|
||||
@bp.post("/install")
|
||||
@permission_required("settings_license.write")
|
||||
def upload():
|
||||
extension = core()
|
||||
manager = extension.license
|
||||
@@ -77,11 +79,11 @@ def upload():
|
||||
|
||||
upload_file = request.files.get("license_file")
|
||||
if upload_file is None or not upload_file.filename:
|
||||
flash("Keine Datei ausgewaehlt.", "danger")
|
||||
flash("Keine Datei ausgewählt.", "danger")
|
||||
return redirect(url_for("own_license.overview"))
|
||||
raw = upload_file.stream.read(MAX_UPLOAD_BYTES + 1)
|
||||
if len(raw) > MAX_UPLOAD_BYTES:
|
||||
flash("Die Datei ist zu gross fuer eine Lizenzdatei.", "danger")
|
||||
flash("Die Datei ist zu groß für eine Lizenzdatei.", "danger")
|
||||
return redirect(url_for("own_license.overview"))
|
||||
|
||||
try:
|
||||
@@ -127,27 +129,27 @@ def _handshake(action: str):
|
||||
return redirect(url_for("own_license.overview"))
|
||||
|
||||
|
||||
@bp.post("/aktivieren")
|
||||
@permission_required("settings_license.edit")
|
||||
@bp.post("/activate")
|
||||
@permission_required("settings_license.write")
|
||||
def activate():
|
||||
return _handshake("activate")
|
||||
|
||||
|
||||
@bp.post("/deaktivieren")
|
||||
@permission_required("settings_license.edit")
|
||||
@bp.post("/deactivate")
|
||||
@permission_required("settings_license.write")
|
||||
@reauth_required
|
||||
def deactivate():
|
||||
return _handshake("deactivate")
|
||||
|
||||
|
||||
@bp.post("/heartbeat")
|
||||
@permission_required("settings_license.edit")
|
||||
@permission_required("settings_license.write")
|
||||
def heartbeat():
|
||||
return _handshake("heartbeat")
|
||||
|
||||
|
||||
@bp.post("/offline-antwort")
|
||||
@permission_required("settings_license.edit")
|
||||
@bp.post("/offline-response")
|
||||
@permission_required("settings_license.write")
|
||||
def offline_response():
|
||||
manager = core().license
|
||||
if manager is None:
|
||||
@@ -164,8 +166,8 @@ def offline_response():
|
||||
return redirect(url_for("own_license.overview"))
|
||||
|
||||
|
||||
@bp.get("/signaturschluessel")
|
||||
@permission_required("settings_license.view")
|
||||
@bp.get("/signing-key")
|
||||
@permission_required("settings_license.read")
|
||||
def signing_key_info():
|
||||
"""Zeigt den oeffentlichen Signaturschluessel -- zum Pinnen beim Kunden.
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ RATE_WINDOW_MINUTES = 15
|
||||
|
||||
GENERIC_ANSWER = (
|
||||
"Falls diese E-Mail-Adresse bei uns hinterlegt ist, wurde soeben eine Nachricht mit "
|
||||
"einem {minutes} Minuten gueltigen Zugangslink verschickt."
|
||||
"einem {minutes} Minuten gültigen Zugangslink verschickt."
|
||||
)
|
||||
|
||||
EDITABLE_FIELDS = (
|
||||
@@ -150,13 +150,13 @@ def request_link():
|
||||
f"Guten Tag{(' ' + str(customer['contact_person'])) if customer['contact_person'] else ''},",
|
||||
"",
|
||||
"mit dem folgenden Link erreichen Sie Ihre Daten im Kundenportal.",
|
||||
f"Der Link ist {minutes} Minuten gueltig und laesst sich nur einmal verwenden:",
|
||||
f"Der Link ist {minutes} Minuten gültig und lässt sich nur einmal verwenden:",
|
||||
"",
|
||||
access_url,
|
||||
"",
|
||||
"Wenn Sie diesen Zugang nicht angefragt haben, ignorieren Sie die Nachricht.",
|
||||
"",
|
||||
"Freundliche Gruesse",
|
||||
"Freundliche Grüße",
|
||||
vendor.get("name") or "Ihr Lizenzgeber",
|
||||
]
|
||||
),
|
||||
@@ -185,7 +185,7 @@ def access(token: str):
|
||||
expires = _parse(row["expires_at"]) if row is not None else None
|
||||
valid = row is not None and row["used_at"] is None and expires is not None and _now() < expires
|
||||
if not valid:
|
||||
flash("Dieser Link ist ungueltig oder abgelaufen.", "danger")
|
||||
flash("Dieser Link ist ungültig oder abgelaufen.", "danger")
|
||||
return redirect(url_for("portal.request_link"))
|
||||
|
||||
minutes = int(extension.settings.get(conn, "portal_session_minutes") or 60)
|
||||
@@ -247,7 +247,7 @@ def me():
|
||||
ip=client_ip(),
|
||||
severity="notice",
|
||||
)
|
||||
flash("Ihre Angaben wurden gespeichert." if changes else "Keine Aenderungen.", "success")
|
||||
flash("Ihre Angaben wurden gespeichert." if changes else "Keine Änderungen.", "success")
|
||||
except issuance.IssuanceError as exc:
|
||||
flash(str(exc), "danger")
|
||||
return redirect(url_for("portal.me"))
|
||||
|
||||
@@ -31,6 +31,8 @@ from .common import (
|
||||
vendor_info,
|
||||
)
|
||||
|
||||
# Die Adresse steht in verschickten Mails und in Tickets -- sie bleibt
|
||||
# deutsch, waehrend der Rest der Anwendung auf englische Pfade wechselt.
|
||||
bp = Blueprint("selfservice", __name__, url_prefix="/lizenz")
|
||||
|
||||
RATE_BUCKET = "selfservice"
|
||||
@@ -60,7 +62,7 @@ def _rate_guard() -> None:
|
||||
with transaction():
|
||||
ratelimit.record(conn, RATE_BUCKET, client_ip() or "unbekannt")
|
||||
if verdict.blocked:
|
||||
abort(429, description="Zu viele Anfragen. Bitte spaeter erneut versuchen.")
|
||||
abort(429, description="Zu viele Anfragen. Bitte später erneut versuchen.")
|
||||
|
||||
|
||||
def _needs_offline(conn, ticket_id: str): # type: ignore[no-untyped-def]
|
||||
@@ -139,8 +141,8 @@ def issue(ticket_id: str):
|
||||
return redirect(url_for("selfservice.overview", ticket_id=ticket_id))
|
||||
if issuance.ticket_has_open_license(conn, ticket_id):
|
||||
flash(
|
||||
"Es existiert bereits eine gueltige Lizenz. Geben Sie diese zuerst frei, wenn Sie "
|
||||
"auf ein anderes System wechseln moechten.",
|
||||
"Es existiert bereits eine gültige Lizenz. Geben Sie diese zuerst frei, wenn Sie "
|
||||
"auf ein anderes System wechseln möchten.",
|
||||
"warning",
|
||||
)
|
||||
return redirect(url_for("selfservice.overview", ticket_id=ticket_id))
|
||||
@@ -236,12 +238,12 @@ def offline(ticket_id: str):
|
||||
_log(
|
||||
"selfservice.offline_rejected",
|
||||
ticket,
|
||||
"Code gehoert nicht zu diesem Vorgang",
|
||||
"Code gehört nicht zu diesem Vorgang",
|
||||
severity="warning",
|
||||
)
|
||||
flash(
|
||||
"Dieser Code gehoert nicht zu Ihrem Vorgang. Bitte pruefen Sie, ob Sie ihn "
|
||||
"vollstaendig kopiert haben.",
|
||||
"Dieser Code gehört nicht zu Ihrem Vorgang. Bitte prüfen Sie, ob Sie ihn "
|
||||
"vollständig kopiert haben.",
|
||||
"danger",
|
||||
)
|
||||
return redirect(url_for("selfservice.offline", ticket_id=ticket_id))
|
||||
@@ -297,7 +299,7 @@ def revoke(ticket_id: str, license_id: str):
|
||||
_log("license.revoked_self_service", ticket, license_id[:12], severity="warning")
|
||||
reporting.report_soon(core())
|
||||
flash(
|
||||
"Die Lizenz wurde freigegeben. Sie koennen jetzt eine neue Lizenz fuer Ihr neues System "
|
||||
"Die Lizenz wurde freigegeben. Sie können jetzt eine neue Lizenz für Ihr neues System "
|
||||
"ausstellen.",
|
||||
"success",
|
||||
)
|
||||
|
||||
@@ -17,7 +17,7 @@ from tesm_core.extension import core, db, transaction
|
||||
from ..services import mail
|
||||
from .common import log_event, master_endpoint, vendor_info
|
||||
|
||||
bp = Blueprint("settings", __name__, url_prefix="/einstellungen")
|
||||
bp = Blueprint("settings", __name__, url_prefix="/administration")
|
||||
|
||||
MAX_LOGO_BYTES = 200 * 1024
|
||||
LOGO_MIME = {
|
||||
@@ -35,7 +35,7 @@ LOGO_MAGIC = {
|
||||
|
||||
|
||||
@bp.get("/system")
|
||||
@permission_required("settings_system.view")
|
||||
@permission_required("settings_system.read")
|
||||
def system():
|
||||
extension = core()
|
||||
conn = db()
|
||||
@@ -54,6 +54,8 @@ def system():
|
||||
"trash_retention_days",
|
||||
"log_rotate_interval",
|
||||
"log_rotate_keep",
|
||||
"session_idle_minutes",
|
||||
"session_absolute_hours",
|
||||
],
|
||||
),
|
||||
specs={spec.key: spec for spec in extension.settings.specs()},
|
||||
@@ -66,8 +68,13 @@ def system():
|
||||
else ""
|
||||
),
|
||||
timezones=_timezone_choices(),
|
||||
nav_items=extension.nav.items,
|
||||
# Nicht ``nav_items``: dieser Name gehoert dem Seitenrahmen. Wer ihn hier
|
||||
# belegt, ueberschreibt die gefilterte Navigation aus dem Kontext --
|
||||
# und die Seitenleiste zeigte dann jeden Punkt, auch den, den dieser
|
||||
# Benutzer nicht darf und den die Lizenz nicht freischaltet.
|
||||
nav_baum=extension.nav.items,
|
||||
nav_order=extension.settings.get(conn, "nav_order") or extension.nav.default_order(),
|
||||
nav_child_order=extension.settings.get(conn, "nav_child_order") or {},
|
||||
sysops_available=bool(sysops and sysops.available),
|
||||
)
|
||||
|
||||
@@ -82,7 +89,7 @@ def _timezone_choices() -> list[str]:
|
||||
|
||||
|
||||
@bp.post("/system")
|
||||
@permission_required("settings_system.edit")
|
||||
@permission_required("settings_system.write")
|
||||
def system_save():
|
||||
extension = core()
|
||||
conn = db()
|
||||
@@ -97,6 +104,8 @@ def system_save():
|
||||
"trash_retention_days",
|
||||
"log_rotate_interval",
|
||||
"log_rotate_keep",
|
||||
"session_idle_minutes",
|
||||
"session_absolute_hours",
|
||||
):
|
||||
spec = extension.settings.spec(key)
|
||||
if spec.kind == "bool":
|
||||
@@ -116,7 +125,7 @@ def system_save():
|
||||
ip=client_ip(),
|
||||
severity="notice",
|
||||
)
|
||||
flash("Einstellungen gespeichert." if changed else "Keine Aenderungen.",
|
||||
flash("Einstellungen gespeichert." if changed else "Keine Änderungen.",
|
||||
"success" if changed else "info")
|
||||
return redirect(url_for("settings.system"))
|
||||
|
||||
@@ -128,10 +137,10 @@ def set_hostname():
|
||||
sysops = extension.sysops
|
||||
hostname = (request.form.get("hostname") or "").strip()
|
||||
if not hostname or len(hostname) > 63 or not hostname.replace("-", "").isalnum():
|
||||
flash("Der Hostname ist ungueltig.", "danger")
|
||||
flash("Der Hostname ist ungültig.", "danger")
|
||||
return redirect(url_for("settings.system"))
|
||||
if sysops is None or not sysops.available:
|
||||
flash("Systemaktionen sind in dieser Umgebung nicht verfuegbar.", "warning")
|
||||
flash("Systemaktionen sind in dieser Umgebung nicht verfügbar.", "warning")
|
||||
return redirect(url_for("settings.system"))
|
||||
result = sysops.set_hostname(hostname)
|
||||
if result.ok:
|
||||
@@ -142,7 +151,7 @@ def set_hostname():
|
||||
return redirect(url_for("settings.system"))
|
||||
|
||||
|
||||
@bp.post("/system/zeitzone")
|
||||
@bp.post("/system/timezone")
|
||||
@permission_required("settings_system.execute")
|
||||
def set_timezone():
|
||||
import time
|
||||
@@ -153,7 +162,7 @@ def set_timezone():
|
||||
flash("Unbekannte Zeitzone.", "danger")
|
||||
return redirect(url_for("settings.system"))
|
||||
if sysops is None or not sysops.available:
|
||||
flash("Systemaktionen sind in dieser Umgebung nicht verfuegbar.", "warning")
|
||||
flash("Systemaktionen sind in dieser Umgebung nicht verfügbar.", "warning")
|
||||
return redirect(url_for("settings.system"))
|
||||
result = sysops.set_timezone(timezone)
|
||||
if result.ok:
|
||||
@@ -173,8 +182,8 @@ def set_timezone():
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@bp.get("/anbieter")
|
||||
@permission_required("settings_vendor.view")
|
||||
@bp.get("/vendor")
|
||||
@permission_required("settings_vendor.read")
|
||||
def vendor():
|
||||
extension = core()
|
||||
conn = db()
|
||||
@@ -195,8 +204,8 @@ def vendor():
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/anbieter")
|
||||
@permission_required("settings_vendor.edit")
|
||||
@bp.post("/vendor")
|
||||
@permission_required("settings_vendor.write")
|
||||
def vendor_save():
|
||||
extension = core()
|
||||
conn = db()
|
||||
@@ -222,7 +231,7 @@ def vendor_save():
|
||||
if suffix not in LOGO_MIME:
|
||||
logo_problem = "Erlaubt sind PNG, JPG und SVG."
|
||||
elif len(data) > MAX_LOGO_BYTES:
|
||||
logo_problem = f"Das Logo ist zu gross (maximal {MAX_LOGO_BYTES // 1024} KB)."
|
||||
logo_problem = f"Das Logo ist zu groß (maximal {MAX_LOGO_BYTES // 1024} KB)."
|
||||
elif not any(head.startswith(magic) for magic in LOGO_MAGIC):
|
||||
logo_problem = "Die Datei ist kein Bild."
|
||||
else:
|
||||
@@ -240,13 +249,13 @@ def vendor_save():
|
||||
log_event(
|
||||
"settings.vendor_saved", detail=", ".join(changed), severity="notice", scope="anbieter"
|
||||
)
|
||||
flash("Anbieterdaten gespeichert." if changed else "Keine Aenderungen.",
|
||||
flash("Anbieterdaten gespeichert." if changed else "Keine Änderungen.",
|
||||
"success" if changed else "info")
|
||||
return redirect(url_for("settings.vendor"))
|
||||
|
||||
|
||||
@bp.post("/anbieter/logo-entfernen")
|
||||
@permission_required("settings_vendor.edit")
|
||||
@bp.post("/vendor/remove-logo")
|
||||
@permission_required("settings_vendor.write")
|
||||
def vendor_logo_remove():
|
||||
with transaction() as conn:
|
||||
core().settings.set(conn, "vendor_logo_base64", "", actor=current_user.username)
|
||||
@@ -260,7 +269,7 @@ def vendor_logo_remove():
|
||||
|
||||
|
||||
@bp.get("/email")
|
||||
@permission_required("settings_mail.view")
|
||||
@permission_required("settings_mail.read")
|
||||
def mail_settings():
|
||||
extension = core()
|
||||
conn = db()
|
||||
@@ -275,7 +284,7 @@ def mail_settings():
|
||||
|
||||
|
||||
@bp.post("/email")
|
||||
@permission_required("settings_mail.edit")
|
||||
@permission_required("settings_mail.write")
|
||||
def mail_save():
|
||||
extension = core()
|
||||
conn = db()
|
||||
@@ -292,7 +301,7 @@ def mail_save():
|
||||
mail.store_secret(conn, request.form.get("client_secret"))
|
||||
changed.append("client_secret")
|
||||
log_event("settings.mail_saved", detail=", ".join(changed), severity="notice", scope="mail")
|
||||
flash("E-Mail-Einstellungen gespeichert." if changed else "Keine Aenderungen.",
|
||||
flash("E-Mail-Einstellungen gespeichert." if changed else "Keine Änderungen.",
|
||||
"success" if changed else "info")
|
||||
return redirect(url_for("settings.mail_settings"))
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ bp = Blueprint("tickets", __name__, url_prefix="/tickets")
|
||||
|
||||
|
||||
@bp.get("/")
|
||||
@permission_required("tickets.view")
|
||||
@permission_required("tickets.read")
|
||||
def overview():
|
||||
conn = db()
|
||||
tickets = issuance.list_tickets(conn)
|
||||
@@ -45,7 +45,7 @@ def overview():
|
||||
)
|
||||
|
||||
|
||||
@bp.get("/neu")
|
||||
@bp.get("/new")
|
||||
@permission_required("tickets.create")
|
||||
def new():
|
||||
conn = db()
|
||||
@@ -60,11 +60,11 @@ def new():
|
||||
type_labels=type_labels(),
|
||||
default_valid_days=int(extension.settings.get(conn, "default_valid_days") or 365),
|
||||
may_create_license_server=can_issue_license_server_licenses(),
|
||||
preselected_customer=request.args.get("kunde", type=int),
|
||||
preselected_customer=request.args.get("customer", type=int),
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/neu")
|
||||
@bp.post("/new")
|
||||
@permission_required("tickets.create")
|
||||
def create():
|
||||
conn = db()
|
||||
@@ -106,14 +106,14 @@ def _endpoint_missing() -> str:
|
||||
if master_endpoint():
|
||||
return ""
|
||||
return (
|
||||
"Es ist kein oeffentlicher Endpunkt hinterlegt (Einstellungen -> Anbieter). "
|
||||
"Solange er fehlt, koennen keine Lizenzen ausgestellt werden: der Endpunkt wird "
|
||||
"in die Lizenz signiert und laesst sich spaeter nicht nachtragen."
|
||||
"Es ist kein öffentlicher Endpunkt hinterlegt (Einstellungen -> Anbieter). "
|
||||
"Solange er fehlt, können keine Lizenzen ausgestellt werden: der Endpunkt wird "
|
||||
"in die Lizenz signiert und lässt sich später nicht nachtragen."
|
||||
)
|
||||
|
||||
|
||||
@bp.get("/<ticket_id>")
|
||||
@permission_required("tickets.view")
|
||||
@permission_required("tickets.read")
|
||||
def detail(ticket_id: str):
|
||||
conn = db()
|
||||
ticket = issuance.get_ticket(conn, ticket_id)
|
||||
@@ -150,7 +150,7 @@ def detail(ticket_id: str):
|
||||
|
||||
|
||||
@bp.post("/<ticket_id>")
|
||||
@permission_required("tickets.edit")
|
||||
@permission_required("tickets.write")
|
||||
def update(ticket_id: str):
|
||||
conn = db()
|
||||
if issuance.get_ticket(conn, ticket_id) is None:
|
||||
@@ -176,9 +176,9 @@ def update(ticket_id: str):
|
||||
)
|
||||
reporting.report_soon(core())
|
||||
flash(
|
||||
"Gespeichert. Die Aenderung wirkt auf die naechste Ausstellung."
|
||||
"Gespeichert. Die Änderung wirkt auf die nächste Ausstellung."
|
||||
if changes
|
||||
else "Keine Aenderungen.",
|
||||
else "Keine Änderungen.",
|
||||
"success" if changes else "info",
|
||||
)
|
||||
except (issuance.IssuanceError, ValueError) as exc:
|
||||
@@ -186,8 +186,8 @@ def update(ticket_id: str):
|
||||
return redirect(url_for("tickets.detail", ticket_id=ticket_id))
|
||||
|
||||
|
||||
@bp.post("/<ticket_id>/sperre")
|
||||
@permission_required("tickets.edit")
|
||||
@bp.post("/<ticket_id>/lock")
|
||||
@permission_required("tickets.write")
|
||||
def toggle_block(ticket_id: str):
|
||||
conn = db()
|
||||
ticket = issuance.get_ticket(conn, ticket_id)
|
||||
@@ -205,7 +205,7 @@ def toggle_block(ticket_id: str):
|
||||
)
|
||||
reporting.report_soon(core())
|
||||
flash(
|
||||
"Ticket gesperrt -- es lassen sich keine neuen Lizenzen mehr ausstellen."
|
||||
"Ticket gesperrt – es lassen sich keine neuen Lizenzen mehr ausstellen."
|
||||
if new_status == "blocked"
|
||||
else "Ticket entsperrt.",
|
||||
"success",
|
||||
@@ -213,7 +213,7 @@ def toggle_block(ticket_id: str):
|
||||
return redirect(url_for("tickets.detail", ticket_id=ticket_id))
|
||||
|
||||
|
||||
@bp.post("/<ticket_id>/loeschen")
|
||||
@bp.post("/<ticket_id>/delete")
|
||||
@permission_required("tickets.delete")
|
||||
def delete(ticket_id: str):
|
||||
conn = db()
|
||||
@@ -231,7 +231,7 @@ def delete(ticket_id: str):
|
||||
scope="ticket",
|
||||
)
|
||||
reporting.report_soon(core())
|
||||
flash("Ticket und seine Lizenzhistorie geloescht.", "success")
|
||||
flash("Ticket und seine Lizenzhistorie gelöscht.", "success")
|
||||
return redirect(url_for("tickets.overview"))
|
||||
except issuance.IssuanceError as exc:
|
||||
flash(str(exc), "danger")
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
"""Kommandozeilenwerkzeuge des Lizenzservers.
|
||||
|
||||
Enthaelt insbesondere ``bootstrap`` -- die eigene Lizenz der Wurzelinstanz.
|
||||
``init-key`` erzeugt den Signaturschluessel dieser Installation -- den braucht
|
||||
**jeder** Lizenzserver, um Kundenlizenzen zu signieren, auch ein
|
||||
Sub-Lizenzserver.
|
||||
|
||||
Warum das ein lokales Skript ist und keine Funktion in der Oberflaeche: die
|
||||
Master-Lizenz ist die einzige Lizenz, fuer die es **keine hoehere Instanz gibt**,
|
||||
die sie bestaetigen koennte. Ein Aktivierungs-Handshake gegen sich selbst waere
|
||||
Theater. Das Skript signiert die Datei mit dem eigenen Schluessel und setzt den
|
||||
Aktivierungszustand direkt -- und nur so kann eine Lizenzdatei je die Rolle
|
||||
``master`` tragen. Die Ticket-Oberflaeche bietet diese Rolle an keiner Stelle an.
|
||||
``bootstrap`` stellt die eigene Lizenz der Wurzelinstanz aus und ist damit der
|
||||
einzige Weg, auf dem eine Lizenzdatei die Rolle ``master`` bekommt (ueber
|
||||
Tickets ist sie auf jedem Server abgewiesen). Die Arbeit dazu liegt im
|
||||
Teilpaket ``tesm_license.master``, das nur die Master-Ausgabe mitbringt; fehlt
|
||||
es, sagt der Befehl das und tut nichts. Siehe dort fuer die Begruendung.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -20,7 +21,6 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import tesm_licensing as lic
|
||||
import tesm_licensing_server as licsrv
|
||||
from tesm_core import audit
|
||||
from tesm_core.cli_web import add_web_setup, run_web_setup
|
||||
from tesm_core.db import Database
|
||||
@@ -28,7 +28,7 @@ from tesm_core.extension import core
|
||||
from tesm_core.rbac import service as rbac
|
||||
from tesm_core.security import passwords
|
||||
|
||||
from . import create_app, master_key_path
|
||||
from . import create_app, is_master_edition, master_key_path
|
||||
from .services import legacy_import, master_key
|
||||
|
||||
|
||||
@@ -42,95 +42,51 @@ def cmd_init_key(args: argparse.Namespace) -> int:
|
||||
path = master_key_path(extension.config)
|
||||
if path.exists() and not args.force:
|
||||
key = master_key.load(path)
|
||||
print(f"Signaturschluessel existiert bereits: {key.key_id} ({path})")
|
||||
print("Ein Austausch macht ALLE bereits ausgestellten Lizenzen unpruefbar.")
|
||||
print(f"Signaturschlüssel existiert bereits: {key.key_id} ({path})")
|
||||
print("Ein Austausch macht ALLE bereits ausgestellten Lizenzen unprüfbar.")
|
||||
return 0
|
||||
if path.exists() and args.force:
|
||||
print(
|
||||
"ABBRUCH: --force auf einen bestehenden Signaturschluessel wird nicht unterstuetzt.\n"
|
||||
"Ein neuer Schluessel entwertet jede ausgestellte Lizenz. Bitte die Datei bewusst\n"
|
||||
"ABBRUCH: --force auf einen bestehenden Signaturschlüssel wird nicht unterstützt.\n"
|
||||
"Ein neuer Schlüssel entwertet jede ausgestellte Lizenz. Bitte die Datei bewusst\n"
|
||||
"von Hand beiseite legen, wenn das wirklich gewollt ist.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
key = master_key.ensure(path)
|
||||
print(f"Signaturschluessel erzeugt: {key.key_id}")
|
||||
print(f"Signaturschlüssel erzeugt: {key.key_id}")
|
||||
print(f"Datei: {path}")
|
||||
print("Bitte SOFORT sichern -- ohne sie ist keine ausgestellte Lizenz mehr pruefbar.")
|
||||
print(f"Oeffentlicher Schluessel (zum Pinnen beim Kunden):\n{key.public_key}")
|
||||
print("Bitte SOFORT sichern – ohne sie ist keine ausgestellte Lizenz mehr prüfbar.")
|
||||
print(f"Öffentlicher Schlüssel (zum Pinnen beim Kunden):\n{key.public_key}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_bootstrap(args: argparse.Namespace) -> int:
|
||||
"""Stellt die eigene Lizenz dieser Wurzelinstanz aus."""
|
||||
"""Stellt die eigene Lizenz dieser Wurzelinstanz aus -- nur in der Master-Ausgabe."""
|
||||
if not is_master_edition():
|
||||
for zeile in (
|
||||
"Diese Ausgabe ist für Sub-Lizenzserver und kann keine Master-Lizenz erzeugen.",
|
||||
"",
|
||||
"Ein Sub-Lizenzserver bekommt seine Lizenz vom Master-Lizenzserver und spielt",
|
||||
"sie unter 'Eigene Lizenz' ein. Danach stellt er selbst Kundenlizenzen aus --",
|
||||
"mit seinem eigenen Signaturschlüssel, der beim Installieren entstanden ist.",
|
||||
"",
|
||||
"Die Master-Ausgabe liegt im Repository tesm-license-master.",
|
||||
):
|
||||
print(zeile, file=sys.stderr)
|
||||
return 2
|
||||
|
||||
from .master import bootstrap as eigene_lizenz_ausstellen
|
||||
|
||||
app, extension = _app_and_core()
|
||||
key = master_key.ensure(master_key_path(extension.config))
|
||||
|
||||
with app.app_context(), extension.database.session() as conn:
|
||||
vendor = {
|
||||
"name": str(extension.settings.get(conn, "vendor_name") or "Eigenbetrieb"),
|
||||
"email": str(extension.settings.get(conn, "vendor_email") or ""),
|
||||
"phone": str(extension.settings.get(conn, "vendor_phone") or ""),
|
||||
"address": str(extension.settings.get(conn, "vendor_address") or ""),
|
||||
"logo_base64": "",
|
||||
}
|
||||
bundle, _ = licsrv.issue_license(
|
||||
customer={"name": args.name, "contact_email": vendor["email"]},
|
||||
license_type="enterprise",
|
||||
modules=[],
|
||||
valid_days=int(args.days),
|
||||
master_private_key_b64=key.private_key,
|
||||
master_public_key_b64=key.public_key,
|
||||
# Bewusst leer: es gibt keinen uebergeordneten Endpunkt. Damit
|
||||
# laeuft der Handshake fuer diese Lizenz nie ins Netz.
|
||||
master_endpoint="",
|
||||
vendor=vendor,
|
||||
role="master",
|
||||
document, state = eigene_lizenz_ausstellen(
|
||||
app, extension, days=int(args.days), name=args.name
|
||||
)
|
||||
document, client_key = lic.split_bundle(bundle)
|
||||
|
||||
config = extension.config
|
||||
config.license_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
config.license_path.write_text(
|
||||
json.dumps(document, indent=2, sort_keys=True, ensure_ascii=False) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
try:
|
||||
config.license_path.chmod(0o640)
|
||||
except OSError:
|
||||
pass
|
||||
from tesm_core.keystore import write_private_file
|
||||
|
||||
write_private_file(
|
||||
config.license_key_path,
|
||||
json.dumps({"license_id": document["license_id"], "client_key": client_key}) + "\n",
|
||||
)
|
||||
|
||||
with extension.database.transaction(conn):
|
||||
conn.execute(
|
||||
"INSERT INTO trusted_issuer_keys (key_id, public_key, label, source, added_at, "
|
||||
"added_by) VALUES (?,?,?,'bootstrap',datetime('now'),'cli') "
|
||||
"ON CONFLICT(key_id) DO NOTHING",
|
||||
(key.key_id, key.public_key, "Eigener Signaturschluessel"),
|
||||
)
|
||||
# Es gibt keine hoehere Instanz, die diese Lizenz aktivieren koennte.
|
||||
extension.settings.set(conn, "license_activation_status", "active", actor="cli")
|
||||
extension.settings.set(conn, "license_pending_request", "", actor="cli")
|
||||
audit.log(
|
||||
conn,
|
||||
actor="cli",
|
||||
action="own_license.bootstrapped",
|
||||
target=document["license_id"],
|
||||
detail=f"Rolle master, {args.days} Tage, Schluessel {key.key_id}",
|
||||
severity="warning",
|
||||
)
|
||||
state = extension.license.reload(conn) if extension.license else None
|
||||
|
||||
print(f"Master-Lizenz ausgestellt: {document['license_id']}")
|
||||
print(f"Gueltig bis: {document['expires_at']}")
|
||||
print(f"Datei: {config.license_path}")
|
||||
print(f"Gültig bis: {document['expires_at']}")
|
||||
print(f"Datei: {extension.config.license_path}")
|
||||
if state is not None:
|
||||
print(f"Zustand: gueltig={state.valid}, aktiviert={state.activated}, Rolle={state.role}")
|
||||
print(f"Zustand: gültig={state.valid}, aktiviert={state.activated}, Rolle={state.role}")
|
||||
return 0
|
||||
|
||||
|
||||
@@ -142,7 +98,7 @@ def cmd_create_admin(args: argparse.Namespace) -> int:
|
||||
return 2
|
||||
password = args.password or getpass.getpass("Passwort: ")
|
||||
if not args.password and password != getpass.getpass("Passwort wiederholen: "):
|
||||
print("Die Passwoerter stimmen nicht ueberein.", file=sys.stderr)
|
||||
print("Die Passwörter stimmen nicht überein.", file=sys.stderr)
|
||||
return 2
|
||||
problems = extension.password_policy.check(password, username=username)
|
||||
if problems and not args.force:
|
||||
@@ -224,7 +180,7 @@ def cmd_status(args: argparse.Namespace) -> int:
|
||||
+ ("unversehrt" if chain["ok"] else f"GEBROCHEN bei {chain['broken_at']}")
|
||||
)
|
||||
print(
|
||||
"Signaturschluessel: "
|
||||
"Signaturschlüssel: "
|
||||
+ (str(key_health.get("key_id")) if key_health.get("present") else "FEHLT")
|
||||
)
|
||||
for problem in key_health.get("problems", []):
|
||||
@@ -297,17 +253,17 @@ def cmd_migrate_legacy(args: argparse.Namespace) -> int:
|
||||
except legacy_import.LegacyImportError as exc:
|
||||
if args.adopt_key:
|
||||
raise
|
||||
print(f"Hinweis: alter Signaturschluessel nicht lesbar ({exc}).")
|
||||
print(f"Hinweis: alter Signaturschlüssel nicht lesbar ({exc}).")
|
||||
|
||||
if key_path.is_file():
|
||||
existing = master_key.load(key_path)
|
||||
report.key_id = existing.key_id
|
||||
if legacy_public and existing.public_key == legacy_public:
|
||||
print(f"Signaturschluessel stimmt bereits ueberein ({existing.key_id}).")
|
||||
print(f"Signaturschlüssel stimmt bereits überein ({existing.key_id}).")
|
||||
elif not legacy_public:
|
||||
issuer_changes = True
|
||||
print(
|
||||
f"Es wird mit dem vorhandenen Signaturschluessel {existing.key_id} "
|
||||
f"Es wird mit dem vorhandenen Signaturschlüssel {existing.key_id} "
|
||||
"gearbeitet."
|
||||
)
|
||||
else:
|
||||
@@ -315,7 +271,7 @@ def cmd_migrate_legacy(args: argparse.Namespace) -> int:
|
||||
# ausgestellte Lizenz waere danach unpruefbar.
|
||||
issuer_changes = True
|
||||
print(
|
||||
f"Diese Installation hat einen eigenen Signaturschluessel "
|
||||
f"Diese Installation hat einen eigenen Signaturschlüssel "
|
||||
f"({existing.key_id}); der alte war {lic.key_id(legacy_public)}. "
|
||||
"Es wird mit dem vorhandenen weitergearbeitet."
|
||||
)
|
||||
@@ -323,12 +279,12 @@ def cmd_migrate_legacy(args: argparse.Namespace) -> int:
|
||||
legacy_private, legacy_public = legacy_import.read_legacy_key(root)
|
||||
report.key_id = legacy_import.adopt_key(legacy_private, legacy_public, key_path)
|
||||
report.key_adopted = True
|
||||
print(f"Signaturschluessel uebernommen: {report.key_id}")
|
||||
print(f"Signaturschlüssel übernommen: {report.key_id}")
|
||||
else:
|
||||
print(
|
||||
"Diese Installation hat noch keinen Signaturschluessel. Mit --adopt-key "
|
||||
"wird der alte uebernommen -- dann bleibt die Anbieteridentitaet erhalten "
|
||||
"und Kunden muessen keinen Ausstellerwechsel bestaetigen.",
|
||||
"Diese Installation hat noch keinen Signaturschlüssel. Mit --adopt-key "
|
||||
"wird der alte übernommen – dann bleibt die Anbieteridentität erhalten "
|
||||
"und Kunden müssen keinen Ausstellerwechsel bestätigen.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
@@ -336,6 +292,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"Übernommen: {einstellung} = {gekuerzt}")
|
||||
|
||||
vendor = {
|
||||
"name": str(extension.settings.get(conn, "vendor_name") or "Eigenbetrieb"),
|
||||
"email": str(extension.settings.get(conn, "vendor_email") or ""),
|
||||
@@ -346,8 +324,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 können übernommene Lizenzen sich später nicht aktivieren.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
@@ -368,10 +347,10 @@ def cmd_migrate_legacy(args: argparse.Namespace) -> int:
|
||||
result.key_id = report.key_id
|
||||
if issuer_changes:
|
||||
result.warnings.append(
|
||||
"Die Lizenzen sind mit einem anderen Schluessel signiert als "
|
||||
"frueher. Kunden, die den alten Aussteller gepinnt haben, muessen "
|
||||
"beim Einspielen einmal 'Wechsel des Ausstellers ausdruecklich "
|
||||
"zulassen' bestaetigen."
|
||||
"Die Lizenzen sind mit einem anderen Schlüssel signiert als "
|
||||
"früher. Kunden, die den alten Aussteller gepinnt haben, müssen "
|
||||
"beim Einspielen einmal 'Wechsel des Ausstellers ausdrücklich "
|
||||
"zulassen' bestätigen."
|
||||
)
|
||||
if args.dry_run:
|
||||
# Nichts festschreiben -- der Lauf war nur zur Ansicht.
|
||||
@@ -406,18 +385,23 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
)
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
init_key = sub.add_parser("init-key", help="Signaturschluessel erzeugen (einmalig)")
|
||||
init_key = sub.add_parser("init-key", help="Signaturschlüssel erzeugen (einmalig)")
|
||||
init_key.add_argument("--force", action="store_true")
|
||||
init_key.set_defaults(func=cmd_init_key)
|
||||
|
||||
bootstrap = sub.add_parser(
|
||||
"bootstrap", help="Eigene Master-Lizenz dieser Wurzelinstanz ausstellen"
|
||||
"bootstrap",
|
||||
help=(
|
||||
"Eigene Master-Lizenz dieser Wurzelinstanz ausstellen"
|
||||
if is_master_edition()
|
||||
else "nicht in dieser Ausgabe (nur Master-Lizenzserver)"
|
||||
),
|
||||
)
|
||||
bootstrap.add_argument("--days", type=int, default=3650)
|
||||
bootstrap.add_argument("--name", default="Lizenzserver (Eigenbetrieb)")
|
||||
bootstrap.set_defaults(func=cmd_bootstrap)
|
||||
|
||||
admin = sub.add_parser("create-admin", help="Administratorkonto anlegen oder zuruecksetzen")
|
||||
admin = sub.add_parser("create-admin", help="Administratorkonto anlegen oder zurücksetzen")
|
||||
admin.add_argument("--username")
|
||||
admin.add_argument("--password")
|
||||
admin.add_argument("--force", action="store_true")
|
||||
@@ -427,16 +411,16 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
status.add_argument("--json", action="store_true")
|
||||
status.set_defaults(func=cmd_status)
|
||||
|
||||
house = sub.add_parser("housekeeping", help="Laufzeitdaten aufraeumen")
|
||||
house = sub.add_parser("housekeeping", help="Laufzeitdaten aufräumen")
|
||||
house.set_defaults(func=cmd_housekeeping)
|
||||
|
||||
migrate = sub.add_parser(
|
||||
"migrate-legacy",
|
||||
help="Lizenzserver der Vorgaengerfassung uebernehmen",
|
||||
help="Lizenzserver der Vorgängerfassung übernehmen",
|
||||
description=(
|
||||
"Uebernimmt Signaturschluessel, Kunden, Tickets und Lizenzen aus einer alten "
|
||||
"Übernimmt Signaturschlüssel, Kunden, Tickets und Lizenzen aus einer alten "
|
||||
"Installation. Die alte Installation wird nur gelesen. Jede Lizenz wird im "
|
||||
"neuen Format neu signiert -- die Kunden brauchen danach eine neue "
|
||||
"neuen Format neu signiert – die Kunden brauchen danach eine neue "
|
||||
"Lizenzdatei, weil die alte Signatur nicht mehr gilt."
|
||||
),
|
||||
)
|
||||
@@ -446,13 +430,13 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
migrate.add_argument(
|
||||
"--adopt-key",
|
||||
action="store_true",
|
||||
help="Den alten Signaturschluessel uebernehmen. Nur moeglich, solange diese "
|
||||
"Installation noch keinen eigenen hat -- ein vorhandener wird nie ueberschrieben.",
|
||||
help="Den alten Signaturschlüssel übernehmen. Nur möglich, solange diese "
|
||||
"Installation noch keinen eigenen hat – ein vorhandener wird nie überschrieben.",
|
||||
)
|
||||
migrate.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Nur anzeigen, was passieren wuerde -- nichts schreiben",
|
||||
help="Nur anzeigen, was passieren würde – nichts schreiben",
|
||||
)
|
||||
migrate.set_defaults(func=cmd_migrate_legacy)
|
||||
|
||||
|
||||
@@ -28,41 +28,41 @@ def _is_master(_user: Any) -> bool:
|
||||
|
||||
NAVIGATION = NavTree(
|
||||
[
|
||||
NavItem(key="index", label="Uebersicht", icon="grid", endpoint="dashboard.index"),
|
||||
NavItem(key="index", label="Übersicht", icon="grid", endpoint="dashboard.index"),
|
||||
NavItem(
|
||||
key="customers",
|
||||
label="Kunden",
|
||||
icon="building",
|
||||
endpoint="customers.overview",
|
||||
permissions=("customers.view",),
|
||||
permissions=("customers.read",),
|
||||
),
|
||||
NavItem(
|
||||
key="tickets",
|
||||
label="Tickets",
|
||||
icon="ticket",
|
||||
endpoint="tickets.overview",
|
||||
permissions=("tickets.view",),
|
||||
permissions=("tickets.read",),
|
||||
),
|
||||
NavItem(
|
||||
key="licenses",
|
||||
label="Lizenzen",
|
||||
icon="shield",
|
||||
endpoint="licenses.overview",
|
||||
permissions=("licenses.view",),
|
||||
permissions=("licenses.read",),
|
||||
),
|
||||
NavItem(
|
||||
key="offline_code",
|
||||
label="Offline-Aktivierung",
|
||||
icon="transfer",
|
||||
endpoint="licenses.manual_code",
|
||||
permissions=("licenses.execute",),
|
||||
permissions=("license_activation.execute",),
|
||||
),
|
||||
NavItem(
|
||||
key="reports",
|
||||
label="Sub-Lizenzserver",
|
||||
icon="share",
|
||||
endpoint="licenses.reports",
|
||||
permissions=("reports.view",),
|
||||
permissions=("reports.read",),
|
||||
condition=_is_master,
|
||||
),
|
||||
NavItem(
|
||||
@@ -72,24 +72,24 @@ NAVIGATION = NavTree(
|
||||
children=(
|
||||
NavItem(
|
||||
key="logs_live",
|
||||
label="Laufend",
|
||||
label="Live",
|
||||
icon="terminal",
|
||||
endpoint="logs.live",
|
||||
permissions=("logs_live.view",),
|
||||
permissions=("logs_live.read",),
|
||||
),
|
||||
NavItem(
|
||||
key="logs_history",
|
||||
label="Verlauf",
|
||||
icon="clock",
|
||||
endpoint="logs.history",
|
||||
permissions=("logs_history.view", "logs_history.export"),
|
||||
permissions=("logs_history.read", "logs_history.export"),
|
||||
),
|
||||
NavItem(
|
||||
key="logs_audit",
|
||||
label="Aenderungen",
|
||||
label="Audit",
|
||||
icon="history",
|
||||
endpoint="logs.audit_view",
|
||||
permissions=("logs_audit.view", "logs_audit.export"),
|
||||
permissions=("logs_audit.read", "logs_audit.export"),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -103,84 +103,84 @@ NAVIGATION = NavTree(
|
||||
label="Benutzer",
|
||||
icon="users",
|
||||
endpoint="admin.users",
|
||||
permissions=("users.view",),
|
||||
permissions=("users.read",),
|
||||
),
|
||||
NavItem(
|
||||
key="groups",
|
||||
label="Gruppen und Rechte",
|
||||
icon="shield-users",
|
||||
endpoint="admin.groups",
|
||||
permissions=("groups.view",),
|
||||
permissions=("groups.read",),
|
||||
),
|
||||
NavItem(
|
||||
key="settings_vendor",
|
||||
label="Anbieterdaten",
|
||||
icon="building",
|
||||
endpoint="settings.vendor",
|
||||
permissions=("settings_vendor.view",),
|
||||
permissions=("settings_vendor.read",),
|
||||
),
|
||||
NavItem(
|
||||
key="settings_mail",
|
||||
label="E-Mail-Versand",
|
||||
icon="mail",
|
||||
endpoint="settings.mail_settings",
|
||||
permissions=("settings_mail.view",),
|
||||
permissions=("settings_mail.read",),
|
||||
),
|
||||
NavItem(
|
||||
key="settings_system",
|
||||
label="System",
|
||||
icon="sliders",
|
||||
endpoint="settings.system",
|
||||
permissions=("settings_system.view",),
|
||||
permissions=("settings_system.read",),
|
||||
),
|
||||
NavItem(
|
||||
key="settings_network",
|
||||
label="Netzwerk",
|
||||
icon="network",
|
||||
endpoint="hostsettings.network",
|
||||
permissions=("settings_network.view",),
|
||||
permissions=("settings_network.read",),
|
||||
),
|
||||
NavItem(
|
||||
key="settings_web",
|
||||
label="Webserver und TLS",
|
||||
icon="server",
|
||||
endpoint="hostsettings.web",
|
||||
permissions=("settings_web.view",),
|
||||
permissions=("settings_web.read",),
|
||||
),
|
||||
NavItem(
|
||||
key="settings_directory",
|
||||
label="Verzeichnisdienst",
|
||||
icon="users",
|
||||
endpoint="hostsettings.directory_settings",
|
||||
permissions=("settings_directory.view",),
|
||||
permissions=("settings_directory.read",),
|
||||
),
|
||||
NavItem(
|
||||
key="settings_license",
|
||||
label="Eigene Lizenz",
|
||||
icon="key",
|
||||
endpoint="own_license.overview",
|
||||
permissions=("settings_license.view",),
|
||||
permissions=("settings_license.read",),
|
||||
),
|
||||
NavItem(
|
||||
key="backup",
|
||||
label="Sicherung",
|
||||
icon="transfer",
|
||||
endpoint="backup.overview",
|
||||
permissions=("backup.view", "backup.export", "backup.execute"),
|
||||
permissions=("backup_export.read", "backup_import.read"),
|
||||
),
|
||||
NavItem(
|
||||
key="trash",
|
||||
label="Papierkorb",
|
||||
icon="trash",
|
||||
endpoint="trash.overview",
|
||||
permissions=("trash.view",),
|
||||
permissions=("trash.read",),
|
||||
),
|
||||
NavItem(
|
||||
key="diagnostics",
|
||||
label="Diagnose",
|
||||
icon="info",
|
||||
endpoint="diagnostics.overview",
|
||||
permissions=("diagnostics.view",),
|
||||
permissions=("diagnostics.read",),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -28,58 +28,97 @@ A = Action
|
||||
CUSTOMERS = Resource(
|
||||
key="customers",
|
||||
label="Kunden",
|
||||
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE),
|
||||
actions=(A.READ, A.CREATE, A.WRITE, A.DELETE),
|
||||
hint="Stammdaten der Kundeninstanzen",
|
||||
action_hints={A.DELETE: "Nur moeglich, solange kein Ticket am Kunden haengt."},
|
||||
action_hints={A.DELETE: "Nur möglich, solange kein Ticket am Kunden hängt."},
|
||||
)
|
||||
|
||||
TICKETS = Resource(
|
||||
key="tickets",
|
||||
label="Tickets",
|
||||
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE),
|
||||
hint="Dauerhafte Anspruechte auf eine Lizenz",
|
||||
actions=(A.READ, A.CREATE, A.WRITE, A.DELETE),
|
||||
hint="Dauerhafte Ansprüche auf eine Lizenz",
|
||||
action_hints={
|
||||
A.EDIT: "Parameter aendern und Ticket sperren/entsperren. Wirkt erst auf die "
|
||||
"naechste Ausstellung -- bereits ausgestellte Lizenzen bleiben unveraendert.",
|
||||
A.DELETE: "Nur moeglich, wenn keine offene Lizenz mehr existiert.",
|
||||
A.WRITE: "Parameter ändern und Ticket sperren/entsperren. Wirkt erst auf die "
|
||||
"nächste Ausstellung – bereits ausgestellte Lizenzen bleiben unverändert.",
|
||||
A.DELETE: "Nur möglich, wenn keine offene Lizenz mehr existiert.",
|
||||
},
|
||||
)
|
||||
|
||||
#: Aus einer Zeile wurden fuenf. "Aendern" hiess hier **widerrufen oder neu
|
||||
#: signieren** -- zwei Eingriffe mit entgegengesetzter Wirkung unter einem
|
||||
#: Haken: der eine verlaengert einem Kunden die Lizenz, der andere nimmt sie
|
||||
#: ihm. Wer eine Laufzeit verlaengern durfte, konnte damit jede Lizenz
|
||||
#: abschalten. Genau das soll die Matrix trennen koennen.
|
||||
LICENSES = Resource(
|
||||
key="licenses",
|
||||
label="Lizenzen",
|
||||
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE, A.EXECUTE, A.EXPORT),
|
||||
hint="Konkrete, signierte Lizenzdateien",
|
||||
actions=(A.READ, A.DELETE, A.EXPORT),
|
||||
hint="Ausgestellte Lizenzen: Laufzeit, Zustand, Datei",
|
||||
action_hints={
|
||||
A.CREATE: "Aus einem Ticket eine Lizenz ausstellen.",
|
||||
A.EDIT: "Lizenz widerrufen oder eine neue Revision signieren.",
|
||||
A.EXECUTE: "Offline-Aktivierungscodes verarbeiten.",
|
||||
A.DELETE: "Eintrag entfernen (nur ohne offene Aktivierung).",
|
||||
A.EXPORT: "Lizenzdatei herunterladen oder per E-Mail versenden.",
|
||||
},
|
||||
)
|
||||
|
||||
LICENSE_ISSUE = Resource(
|
||||
key="license_issue",
|
||||
parent="licenses",
|
||||
label="Lizenz ausstellen",
|
||||
actions=(A.READ, A.EXECUTE),
|
||||
hint="Aus einem Ticket eine neue Lizenz signieren",
|
||||
action_hints={A.EXECUTE: "Ausstellen und signieren."},
|
||||
)
|
||||
|
||||
LICENSE_RENEW = Resource(
|
||||
key="license_renew",
|
||||
parent="licenses",
|
||||
label="Lizenz erneuern",
|
||||
actions=(A.READ, A.EXECUTE),
|
||||
hint="Neue Revision einer bestehenden Lizenz signieren",
|
||||
action_hints={A.EXECUTE: "Erneuern – die alte Revision verliert ihre Gültigkeit."},
|
||||
)
|
||||
|
||||
LICENSE_REVOKE = Resource(
|
||||
key="license_revoke",
|
||||
parent="licenses",
|
||||
label="Lizenz widerrufen",
|
||||
actions=(A.READ, A.EXECUTE),
|
||||
hint="Eine ausgestellte Lizenz ungültig machen",
|
||||
action_hints={A.EXECUTE: "Widerrufen. Der Kunde verliert den Zugang."},
|
||||
)
|
||||
|
||||
LICENSE_ACTIVATION = Resource(
|
||||
key="license_activation",
|
||||
parent="licenses",
|
||||
label="Aktivierung von Hand",
|
||||
actions=(A.READ, A.EXECUTE),
|
||||
hint="Offline-Codes für Kunden ohne Internetzugang",
|
||||
action_hints={A.EXECUTE: "Code prüfen und Antwortcode erzeugen."},
|
||||
)
|
||||
|
||||
PORTAL = Resource(
|
||||
key="portal",
|
||||
label="Kundenportal",
|
||||
actions=(A.VIEW, A.EXECUTE),
|
||||
hint="Zugangslinks fuer Kunden",
|
||||
actions=(A.READ, A.EXECUTE),
|
||||
hint="Zugangslinks für Kunden",
|
||||
action_hints={A.EXECUTE: "Zugangslink erzeugen und versenden."},
|
||||
)
|
||||
|
||||
REPORTS = Resource(
|
||||
key="reports",
|
||||
label="Berichte der Sub-Lizenzserver",
|
||||
actions=(A.VIEW,),
|
||||
actions=(A.READ,),
|
||||
hint="Von untergeordneten Lizenzservern gemeldete Kunden, Tickets und Lizenzen",
|
||||
)
|
||||
|
||||
MAIL = Resource(
|
||||
key="settings_mail",
|
||||
label="E-Mail-Versand",
|
||||
actions=(A.VIEW, A.EDIT, A.EXECUTE, A.SECRETS),
|
||||
actions=(A.READ, A.WRITE, A.EXECUTE, A.SECRETS),
|
||||
hint="Microsoft Graph (App-only)",
|
||||
action_hints={
|
||||
A.EXECUTE: "Verbindungstest ausfuehren.",
|
||||
A.EXECUTE: "Verbindungstest ausführen.",
|
||||
A.SECRETS: "Client-Secret setzen.",
|
||||
},
|
||||
)
|
||||
@@ -87,8 +126,8 @@ MAIL = Resource(
|
||||
VENDOR = Resource(
|
||||
key="settings_vendor",
|
||||
label="Anbieterdaten",
|
||||
actions=(A.VIEW, A.EDIT),
|
||||
hint="Name, Kontakt, Logo und oeffentlicher Endpunkt -- reisen in jeder Lizenz mit",
|
||||
actions=(A.READ, A.WRITE),
|
||||
hint="Name, Kontakt, Logo und öffentlicher Endpunkt – reisen in jeder Lizenz mit",
|
||||
)
|
||||
|
||||
LICENSING_AREA = Area(
|
||||
@@ -96,7 +135,17 @@ LICENSING_AREA = Area(
|
||||
label="Lizenzierung",
|
||||
icon="key",
|
||||
hint="Kunden, Tickets, Lizenzen. Ohne diesen Zugang bleibt der gesamte Bereich verborgen.",
|
||||
resources=(CUSTOMERS, TICKETS, LICENSES, PORTAL, REPORTS),
|
||||
resources=(
|
||||
CUSTOMERS,
|
||||
TICKETS,
|
||||
LICENSES,
|
||||
LICENSE_ISSUE,
|
||||
LICENSE_RENEW,
|
||||
LICENSE_REVOKE,
|
||||
LICENSE_ACTIVATION,
|
||||
PORTAL,
|
||||
REPORTS,
|
||||
),
|
||||
)
|
||||
|
||||
PERMISSIONS = PermissionTree(
|
||||
@@ -108,76 +157,70 @@ PERMISSIONS = PermissionTree(
|
||||
)
|
||||
|
||||
|
||||
#: Alle Funktionsrechte ausserhalb der Verwaltung, in einer Gruppe.
|
||||
#:
|
||||
#: Seit 2.8.0 heisst das Administratorkennzeichen "Verwaltung" und nicht
|
||||
#: "alles": Geraete, Neustarts, DHCP und Freigaben kommen ueber Gruppen. Diese
|
||||
#: Vorlage ist die Gruppe, die man sich dafuer zuweist -- und die Migration
|
||||
#: gibt sie bestehenden Administratoren, damit nach dem Update nichts fehlt,
|
||||
#: was vorher ging. Entfernen kann man sie mit einem Klick.
|
||||
#:
|
||||
#: Die Liste entsteht aus dem Baum und nicht aus Tippen: eine neue Zeile in der
|
||||
#: Matrix ist sonst genau die, die hier vergessen wird.
|
||||
FUNKTIONEN_VOLLZUGRIFF = RolePreset(
|
||||
key="functions",
|
||||
name="Funktionen (Vollzugriff)",
|
||||
description=(
|
||||
"Alle Rechte ausserhalb der Verwaltung. Gedacht fuer Administratoren, "
|
||||
"die nicht nur einstellen, sondern auch bedienen."
|
||||
),
|
||||
grants=tuple(
|
||||
(resource.key, "*")
|
||||
for area in PERMISSIONS.areas
|
||||
if area.key != PermissionTree.VERWALTUNGSBEREICH
|
||||
for resource in area.resources
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
#: Wie in TESM: drei Gruppen ab Werk, nicht sechs.
|
||||
#:
|
||||
#: Auch "Vertrieb" ist hier entfallen, obwohl es die Rolle ist, die den
|
||||
#: Lizenzserver taeglich benutzt -- gerade deshalb: wer Lizenzen ausstellt,
|
||||
#: schneidet die Gruppe ohnehin auf sein Haus zu (darf Vertrieb widerrufen?
|
||||
#: Tickets loeschen? in die Protokolle sehen?). Eine mitgelieferte Antwort auf
|
||||
#: diese Frage wird entweder uebernommen, ohne sie zu pruefen, oder sie wird
|
||||
#: umgebaut. "Funktionen (Vollzugriff)" ist der ehrlichere Startpunkt.
|
||||
ROLE_PRESETS: tuple[RolePreset, ...] = (
|
||||
RolePreset(
|
||||
key="viewer",
|
||||
name="Benutzer",
|
||||
description="Standardgruppe: nur das laufende Protokoll lesen.",
|
||||
grants=(("logs_live", (A.VIEW,)),),
|
||||
grants=(("logs_live", (A.READ,)),),
|
||||
is_default=True,
|
||||
),
|
||||
RolePreset(
|
||||
key="sales",
|
||||
name="Vertrieb",
|
||||
description="Kunden und Tickets pflegen, Lizenzen ausstellen und versenden.",
|
||||
grants=(
|
||||
("customers", "*"),
|
||||
("tickets", "*"),
|
||||
("licenses", (A.VIEW, A.CREATE, A.EDIT, A.EXPORT)),
|
||||
("portal", "*"),
|
||||
("logs_live", (A.VIEW,)),
|
||||
("logs_audit", (A.VIEW,)),
|
||||
),
|
||||
),
|
||||
RolePreset(
|
||||
key="support",
|
||||
name="Support",
|
||||
description="Lesen und Offline-Aktivierungen verarbeiten.",
|
||||
grants=(
|
||||
("customers", (A.VIEW,)),
|
||||
("tickets", (A.VIEW,)),
|
||||
("licenses", (A.VIEW, A.EXECUTE)),
|
||||
("reports", (A.VIEW,)),
|
||||
("logs_live", (A.VIEW,)),
|
||||
),
|
||||
),
|
||||
RolePreset(
|
||||
key="auditor",
|
||||
name="Revision",
|
||||
description="Nur lesen -- inklusive Aenderungsprotokoll und Diagnose.",
|
||||
grants=(
|
||||
("customers", (A.VIEW,)),
|
||||
("tickets", (A.VIEW,)),
|
||||
("licenses", (A.VIEW,)),
|
||||
("reports", (A.VIEW,)),
|
||||
("users", (A.VIEW,)),
|
||||
("groups", (A.VIEW,)),
|
||||
("logs_live", (A.VIEW,)),
|
||||
("logs_history", (A.VIEW, A.EXPORT)),
|
||||
("logs_audit", (A.VIEW, A.EXPORT)),
|
||||
("diagnostics", (A.VIEW,)),
|
||||
),
|
||||
),
|
||||
RolePreset(
|
||||
key="administration",
|
||||
name="Administration",
|
||||
description="Benutzer, Gruppen und Einstellungen -- ohne Admin-Kennzeichen.",
|
||||
description="Benutzer, Gruppen und Einstellungen – ohne Admin-Kennzeichen.",
|
||||
grants=(
|
||||
("users", "*"),
|
||||
("groups", "*"),
|
||||
("settings_system", "*"),
|
||||
("settings_nav", "*"),
|
||||
("settings_license", "*"),
|
||||
("settings_directory", (A.VIEW, A.EDIT, A.EXECUTE)),
|
||||
("settings_directory", (A.READ, A.WRITE, A.EXECUTE)),
|
||||
("settings_web", "*"),
|
||||
("settings_network", "*"),
|
||||
("settings_mail", (A.VIEW, A.EDIT, A.EXECUTE)),
|
||||
("settings_mail", (A.READ, A.WRITE, A.EXECUTE)),
|
||||
("settings_vendor", "*"),
|
||||
("backup", (A.VIEW, A.EXECUTE, A.EXPORT)),
|
||||
("backup", (A.READ, A.EXECUTE, A.EXPORT)),
|
||||
("trash", "*"),
|
||||
("diagnostics", (A.VIEW,)),
|
||||
("logs_audit", (A.VIEW,)),
|
||||
("diagnostics", (A.READ,)),
|
||||
("logs_audit", (A.READ,)),
|
||||
),
|
||||
),
|
||||
FUNKTIONEN_VOLLZUGRIFF,
|
||||
)
|
||||
|
||||
__all__ = ["PERMISSIONS", "ROLE_PRESETS"]
|
||||
@@ -27,6 +27,8 @@ Aenderungen gegenueber dem Vorgaenger:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from tesm_core.migrations import Migration
|
||||
|
||||
V1 = """
|
||||
@@ -210,7 +212,92 @@ CREATE INDEX IF NOT EXISTS idx_licenses_revision ON licenses(revision_at);
|
||||
"""
|
||||
|
||||
|
||||
def uebergang_funktionsgruppe(conn) -> None: # type: ignore[no-untyped-def]
|
||||
"""Wie in TESM: das Kennzeichen heisst ab 2.8.0 "Verwaltung".
|
||||
|
||||
Ohne diesen Schritt koennte ein bestehender Administrator nach dem Update
|
||||
keine Lizenz mehr ausstellen -- und wuerde das fuer einen Fehler halten.
|
||||
"""
|
||||
from tesm_core.rbac import service as rbac
|
||||
|
||||
from .permissions import FUNKTIONEN_VOLLZUGRIFF, PERMISSIONS
|
||||
|
||||
rbac.funktionsgruppe_fuer_administratoren(conn, PERMISSIONS, FUNKTIONEN_VOLLZUGRIFF)
|
||||
|
||||
|
||||
#: Die drei Vorlagen, die es bis 2.8.2 gab -- eingefrorene Kopie, siehe TESM.
|
||||
ABGELEGTE_VORLAGEN: tuple[tuple[str, str, tuple[tuple[str, Any], ...]], ...] = (
|
||||
(
|
||||
"sales",
|
||||
"Vertrieb",
|
||||
(
|
||||
("customers", "*"),
|
||||
("tickets", "*"),
|
||||
("licenses", ("read", "export")),
|
||||
("license_issue", "*"),
|
||||
("license_renew", "*"),
|
||||
("license_revoke", "*"),
|
||||
("portal", "*"),
|
||||
("logs_live", ("read",)),
|
||||
("logs_audit", ("read",)),
|
||||
),
|
||||
),
|
||||
(
|
||||
"support",
|
||||
"Support",
|
||||
(
|
||||
("customers", ("read",)),
|
||||
("tickets", ("read",)),
|
||||
("licenses", ("read",)),
|
||||
("license_activation", "*"),
|
||||
("reports", ("read",)),
|
||||
("logs_live", ("read",)),
|
||||
),
|
||||
),
|
||||
(
|
||||
"auditor",
|
||||
"Revision",
|
||||
(
|
||||
("customers", ("read",)),
|
||||
("tickets", ("read",)),
|
||||
("licenses", ("read",)),
|
||||
("reports", ("read",)),
|
||||
("users", ("read",)),
|
||||
("groups", ("read",)),
|
||||
("logs_live", ("read",)),
|
||||
("logs_history", ("read", "export")),
|
||||
("logs_audit", ("read", "export")),
|
||||
("diagnostics", ("read",)),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def uebergang_standardgruppen(conn) -> None: # type: ignore[no-untyped-def]
|
||||
"""Wie in TESM: unberuehrte Vorlagen in den Papierkorb, benutzte bleiben."""
|
||||
from tesm_core.rbac import service as rbac
|
||||
from tesm_core.rbac.model import Action, RolePreset
|
||||
|
||||
from .permissions import PERMISSIONS
|
||||
|
||||
vorlagen = [
|
||||
RolePreset(
|
||||
key=key,
|
||||
name=name,
|
||||
description="",
|
||||
grants=tuple(
|
||||
(ressource, a if a == "*" else tuple(Action(x) for x in a))
|
||||
for ressource, a in grants
|
||||
),
|
||||
)
|
||||
for key, name, grants in ABGELEGTE_VORLAGEN
|
||||
]
|
||||
rbac.unberuehrte_vorlagen_entfernen(conn, PERMISSIONS, vorlagen)
|
||||
|
||||
|
||||
LICENSE_MIGRATIONS: tuple[Migration, ...] = (
|
||||
Migration(version=1, name="baseline", sql=V1, allow_integrity_error=True),
|
||||
Migration(version=2, name="revision-timestamp", sql=V2),
|
||||
Migration(version=3, name="funktionsgruppe-fuer-admins", fn=uebergang_funktionsgruppe),
|
||||
Migration(version=4, name="standardgruppen-aufraeumen", fn=uebergang_standardgruppen),
|
||||
)
|
||||
@@ -132,7 +132,7 @@ def _verify(
|
||||
seen_nonce=seen,
|
||||
)
|
||||
except lic.SignatureInvalid:
|
||||
return None, _error(400, "invalid_signature", "Signatur der Anfrage ist ungueltig.")
|
||||
return None, _error(400, "invalid_signature", "Signatur der Anfrage ist ungültig.")
|
||||
except lic.ReplayDetected as exc:
|
||||
return None, _error(409, "replay", str(exc))
|
||||
except lic.ProtocolViolation as exc:
|
||||
@@ -150,7 +150,7 @@ def process_activate(
|
||||
) -> Outcome:
|
||||
row, error = _verify(conn, request, expected_action="activate")
|
||||
if error is not None or row is None:
|
||||
return error or _error(400, "bad_request", "Ungueltige Anfrage.")
|
||||
return error or _error(400, "bad_request", "Ungültige Anfrage.")
|
||||
|
||||
fingerprint = str(request["fingerprint"])
|
||||
hostname = str(request.get("hostname") or "")
|
||||
@@ -202,7 +202,7 @@ def process_activate(
|
||||
target=str(row["license_id"]),
|
||||
detail=(
|
||||
f"Fingerprint {fingerprint[:16]}..., Host {hostname or 'unbekannt'}"
|
||||
+ (f", verdraengt {len(superseded)} Lizenz(en)" if superseded else "")
|
||||
+ (f", verdrängt {len(superseded)} Lizenz(en)" if superseded else "")
|
||||
),
|
||||
severity="notice",
|
||||
)
|
||||
@@ -211,7 +211,7 @@ def process_activate(
|
||||
request,
|
||||
master_private_key=master_private_key,
|
||||
status="ok",
|
||||
detail="Aktivierung bestaetigt.",
|
||||
detail="Aktivierung bestätigt.",
|
||||
)
|
||||
|
||||
|
||||
@@ -220,7 +220,7 @@ def process_deactivate(
|
||||
) -> Outcome:
|
||||
row, error = _verify(conn, request, expected_action="deactivate")
|
||||
if error is not None or row is None:
|
||||
return error or _error(400, "bad_request", "Ungueltige Anfrage.")
|
||||
return error or _error(400, "bad_request", "Ungültige Anfrage.")
|
||||
|
||||
fingerprint = str(request["fingerprint"])
|
||||
if str(row["status"]) != "active" or str(row["fingerprint"] or "") != fingerprint:
|
||||
@@ -249,7 +249,7 @@ def process_deactivate(
|
||||
request,
|
||||
master_private_key=master_private_key,
|
||||
status="ok",
|
||||
detail="Deaktivierung bestaetigt. Die Lizenz kann auf einer anderen Maschine aktiviert werden.",
|
||||
detail="Deaktivierung bestätigt. Die Lizenz kann auf einer anderen Maschine aktiviert werden.",
|
||||
)
|
||||
|
||||
|
||||
@@ -258,7 +258,7 @@ def process_heartbeat(
|
||||
) -> Outcome:
|
||||
row, error = _verify(conn, request, expected_action="heartbeat")
|
||||
if error is not None or row is None:
|
||||
return error or _error(400, "bad_request", "Ungueltige Anfrage.")
|
||||
return error or _error(400, "bad_request", "Ungültige Anfrage.")
|
||||
|
||||
fingerprint = str(request["fingerprint"])
|
||||
hostname = str(request.get("hostname") or "")
|
||||
@@ -306,7 +306,7 @@ def process_heartbeat(
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"Bericht von Lizenz %s verworfen -- Rolle ist %s, nicht license_server",
|
||||
"Bericht von Lizenz %s verworfen | Rolle ist %s, nicht license_server",
|
||||
row["license_id"],
|
||||
row["role"],
|
||||
)
|
||||
@@ -322,7 +322,7 @@ def process_heartbeat(
|
||||
request,
|
||||
master_private_key=master_private_key,
|
||||
status="ok",
|
||||
detail="Verbindung bestaetigt.",
|
||||
detail="Verbindung bestätigt.",
|
||||
license_update=update,
|
||||
)
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ def validate_email(value: str, *, required: bool) -> str:
|
||||
# sollen weiter bearbeitbar bleiben, statt ausgesperrt zu werden.
|
||||
return ""
|
||||
if not EMAIL_RE.match(text):
|
||||
raise IssuanceError(f"{text!r} ist keine gueltige E-Mail-Adresse.")
|
||||
raise IssuanceError(f"{text!r} ist keine gültige E-Mail-Adresse.")
|
||||
return text
|
||||
|
||||
|
||||
@@ -137,7 +137,7 @@ def delete_customer(conn: sqlite3.Connection, customer_id: int) -> None:
|
||||
)
|
||||
if int(tickets or 0):
|
||||
raise IssuanceError(
|
||||
f"An diesem Kunden haengen noch {tickets} Ticket(s). Bitte zuerst entfernen."
|
||||
f"An diesem Kunden hängen noch {tickets} Ticket(s). Bitte zuerst entfernen."
|
||||
)
|
||||
conn.execute("DELETE FROM license_customers WHERE id=?", (customer_id,))
|
||||
|
||||
@@ -224,7 +224,7 @@ def create_ticket(
|
||||
raise IssuanceError("Unbekannter Lizenztyp.")
|
||||
if role not in lic.LICENSE_ROLES or role == "master":
|
||||
# "master" entsteht ausschliesslich lokal per Bootstrap-Skript.
|
||||
raise IssuanceError("Unzulaessige Rolle.")
|
||||
raise IssuanceError("Unzulässige Rolle.")
|
||||
|
||||
# Trial + unbefristet ist ein sinnloser Zustand: eine permanente Lizenz ohne
|
||||
# Module. Diese Regel wird an drei Stellen geprueft (hier, beim Bearbeiten
|
||||
@@ -290,7 +290,7 @@ def update_ticket(
|
||||
if license_type != str(ticket["type"]):
|
||||
changes.append(f"Typ: {ticket['type']} -> {license_type}")
|
||||
if new_modules != str(ticket["modules"]):
|
||||
changes.append("Module geaendert")
|
||||
changes.append("Module geändert")
|
||||
if int(valid_days) != int(ticket["valid_days"]):
|
||||
changes.append(f"Laufzeit: {ticket['valid_days']} -> {valid_days} Tage")
|
||||
if bool(lifetime) != bool(ticket["lifetime"]):
|
||||
@@ -428,7 +428,7 @@ def issue_from_ticket(
|
||||
role = str(ticket["role"])
|
||||
if as_trial and role != "customer":
|
||||
raise IssuanceError(
|
||||
"Eine Testlizenz gibt es nur fuer Kundeninstanzen, nicht fuer Lizenzserver."
|
||||
"Eine Testlizenz gibt es nur für Kundeninstanzen, nicht für Lizenzserver."
|
||||
)
|
||||
|
||||
if as_trial:
|
||||
@@ -452,9 +452,9 @@ def issue_from_ticket(
|
||||
# hier, an der einzigen Stelle, durch die jede Ausstellung laeuft.
|
||||
if not str(master_endpoint or "").strip():
|
||||
raise IssuanceError(
|
||||
"Es ist kein oeffentlicher Endpunkt hinterlegt (Einstellungen -> Anbieter). "
|
||||
"Ohne ihn koennte sich diese Lizenz nie online aktivieren -- der Endpunkt "
|
||||
"wird in die Lizenz signiert und laesst sich spaeter nicht nachtragen."
|
||||
"Es ist kein öffentlicher Endpunkt hinterlegt (Einstellungen -> Anbieter). "
|
||||
"Ohne ihn könnte sich diese Lizenz nie online aktivieren – der Endpunkt "
|
||||
"wird in die Lizenz signiert und lässt sich später nicht nachtragen."
|
||||
)
|
||||
|
||||
customer = get_customer(conn, int(ticket["customer_id"]))
|
||||
@@ -550,7 +550,7 @@ def delivery_bundle(conn: sqlite3.Connection, license_id: str, *, keystore: Any)
|
||||
bundle["client_key"] = keystore.decrypt(encrypted, aad=client_key_aad(license_id))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise IssuanceError(
|
||||
"Der Client-Schluessel dieser Lizenz konnte nicht entschluesselt werden. "
|
||||
"Der Client-Schlüssel dieser Lizenz konnte nicht entschlüsselt werden. "
|
||||
"Bitte eine neue Lizenz ausstellen."
|
||||
) from exc
|
||||
return bundle
|
||||
@@ -580,7 +580,7 @@ def delete_license(conn: sqlite3.Connection, license_id: str) -> None:
|
||||
raise IssuanceError("Diese Lizenz existiert nicht.")
|
||||
if str(row["status"]) not in {"revoked", "issued"}:
|
||||
raise IssuanceError(
|
||||
"Eine aktive Lizenz kann nicht geloescht werden. Bitte zuerst widerrufen."
|
||||
"Eine aktive Lizenz kann nicht gelöscht werden. Bitte zuerst widerrufen."
|
||||
)
|
||||
conn.execute("DELETE FROM licenses WHERE license_id=?", (license_id,))
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -59,7 +61,7 @@ class Report:
|
||||
|
||||
def as_text(self) -> str:
|
||||
lines = [
|
||||
f"Signaturschluessel uebernommen: {'ja' if self.key_adopted else 'nein'}"
|
||||
f"Signaturschlüssel übernommen: {'ja' if self.key_adopted else 'nein'}"
|
||||
+ (f" ({self.key_id})" if self.key_id else ""),
|
||||
f"Kunden : {self.customers}",
|
||||
f"Tickets : {self.tickets}",
|
||||
@@ -67,7 +69,7 @@ class Report:
|
||||
]
|
||||
if self.reissued:
|
||||
lines.append("")
|
||||
lines.append("Neu signiert -- diese Kunden brauchen eine neue Lizenzdatei:")
|
||||
lines.append("Neu signiert – diese Kunden brauchen eine neue Lizenzdatei:")
|
||||
lines.extend(f" * {entry}" for entry in self.reissued)
|
||||
if self.skipped:
|
||||
lines.append("")
|
||||
@@ -93,7 +95,7 @@ def open_legacy(root: Path) -> sqlite3.Connection:
|
||||
"""Oeffnet die alte Datenbank **nur lesend**."""
|
||||
path = Path(root) / "sqlite.db"
|
||||
if not path.is_file():
|
||||
raise LegacyImportError(f"Unter {path} liegt keine Datenbank der Vorgaengerfassung.")
|
||||
raise LegacyImportError(f"Unter {path} liegt keine Datenbank der Vorgängerfassung.")
|
||||
try:
|
||||
conn = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
|
||||
except sqlite3.Error as exc:
|
||||
@@ -105,30 +107,74 @@ def open_legacy(root: Path) -> sqlite3.Connection:
|
||||
if missing:
|
||||
conn.close()
|
||||
raise LegacyImportError(
|
||||
f"Das sieht nicht nach einem Lizenzserver aus -- es fehlen: {', '.join(sorted(missing))}"
|
||||
f"Das sieht nicht nach einem Lizenzserver aus – es fehlen: {', '.join(sorted(missing))}"
|
||||
)
|
||||
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 Signaturschlüssel 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 Signaturschlüssel ist {len(rohbytes)} Byte lang, "
|
||||
"erwartet werden 32 (Ed25519)."
|
||||
)
|
||||
return base64.b64encode(rohbytes).decode("ascii")
|
||||
raise LegacyImportError(f"{feld} im alten Signaturschlüssel 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"
|
||||
if not path.is_file():
|
||||
raise LegacyImportError(f"Der alte Signaturschluessel {path} fehlt.")
|
||||
raise LegacyImportError(f"Der alte Signaturschlüssel {path} fehlt.")
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError) as exc:
|
||||
raise LegacyImportError(f"Der alte Signaturschluessel ist unlesbar: {exc}") from exc
|
||||
raise LegacyImportError(f"Der alte Signaturschlüssel 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:
|
||||
raise LegacyImportError("Der alte Signaturschlüssel ist unvollständig.")
|
||||
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 Signaturschlüssel ist unbrauchbar: {exc}") from exc
|
||||
if not passt:
|
||||
raise LegacyImportError(
|
||||
"Im alten Signaturschluessel passen privater und oeffentlicher Teil nicht "
|
||||
"zueinander. Die Datei ist beschaedigt."
|
||||
"Im alten Signaturschlüssel passen privater und öffentlicher Teil nicht "
|
||||
"zueinander. Die Datei ist beschädigt."
|
||||
)
|
||||
return private, public
|
||||
|
||||
@@ -144,16 +190,16 @@ def adopt_key(private: str, public: str, target: Path) -> str:
|
||||
|
||||
if Path(target).is_file():
|
||||
raise LegacyImportError(
|
||||
f"Unter {target} liegt bereits ein Signaturschluessel. Er wird nicht "
|
||||
"ueberschrieben -- das wuerde jede damit ausgestellte Lizenz unbrauchbar "
|
||||
"machen. Fuer eine Uebernahme bitte eine frische Installation verwenden."
|
||||
f"Unter {target} liegt bereits ein Signaturschlüssel. Er wird nicht "
|
||||
"überschrieben – das würde jede damit ausgestellte Lizenz unbrauchbar "
|
||||
"machen. Für eine Übernahme bitte eine frische Installation verwenden."
|
||||
)
|
||||
payload = {
|
||||
"private_key": private,
|
||||
"public_key": public,
|
||||
"key_id": lic.key_id(public),
|
||||
"created_at": _now(),
|
||||
"origin": "Uebernommen aus der Vorgaengerfassung.",
|
||||
"origin": "Übernommen aus der Vorgängerfassung.",
|
||||
}
|
||||
write_private_file(Path(target), json.dumps(payload, indent=2, sort_keys=True) + "\n")
|
||||
return lic.key_id(public)
|
||||
@@ -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,
|
||||
@@ -214,7 +309,7 @@ def import_all(
|
||||
)
|
||||
if existing is not None:
|
||||
customer_map[int(row["id"])] = int(existing["id"])
|
||||
report.warnings.append(f"Kunde {name!r} war schon vorhanden -- nicht doppelt angelegt.")
|
||||
report.warnings.append(f"Kunde {name!r} war schon vorhanden – nicht doppelt angelegt.")
|
||||
continue
|
||||
new_id = issuance.create_customer(
|
||||
conn,
|
||||
@@ -236,7 +331,7 @@ def import_all(
|
||||
for row in legacy.execute("SELECT * FROM license_tickets ORDER BY created_at"):
|
||||
customer_id = customer_map.get(int(row["customer_id"] or 0))
|
||||
if customer_id is None:
|
||||
report.skipped.append(f"Ticket {str(row['id'])[:12]} -- Kunde nicht uebernommen")
|
||||
report.skipped.append(f"Ticket {str(row['id'])[:12]} -- Kunde nicht übernommen")
|
||||
continue
|
||||
if Database.one(conn, "SELECT id FROM license_tickets WHERE id=?", (row["id"],)):
|
||||
continue
|
||||
@@ -253,7 +348,7 @@ def import_all(
|
||||
int(row["lifetime"] or 0),
|
||||
str(row["status"] or "open"),
|
||||
str(row["role"] or "customer"),
|
||||
"Aus der Vorgaengerfassung uebernommen.",
|
||||
"Aus der Vorgängerfassung übernommen.",
|
||||
str(row["created_by"] or actor),
|
||||
str(row["created_at"] or _now()),
|
||||
_now(),
|
||||
@@ -266,7 +361,7 @@ def import_all(
|
||||
license_id = str(row["license_id"] or "")
|
||||
customer_id = customer_map.get(int(row["customer_id"] or 0))
|
||||
if not license_id or customer_id is None:
|
||||
report.skipped.append(f"Lizenz {license_id[:12] or '?'} -- Kunde nicht uebernommen")
|
||||
report.skipped.append(f"Lizenz {license_id[:12] or '?'} -- Kunde nicht übernommen")
|
||||
continue
|
||||
if Database.one(conn, "SELECT id FROM licenses WHERE license_id=?", (license_id,)):
|
||||
continue
|
||||
@@ -276,7 +371,7 @@ def import_all(
|
||||
modules = _modules(row["modules"])
|
||||
|
||||
if not reissue:
|
||||
report.skipped.append(f"{customer['name']} / {license_id[:12]} -- nur gezaehlt")
|
||||
report.skipped.append(f"{customer['name']} / {license_id[:12]} -- nur gezählt")
|
||||
report.licenses += 1
|
||||
continue
|
||||
|
||||
@@ -288,7 +383,7 @@ def import_all(
|
||||
if not lifetime and valid_days <= 0:
|
||||
report.skipped.append(
|
||||
f"{customer['name']} / {license_id[:12]} -- abgelaufen am "
|
||||
f"{str(row['expires_at'])[:10]}, nicht uebernommen"
|
||||
f"{str(row['expires_at'])[:10]}, nicht übernommen"
|
||||
)
|
||||
continue
|
||||
|
||||
@@ -349,7 +444,7 @@ def import_all(
|
||||
if report.reissued:
|
||||
report.warnings.append(
|
||||
"Die neu signierten Lizenzen sind noch nicht aktiviert: die Bindung an eine "
|
||||
"Maschine gilt nur fuer die alte Datei. Nach dem Einspielen aktiviert der "
|
||||
"Maschine gilt nur für die alte Datei. Nach dem Einspielen aktiviert der "
|
||||
"Kunde erneut."
|
||||
)
|
||||
return report
|
||||
@@ -92,7 +92,7 @@ def _secret(conn: sqlite3.Connection) -> str:
|
||||
return core().keystore.decrypt(str(value), aad=AAD_GRAPH_SECRET)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise MailError(
|
||||
"Das Client-Secret konnte nicht entschluesselt werden -- bitte neu eintragen."
|
||||
"Das Client-Secret konnte nicht entschlüsselt werden – bitte neu eintragen."
|
||||
) from exc
|
||||
|
||||
|
||||
@@ -162,7 +162,7 @@ def fetch_token(conn: sqlite3.Connection) -> str:
|
||||
def test_connection(conn: sqlite3.Connection) -> dict[str, Any]:
|
||||
"""Holt nur ein Token -- verschickt bewusst keine echte Nachricht."""
|
||||
if not is_enabled(conn):
|
||||
return {"ok": False, "message": "Der E-Mail-Versand ist nicht vollstaendig konfiguriert."}
|
||||
return {"ok": False, "message": "Der E-Mail-Versand ist nicht vollständig konfiguriert."}
|
||||
try:
|
||||
fetch_token(conn)
|
||||
except MailError as exc:
|
||||
|
||||
@@ -48,22 +48,22 @@ class MasterKey:
|
||||
def load(path: Path) -> MasterKey:
|
||||
if not path.is_file():
|
||||
raise MasterKeyError(
|
||||
f"Der Signaturschluessel {path} fehlt. Ohne ihn kann keine Lizenz ausgestellt "
|
||||
f"Der Signaturschlüssel {path} fehlt. Ohne ihn kann keine Lizenz ausgestellt "
|
||||
"werden. Erzeugen Sie ihn mit 'tesm-license-admin init-key'."
|
||||
)
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError) as exc:
|
||||
raise MasterKeyError(f"Der Signaturschluessel ist unlesbar: {exc}") from exc
|
||||
raise MasterKeyError(f"Der Signaturschlüssel ist unlesbar: {exc}") from exc
|
||||
|
||||
private = str(data.get("private_key") or "")
|
||||
public = str(data.get("public_key") or "")
|
||||
if not private or not public:
|
||||
raise MasterKeyError("Der Signaturschluessel ist unvollstaendig.")
|
||||
raise MasterKeyError("Der Signaturschlüssel ist unvollständig.")
|
||||
if licsrv.public_from_private(private) != public:
|
||||
raise MasterKeyError(
|
||||
"Der oeffentliche Teil des Signaturschluessels passt nicht zum privaten. "
|
||||
"Die Datei ist beschaedigt oder wurde manipuliert."
|
||||
"Der öffentliche Teil des Signaturschlüssels passt nicht zum privaten. "
|
||||
"Die Datei ist beschädigt oder wurde manipuliert."
|
||||
)
|
||||
return MasterKey(
|
||||
private_key=private, public_key=public, created_at=str(data.get("created_at") or "")
|
||||
@@ -87,7 +87,7 @@ def ensure(path: Path) -> MasterKey:
|
||||
}
|
||||
write_private_file(path, json.dumps(payload, indent=2, sort_keys=True) + "\n")
|
||||
logger.warning(
|
||||
"Neuer Signaturschluessel erzeugt (%s). Diese Datei ist unwiederbringlich -- "
|
||||
"Neuer Signaturschlüssel erzeugt (%s). Diese Datei ist unwiederbringlich – "
|
||||
"bitte sofort gesichert ablegen: %s",
|
||||
lic.key_id(public),
|
||||
path,
|
||||
@@ -98,7 +98,7 @@ def ensure(path: Path) -> MasterKey:
|
||||
def health(path: Path) -> dict[str, object]:
|
||||
"""Zustand fuer die Diagnoseseite."""
|
||||
if not path.is_file():
|
||||
return {"present": False, "problems": ["Signaturschluessel fehlt"]}
|
||||
return {"present": False, "problems": ["Signaturschlüssel fehlt"]}
|
||||
problems = check_private_file(path)
|
||||
try:
|
||||
key = load(path)
|
||||
|
||||
@@ -40,6 +40,16 @@ class LiveLog:
|
||||
def info(self, scope: str, message: str) -> None:
|
||||
self.write("INFO", scope, message)
|
||||
|
||||
def notice(self, scope: str, message: str) -> None:
|
||||
"""Etwas ist passiert, und es ist in Ordnung -- aber es gehoert gesehen.
|
||||
|
||||
Zwischen "laeuft" und "stimmt etwas nicht" fehlte eine Stufe. Ein
|
||||
Geraet, das neu gestartet wird, ist kein Fehler; es einfach als INFO
|
||||
zwischen zweihundert Erreichbarkeitsmeldungen zu stellen heisst aber,
|
||||
dass es niemand findet.
|
||||
"""
|
||||
self.write("HINWEIS", scope, message)
|
||||
|
||||
def warning(self, scope: str, message: str) -> None:
|
||||
self.write("WARNUNG", scope, message)
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import sqlite3
|
||||
from typing import Any, Callable, Iterable
|
||||
|
||||
from tesm_core.db import Database
|
||||
from tesm_core.rbac.legacy_permissions import umgeschrieben
|
||||
from tesm_core.transfer import (
|
||||
MIN_PASSPHRASE_LENGTH,
|
||||
PREVIEW_TTL_MINUTES,
|
||||
@@ -41,11 +42,11 @@ APP_TAG = "tesm-license"
|
||||
|
||||
CATEGORIES: tuple[Category, ...] = (
|
||||
Category("customers", "Kunden", "Stammdaten der Kundeninstanzen"),
|
||||
Category("tickets", "Tickets", "Anspruechte inklusive Self-Service-Schluessel", sensitive=True),
|
||||
Category("tickets", "Tickets", "Ansprüche inklusive Self-Service-Schlüssel", sensitive=True),
|
||||
Category(
|
||||
"licenses",
|
||||
"Lizenzen",
|
||||
"Ausgestellte Lizenzen mit signiertem Dokument und verschluesseltem Client-Schluessel",
|
||||
"Ausgestellte Lizenzen mit signiertem Dokument und verschlüsseltem Client-Schlüssel",
|
||||
sensitive=True,
|
||||
),
|
||||
Category("users", "Benutzer", "Lokale Konten mit Passwort-Hash", admin_only=True),
|
||||
@@ -153,6 +154,10 @@ def apply_import(
|
||||
zusammengefuehrt; Tickets und Lizenzen ueber ihre ID, weil die zugleich
|
||||
kryptographisch relevant ist (Self-Service-Link bzw. signierte Lizenz-ID).
|
||||
"""
|
||||
#: Merkt, ob das Paket alte Rechteschluessel mitbringt -- dann stammt es
|
||||
#: von vor 2.8.0, und ein Administrator konnte dort noch alles.
|
||||
alte_fassung: set[bool] = set()
|
||||
|
||||
wanted = {key for key in categories if key in CATEGORY_BY_KEY}
|
||||
report: dict[str, dict[str, int]] = {}
|
||||
stamp = _stamp()
|
||||
@@ -332,9 +337,16 @@ def apply_import(
|
||||
group_id = int(existing["id"])
|
||||
bump("groups", "updated")
|
||||
conn.execute("DELETE FROM group_permissions WHERE group_id=?", (group_id,))
|
||||
# Wie in TESM: eine Sicherung aus 2.7.x nennt customers.view und
|
||||
# tickets.edit. Unbekannte Schluessel wirft sanitize() still weg --
|
||||
# die wiederhergestellte Gruppe waere leer, ohne Meldung.
|
||||
mitgebracht = [str(recht) for recht in entry.get("permissions", []) or []]
|
||||
neue = umgeschrieben(mitgebracht)
|
||||
if sorted(set(mitgebracht)) != neue:
|
||||
alte_fassung.add(True)
|
||||
conn.executemany(
|
||||
"INSERT OR IGNORE INTO group_permissions (group_id, permission) VALUES (?,?)",
|
||||
[(group_id, str(permission)) for permission in entry.get("permissions", [])],
|
||||
[(group_id, permission) for permission in neue],
|
||||
)
|
||||
|
||||
if "users" in wanted:
|
||||
@@ -393,6 +405,18 @@ def apply_import(
|
||||
set_setting(str(key), value)
|
||||
bump("settings", "updated")
|
||||
|
||||
if alte_fassung and "users" in wanted:
|
||||
# Wie die Migration: aus einer Sicherung von vor 2.8.0 kommen
|
||||
# Administratoren, die alles konnten. Ohne die Gruppe koennten sie
|
||||
# nach dem Einlesen keine Lizenz mehr ausstellen.
|
||||
from tesm_core.rbac import service as rbac
|
||||
|
||||
from ..permissions import FUNKTIONEN_VOLLZUGRIFF, PERMISSIONS
|
||||
|
||||
rbac.funktionsgruppe_fuer_administratoren(
|
||||
conn, PERMISSIONS, FUNKTIONEN_VOLLZUGRIFF
|
||||
)
|
||||
|
||||
return report
|
||||
|
||||
|
||||
|
||||
@@ -25,8 +25,8 @@ VENDOR_SETTINGS: tuple[SettingSpec, ...] = (
|
||||
"master_endpoint",
|
||||
"",
|
||||
"str",
|
||||
"Oeffentlicher Endpunkt dieses Lizenzservers",
|
||||
hint="Basis-URL, unter der Kundeninstanzen diesen Server erreichen -- z. B. "
|
||||
"Öffentlicher Endpunkt dieses Lizenzservers",
|
||||
hint="Basis-URL, unter der Kundeninstanzen diesen Server erreichen – z. B. "
|
||||
"https://lizenz.firma.de. Wird in jede Lizenz eingebettet und fuer alle "
|
||||
"kundengerichteten Links verwendet.",
|
||||
section="vendor",
|
||||
@@ -38,8 +38,8 @@ PORTAL_SETTINGS: tuple[SettingSpec, ...] = (
|
||||
"portal_link_minutes",
|
||||
30,
|
||||
"int",
|
||||
"Gueltigkeit des Zugangslinks (Minuten)",
|
||||
hint="Der Link ist zusaetzlich nur einmal verwendbar.",
|
||||
"Gültigkeit des Zugangslinks (Minuten)",
|
||||
hint="Der Link ist zusätzlich nur einmal verwendbar.",
|
||||
section="portal",
|
||||
minimum=5,
|
||||
maximum=1440,
|
||||
@@ -48,9 +48,9 @@ PORTAL_SETTINGS: tuple[SettingSpec, ...] = (
|
||||
"portal_session_minutes",
|
||||
60,
|
||||
"int",
|
||||
"Gueltigkeit der Portalsitzung (Minuten)",
|
||||
hint="Zweites, unabhaengiges Zeitfenster: eines fuer das Anklicken des Links, "
|
||||
"eines fuer die daraus entstehende Sitzung.",
|
||||
"Gültigkeit der Portalsitzung (Minuten)",
|
||||
hint="Zweites, unabhängiges Zeitfenster: eines für das Anklicken des Links, "
|
||||
"eines für die daraus entstehende Sitzung.",
|
||||
section="portal",
|
||||
minimum=5,
|
||||
maximum=1440,
|
||||
@@ -64,7 +64,7 @@ PORTAL_SETTINGS: tuple[SettingSpec, ...] = (
|
||||
"bool",
|
||||
"Self-Service-Link je Ticket aktiv",
|
||||
hint="Erlaubt dem Kunden, seine Lizenz selbst herunterzuladen, zu widerrufen und "
|
||||
"neu auszustellen -- ohne Anmeldung, allein ueber den unratbaren Ticket-Link.",
|
||||
"neu auszustellen – ohne Anmeldung, allein über den unratbaren Ticket-Link.",
|
||||
section="portal",
|
||||
),
|
||||
)
|
||||
@@ -83,8 +83,8 @@ DEFAULT_TICKET_SETTINGS: tuple[SettingSpec, ...] = (
|
||||
"allow_customer_revoke",
|
||||
True,
|
||||
"bool",
|
||||
"Kunden duerfen ihre Lizenz selbst widerrufen",
|
||||
hint="Notwendig fuer einen Systemwechsel ohne Support-Anruf.",
|
||||
"Kunden dürfen ihre Lizenz selbst widerrufen",
|
||||
hint="Notwendig für einen Systemwechsel ohne Support-Anruf.",
|
||||
section="licensing",
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
{% block topbar_extra %}
|
||||
{% if current_user.has('tickets.create') %}
|
||||
<a class="btn btn--primary btn--sm" href="{{ url_for('tickets.new', kunde=customer.id) }}">
|
||||
<a class="btn btn--primary btn--sm" href="{{ url_for('tickets.new', customer=customer.id) }}">
|
||||
{{ icon('plus', size=15) }} Ticket anlegen
|
||||
</a>
|
||||
{% endif %}
|
||||
@@ -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">
|
||||
@@ -171,43 +171,43 @@
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<div class="card__body stack">
|
||||
{{ field('name', 'Firmenname', value=customer.name, required=True,
|
||||
disabled=not current_user.has('customers.edit')) }}
|
||||
disabled=not current_user.has('customers.write')) }}
|
||||
{{ field('contact_email', 'E-Mail', value=customer.contact_email, type='email',
|
||||
disabled=not current_user.has('customers.edit')) }}
|
||||
disabled=not current_user.has('customers.write')) }}
|
||||
{{ field('contact_person', 'Ansprechpartner', value=customer.contact_person,
|
||||
disabled=not current_user.has('customers.edit')) }}
|
||||
disabled=not current_user.has('customers.write')) }}
|
||||
{{ field('contact_phone', 'Telefon', value=customer.contact_phone,
|
||||
disabled=not current_user.has('customers.edit')) }}
|
||||
disabled=not current_user.has('customers.write')) }}
|
||||
<div class="form-grid">
|
||||
{{ field('street', 'Strasse', value=customer.street,
|
||||
disabled=not current_user.has('customers.edit')) }}
|
||||
disabled=not current_user.has('customers.write')) }}
|
||||
{{ field('house_number', 'Nr.', value=customer.house_number,
|
||||
disabled=not current_user.has('customers.edit')) }}
|
||||
disabled=not current_user.has('customers.write')) }}
|
||||
{{ field('postal_code', 'PLZ', value=customer.postal_code,
|
||||
disabled=not current_user.has('customers.edit')) }}
|
||||
disabled=not current_user.has('customers.write')) }}
|
||||
{{ field('city', 'Ort', value=customer.city,
|
||||
disabled=not current_user.has('customers.edit')) }}
|
||||
disabled=not current_user.has('customers.write')) }}
|
||||
</div>
|
||||
{{ textarea_field('notes', 'Notizen', value=customer.notes, rows=3,
|
||||
extra_class='field--full') }}
|
||||
<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 }}">
|
||||
{{ icon('copy', size=13) }}</button></dd>
|
||||
</dl>
|
||||
</div>
|
||||
{% if current_user.has('customers.edit') %}
|
||||
{% if current_user.has('customers.write') %}
|
||||
<div class="card__footer modal__footer--split">
|
||||
{% 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 %}
|
||||
@@ -101,13 +101,13 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% if current_user.has('licenses.edit') and license.status != 'revoked' %}
|
||||
{% if current_user.has('license_renew.execute') and license.status != 'revoked' %}
|
||||
<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">
|
||||
@@ -204,18 +204,18 @@
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if current_user.has('licenses.edit') or current_user.has('licenses.delete') %}
|
||||
{% if current_user.has('license_revoke.execute') or current_user.has('licenses.delete') %}
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1"><div class="card__title">{{ icon('alert', size=17) }} Lebenszyklus</div></div>
|
||||
</div>
|
||||
<div class="card__body stack stack--tight">
|
||||
{% if current_user.has('licenses.edit') and license.status != 'revoked' %}
|
||||
{% if current_user.has('license_revoke.execute') and license.status != 'revoked' %}
|
||||
<button class="btn btn--sm btn--danger btn--block" type="button"
|
||||
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.
|
||||
Das Ticket bleibt offen – eine neue Lizenz kann sofort ausgestellt werden. Genau so
|
||||
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 %}
|
||||
@@ -239,12 +239,12 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block dialogs %}
|
||||
{% if current_user.has('licenses.edit') and license.status != 'revoked' %}
|
||||
{% if current_user.has('license_revoke.execute') and license.status != 'revoked' %}
|
||||
{% call modal('revoke-dialog', 'Lizenz widerrufen', license.customer_name, 'narrow') %}
|
||||
<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>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
{% block topbar_extra %}
|
||||
{{ search_box('licenses-table', 'Kunde, Lizenz-ID, Host ...') }}
|
||||
{% if current_user.has('licenses.execute') %}
|
||||
{% if current_user.has('license_activation.execute') %}
|
||||
<a class="btn btn--sm" href="{{ url_for('licenses.manual_code') }}">
|
||||
{{ icon('transfer', size=15) }} Offline-Code
|
||||
</a>
|
||||
|
||||
@@ -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>
|
||||
@@ -89,7 +89,7 @@
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
{% if current_user.has('settings_license.edit') %}
|
||||
{% if current_user.has('settings_license.write') %}
|
||||
<div class="card__footer">
|
||||
{% if not state.activated %}
|
||||
<form method="post" action="{{ url_for('own_license.activate') }}" data-no-guard>
|
||||
@@ -116,9 +116,16 @@
|
||||
{% endif %}
|
||||
</section>
|
||||
{% else %}
|
||||
{{ empty_state('Keine gueltige Lizenz',
|
||||
{% if master_edition %}
|
||||
{{ 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') }}
|
||||
{% else %}
|
||||
{{ empty_state('Keine gültige Lizenz',
|
||||
'Diese Ausgabe ist ein Sub-Lizenzserver: fordern Sie beim Master-Lizenzserver eine
|
||||
Lizenz für diesen Server an und spielen Sie die Datei hier ein. Eine Master-Lizenz
|
||||
kann diese Ausgabe nicht erzeugen.', 'key') }}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if pending %}
|
||||
@@ -133,24 +140,24 @@
|
||||
<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>
|
||||
{% endif %}
|
||||
|
||||
{% if current_user.has('settings_license.edit') %}
|
||||
{% if current_user.has('settings_license.write') %}
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<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 +167,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 +189,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 +198,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 +207,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,13 +9,13 @@
|
||||
<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">
|
||||
<span class="field__label">Antwortcode -- in TESM eintragen</span>
|
||||
<span class="field__label">Antwortcode – in TESM eintragen</span>
|
||||
<pre class="log-view" id="offline-answer" style="max-height:16dvh">{{ response_code }}</pre>
|
||||
<div class="row row--end mt-2">
|
||||
<button class="btn btn--sm" type="button" data-copy="#offline-answer">
|
||||
@@ -23,7 +23,7 @@
|
||||
</button>
|
||||
</div>
|
||||
<p class="fs-sm muted mt-2">
|
||||
Tragen Sie ihn in TESM unter Verwaltung -- Lizenz bei
|
||||
Tragen Sie ihn in TESM unter Verwaltung → Lizenz bei
|
||||
„Antwortcode des Lizenzservers“ ein.
|
||||
{% if summary.hostname %}
|
||||
Die Anfrage kam von <span class="mono">{{ summary.hostname }}</span>.
|
||||
@@ -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') }}
|
||||
|
||||
@@ -33,20 +33,20 @@ bewusst keine echte Nachricht.', 'info') }}
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<div class="card__body stack">
|
||||
{{ checkbox('graph_enabled', 'E-Mail-Versand aktiv', checked=values.graph_enabled,
|
||||
disabled=not current_user.has('settings_mail.edit')) }}
|
||||
disabled=not current_user.has('settings_mail.write')) }}
|
||||
<div class="form-grid">
|
||||
{{ field('graph_tenant_id', 'Verzeichnis-ID (Tenant)', value=values.graph_tenant_id,
|
||||
disabled=not current_user.has('settings_mail.edit')) }}
|
||||
disabled=not current_user.has('settings_mail.write')) }}
|
||||
{{ field('graph_client_id', 'Anwendungs-ID (Client)', value=values.graph_client_id,
|
||||
disabled=not current_user.has('settings_mail.edit')) }}
|
||||
disabled=not current_user.has('settings_mail.write')) }}
|
||||
{{ field('graph_sender', 'Absender-Postfach', value=values.graph_sender, type='email',
|
||||
disabled=not current_user.has('settings_mail.edit')) }}
|
||||
disabled=not current_user.has('settings_mail.write')) }}
|
||||
{{ field('graph_reply_to', 'Antwortadresse', value=values.graph_reply_to, type='email',
|
||||
disabled=not current_user.has('settings_mail.edit')) }}
|
||||
disabled=not current_user.has('settings_mail.write')) }}
|
||||
</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">
|
||||
@@ -55,7 +55,7 @@ bewusst keine echte Nachricht.', 'info') }}
|
||||
{% endif %}
|
||||
</fieldset>
|
||||
</div>
|
||||
{% if current_user.has('settings_mail.edit') %}
|
||||
{% if current_user.has('settings_mail.write') %}
|
||||
<div class="card__footer"><button class="btn btn--primary" type="submit">Speichern</button></div>
|
||||
{% endif %}
|
||||
</form>
|
||||
@@ -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>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
{% extends "tesm_core/base.html" %}
|
||||
{% from "tesm_core/macros.html" import field, select_field, checkbox, alert %}
|
||||
{% from "tesm_core/macros.html" import field, select_field, checkbox, alert, nav_order_card %}
|
||||
|
||||
{% block page_title %}Systemeinstellungen{% endblock %}
|
||||
{% block page_sub %}Portal, Self-Service, Protokolle, Host{% endblock %}
|
||||
|
||||
{% 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>
|
||||
@@ -70,6 +70,34 @@
|
||||
</div>
|
||||
|
||||
<div class="stack">
|
||||
{# Eigene Karte: die Abmeldung stand zwischen Protokollrotation und
|
||||
Papierkorb -- drei Dinge ohne Zusammenhang. Wer die Sitzungsdauer sucht,
|
||||
sucht sie nicht unter "Protokolle". #}
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('clock', size=17) }} Sitzungen</div>
|
||||
<div class="card__sub">Wie lange eine Anmeldung gilt</div>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" action="{{ url_for('settings.system_save') }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<div class="card__body">
|
||||
<div class="form-grid">
|
||||
{{ field('session_idle_minutes', 'Automatische Abmeldung nach Leerlauf (Minuten)',
|
||||
value=values.session_idle_minutes, type='number',
|
||||
attrs='min="5" max="1440" step="1"',
|
||||
hint_text=specs['session_idle_minutes'].hint) }}
|
||||
{{ field('session_absolute_hours', 'Längstmögliche Sitzungsdauer (Stunden)',
|
||||
value=values.session_absolute_hours, type='number',
|
||||
attrs='min="1" max="168" step="1"',
|
||||
hint_text=specs['session_absolute_hours'].hint) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card__footer"><button class="btn btn--primary" type="submit">Speichern</button></div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1"><div class="card__title">{{ icon('server', size=17) }} Host</div></div>
|
||||
@@ -101,33 +129,9 @@
|
||||
</section>
|
||||
|
||||
{% if current_user.is_admin %}
|
||||
<section class="card">
|
||||
<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>
|
||||
</div>
|
||||
<form method="post" action="{{ url_for('admin.nav_order') }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<div class="card__body">
|
||||
<ol class="stack stack--tight">
|
||||
{% for key in nav_order %}
|
||||
{% set item = nav_items | selectattr('key', 'equalto', key) | list | first %}
|
||||
{% if item %}
|
||||
<li class="row row--tight">
|
||||
<input type="hidden" name="order" value="{{ item.key }}">
|
||||
<span class="flex-1">{{ icon(item.icon, size=15) }} {{ item.label }}</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</ol>
|
||||
</div>
|
||||
<div class="card__footer">
|
||||
<button class="btn btn--sm" type="submit">Reihenfolge speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
{{ nav_order_card(nav_baum, nav_order, nav_child_order,
|
||||
url_for('admin.nav_order'), csrf_field_name, csrf_token(),
|
||||
sub='Gilt für alle Benutzer') }}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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">
|
||||
@@ -19,38 +19,38 @@ welche Basis-URL in kundengerichteten Links steht.', 'info') }}
|
||||
<div class="card__body stack">
|
||||
<div class="form-grid">
|
||||
{{ field('vendor_name', 'Anbietername', value=values.vendor_name,
|
||||
disabled=not current_user.has('settings_vendor.edit')) }}
|
||||
disabled=not current_user.has('settings_vendor.write')) }}
|
||||
{{ field('vendor_email', 'E-Mail', value=values.vendor_email, type='email',
|
||||
disabled=not current_user.has('settings_vendor.edit')) }}
|
||||
disabled=not current_user.has('settings_vendor.write')) }}
|
||||
{{ field('vendor_phone', 'Telefon', value=values.vendor_phone,
|
||||
disabled=not current_user.has('settings_vendor.edit')) }}
|
||||
disabled=not current_user.has('settings_vendor.write')) }}
|
||||
</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'),
|
||||
disabled=not current_user.has('settings_vendor.write'),
|
||||
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>
|
||||
{% endif %}
|
||||
<input class="input" type="file" name="vendor_logo" accept="image/png,image/jpeg,image/svg+xml"
|
||||
{% if not current_user.has('settings_vendor.edit') %}disabled{% endif %}>
|
||||
{% if not current_user.has('settings_vendor.write') %}disabled{% endif %}>
|
||||
<span class="field__hint">
|
||||
Ein zu grosses Logo wird abgelehnt, die uebrigen Angaben werden trotzdem gespeichert.
|
||||
Ein zu großes Logo wird abgelehnt, die übrigen Angaben werden trotzdem gespeichert.
|
||||
</span>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
{% if current_user.has('settings_vendor.edit') %}
|
||||
{% if current_user.has('settings_vendor.write') %}
|
||||
<div class="card__footer modal__footer--split">
|
||||
{% if values.vendor_logo_base64 %}
|
||||
<button class="btn btn--sm btn--ghost" type="submit"
|
||||
@@ -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 %}
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
<div class="card__title">{{ icon('shield', size=17) }} Lizenz ausstellen</div>
|
||||
<div class="card__sub">
|
||||
{% if has_open_license %}
|
||||
Es existiert bereits eine offene Lizenz -- widerrufen Sie sie zuerst.
|
||||
Es existiert bereits eine offene Lizenz – widerrufen Sie sie zuerst.
|
||||
{% else %}
|
||||
Erzeugt eine signierte Lizenzdatei mit den Werten dieses Tickets.
|
||||
{% endif %}
|
||||
@@ -41,7 +41,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="card__body row row--tight">
|
||||
{% if current_user.has('licenses.create') %}
|
||||
{% if current_user.has('license_issue.execute') %}
|
||||
<form method="post" action="{{ url_for('licenses.issue', ticket_id=ticket.id) }}"
|
||||
data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
@@ -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,12 +158,12 @@
|
||||
<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"
|
||||
{% if not current_user.has('tickets.edit') %}disabled{% endif %}>
|
||||
{% if not current_user.has('tickets.write') %}disabled{% endif %}>
|
||||
{% for value in types %}
|
||||
<option value="{{ value }}" {% if value == ticket.type %}selected{% endif %}>
|
||||
{{ type_labels.get(value, value) }}
|
||||
@@ -178,15 +178,15 @@
|
||||
{% for module in modules %}
|
||||
{{ checkbox('modules', module_labels[module], value=module,
|
||||
checked=module in ticket_modules,
|
||||
disabled=not current_user.has('tickets.edit')) }}
|
||||
disabled=not current_user.has('tickets.write')) }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</fieldset>
|
||||
{% endif %}
|
||||
{{ field('valid_days', 'Laufzeit (Tage)', value=ticket.valid_days, type='number',
|
||||
disabled=not current_user.has('tickets.edit')) }}
|
||||
disabled=not current_user.has('tickets.write')) }}
|
||||
{{ checkbox('lifetime', 'Unbefristet', checked=ticket.lifetime,
|
||||
disabled=not current_user.has('tickets.edit')) }}
|
||||
disabled=not current_user.has('tickets.write')) }}
|
||||
{{ textarea_field('notes', 'Interne Notiz', value=ticket.notes, rows=2) }}
|
||||
<div class="divider"></div>
|
||||
<dl class="kv fs-xs">
|
||||
@@ -195,7 +195,7 @@
|
||||
<dt>Angelegt</dt><dd>{{ ticket.created_at | dt }} von {{ ticket.created_by or '-' }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
{% if current_user.has('tickets.edit') %}
|
||||
{% if current_user.has('tickets.write') %}
|
||||
<div class="card__footer">
|
||||
<button class="btn btn--primary" type="submit">Speichern</button>
|
||||
</div>
|
||||
@@ -203,20 +203,20 @@
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{% if current_user.has('tickets.edit') or current_user.has('tickets.delete') %}
|
||||
{% if current_user.has('tickets.write') 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') %}
|
||||
{% if current_user.has('tickets.write') %}
|
||||
<form method="post" action="{{ url_for('tickets.toggle_block', ticket_id=ticket.id) }}"
|
||||
data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<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">
|
||||
@@ -103,7 +103,7 @@
|
||||
<div class="card__body">
|
||||
<ol class="stack stack--tight fs-sm muted" style="list-style:decimal;padding-left:1.2em">
|
||||
<li>Ticket anlegen (dieser Schritt).</li>
|
||||
<li>Auf der Ticketseite eine Lizenz ausstellen -- optional als 30-Tage-Testlizenz mit
|
||||
<li>Auf der Ticketseite eine Lizenz ausstellen – optional als 30-Tage-Testlizenz mit
|
||||
allen Modulen.</li>
|
||||
<li>Lizenzdatei herunterladen oder per E-Mail versenden.</li>
|
||||
<li>Der Kunde spielt sie ein und aktiviert sie; die Aktivierung bindet sie an seine
|
||||
@@ -112,7 +112,7 @@
|
||||
<div class="divider"></div>
|
||||
<p class="fs-sm muted">
|
||||
Der Self-Service-Link des Tickets erlaubt dem Kunden, seine Lizenz selbst
|
||||
herunterzuladen und bei einem Systemwechsel freizugeben -- ohne Support-Anruf.
|
||||
herunterzuladen und bei einem Systemwechsel freizugeben – ohne Support-Anruf.
|
||||
</p>
|
||||
</div>
|
||||
<div class="card__footer">
|
||||
|
||||
@@ -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 %}
|
||||
+314
-40
@@ -28,6 +28,9 @@ set -euo pipefail
|
||||
|
||||
APP=""
|
||||
INSTANCE=""
|
||||
#: Verzeichnis der Umgebungsdateien. Ueberschreibbar, damit sich die
|
||||
#: Portsuche in einem Test wirklich ausfuehren laesst.
|
||||
ETC_TESM="${TESM_ETC_DIR:-/etc/tesm}"
|
||||
PORT=""
|
||||
HTTP_PORT=""
|
||||
SKIP_PACKAGES=0
|
||||
@@ -36,6 +39,7 @@ HTTPS=0
|
||||
HTTPS_PORT=""
|
||||
DOMAIN=""
|
||||
ACME_EMAIL=""
|
||||
ERZWINGEN=0
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
@@ -46,6 +50,95 @@ 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' "$*"; }
|
||||
|
||||
# -- Welche Fassung liegt hier, und welche laeuft schon? ---------------------
|
||||
#
|
||||
# Ein Installationslauf auf dieselbe Fassung macht nichts besser: er baut die
|
||||
# Python-Umgebung neu, kopiert dieselben Dateien, laedt nginx neu und startet
|
||||
# den Dienst -- mit der einzigen Wirkung, dass die Anwendung ein paar Sekunden
|
||||
# weg ist. Wer "update.sh" in einen Zeitplan haengt, bekaeme das taeglich.
|
||||
#
|
||||
# Verglichen wird die Anwendung **und** der Kern: die beiden Nummern laufen
|
||||
# absichtlich auseinander (siehe pyproject.toml), und eine Auslieferung kann
|
||||
# denselben Anwendungsstand mit neuem Kern enthalten.
|
||||
|
||||
#: Die Nummer aus einer pyproject.toml. Die erste Zeile "version = ..." gilt --
|
||||
#: weitere Vorkommen stehen in Abhaengigkeiten und gehen uns nichts an.
|
||||
paket_version() {
|
||||
sed -n 's/^version = "\([^"]*\)"/\1/p' "$1" 2>/dev/null | head -1
|
||||
}
|
||||
|
||||
#: Was in dieser Installation laeuft: "<anwendung> <kern>", sonst leer.
|
||||
laufende_version() {
|
||||
local wurzel="$1" modul="$2"
|
||||
[[ -x "$wurzel/venv/bin/python" ]] || return 0
|
||||
"$wurzel/venv/bin/python" -c \
|
||||
"import $modul, tesm_core; print($modul.__version__, tesm_core.__version__)" \
|
||||
2>/dev/null || true
|
||||
}
|
||||
|
||||
#: Ist das, was hier liegt, schon installiert? Eine eigene Funktion, weil genau
|
||||
#: diese Frage sich pruefen lassen muss -- ohne einen Server aufzusetzen.
|
||||
gleiche_version() {
|
||||
local laeuft="$1" liegt="$2"
|
||||
[[ -n "$laeuft" && "$laeuft" == "$liegt" ]]
|
||||
}
|
||||
|
||||
#: Merker fuer die EXIT-Falle. Zurueckgerollt wird nur, was auch gesichert ist.
|
||||
SICHERUNG_STEHT=0
|
||||
INSTALL_FERTIG=0
|
||||
ZURUECKGEROLLT=0
|
||||
|
||||
zurueckrollen_install() {
|
||||
[[ $SICHERUNG_STEHT -eq 1 && $ZURUECKGEROLLT -eq 0 ]] || return 0
|
||||
# Kommt nur zum Zug, wenn die Sicherung steht -- dann sind diese Variablen
|
||||
# gesetzt. Der Vollstaendigkeit halber trotzdem gepruefte Namen.
|
||||
[[ -n "${BACKUP_DIR:-}" && -d "$BACKUP_DIR" ]] || { warn "Keine Sicherung gefunden -- nichts wiederhergestellt."; return 0; }
|
||||
ZURUECKGEROLLT=1
|
||||
warn "Alten Stand wiederherstellen."
|
||||
systemctl stop "$UNIT" 2>/dev/null || true
|
||||
[[ "$APP" == "tesm" ]] && systemctl stop "$MONITOR_UNIT" 2>/dev/null || true
|
||||
rm -rf "$FAILED_DIR"
|
||||
mv "$ROOT" "$FAILED_DIR" 2>/dev/null || true
|
||||
cp -a "$BACKUP_DIR" "$ROOT"
|
||||
systemctl start "$UNIT" 2>/dev/null || true
|
||||
[[ "$APP" == "tesm" ]] && systemctl start "$MONITOR_UNIT" 2>/dev/null || true
|
||||
warn "Wiederhergestellt aus $BACKUP_DIR"
|
||||
warn "Fehlgeschlagener Stand liegt unter $FAILED_DIR"
|
||||
}
|
||||
|
||||
# Bei **jedem** Fehlschlag, nicht nur bei einem, an den ich gedacht habe:
|
||||
# Systempakete, venv, sudoers, nginx, ein Ctrl-C. Vorher blieb der Dienst
|
||||
# gestoppt und das Verzeichnis teils ersetzt zurueck.
|
||||
bei_fehler() {
|
||||
local code=$?
|
||||
[[ $INSTALL_FERTIG -eq 1 || "$code" == "0" ]] && return "$code"
|
||||
if [[ $SICHERUNG_STEHT -eq 1 ]]; then
|
||||
zurueckrollen_install
|
||||
else
|
||||
# Neuinstallation: es gibt keinen alten Stand. Nichts loeschen -- im
|
||||
# Zielverzeichnis kann eine Fassung 1.x liegen, deren Sicherung ein
|
||||
# anderes Skript haelt (upgrade.sh).
|
||||
# Vorsichtige Ersetzung: bricht es schon beim Auswerten der Argumente ab,
|
||||
# gibt es $ROOT noch nicht -- und "set -u" wuerde die Falle selbst zum
|
||||
# Fehler machen.
|
||||
if [[ ${IS_UPDATE:-0} -eq 1 ]]; then
|
||||
# Abbruch vor dem Sicherungsschritt: die Installation ist unangetastet.
|
||||
warn "Abgebrochen (Code $code), bevor etwas geaendert wurde."
|
||||
warn "Die bestehende Installation ist unberuehrt -- nichts zurueckzurollen."
|
||||
else
|
||||
warn "Abgebrochen (Code $code). Es gab keinen vorherigen Stand dieser Fassung,"
|
||||
warn "also wurde nichts wiederhergestellt und nichts geloescht."
|
||||
# Nur sagen, was es wirklich gibt: bei einem Abbruch an der Rueckfrage
|
||||
# existiert das Verzeichnis noch nicht.
|
||||
if [[ -n "${ROOT:-}" && -d "${ROOT:-}" ]]; then
|
||||
warn "Angelegt wurde bis hierhin: $ROOT (Daten unter data/ unberuehrt)."
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
return "$code"
|
||||
}
|
||||
trap bei_fehler EXIT
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Verwendung: install.sh --app <tesm|tesm-license> [Optionen]
|
||||
@@ -70,6 +163,7 @@ Verwendung: install.sh --app <tesm|tesm-license> [Optionen]
|
||||
--acme-email <a> Kontaktadresse fuer Let's Encrypt.
|
||||
--skip-packages Keine Systempakete installieren
|
||||
--yes Rueckfragen ueberspringen
|
||||
--force Auch dann einrichten, wenn dieselbe Fassung schon laeuft
|
||||
USAGE
|
||||
}
|
||||
|
||||
@@ -85,6 +179,7 @@ while [[ $# -gt 0 ]]; do
|
||||
--acme-email) ACME_EMAIL="${2:?}"; shift 2 ;;
|
||||
--skip-packages) SKIP_PACKAGES=1; shift ;;
|
||||
--yes|-y) ASSUME_YES=1; shift ;;
|
||||
--force) ERZWINGEN=1; shift ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) fail "unbekannte Option: $1" ;;
|
||||
esac
|
||||
@@ -109,13 +204,67 @@ LOG_DIR="/var/log/$NAME"
|
||||
SERVICE_USER="$APP"
|
||||
UNIT="$NAME.service"
|
||||
MONITOR_UNIT="$NAME-monitor.service"
|
||||
CLEANUP_UNIT="$NAME-cleanup.service"
|
||||
CLEANUP_TIMER="$NAME-cleanup.timer"
|
||||
NGINX_SITE="/etc/nginx/sites-available/$NAME"
|
||||
ENV_PREFIX="TESM_"
|
||||
[[ "$APP" == "tesm-license" ]] && ENV_PREFIX="TESM_LICENSE_"
|
||||
|
||||
# Ausgabe des Lizenzservers: die Master-Ausgabe bringt das Teilpaket
|
||||
# tesm_license/master mit -- den einzigen Weg, auf dem eine Lizenzdatei die
|
||||
# Rolle "master" bekommt. Das Release fuer Sub-Lizenzserver hat es nicht.
|
||||
# Die Ausgabe haengt an der Anwesenheit des Verzeichnisses, nicht an einer
|
||||
# Einstellung: eine Einstellung liesse sich vor Ort umstellen.
|
||||
MASTER_TEILPAKET="apps/tesm-license/src/tesm_license/master"
|
||||
EDITION="standard"
|
||||
if [[ "$APP" == "tesm-license" ]]; then
|
||||
if [[ -d "$REPO_DIR/$MASTER_TEILPAKET" ]]; then EDITION="master"; else EDITION="sub"; fi
|
||||
fi
|
||||
# Bei einer Aktualisierung gilt zuerst, was schon eingerichtet ist. Sonst
|
||||
# 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"
|
||||
# Wo die Daten liegen, steht in der Umgebungsdatei; ohne sie beim Vorgabeort.
|
||||
# "|| true": bei einer Neuinstallation gibt es die Datei noch nicht, und ein
|
||||
# scheiterndes sed in einer Zuweisung beendet unter "set -e" das ganze Skript.
|
||||
DATEN_DIR="$(sed -n "s/^${ENV_PREFIX}DATA_DIR=//p" "$ENV_FILE" 2>/dev/null || true)"
|
||||
DATEN_DIR="$(printf '%s' "$DATEN_DIR" | tail -1)"
|
||||
DATEN_DIR="${DATEN_DIR:-$ROOT/data}"
|
||||
if [[ -z "$PORT" && -f "$ENV_FILE" ]]; then
|
||||
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
|
||||
|
||||
port_belegt() {
|
||||
local p="$1"
|
||||
# Schon von einer anderen Installation vergeben? Die Umgebungsdateien sind
|
||||
# die Wahrheit -- ein Dienst kann gerade gestoppt sein und den Port trotzdem
|
||||
# beanspruchen.
|
||||
grep -qs "BIND=127\.0\.0\.1:$p\$" "$ETC_TESM"/*.env 2>/dev/null &&
|
||||
return 0
|
||||
# Oder gerade in Benutzung, von was auch immer.
|
||||
command -v ss >/dev/null 2>&1 && [[ -n "$(ss -Hltn "sport = :$p" 2>/dev/null)" ]] && return 0
|
||||
return 1
|
||||
}
|
||||
|
||||
if [[ -z "$PORT" ]]; then
|
||||
PORT=5000
|
||||
[[ "$APP" == "tesm-license" ]] && PORT=5001
|
||||
[[ -n "$INSTANCE" ]] && PORT=$((PORT + 100))
|
||||
BASISPORT=5000
|
||||
[[ "$APP" == "tesm-license" ]] && BASISPORT=5001
|
||||
if [[ -n "$INSTANCE" ]]; then
|
||||
# Frueher bekam **jede** Instanz Basis+100. Die zweite startete nie
|
||||
# ("Address already in use") -- und die Gesundheitspruefung merkte es
|
||||
# nicht, weil die erste auf demselben Port antwortete.
|
||||
PORT=$((BASISPORT + 100))
|
||||
while port_belegt "$PORT"; do PORT=$((PORT + 1)); done
|
||||
else
|
||||
PORT=$BASISPORT
|
||||
fi
|
||||
fi
|
||||
if [[ -z "$HTTP_PORT" ]]; then
|
||||
HTTP_PORT=80
|
||||
@@ -134,7 +283,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
|
||||
@@ -148,6 +297,23 @@ FAILED_DIR="/srv/$NAME-failed-$TIMESTAMP"
|
||||
IS_UPDATE=0
|
||||
[[ -f "$ROOT/data/app.db" ]] && IS_UPDATE=1
|
||||
|
||||
# Laeuft hier schon genau das, was hier liegt? Dann ist Schluss, bevor
|
||||
# irgendetwas angefasst wird -- und zwar mit Ausstiegscode 0: es ist nichts
|
||||
# schiefgegangen, es war nur nichts zu tun.
|
||||
MODUL="tesm"
|
||||
[[ "$APP" == "tesm-license" ]] && MODUL="tesm_license"
|
||||
LIEGT="$(paket_version "$REPO_DIR/apps/$APP/pyproject.toml") $(
|
||||
paket_version "$REPO_DIR/packages/tesm-core/pyproject.toml")"
|
||||
LAEUFT="$(laufende_version "$ROOT" "$MODUL")"
|
||||
if [[ $IS_UPDATE -eq 1 && $ERZWINGEN -eq 0 ]] && gleiche_version "$LAEUFT" "$LIEGT"; then
|
||||
step "Nichts zu tun"
|
||||
info "$APP ${LIEGT%% *} (tesm-core ${LIEGT##* }) laeuft hier bereits."
|
||||
info "Ein zweiter Durchlauf wuerde nur die Umgebung neu bauen und den"
|
||||
info "Dienst neu starten -- ohne dass sich etwas aendert."
|
||||
info "Trotzdem neu einrichten: --force"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
step "TESM-Installation"
|
||||
info "Anwendung : $APP${INSTANCE:+ (Instanz $INSTANCE)}"
|
||||
info "Ziel : $ROOT"
|
||||
@@ -165,6 +331,7 @@ else
|
||||
info "nginx : Port $HTTP_PORT (nur HTTP)"
|
||||
fi
|
||||
info "Modus : $([[ $IS_UPDATE -eq 1 ]] && echo Aktualisierung || echo Neuinstallation)"
|
||||
info "Fassung : ${LIEGT%% *} (tesm-core ${LIEGT##* })${LAEUFT:+, bisher ${LAEUFT%% *}}"
|
||||
|
||||
if [[ $ASSUME_YES -eq 0 ]]; then
|
||||
read -r -p "Fortfahren? [j/N] " answer
|
||||
@@ -197,12 +364,41 @@ fi
|
||||
# Der Vorgaenger lief als root. Hier laeuft der Dienst unprivilegiert; Rootrechte
|
||||
# gibt es ausschliesslich ueber den Helfer.
|
||||
|
||||
# Ein Sub-Paket auf eine Master-Installation waere ein stiller Rueckbau: der
|
||||
# Abgleich unten laeuft mit --delete und wuerde das Teilpaket entfernen. Danach
|
||||
# gaebe es auf dem Master keinen Weg mehr, eine Master-Lizenz auszustellen --
|
||||
# und niemand haette es gesagt.
|
||||
# Die Rolle steht in der eingespielten Lizenz. Das ist die zuverlaessigere
|
||||
# Haelfte der Pruefung: eine Master-Installation aus einer aelteren Fassung
|
||||
# kennt das Teilpaket noch nicht, ist aber trotzdem eine Wurzelinstanz.
|
||||
ROLLE_ALT=""
|
||||
if [[ "$EDITION" == "sub" && -s "$DATEN_DIR/license.json" ]]; then
|
||||
ROLLE_ALT="$(python3 -c '
|
||||
import json, sys
|
||||
try:
|
||||
print(json.load(open(sys.argv[1])).get("role", ""))
|
||||
except Exception:
|
||||
pass
|
||||
' "$DATEN_DIR/license.json" 2>/dev/null || true)"
|
||||
fi
|
||||
|
||||
if [[ "$EDITION" == "sub" && ( -d "$ROOT/$MASTER_TEILPAKET" || "$ROLLE_ALT" == "master" ) ]]; then
|
||||
fail "Hier laeuft eine Wurzelinstanz${ROLLE_ALT:+ (Lizenz mit Rolle $ROLLE_ALT)}, dieses Paket
|
||||
ist die Ausgabe fuer Sub-Lizenzserver.
|
||||
Eine Aktualisierung damit wuerde die Master-Ausgabe entfernen.
|
||||
Passendes Paket: Repository tesm-license-master (privat).
|
||||
Bewusster Rueckbau: eine vom Master ausgestellte Lizenz einspielen (Rolle
|
||||
license_server) und $ROOT/$MASTER_TEILPAKET entfernen -- danach ist dies ein
|
||||
Sub-Lizenzserver und dieses Paket das richtige."
|
||||
fi
|
||||
|
||||
# -- Backup ------------------------------------------------------------------
|
||||
if [[ $IS_UPDATE -eq 1 ]]; then
|
||||
step "Sicherung vor der Aktualisierung"
|
||||
systemctl stop "$UNIT" 2>/dev/null || true
|
||||
[[ "$APP" == "tesm" ]] && systemctl stop "$MONITOR_UNIT" 2>/dev/null || true
|
||||
cp -a "$ROOT" "$BACKUP_DIR"
|
||||
SICHERUNG_STEHT=1
|
||||
info "Sicherung: $BACKUP_DIR"
|
||||
fi
|
||||
|
||||
@@ -222,26 +418,16 @@ info "Dateien abgeglichen (Instanzdaten unter data/ bleiben unangetastet)"
|
||||
|
||||
# -- venv --------------------------------------------------------------------
|
||||
step "Python-Umgebung"
|
||||
if [[ ! -x "$ROOT/venv/bin/python3" ]]; then
|
||||
python3 -m venv "$ROOT/venv"
|
||||
info "venv erzeugt"
|
||||
fi
|
||||
# Immer ueber "python3 -m pip" -- der pip-Shim im venv baeckt einen absoluten
|
||||
# Pfad in seinen Shebang, der nach einer Umbenennung des Verzeichnisses bricht.
|
||||
"$ROOT/venv/bin/python3" -m pip install --quiet --upgrade pip setuptools wheel
|
||||
PAKETE=("$ROOT/packages/tesm-licensing" "$ROOT/packages/tesm-core")
|
||||
# Die Serverhaelfte des Lizenzprotokolls -- ausstellen, erneuern, antworten --
|
||||
# gehoert ausschliesslich zum Lizenzserver. Ein Client verifiziert Lizenzen und
|
||||
# stellt Anfragen; den Code zum Ausstellen soll er nicht einmal mitbringen.
|
||||
if [[ "$APP" == "tesm-license" ]]; then
|
||||
[[ -d "$ROOT/packages/tesm-licensing-server" ]] ||
|
||||
fail "packages/tesm-licensing-server fehlt -- ohne sie kann der Lizenzserver nichts ausstellen"
|
||||
PAKETE+=("$ROOT/packages/tesm-licensing-server")
|
||||
fi
|
||||
"$ROOT/venv/bin/python3" -m pip install --quiet "${PAKETE[@]}" "$ROOT/apps/$APP" gunicorn
|
||||
if [[ "$APP" == "tesm" ]]; then
|
||||
"$ROOT/venv/bin/python3" -m pip install --quiet flask-sock
|
||||
fi
|
||||
# Aufbauen, pruefen und reparieren steht in genau einem Programm -- und
|
||||
# dasselbe laeuft als ExecStartPre vor jedem Dienststart. Zwei getrennte
|
||||
# Paketlisten waeren zwei Listen, die auseinanderlaufen: die Reparatur baute
|
||||
# dann eine Umgebung, die nicht mehr der Installation entspricht.
|
||||
# Ueber "bash" und nicht als Programm: Dateien im Baum tragen kein
|
||||
# Ausfuehrungsrecht -- git kennt auf einer Windows-Buildmaschine keines, und
|
||||
# install.sh, tesm-helper und tesm-motd werden aus demselben Grund ebenso
|
||||
# aufgerufen oder mit ausdruecklichem Modus installiert. Ohne das brach die
|
||||
# Aktualisierung mit "Permission denied" ab (und rollte zurueck).
|
||||
bash "$ROOT/deploy/tesm-venv" "$ROOT" "$APP" --immer
|
||||
info "Abhaengigkeiten installiert"
|
||||
|
||||
# -- Statische Dateien fuer nginx --------------------------------------------
|
||||
@@ -271,6 +457,12 @@ install_if_changed() {
|
||||
install_if_changed "$ROOT/deploy/tesm-helper" /usr/local/lib/tesm/tesm-helper 0755 && \
|
||||
info "Helfer aktualisiert" || info "Helfer unveraendert"
|
||||
|
||||
# Derselbe Baustein, der oben die Umgebung gebaut hat: die Dienste rufen ihn
|
||||
# vor jedem Start auf und reparieren damit, was ein Release-Upgrade des
|
||||
# Betriebssystems ihnen unter den Fuessen weggezogen hat.
|
||||
install_if_changed "$ROOT/deploy/tesm-venv" /usr/local/lib/tesm/tesm-venv 0755 >/dev/null \
|
||||
&& info "Umgebungspfleger aktualisiert" || true
|
||||
|
||||
# visudo -c prueft die Datei, bevor sie aktiv wird -- eine kaputte sudoers-Datei
|
||||
# waere sonst ein Totalausfall der Administration.
|
||||
TMP_SUDO="$(mktemp)"
|
||||
@@ -280,6 +472,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"
|
||||
@@ -296,8 +524,8 @@ step "Umgebung"
|
||||
# EnvironmentFile, die Kommandozeilenwerkzeuge ueber ihren Wrapper. Ohne das
|
||||
# wuesste "tesm-admin" nicht, wo die Instanz liegt, und wuerde sich eine leere
|
||||
# Datenbank im Arbeitsverzeichnis anlegen.
|
||||
install -d -o root -g root -m 0755 /etc/tesm
|
||||
ENV_FILE="/etc/tesm/$NAME.env"
|
||||
install -d -o root -g root -m 0755 "$ETC_TESM"
|
||||
ENV_FILE="$ETC_TESM/$NAME.env"
|
||||
TMP_ENV="$(mktemp)"
|
||||
{
|
||||
echo "# Von install.sh erzeugt fuer $NAME -- nicht von Hand bearbeiten."
|
||||
@@ -363,6 +591,11 @@ render_unit "$ROOT/deploy/systemd/$APP.service" "/etc/systemd/system/$UNIT"
|
||||
if [[ "$APP" == "tesm" ]]; then
|
||||
render_unit "$ROOT/deploy/systemd/tesm-monitor.service" "/etc/systemd/system/$MONITOR_UNIT"
|
||||
fi
|
||||
# Aufraeumen lief bisher nur von Hand -- also praktisch nie. Auf einer
|
||||
# laufenden Installation lagen deshalb hunderte abgelaufene Sitzungszeilen in
|
||||
# der Datenbank, und das Aenderungsprotokoll waere nie archiviert worden.
|
||||
render_unit "$ROOT/deploy/systemd/$APP-cleanup.service" "/etc/systemd/system/$CLEANUP_UNIT"
|
||||
render_unit "$ROOT/deploy/systemd/$APP-cleanup.timer" "/etc/systemd/system/$CLEANUP_TIMER"
|
||||
[[ $UNITS_CHANGED -eq 1 ]] && systemctl daemon-reload && info "Units neu geladen" \
|
||||
|| info "Units unveraendert"
|
||||
|
||||
@@ -435,12 +668,18 @@ systemctl enable --now "$UNIT" >/dev/null
|
||||
if [[ "$APP" == "tesm" ]]; then
|
||||
systemctl enable --now "$MONITOR_UNIT" >/dev/null
|
||||
fi
|
||||
# Der Timer, nicht der Dienst: der Dienst laeuft einmal und ist fertig.
|
||||
systemctl enable --now "$CLEANUP_TIMER" >/dev/null 2>&1 && info "Aufraeumen: taeglich 03:30" || info "Aufraeum-Timer nicht aktiviert -- 'systemctl status $CLEANUP_TIMER' zeigt warum"
|
||||
|
||||
# -- Health-Check und Rollback ----------------------------------------------
|
||||
step "Gesundheitspruefung"
|
||||
HEALTHY=0
|
||||
for attempt in $(seq 1 20); do
|
||||
if curl -fsS --max-time 3 "http://127.0.0.1:$PORT/gesundheit" >/dev/null 2>&1; then
|
||||
# Beides: der Dienst muss laufen **und** antworten. Nur die Antwort zu
|
||||
# pruefen ging schon einmal schief -- auf einem gemeinsamen Port antwortete
|
||||
# eine andere Instanz, und ein toter Dienst galt als gesund.
|
||||
if systemctl is-active --quiet "$UNIT" &&
|
||||
curl -fsS --max-time 3 "http://127.0.0.1:$PORT/health" >/dev/null 2>&1; then
|
||||
HEALTHY=1
|
||||
info "Antwort nach ${attempt}x"
|
||||
break
|
||||
@@ -451,13 +690,9 @@ done
|
||||
if [[ $HEALTHY -eq 0 ]]; then
|
||||
warn "Die Anwendung antwortet nicht."
|
||||
if [[ $IS_UPDATE -eq 1 ]]; then
|
||||
warn "Rollback wird ausgefuehrt."
|
||||
systemctl stop "$UNIT" 2>/dev/null || true
|
||||
[[ "$APP" == "tesm" ]] && systemctl stop "$MONITOR_UNIT" 2>/dev/null || true
|
||||
mv "$ROOT" "$FAILED_DIR"
|
||||
cp -a "$BACKUP_DIR" "$ROOT"
|
||||
systemctl start "$UNIT" || true
|
||||
[[ "$APP" == "tesm" ]] && systemctl start "$MONITOR_UNIT" || true
|
||||
# Dieselbe Funktion wie die EXIT-Falle -- zwei Wege, die dasselbe tun
|
||||
# sollen, laufen sonst irgendwann auseinander.
|
||||
zurueckrollen_install
|
||||
fail "Aktualisierung fehlgeschlagen. Alter Stand wiederhergestellt.
|
||||
Fehlgeschlagene Version : $FAILED_DIR
|
||||
Sicherung : $BACKUP_DIR
|
||||
@@ -517,12 +752,41 @@ if [[ $HTTPS -eq 1 ]]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
# -- Lizenzserver einrichten -------------------------------------------------
|
||||
# Bei einer Neuinstallation: Signaturschluessel anlegen (braucht jeder
|
||||
# Lizenzserver, um Kundenlizenzen zu signieren) und in der Master-Ausgabe
|
||||
# gleich die eigene Master-Lizenz. Bei einer Aktualisierung nichts davon --
|
||||
# ein bestehender Schluessel darf nie angetastet werden.
|
||||
if [[ "$APP" == "tesm-license" && $IS_UPDATE -eq 0 ]]; then
|
||||
step "Signaturschluessel"
|
||||
if sudo -u "$SERVICE_USER" "/usr/local/bin/$NAME-admin" init-key; then
|
||||
info "Bitte die Datei $DATEN_DIR/master_signing_key.json SOFORT sichern."
|
||||
else
|
||||
fail "Der Signaturschluessel liess sich nicht erzeugen. Ohne ihn kann dieser Server
|
||||
keine Lizenz ausstellen. Protokoll: journalctl -u $UNIT -n 50"
|
||||
fi
|
||||
|
||||
if [[ "$EDITION" == "master" ]]; then
|
||||
step "Eigene Master-Lizenz"
|
||||
if [[ -s "$DATEN_DIR/license.json" ]]; then
|
||||
info "Es liegt schon eine Lizenzdatei -- unangetastet."
|
||||
elif sudo -u "$SERVICE_USER" "/usr/local/bin/$NAME-admin" bootstrap; then
|
||||
systemctl restart "$UNIT"
|
||||
info "Dienst neu gestartet, damit die Lizenz gelesen wird."
|
||||
else
|
||||
fail "Die Master-Lizenz liess sich nicht ausstellen.
|
||||
Nachtraeglich: sudo -u $SERVICE_USER $NAME-admin bootstrap"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ $IS_UPDATE -eq 1 ]]; then
|
||||
rm -rf "$BACKUP_DIR"
|
||||
info "Sicherung entfernt (Aktualisierung erfolgreich)"
|
||||
fi
|
||||
|
||||
# -- Abschluss ---------------------------------------------------------------
|
||||
INSTALL_FERTIG=1
|
||||
step "Fertig"
|
||||
ADMIN_CMD="tesm-admin"
|
||||
[[ "$APP" == "tesm-license" ]] && ADMIN_CMD="tesm-license-admin"
|
||||
@@ -538,14 +802,24 @@ cat <<SUMMARY
|
||||
Naechste Schritte:
|
||||
SUMMARY
|
||||
|
||||
if [[ "$APP" == "tesm-license" ]]; then
|
||||
if [[ "$APP" == "tesm-license" && "$EDITION" == "master" ]]; then
|
||||
cat <<SUMMARY
|
||||
1. Signaturschluessel erzeugen und sofort sichern:
|
||||
sudo -u $SERVICE_USER $NAME-admin init-key
|
||||
2. Eigene Master-Lizenz ausstellen:
|
||||
sudo -u $SERVICE_USER $NAME-admin bootstrap
|
||||
3. Administrator anlegen:
|
||||
1. Administrator anlegen:
|
||||
sudo -u $SERVICE_USER $NAME-admin create-admin
|
||||
2. Signaturschluessel sichern -- ohne ihn ist keine ausgestellte Lizenz
|
||||
mehr pruefbar:
|
||||
$ROOT/data/master_signing_key.json
|
||||
SUMMARY
|
||||
elif [[ "$APP" == "tesm-license" ]]; then
|
||||
cat <<SUMMARY
|
||||
1. Administrator anlegen:
|
||||
sudo -u $SERVICE_USER $NAME-admin create-admin
|
||||
2. Beim Master-Lizenzserver eine Lizenz fuer diesen Server anfordern und
|
||||
unter "Eigene Lizenz" einspielen. Diese Ausgabe ist ein Sub-Lizenzserver
|
||||
und stellt keine eigene Master-Lizenz aus.
|
||||
3. Signaturschluessel sichern -- mit ihm signiert dieser Server seine
|
||||
Kundenlizenzen:
|
||||
$ROOT/data/master_signing_key.json
|
||||
SUMMARY
|
||||
else
|
||||
cat <<SUMMARY
|
||||
|
||||
+10
-1
@@ -14,13 +14,22 @@ TAG="${1:?Verwendung: release.sh vX.Y.Z [name]}"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
|
||||
# Vorgabe: der Name der einzigen Anwendung im Baum, sonst der Verzeichnisname.
|
||||
# Vorgabe: der Name des Repositoriums. Genau den erwartet der Bootstrap von
|
||||
# update.sh als Anhangsnamen ("$repo-$TAG.tar.gz"). Die Anwendung heisst nicht
|
||||
# immer so: im Repositorium tesm-license-master liegt die Anwendung
|
||||
# "tesm-license", und ein Anhang unter deren Namen waere nie zu finden.
|
||||
if [[ -n "${2:-}" ]]; then
|
||||
NAME="$2"
|
||||
else
|
||||
HERKUNFT="$(git -C "$REPO_DIR" config --get remote.origin.url 2>/dev/null || true)"
|
||||
if [[ -n "$HERKUNFT" ]]; then
|
||||
NAME="$(basename "$HERKUNFT" .git)"
|
||||
else
|
||||
# Ohne Gegenseite: die einzige Anwendung im Baum, sonst das Verzeichnis.
|
||||
mapfile -t APPS < <(ls -1 "$REPO_DIR/apps" 2>/dev/null || true)
|
||||
if [[ ${#APPS[@]} -eq 1 ]]; then NAME="${APPS[0]}"; else NAME="$(basename "$REPO_DIR")"; fi
|
||||
fi
|
||||
fi
|
||||
|
||||
OUT="$NAME-$TAG.tar.gz"
|
||||
git -c core.autocrlf=false -C "$REPO_DIR" archive --format=tar.gz --prefix="$NAME/" -o "$OUT" "$TAG"
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# Benennt eine Installation um -- aus einer Instanz wird die Hauptinstallation
|
||||
# (oder umgekehrt).
|
||||
#
|
||||
# sudo bash deploy/rename.sh --app tesm --from tesm-test --to tesm
|
||||
# sudo bash deploy/rename.sh --app tesm-license --from tesm-license-test \
|
||||
# --to tesm-license
|
||||
#
|
||||
# Der Name steckt an mehr Stellen, als man beim Verschieben eines Verzeichnisses
|
||||
# erwartet, und drei davon sind leicht zu vergessen:
|
||||
#
|
||||
# * ``SITE_KEY`` in der Umgebungsdatei -- daraus entsteht der Name des
|
||||
# Sitzungscookies. Bleibt er alt, heisst das Cookie weiter wie die alte
|
||||
# Instanz.
|
||||
# * ``web_cert_path``/``web_key_path``/``web_acme_webroot`` in der Datenbank.
|
||||
# Sie enthalten den Namen; ein Zertifikat unter dem alten Pfad findet nginx
|
||||
# nach dem Umzug nicht mehr.
|
||||
# * ``server_name`` der nginx-Site. install.sh schreibt die Datei neu, sobald
|
||||
# sich die alias-Pfade aendern -- und die aendern sich hier zwangslaeufig.
|
||||
# Ohne den Namen aus der Datenbank stuenden danach zwei Sites mit ``_`` auf
|
||||
# Port 80 und die erste gewinnt.
|
||||
#
|
||||
# Belegt der Zielname noch eine Installation der Fassung 1.x, wird sie beiseite
|
||||
# geschoben: Verzeichnis nach ``<ziel>-<suffix>``, ihre systemd-Units und ihre
|
||||
# nginx-Site als Kopie hinein. Danach ist der Name frei und nichts auf dem
|
||||
# System zeigt mehr ins Leere.
|
||||
#
|
||||
# Alle angemeldeten Benutzer werden abgemeldet: das Sitzungscookie heisst
|
||||
# danach anders.
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
APP=""
|
||||
FROM=""
|
||||
TO=""
|
||||
SUFFIX="sonnet5"
|
||||
ASSUME_YES=0
|
||||
DRY_RUN=0
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
COLOR_OK=$'\033[32m'; COLOR_WARN=$'\033[33m'; COLOR_ERR=$'\033[31m'; COLOR_OFF=$'\033[0m'
|
||||
step() { printf '\n%s==>%s %s\n' "$COLOR_OK" "$COLOR_OFF" "$*"; }
|
||||
warn() { printf '%s !!%s %s\n' "$COLOR_WARN" "$COLOR_OFF" "$*"; }
|
||||
fail() { printf '%s !!%s %s\n' "$COLOR_ERR" "$COLOR_OFF" "$*" >&2; exit 1; }
|
||||
info() { printf ' %s\n' "$*"; }
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Verwendung: rename.sh --app <tesm|tesm-license> --from <alt> --to <neu> [Optionen]
|
||||
|
||||
--app <name> Welche Anwendung (Pflicht)
|
||||
--from <name> Bisheriger Installationsname, z.B. tesm-test (Pflicht)
|
||||
--to <name> Neuer Installationsname, z.B. tesm (Pflicht)
|
||||
--suffix <text> Namenszusatz fuer eine beiseite geschobene Installation
|
||||
der Fassung 1.x (Vorgabe: sonnet5)
|
||||
--dry-run Nur zeigen, was geschehen wuerde
|
||||
--yes Keine Rueckfrage
|
||||
USAGE
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--app) APP="${2:?}"; shift 2 ;;
|
||||
--from) FROM="${2:?}"; shift 2 ;;
|
||||
--to) TO="${2:?}"; shift 2 ;;
|
||||
--suffix) SUFFIX="${2:?}"; shift 2 ;;
|
||||
--dry-run) DRY_RUN=1; shift ;;
|
||||
--yes|-y) ASSUME_YES=1; shift ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) usage; fail "unbekannte Option: $1" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ "$APP" == "tesm" || "$APP" == "tesm-license" ]] || { usage; fail "--app fehlt oder ist unbekannt"; }
|
||||
[[ -n "$FROM" && -n "$TO" ]] || { usage; fail "--from und --to sind Pflicht"; }
|
||||
[[ "$FROM" != "$TO" ]] || fail "--from und --to sind gleich"
|
||||
[[ "$TO" =~ ^[a-z0-9-]{1,32}$ ]] || fail "ungueltiger Zielname: $TO"
|
||||
[[ "$TO" == "$APP" || "$TO" == "$APP-"* ]] || fail "der Zielname muss mit '$APP' beginnen"
|
||||
[[ "$(id -u)" -eq 0 ]] || fail "bitte mit sudo ausfuehren"
|
||||
[[ -f "$SCRIPT_DIR/install.sh" ]] || fail "install.sh fehlt neben diesem Skript"
|
||||
[[ -d "/srv/$FROM" ]] || fail "/srv/$FROM gibt es nicht"
|
||||
[[ -f "/srv/$FROM/data/app.db" ]] || fail "/srv/$FROM sieht nicht nach einer Installation dieser Fassung aus"
|
||||
|
||||
ENV_PREFIX="TESM_"
|
||||
[[ "$APP" == "tesm-license" ]] && ENV_PREFIX="TESM_LICENSE_"
|
||||
|
||||
# Alles, was den Namen traegt. Reihenfolge: erst Daten, dann Konfiguration.
|
||||
VERSCHIEBEN=(
|
||||
"/srv/$FROM:/srv/$TO"
|
||||
"/var/log/$FROM:/var/log/$TO"
|
||||
"/etc/tesm/$FROM.env:/etc/tesm/$TO.env"
|
||||
"/var/www/$FROM-acme:/var/www/$TO-acme"
|
||||
"/etc/tesm/certs/$FROM:/etc/tesm/certs/$TO"
|
||||
)
|
||||
ENTFERNEN=(
|
||||
"/etc/systemd/system/$FROM.service"
|
||||
"/etc/systemd/system/$FROM-monitor.service"
|
||||
"/etc/nginx/sites-enabled/$FROM"
|
||||
"/etc/nginx/sites-available/$FROM"
|
||||
"/usr/local/bin/$FROM-admin"
|
||||
"/etc/logrotate.d/$FROM"
|
||||
"/etc/update-motd.d/50-$FROM"
|
||||
)
|
||||
|
||||
# -- Webeinstellungen auslesen, bevor irgendetwas umzieht -------------------- #
|
||||
# Mit dem System-Python und der Standardbibliothek: das venv zieht gleich mit
|
||||
# um, und ein zusaetzliches Werkzeug (sqlite3) ist auf einem schlanken Server
|
||||
# nicht garantiert.
|
||||
lese_einstellung() {
|
||||
python3 - "$1" "/srv/$FROM/data/app.db" <<'PY'
|
||||
import sqlite3, sys
|
||||
schluessel, datenbank = sys.argv[1], sys.argv[2]
|
||||
try:
|
||||
conn = sqlite3.connect(f"file:{datenbank}?mode=ro", uri=True)
|
||||
row = conn.execute("SELECT value FROM settings WHERE key=?", (schluessel,)).fetchone()
|
||||
conn.close()
|
||||
except sqlite3.Error:
|
||||
row = None
|
||||
print((row[0] if row and row[0] is not None else "").strip())
|
||||
PY
|
||||
}
|
||||
|
||||
DOMAIN="$(lese_einstellung web_domain || true)"
|
||||
HTTPS_AN="$(lese_einstellung web_https_enabled || true)"
|
||||
HSTS_AN="$(lese_einstellung web_hsts_enabled || true)"
|
||||
CERT_ALT="$(lese_einstellung web_cert_path || true)"
|
||||
|
||||
step "Vorhaben"
|
||||
info "Anwendung : $APP"
|
||||
info "Alt : $FROM"
|
||||
info "Neu : $TO"
|
||||
info "Domain : ${DOMAIN:-(keine)}"
|
||||
info "HTTPS : $([[ "$HTTPS_AN" == "1" ]] && echo "ja${CERT_ALT:+, Zertifikat $CERT_ALT}" || echo nein)"
|
||||
for paar in "${VERSCHIEBEN[@]}"; do
|
||||
quelle="${paar%%:*}"; ziel="${paar##*:}"
|
||||
[[ -e "$quelle" ]] && info "verschieben: $quelle -> $ziel"
|
||||
done
|
||||
for pfad in "${ENTFERNEN[@]}"; do
|
||||
[[ -e "$pfad" ]] && info "entfernen : $pfad (install.sh legt es unter dem neuen Namen neu an)"
|
||||
done
|
||||
|
||||
# -- Was belegt den Zielnamen? ----------------------------------------------- #
|
||||
# Nicht nur das Verzeichnis unter /srv: eine Installation der Fassung 1.x
|
||||
# hinterlaesst auch /var/log/<name>, /etc/logrotate.d/<name> und den
|
||||
# ACME-Pfad. Der Umzug bricht sonst mitten drin ab -- live genau so passiert.
|
||||
BELEGT=()
|
||||
for paar in "${VERSCHIEBEN[@]}"; do
|
||||
ziel="${paar##*:}"
|
||||
[[ -e "$ziel" ]] && BELEGT+=("$ziel")
|
||||
done
|
||||
for weiteres in "/etc/logrotate.d/$TO" "/etc/update-motd.d/50-$TO"; do
|
||||
[[ -e "$weiteres" ]] && BELEGT+=("$weiteres")
|
||||
done
|
||||
|
||||
BEISEITE=""
|
||||
if [[ ${#BELEGT[@]} -gt 0 ]]; then
|
||||
if [[ -f "/srv/$TO/data/app.db" ]]; then
|
||||
fail "/srv/$TO ist bereits eine Installation dieser Fassung. Welche gewinnt, entscheidet niemand automatisch."
|
||||
fi
|
||||
BEISEITE="/srv/$TO-$SUFFIX"
|
||||
if [[ -e "$BEISEITE" ]]; then
|
||||
# Ein vorhandenes Archiv derselben Verdraengung wird weiterbenutzt -- die
|
||||
# Reste einer Installation gehoeren zusammen und nicht in ein zweites
|
||||
# Verzeichnis. Alles andere unter diesem Namen bleibt unangetastet.
|
||||
if [[ -f "$BEISEITE/sqlite.db" || -d "$BEISEITE/systemd-alt" ]]; then
|
||||
info "Archiv $BEISEITE ist vorhanden und wird weiterbenutzt."
|
||||
else
|
||||
fail "$BEISEITE existiert und sieht nicht nach einem solchen Archiv aus -- bitte --suffix waehlen"
|
||||
fi
|
||||
fi
|
||||
info "Der Zielname ist belegt. Alles davon wandert nach $BEISEITE:"
|
||||
for pfad in "${BELEGT[@]}"; do info " $pfad"; done
|
||||
fi
|
||||
|
||||
if [[ $DRY_RUN -eq 1 ]]; then
|
||||
step "--dry-run: hier ist Schluss."
|
||||
exit 0
|
||||
fi
|
||||
if [[ $ASSUME_YES -eq 0 ]]; then
|
||||
read -r -p "Fortfahren? Alle angemeldeten Benutzer werden abgemeldet. [j/N] " answer
|
||||
[[ "$answer" =~ ^[jJyY]$ ]] || { echo "Abgebrochen."; exit 0; }
|
||||
fi
|
||||
|
||||
# -- 1. Dienste anhalten ----------------------------------------------------- #
|
||||
step "Dienste anhalten"
|
||||
for unit in "$FROM.service" "$FROM-monitor.service"; do
|
||||
if systemctl list-unit-files "$unit" --no-legend 2>/dev/null | grep -q .; then
|
||||
systemctl disable --now "$unit" >/dev/null 2>&1 || true
|
||||
info "$unit gestoppt und deaktiviert"
|
||||
fi
|
||||
done
|
||||
|
||||
# -- 2. Zielnamen freimachen ------------------------------------------------- #
|
||||
if [[ -n "$BEISEITE" ]]; then
|
||||
step "Zielnamen freimachen"
|
||||
# Alles wandert ins Archiv, nichts wird geloescht: unter /var/log/<name>
|
||||
# liegen Protokolle, unter /srv/<name> im Zweifel eine ganze Installation.
|
||||
# Die Units und die Site kommen mit -- sie zeigen auf das alte Verzeichnis
|
||||
# und waeren auf dem System nur noch Fehlerquellen, im Archiv aber ein Beleg.
|
||||
mkdir -p "$BEISEITE"
|
||||
for pfad in "${BELEGT[@]}"; do
|
||||
ziel="$BEISEITE/$(basename "$pfad")"
|
||||
if [[ "$pfad" == "/srv/$TO" ]]; then
|
||||
# Das Verzeichnis selbst wird das Archiv -- sein Inhalt bleibt, wie er ist.
|
||||
for eintrag in "$pfad"/* "$pfad"/.[!.]*; do
|
||||
[[ -e "$eintrag" ]] || continue
|
||||
mv "$eintrag" "$BEISEITE/"
|
||||
done
|
||||
rmdir "$pfad"
|
||||
info "/srv/$TO -> $BEISEITE/"
|
||||
continue
|
||||
fi
|
||||
if [[ -e "$ziel" ]]; then
|
||||
ziel="$ziel-$(date +%Y%m%d-%H%M%S)"
|
||||
warn "Im Archiv lag schon etwas mit diesem Namen -- ablegen als $(basename "$ziel")"
|
||||
fi
|
||||
mv "$pfad" "$ziel"
|
||||
info "$pfad -> $ziel"
|
||||
done
|
||||
mkdir -p "$BEISEITE/systemd-alt"
|
||||
for unit in "/etc/systemd/system/$TO.service" "/etc/systemd/system/$TO-check.service" \
|
||||
"/etc/systemd/system/$TO-check.timer" "/etc/systemd/system/$TO-check-restart.service" \
|
||||
"/etc/systemd/system/$TO-check-restart.timer"; do
|
||||
[[ -f "$unit" ]] || continue
|
||||
systemctl disable --now "$(basename "$unit")" >/dev/null 2>&1 || true
|
||||
mv "$unit" "$BEISEITE/systemd-alt/"
|
||||
info "$(basename "$unit") archiviert"
|
||||
done
|
||||
for site in "/etc/nginx/sites-enabled/$TO" "/etc/nginx/sites-available/$TO"; do
|
||||
[[ -e "$site" ]] || continue
|
||||
[[ -L "$site" ]] && { rm -f "$site"; continue; }
|
||||
mv "$site" "$BEISEITE/systemd-alt/nginx-site"
|
||||
info "nginx-Site archiviert"
|
||||
done
|
||||
for motd in "/etc/update-motd.d/50-$TO"; do
|
||||
[[ -f "$motd" ]] && grep -q "motd_banner.py" "$motd" 2>/dev/null && rm -f "$motd" \
|
||||
&& info "Banner der Fassung 1.x entfernt"
|
||||
done
|
||||
info "Alles Verdraengte liegt unter $BEISEITE"
|
||||
fi
|
||||
|
||||
# -- 3. Umziehen ------------------------------------------------------------- #
|
||||
step "Umziehen"
|
||||
for paar in "${VERSCHIEBEN[@]}"; do
|
||||
quelle="${paar%%:*}"; ziel="${paar##*:}"
|
||||
[[ -e "$quelle" ]] || continue
|
||||
[[ -e "$ziel" ]] && fail "$ziel existiert bereits -- Abbruch, damit nichts vermischt wird"
|
||||
mkdir -p "$(dirname "$ziel")"
|
||||
mv "$quelle" "$ziel"
|
||||
info "$quelle -> $ziel"
|
||||
done
|
||||
|
||||
# -- 4. Namen in den Dateien nachziehen -------------------------------------- #
|
||||
step "Namen nachziehen"
|
||||
ENV_FILE="/etc/tesm/$TO.env"
|
||||
if [[ -f "$ENV_FILE" ]]; then
|
||||
# Der SITE_KEY bestimmt den Namen des Sitzungscookies. Bleibt er alt, heisst
|
||||
# das Cookie weiter wie die alte Instanz -- unauffaellig und falsch.
|
||||
sed -i "s#^${ENV_PREFIX}SITE_KEY=.*#${ENV_PREFIX}SITE_KEY=$TO#" "$ENV_FILE"
|
||||
info "SITE_KEY=$TO"
|
||||
fi
|
||||
|
||||
# Pfade in der Datenbank, die den Namen enthalten.
|
||||
python3 - "$TO" "$FROM" "/srv/$TO/data/app.db" <<'PY'
|
||||
import sqlite3, sys
|
||||
neu, alt, datenbank = sys.argv[1], sys.argv[2], sys.argv[3]
|
||||
schluessel = ("web_cert_path", "web_key_path", "web_acme_webroot")
|
||||
try:
|
||||
conn = sqlite3.connect(datenbank, timeout=5.0)
|
||||
except sqlite3.Error as fehler:
|
||||
print(f" Datenbank nicht schreibbar: {fehler}")
|
||||
raise SystemExit(0)
|
||||
try:
|
||||
with conn:
|
||||
for key in schluessel:
|
||||
row = conn.execute("SELECT value FROM settings WHERE key=?", (key,)).fetchone()
|
||||
if not row or not row[0]:
|
||||
continue
|
||||
wert = str(row[0])
|
||||
ersetzt = wert.replace(alt, neu)
|
||||
if ersetzt != wert:
|
||||
conn.execute("UPDATE settings SET value=? WHERE key=?", (ersetzt, key))
|
||||
print(f" {key}: {wert} -> {ersetzt}")
|
||||
finally:
|
||||
conn.close()
|
||||
PY
|
||||
|
||||
# -- 5. Alte Konfiguration entfernen ----------------------------------------- #
|
||||
step "Alte Konfiguration entfernen"
|
||||
for pfad in "${ENTFERNEN[@]}"; do
|
||||
[[ -e "$pfad" ]] || continue
|
||||
rm -f "$pfad"
|
||||
info "$pfad"
|
||||
done
|
||||
systemctl daemon-reload
|
||||
|
||||
# -- 6. Unter dem neuen Namen einrichten ------------------------------------- #
|
||||
# Das venv wird verworfen und neu gebaut. Es ist abgeleiteter Zustand, und
|
||||
# seine Konsolenskripte tragen den absoluten Pfad im Shebang: nach dem Umzug
|
||||
# zeigt "venv/bin/gunicorn" auf einen Interpreter, den es nicht mehr gibt, und
|
||||
# systemd meldet nur "No such file or directory" -- fuer eine Datei, die
|
||||
# sichtbar da ist. Live auf POETEST genau so passiert.
|
||||
if [[ -d "/srv/$TO/venv" ]]; then
|
||||
step "venv verwerfen (Shebangs tragen den alten Pfad)"
|
||||
rm -rf "/srv/$TO/venv"
|
||||
info "wird von install.sh neu gebaut"
|
||||
fi
|
||||
|
||||
step "Unter dem neuen Namen einrichten"
|
||||
INSTALL_ARGS=(--app "$APP" --yes)
|
||||
[[ "$TO" != "$APP" ]] && INSTALL_ARGS+=(--instance "${TO#"$APP"-}")
|
||||
bash "$SCRIPT_DIR/install.sh" "${INSTALL_ARGS[@]}"
|
||||
|
||||
# -- 7. nginx-Site aus den Einstellungen neu schreiben ----------------------- #
|
||||
# install.sh schreibt die Site aus seiner Vorlage, sobald sich die alias-Pfade
|
||||
# aendern -- und die aendern sich hier zwangslaeufig. Der Name der Site steht
|
||||
# aber in der Datenbank, nicht in der Vorlage. Ohne diesen Schritt stuenden
|
||||
# zwei Sites mit "_" auf Port 80 und die erste gewinnt.
|
||||
if [[ -n "$DOMAIN" ]]; then
|
||||
step "Webserver-Konfiguration erneuern"
|
||||
# --apply nicht vergessen: ohne ihn speichert web-setup nur die Einstellungen
|
||||
# und laesst die Site-Datei stehen, die install.sh aus seiner Vorlage
|
||||
# geschrieben hat -- mit "server_name _" und ohne TLS.
|
||||
WEB_ARGS=(web-setup --apply --domain "$DOMAIN")
|
||||
if [[ "$HTTPS_AN" == "1" ]]; then
|
||||
WEB_ARGS+=(--https)
|
||||
[[ "$HSTS_AN" == "1" ]] && WEB_ARGS+=(--hsts)
|
||||
# Das Zertifikat ist mitgezogen und sein Pfad in der Datenbank nachgefuehrt
|
||||
# -- "web-setup --https" laesst es dann unangetastet. Ein neues erzeugen
|
||||
# wuerde nur eine weitere Browserwarnung produzieren. Nur wenn nach dem
|
||||
# Umzug wirklich keines da ist, wird eines erzeugt.
|
||||
CERT_NEU="${CERT_ALT//$FROM/$TO}"
|
||||
if [[ -n "$CERT_NEU" && -f "$CERT_NEU" ]]; then
|
||||
info "HTTPS mit dem vorhandenen Zertifikat: $CERT_NEU"
|
||||
else
|
||||
WEB_ARGS+=(--self-signed)
|
||||
warn "Zertifikat nicht gefunden (${CERT_NEU:-keins hinterlegt}) -- es wird ein neues"
|
||||
warn "selbstsigniertes erzeugt. Der Browser warnt dann einmal erneut."
|
||||
fi
|
||||
fi
|
||||
if sudo -u "$APP" "/usr/local/bin/$TO-admin" "${WEB_ARGS[@]}"; then
|
||||
info "Site neu geschrieben: server_name $DOMAIN"
|
||||
else
|
||||
warn "web-setup ist fehlgeschlagen -- bitte in der Anwendung unter"
|
||||
warn "'Verwaltung -> Webserver und TLS' die Domain eintragen und uebernehmen."
|
||||
fi
|
||||
fi
|
||||
|
||||
# -- 8. Ergebnis ------------------------------------------------------------- #
|
||||
step "Fertig"
|
||||
info "Verzeichnis : /srv/$TO"
|
||||
info "Dienst : systemctl status $TO.service"
|
||||
info "Werkzeug : $TO-admin"
|
||||
info "Aktualisieren: sudo bash /srv/$TO/deploy/update.sh $APP$([[ "$TO" != "$APP" ]] && echo " latest --instance ${TO#"$APP"-}")"
|
||||
[[ -n "$BEISEITE" ]] && info "Fassung 1.x : $BEISEITE (Units und Site darin unter systemd-alt/)"
|
||||
echo
|
||||
warn "Alle Benutzer sind abgemeldet: das Sitzungscookie heisst jetzt anders."
|
||||
+7
-1
@@ -5,5 +5,11 @@
|
||||
#
|
||||
# Bewusst KEIN "ALL=(ALL) NOPASSWD: ALL". Der Vorgaenger loeste dasselbe
|
||||
# Problem, indem die gesamte Anwendung als root lief.
|
||||
Defaults:tesm !requiretty
|
||||
#
|
||||
# Hier stand "Defaults:tesm !requiretty". Auf Debian und Ubuntu ist
|
||||
# requiretty nie gesetzt, die Zeile war also von Anfang an wirkungslos -- und
|
||||
# ab sudo 1.9.16 kennt visudo die Einstellung nicht mehr und lehnt die ganze
|
||||
# Datei ab ("unknown setting: 'requiretty'"). Auf einem frisch aufgesetzten
|
||||
# Server brach die Installation deshalb mitten drin ab. Nicht wieder
|
||||
# hinzufuegen; ein Hygienetest haelt das fest.
|
||||
tesm ALL=(root) NOPASSWD: /usr/local/lib/tesm/tesm-helper
|
||||
@@ -5,5 +5,11 @@
|
||||
#
|
||||
# Bewusst KEIN "ALL=(ALL) NOPASSWD: ALL". Der Vorgaenger loeste dasselbe
|
||||
# Problem, indem die gesamte Anwendung als root lief.
|
||||
Defaults:tesm-license !requiretty
|
||||
#
|
||||
# Hier stand "Defaults:tesm-license !requiretty". Auf Debian und Ubuntu ist
|
||||
# requiretty nie gesetzt, die Zeile war also von Anfang an wirkungslos -- und
|
||||
# ab sudo 1.9.16 kennt visudo die Einstellung nicht mehr und lehnt die ganze
|
||||
# Datei ab ("unknown setting: 'requiretty'"). Auf einem frisch aufgesetzten
|
||||
# Server brach die Installation deshalb mitten drin ab. Nicht wieder
|
||||
# hinzufuegen; ein Hygienetest haelt das fest.
|
||||
tesm-license ALL=(root) NOPASSWD: /usr/local/lib/tesm/tesm-helper
|
||||
@@ -0,0 +1,34 @@
|
||||
[Unit]
|
||||
Description=TESM -- Aufraeumen: Sitzungen, Ratenereignisse, Protokollarchiv
|
||||
After=tesm.service
|
||||
# Ohne die laufende Anwendung ist nichts kaputt -- der Befehl arbeitet direkt
|
||||
# auf der Datenbank. "After" sorgt nur fuer eine sinnvolle Reihenfolge beim
|
||||
# Hochfahren.
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=tesm
|
||||
Group=tesm
|
||||
WorkingDirectory=/srv/tesm
|
||||
Environment=TESM_BASE_DIR=/srv/tesm
|
||||
Environment=TESM_DATA_DIR=/srv/tesm/data
|
||||
Environment=TESM_LOG_DIR=/var/log/tesm
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
ExecStart=/srv/tesm/venv/bin/tesm-admin housekeeping
|
||||
|
||||
# Hier darf voll gehaertet werden: dieser Aufruf fasst nur die Datenbank und
|
||||
# das Protokollverzeichnis an, keine Systemdateien und keinen Helfer per sudo.
|
||||
NoNewPrivileges=yes
|
||||
PrivateTmp=yes
|
||||
ProtectSystem=strict
|
||||
ProtectHome=yes
|
||||
ProtectKernelTunables=yes
|
||||
ProtectKernelModules=yes
|
||||
ProtectControlGroups=yes
|
||||
RestrictSUIDSGID=yes
|
||||
RestrictNamespaces=yes
|
||||
RestrictAddressFamilies=AF_UNIX
|
||||
LockPersonality=yes
|
||||
SystemCallArchitectures=native
|
||||
UMask=0027
|
||||
ReadWritePaths=/srv/tesm/data /var/log/tesm
|
||||
@@ -0,0 +1,18 @@
|
||||
[Unit]
|
||||
Description=TESM -- taegliches Aufraeumen
|
||||
|
||||
[Timer]
|
||||
# Nachts, wenn niemand arbeitet: abgelaufene Sitzungen, alte Ratenereignisse
|
||||
# und die Archivierung des Aenderungsprotokolls.
|
||||
#
|
||||
# Vorher lief das nur, wenn jemand "tesm-admin housekeeping" aufrief oder in
|
||||
# der Diagnose darauf klickte -- also praktisch nie. Gemessen auf einer
|
||||
# Installation nach vier Wochen: 747 Sitzungszeilen, 682 davon abgelaufen.
|
||||
OnCalendar=*-*-* 03:30:00
|
||||
# Streuung, damit nicht jede Installation im Netz zur selben Sekunde loslegt.
|
||||
RandomizedDelaySec=15m
|
||||
# Nachgeholt, wenn die Maschine nachts aus war.
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -0,0 +1,34 @@
|
||||
[Unit]
|
||||
Description=TESM-Lizenzserver -- Aufraeumen: Sitzungen, Ratenereignisse, Protokollarchiv
|
||||
After=tesm-license.service
|
||||
# Ohne die laufende Anwendung ist nichts kaputt -- der Befehl arbeitet direkt
|
||||
# auf der Datenbank. "After" sorgt nur fuer eine sinnvolle Reihenfolge beim
|
||||
# Hochfahren.
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=tesm-license
|
||||
Group=tesm-license
|
||||
WorkingDirectory=/srv/tesm-license
|
||||
Environment=TESM_LICENSE_BASE_DIR=/srv/tesm-license
|
||||
Environment=TESM_LICENSE_DATA_DIR=/srv/tesm-license/data
|
||||
Environment=TESM_LICENSE_LOG_DIR=/var/log/tesm-license
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
ExecStart=/srv/tesm-license/venv/bin/tesm-license-admin housekeeping
|
||||
|
||||
# Hier darf voll gehaertet werden: dieser Aufruf fasst nur die Datenbank und
|
||||
# das Protokollverzeichnis an, keine Systemdateien und keinen Helfer per sudo.
|
||||
NoNewPrivileges=yes
|
||||
PrivateTmp=yes
|
||||
ProtectSystem=strict
|
||||
ProtectHome=yes
|
||||
ProtectKernelTunables=yes
|
||||
ProtectKernelModules=yes
|
||||
ProtectControlGroups=yes
|
||||
RestrictSUIDSGID=yes
|
||||
RestrictNamespaces=yes
|
||||
RestrictAddressFamilies=AF_UNIX
|
||||
LockPersonality=yes
|
||||
SystemCallArchitectures=native
|
||||
UMask=0027
|
||||
ReadWritePaths=/srv/tesm-license/data /var/log/tesm-license
|
||||
@@ -0,0 +1,18 @@
|
||||
[Unit]
|
||||
Description=TESM-Lizenzserver -- taegliches Aufraeumen
|
||||
|
||||
[Timer]
|
||||
# Nachts, wenn niemand arbeitet: abgelaufene Sitzungen, alte Ratenereignisse
|
||||
# und die Archivierung des Aenderungsprotokolls.
|
||||
#
|
||||
# Vorher lief das nur, wenn jemand "tesm-license-admin housekeeping" aufrief oder in
|
||||
# der Diagnose darauf klickte -- also praktisch nie. Gemessen auf einer
|
||||
# Installation nach vier Wochen: 747 Sitzungszeilen, 682 davon abgelaufen.
|
||||
OnCalendar=*-*-* 03:30:00
|
||||
# Streuung, damit nicht jede Installation im Netz zur selben Sekunde loslegt.
|
||||
RandomizedDelaySec=15m
|
||||
# Nachgeholt, wenn die Maschine nachts aus war.
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -15,11 +15,23 @@ Environment=TESM_LICENSE_LOG_DIR=/var/log/tesm-license
|
||||
Environment=TESM_LICENSE_WEB_PROCESS=1
|
||||
Environment=TESM_LICENSE_COOKIE_SECURE=1
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
# Vor jedem Start: traegt die Python-Umgebung noch? Ein Release-Upgrade des
|
||||
# Betriebssystems tauscht die Minor-Fassung von Python aus, und der venv haengt
|
||||
# daran -- danach findet der Dienst kein einziges Paket mehr und scheitert mit
|
||||
# "No module named gunicorn". Ist alles heil, kostet diese Zeile Millisekunden;
|
||||
# sonst baut sie die Umgebung neu, einmal je Systemstart.
|
||||
#
|
||||
# Das "+" laesst den Schritt als root laufen. Noetig, weil der venv root
|
||||
# gehoert, der Dienst aber als unprivilegierter Benutzer laeuft.
|
||||
ExecStartPre=+/usr/local/lib/tesm/tesm-venv /srv/tesm-license tesm-license
|
||||
ExecStart=/srv/tesm-license/venv/bin/gunicorn \
|
||||
--workers 1 --worker-class gthread --threads 8 \
|
||||
--bind 127.0.0.1:5001 \
|
||||
--access-logfile - --error-logfile - \
|
||||
tesm_license.wsgi:app
|
||||
# Ein Neuaufbau der Python-Umgebung laedt Pakete und dauert Minuten --
|
||||
# die Vorgabe von 90 Sekunden wuerde ihn mittendrin abschneiden.
|
||||
TimeoutStartSec=900
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
|
||||
@@ -12,7 +12,19 @@ Environment=TESM_BASE_DIR=/srv/tesm
|
||||
Environment=TESM_DATA_DIR=/srv/tesm/data
|
||||
Environment=TESM_LOG_DIR=/var/log/tesm
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
# Vor jedem Start: traegt die Python-Umgebung noch? Ein Release-Upgrade des
|
||||
# Betriebssystems tauscht die Minor-Fassung von Python aus, und der venv haengt
|
||||
# daran -- danach findet der Dienst kein einziges Paket mehr und scheitert mit
|
||||
# "No module named gunicorn". Ist alles heil, kostet diese Zeile Millisekunden;
|
||||
# sonst baut sie die Umgebung neu, einmal je Systemstart.
|
||||
#
|
||||
# Das "+" laesst den Schritt als root laufen. Noetig, weil der venv root
|
||||
# gehoert, der Dienst aber als unprivilegierter Benutzer laeuft.
|
||||
ExecStartPre=+/usr/local/lib/tesm/tesm-venv /srv/tesm tesm
|
||||
ExecStart=/srv/tesm/venv/bin/tesm-monitor
|
||||
# Ein Neuaufbau der Python-Umgebung laedt Pakete und dauert Minuten --
|
||||
# die Vorgabe von 90 Sekunden wuerde ihn mittendrin abschneiden.
|
||||
TimeoutStartSec=900
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
|
||||
|
||||
@@ -15,11 +15,23 @@ Environment=TESM_LOG_DIR=/var/log/tesm
|
||||
Environment=TESM_WEB_PROCESS=1
|
||||
Environment=TESM_COOKIE_SECURE=1
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
# Vor jedem Start: traegt die Python-Umgebung noch? Ein Release-Upgrade des
|
||||
# Betriebssystems tauscht die Minor-Fassung von Python aus, und der venv haengt
|
||||
# daran -- danach findet der Dienst kein einziges Paket mehr und scheitert mit
|
||||
# "No module named gunicorn". Ist alles heil, kostet diese Zeile Millisekunden;
|
||||
# sonst baut sie die Umgebung neu, einmal je Systemstart.
|
||||
#
|
||||
# Das "+" laesst den Schritt als root laufen. Noetig, weil der venv root
|
||||
# gehoert, der Dienst aber als unprivilegierter Benutzer laeuft.
|
||||
ExecStartPre=+/usr/local/lib/tesm/tesm-venv /srv/tesm tesm
|
||||
ExecStart=/srv/tesm/venv/bin/gunicorn \
|
||||
--workers 1 --worker-class gthread --threads 16 --timeout 0 \
|
||||
--bind 127.0.0.1:5000 \
|
||||
--access-logfile - --error-logfile - \
|
||||
tesm.wsgi:app
|
||||
# Ein Neuaufbau der Python-Umgebung laedt Pakete und dauert Minuten --
|
||||
# die Vorgabe von 90 Sekunden wuerde ihn mittendrin abschneiden.
|
||||
TimeoutStartSec=900
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
@@ -59,7 +71,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,
|
||||
|
||||
+32
-1
@@ -47,7 +47,7 @@ check_in_list() {
|
||||
}
|
||||
|
||||
# Name einer Installation auf diesem Host: entweder die Hauptinstallation
|
||||
# ("tesm") oder eine benannte Instanz daneben ("tesm-opus"). Mehr als diese
|
||||
# ("tesm") oder eine benannte Instanz daneben ("tesm-test"). Mehr als diese
|
||||
# beiden Formen gibt es nicht -- so kann keine erfundene Zeichenkette einen
|
||||
# Pfad ausserhalb des vorgesehenen Namensraums erzeugen.
|
||||
check_app_key() {
|
||||
@@ -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,201 @@
|
||||
#!/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)
|
||||
|
||||
# Gibt es diese Installation ueberhaupt noch? Wenn nicht, ist hier nichts
|
||||
# zu melden -- auch kein Fehler.
|
||||
#
|
||||
# Der Hook unter /etc/update-motd.d bleibt liegen, wenn jemand eine
|
||||
# Installation von Hand entfernt; einen Rueckbau bringt TESM nicht mit.
|
||||
# Vorher druckte jeder dieser Hooks trotzdem seinen vollen Banner und
|
||||
# darunter "Datenbank nicht lesbar". Auf dem Testserver standen so
|
||||
# vierzehn Bloecke fuer vierzehn laengst geloeschte Probeinstallationen
|
||||
# vor jeder Anmeldung -- und der echte Banner ging darin unter.
|
||||
#
|
||||
# Eine entfernte Installation ist keine kaputte. Eine Datenbank, die da
|
||||
# ist und sich nicht lesen laesst, dagegen schon: die meldet sich weiter.
|
||||
if not (root / "data" / "app.db").is_file():
|
||||
return 0
|
||||
|
||||
# Kein isatty()-Test: dieses Skript schreibt beim Login nicht auf ein
|
||||
# Terminal, sondern ueber run-parts in eine Datei. Der Betrachter ist
|
||||
# trotzdem ein echtes Terminal -- deshalb immer Farbe.
|
||||
print(f"{BOLD}{AMBER}TESM{RESET} · {TITEL[args.app]}")
|
||||
if TAGLINE[args.app]:
|
||||
print(f"{DIM}{TAGLINE[args.app]}{RESET}")
|
||||
print()
|
||||
try:
|
||||
conn = _oeffnen(root)
|
||||
except sqlite3.Error:
|
||||
print(f"{DIM}Datenbank nicht lesbar: {root}{RESET}")
|
||||
print()
|
||||
return 0
|
||||
try:
|
||||
if args.app == "tesm":
|
||||
print(f"{_flotte(conn)} · DHCP: {_dhcp()}")
|
||||
else:
|
||||
print(_lizenzen(conn))
|
||||
except sqlite3.Error as fehler:
|
||||
print(f"{DIM}Zustand nicht verfuegbar ({fehler}){RESET}")
|
||||
finally:
|
||||
conn.close()
|
||||
print()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except SystemExit:
|
||||
raise
|
||||
except Exception: # noqa: BLE001 - ein Banner darf keinen Login stoeren
|
||||
sys.exit(0)
|
||||
@@ -0,0 +1,152 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# Die Python-Umgebung einer Installation -- aufbauen, pruefen, reparieren.
|
||||
#
|
||||
# tesm-venv /srv/tesm tesm # reparieren, falls noetig (Dienststart)
|
||||
# tesm-venv /srv/tesm tesm --immer # neu bestuecken (Installation)
|
||||
# tesm-venv /srv/tesm tesm --pruefen # nur melden: 0 heil, 1 kaputt
|
||||
#
|
||||
# **Warum es das gibt.** Ein venv haengt an der Minor-Fassung von Python: die
|
||||
# Pakete liegen unter lib/python3.X. Ein Release-Upgrade des Betriebssystems
|
||||
# tauscht diese Fassung aus (Ubuntu 24.04 bringt 3.12, 26.04 bringt 3.14) --
|
||||
# und danach zeigt venv/bin/python3 weiter auf /usr/bin/python3, also auf den
|
||||
# **neuen** Interpreter. Der venv ist ausfuehrbar und trotzdem tot: er findet
|
||||
# kein einziges Paket mehr, nicht einmal pip. Jeder Dienst scheitert mit
|
||||
# "ModuleNotFoundError: No module named 'gunicorn'", nginx meldet 502.
|
||||
#
|
||||
# Real passiert, auf beiden Testservern, am 16.09.2026.
|
||||
#
|
||||
# Dieses Programm laeuft deshalb als ExecStartPre vor jedem Dienststart. Ist
|
||||
# alles heil, endet es nach Millisekunden. Sonst baut es die Umgebung neu --
|
||||
# aus denselben Paketen, die auch die Installation einspielt, denn es ist
|
||||
# dasselbe Programm: install.sh ruft es mit --immer auf. Zwei getrennte Listen
|
||||
# waeren zwei Listen, die auseinanderlaufen.
|
||||
#
|
||||
# **Was es nicht tut:** die alten Pakete unter lib/python3.X uebernehmen.
|
||||
# cryptography, argon2-cffi und bcrypt sind kompiliert und fuer die alte
|
||||
# Fassung gebaut; ein halb uebernommener venv waere schlimmer als ein neuer.
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
# **Der venv muss fuer den Dienstbenutzer lesbar sein.**
|
||||
#
|
||||
# Dieses Programm laeuft als ExecStartPre der Dienst-Unit -- und erbt damit
|
||||
# deren UMask=0027. Alles, was es anlegt, gehoerte root und waere 0750: der
|
||||
# Dienst selbst laeuft unprivilegiert und koennte sein eigenes site-packages
|
||||
# nicht einmal betreten. Gemessen: nach der Reparatur ging "import gunicorn"
|
||||
# als root, als Benutzer tesm nicht -- die Reparatur baute etwas, das genau
|
||||
# der Prozess nicht benutzen konnte, fuer den sie gedacht war.
|
||||
#
|
||||
# Im venv liegt nichts Geheimes; Schluessel und Datenbank stehen woanders.
|
||||
umask 022
|
||||
|
||||
ROOT="${1:-}"
|
||||
APP="${2:-}"
|
||||
MODUS="reparieren"
|
||||
[[ "${3:-}" == "--immer" ]] && MODUS="immer"
|
||||
[[ "${3:-}" == "--pruefen" ]] && MODUS="pruefen"
|
||||
|
||||
[[ -n "$ROOT" && -n "$APP" ]] || {
|
||||
echo "Aufruf: tesm-venv <verzeichnis> <tesm|tesm-license> [--immer|--pruefen]" >&2
|
||||
exit 2
|
||||
}
|
||||
[[ "$APP" == "tesm" || "$APP" == "tesm-license" ]] || {
|
||||
echo "tesm-venv: unbekannte Anwendung '$APP'" >&2
|
||||
exit 2
|
||||
}
|
||||
[[ -d "$ROOT" ]] || { echo "tesm-venv: $ROOT gibt es nicht" >&2; exit 2; }
|
||||
|
||||
NAME="$(basename "$ROOT")"
|
||||
VENV="$ROOT/venv"
|
||||
PY="$VENV/bin/python3"
|
||||
MODUL="tesm"; [[ "$APP" == "tesm-license" ]] && MODUL="tesm_license"
|
||||
LOG_DIR="${TESM_LOG_DIR:-/var/log/$NAME}"
|
||||
#: Ein Versuch je Systemstart. /run ist ein tmpfs, der Merker verschwindet
|
||||
#: also beim naechsten Start von selbst. Ohne ihn wuerde "Restart=always" nach
|
||||
#: einem misslungenen Neuaufbau (kein Netz, kein PyPI) alle fuenf Sekunden
|
||||
#: einen neuen pip-Lauf starten -- gegen einen Server, der ohnehin gerade
|
||||
#: nicht erreichbar ist.
|
||||
MERKER="/run/tesm-venv-$NAME.versucht"
|
||||
|
||||
melden() {
|
||||
local stufe="$1" text="$2"
|
||||
# Ins Journal: ExecStartPre-Ausgabe landet dort ohnehin.
|
||||
printf 'tesm-venv: %s\n' "$text" >&2
|
||||
# Und ins laufende Protokoll, damit es in der Oberflaeche sichtbar ist.
|
||||
# Fehlschlaege hier duerfen den Start nicht kosten -- ein Protokoll ist
|
||||
# kein Grund, einen Dienst nicht zu starten.
|
||||
if [[ -d "$LOG_DIR" ]]; then
|
||||
printf '%s %-7s %-10s %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$stufe" "venv" "$text" \
|
||||
>> "$LOG_DIR/live.log" 2>/dev/null || true
|
||||
chown "$APP:$APP" "$LOG_DIR/live.log" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
#: Traegt diese Umgebung? Gefragt wird nicht nach der Versionsnummer, sondern
|
||||
#: nach dem, was gebraucht wird -- so faellt auch ein venv auf, dem jemand ein
|
||||
#: Paket herausgeloescht hat.
|
||||
traegt() {
|
||||
[[ -x "$PY" ]] || return 1
|
||||
"$PY" -c "import pip" >/dev/null 2>&1 || return 1
|
||||
# Beim allerersten Lauf ist die Anwendung noch nicht eingespielt -- dann
|
||||
# zaehlt allein, dass pip benutzbar ist.
|
||||
[[ "$MODUS" == "immer" ]] && return 0
|
||||
"$PY" -c "import $MODUL" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
bestuecken() {
|
||||
"$PY" -m pip install --quiet --upgrade pip setuptools wheel
|
||||
local pakete=("$ROOT/packages/tesm-licensing" "$ROOT/packages/tesm-core")
|
||||
if [[ "$APP" == "tesm-license" ]]; then
|
||||
[[ -d "$ROOT/packages/tesm-licensing-server" ]] || {
|
||||
echo "tesm-venv: packages/tesm-licensing-server fehlt" >&2
|
||||
return 1
|
||||
}
|
||||
pakete+=("$ROOT/packages/tesm-licensing-server")
|
||||
fi
|
||||
"$PY" -m pip install --quiet "${pakete[@]}" "$ROOT/apps/$APP" gunicorn
|
||||
# Die WebSocket-Bruecke braucht nur TESM (Terminal und Bildschirm).
|
||||
[[ "$APP" == "tesm" ]] && "$PY" -m pip install --quiet flask-sock
|
||||
return 0
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
if [[ "$MODUS" == "pruefen" ]]; then
|
||||
traegt && exit 0 || exit 1
|
||||
fi
|
||||
|
||||
if traegt && [[ "$MODUS" != "immer" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$MODUS" != "immer" && -e "$MERKER" ]]; then
|
||||
melden "WARNUNG" \
|
||||
"Die Python-Umgebung von $NAME ist unbrauchbar, und der Neuaufbau ist seit dem letzten Systemstart schon einmal gescheitert. Erneuter Versuch: 'bash $ROOT/deploy/install.sh --app $APP --yes'."
|
||||
exit 1
|
||||
fi
|
||||
touch "$MERKER" 2>/dev/null || true
|
||||
|
||||
if ! [[ -x "$PY" ]] || ! "$PY" -c "import pip" >/dev/null 2>&1; then
|
||||
# Ohne --clear: ein erneutes "venv" ergaenzt das fehlende lib-Verzeichnis
|
||||
# und holt pip zurueck. Gemessen, nicht angenommen.
|
||||
python3 -m venv "$VENV"
|
||||
[[ "$MODUS" == "immer" ]] ||
|
||||
melden "HINWEIS" \
|
||||
"Python-Umgebung von $NAME neu aufgebaut ($(python3 -V 2>&1)) -- das System hat seine Python-Fassung gewechselt."
|
||||
fi
|
||||
|
||||
if ! bestuecken; then
|
||||
melden "FEHLER" \
|
||||
"Die Pakete fuer $NAME liessen sich nicht einspielen. Hat dieser Server Zugriff auf das Paketverzeichnis?"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Und noch einmal ausdruecklich, fuer den Fall, dass ein frueherer Lauf mit
|
||||
# strengerer Maske gebaut hat: was hier liegt, muss lesbar sein. Idempotent
|
||||
# und in einer Sekunde durch.
|
||||
chmod -R a+rX "$VENV" 2>/dev/null || true
|
||||
|
||||
[[ "$MODUS" == "immer" ]] || melden "INFO" "Python-Umgebung von $NAME ist wieder vollstaendig."
|
||||
rm -f "$MERKER" 2>/dev/null || true
|
||||
exit 0
|
||||
@@ -0,0 +1,303 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# Rueckbau einer Installation von TESM oder TESM-Lizenzserver.
|
||||
#
|
||||
# Aufruf:
|
||||
# sudo bash deploy/uninstall.sh --app tesm
|
||||
# sudo bash deploy/uninstall.sh --app tesm-license --instance test
|
||||
# sudo bash deploy/uninstall.sh --app tesm --dry-run
|
||||
#
|
||||
# Das genaue Gegenstueck zu install.sh -- und es fehlte. Wer eine Installation
|
||||
# von Hand loeschte ("rm -rf /srv/tesm-probe"), liess zurueck: die systemd-Unit
|
||||
# (die systemd danach in einer Dauerschleife neu zu starten versuchte), den
|
||||
# Begruessungshaken (der bei jeder Anmeldung seinen Banner druckte), den
|
||||
# Shim unter /usr/local/bin, die Umgebungsdatei, die nginx-Site und den
|
||||
# Dienstbenutzer. Auf dem Testserver standen so neun tote Dienste und vierzehn
|
||||
# Banner. Genau das raeumt dieses Skript ab.
|
||||
#
|
||||
# Zwei Regeln, die den Unterschied machen:
|
||||
#
|
||||
# * **Daten bleiben.** Datenbank, Schluessel und Lizenz werden vorher in ein
|
||||
# Archiv gelegt, nicht geloescht. Wer sie wirklich los sein will, sagt es
|
||||
# mit --purge. Ein Rueckbau, der beilaeufig die Datenbank mitnimmt, ist
|
||||
# keiner, sondern ein Unfall.
|
||||
# * **Geteiltes bleibt, solange es jemand braucht.** sudoers-Datei und
|
||||
# Dienstbenutzer gehoeren der *Anwendung* (nicht der Instanz), der Helfer
|
||||
# unter /usr/local/lib/tesm allen Installationen beider Anwendungen. Sie
|
||||
# verschwinden erst, wenn die letzte geht -- sonst legt der Rueckbau einer
|
||||
# Testinstanz die Produktion daneben lahm.
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
APP=""
|
||||
INSTANCE=""
|
||||
PURGE=0
|
||||
ASSUME_YES=0
|
||||
DRY_RUN=0
|
||||
|
||||
#: Ueberschreibbar, damit sich die Entscheidungen dieses Skripts in einem Test
|
||||
#: wirklich ausfuehren lassen -- und nicht nur als Textmuster geprueft werden.
|
||||
ETC_TESM="${TESM_ETC_DIR:-/etc/tesm}"
|
||||
SRV_DIR="${TESM_SRV_DIR:-/srv}"
|
||||
UNIT_DIR="${TESM_SYSTEMD_DIR:-/etc/systemd/system}"
|
||||
MOTD_DIR="${TESM_MOTD_DIR:-/etc/update-motd.d}"
|
||||
BIN_DIR="${TESM_BIN_DIR:-/usr/local/bin}"
|
||||
LIB_DIR="${TESM_LIB_DIR:-/usr/local/lib/tesm}"
|
||||
SUDOERS_DIR="${TESM_SUDOERS_DIR:-/etc/sudoers.d}"
|
||||
LOGROTATE_DIR="${TESM_LOGROTATE_DIR:-/etc/logrotate.d}"
|
||||
NGINX_DIR="${TESM_NGINX_DIR:-/etc/nginx}"
|
||||
LOG_BASE="${TESM_LOG_BASE:-/var/log}"
|
||||
|
||||
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 <<'TEXT'
|
||||
Rueckbau einer Installation.
|
||||
|
||||
--app <tesm|tesm-license> welche Anwendung (Pflicht)
|
||||
--instance <name> benannte Instanz (sonst die Hauptinstallation)
|
||||
--purge Daten nicht sichern, sondern loeschen
|
||||
--dry-run nur zeigen, was geschehen wuerde
|
||||
--yes, -y nicht nachfragen
|
||||
TEXT
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--app) APP="${2:?}"; shift 2 ;;
|
||||
--instance) INSTANCE="${2:?}"; shift 2 ;;
|
||||
--purge) PURGE=1; shift ;;
|
||||
--dry-run) DRY_RUN=1; shift ;;
|
||||
--yes|-y) ASSUME_YES=1; shift ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) fail "unbekannte Option: $1" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ -n "$APP" ]] || { usage; fail "--app fehlt"; }
|
||||
[[ "$APP" == "tesm" || "$APP" == "tesm-license" ]] || fail "--app muss tesm oder tesm-license sein"
|
||||
[[ -z "$INSTANCE" || "$INSTANCE" =~ ^[a-z0-9-]{1,16}$ ]] || fail "ungueltiger Instanzname"
|
||||
# Root braucht es wegen /srv, /etc und systemd. Wer die Pfade umbiegt (Test),
|
||||
# fasst nichts davon an -- dann ist die Huerde keine Sicherheit, sondern nur im
|
||||
# Weg, und ein Rueckbau, der sich nicht pruefen laesst, ist ein Risiko fuer sich.
|
||||
if [[ $DRY_RUN -eq 0 && "$SRV_DIR" == "/srv" && "$(id -u)" -ne 0 ]]; then
|
||||
fail "bitte mit sudo ausfuehren"
|
||||
fi
|
||||
|
||||
NAME="$APP"
|
||||
[[ -n "$INSTANCE" ]] && NAME="$APP-$INSTANCE"
|
||||
ROOT="$SRV_DIR/$NAME"
|
||||
|
||||
# Das Datenverzeichnis kann verschoben sein -- dann steht es in der
|
||||
# Umgebungsdatei, und nur dort. Ein Rueckbau, der stattdessen "$ROOT/data"
|
||||
# annimmt, sichert ein leeres Verzeichnis und laesst die echte Datenbank liegen.
|
||||
ENV_FILE="$ETC_TESM/$NAME.env"
|
||||
DATEN_DIR="$(sed -n 's/^TESM_DATA_DIR=//p' "$ENV_FILE" 2>/dev/null | tail -1 || true)"
|
||||
DATEN_DIR="${DATEN_DIR:-$ROOT/data}"
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Wer teilt sich was?
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
#: Gibt es neben dieser noch eine weitere Installation **derselben** Anwendung?
|
||||
#: Daran haengen sudoers-Datei und Dienstbenutzer -- beide tragen den Namen der
|
||||
#: Anwendung, nicht den der Instanz.
|
||||
weitere_dieser_anwendung() {
|
||||
local eintrag name
|
||||
for eintrag in "$ETC_TESM/$APP.env" "$ETC_TESM/$APP-"*.env; do
|
||||
[[ -f "$eintrag" ]] || continue
|
||||
name="$(basename "$eintrag" .env)"
|
||||
[[ "$name" == "$NAME" ]] && continue
|
||||
return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
#: Gibt es ueberhaupt noch irgendeine Installation? Daran haengen der Helfer
|
||||
#: und das Banner-Programm unter /usr/local/lib/tesm -- die gehoeren allen.
|
||||
weitere_ueberhaupt() {
|
||||
local eintrag name
|
||||
for eintrag in "$ETC_TESM"/*.env; do
|
||||
[[ -f "$eintrag" ]] || continue
|
||||
name="$(basename "$eintrag" .env)"
|
||||
[[ "$name" == "$NAME" ]] && continue
|
||||
return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Der Plan -- erst zeigen, dann tun
|
||||
# --------------------------------------------------------------------------- #
|
||||
UNITS=("$NAME.service" "$NAME-monitor.service" "$NAME-cleanup.service" "$NAME-cleanup.timer")
|
||||
DATEIEN=(
|
||||
"$NGINX_DIR/sites-enabled/$NAME"
|
||||
"$NGINX_DIR/sites-available/$NAME"
|
||||
"$MOTD_DIR/50-$NAME"
|
||||
"$BIN_DIR/$NAME-admin"
|
||||
"$LOGROTATE_DIR/$NAME"
|
||||
"$ENV_FILE"
|
||||
)
|
||||
VERZEICHNISSE=("$ETC_TESM/certs/$NAME" "$LOG_BASE/$NAME")
|
||||
|
||||
gefunden=0
|
||||
step "Rueckbau von $NAME"
|
||||
info "Anwendung : $APP${INSTANCE:+ (Instanz $INSTANCE)}"
|
||||
info "Verzeichnis : $ROOT"
|
||||
|
||||
echo
|
||||
echo " Dienste:"
|
||||
for unit in "${UNITS[@]}"; do
|
||||
if [[ -f "$UNIT_DIR/$unit" ]]; then info "- $unit"; gefunden=1; fi
|
||||
done
|
||||
[[ $gefunden -eq 1 ]] || info "(keine)"
|
||||
|
||||
echo
|
||||
echo " Dateien und Verzeichnisse:"
|
||||
for pfad in "${DATEIEN[@]}" "${VERZEICHNISSE[@]}"; do
|
||||
if [[ -e "$pfad" ]]; then info "- $pfad"; gefunden=1; fi
|
||||
done
|
||||
if [[ -d "$ROOT" ]]; then info "- $ROOT"; gefunden=1; fi
|
||||
|
||||
if [[ $gefunden -eq 0 ]]; then
|
||||
echo
|
||||
info "Von '$NAME' ist nichts mehr da -- nichts zu tun."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo
|
||||
echo " Daten (Datenbank, Schluessel, Lizenz):"
|
||||
if [[ -d "$DATEN_DIR" ]]; then
|
||||
if [[ $PURGE -eq 1 ]]; then
|
||||
warn "$DATEN_DIR wird GELOESCHT (--purge)"
|
||||
else
|
||||
info "$DATEN_DIR wird vorher gesichert"
|
||||
fi
|
||||
else
|
||||
info "(kein Datenverzeichnis gefunden)"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo " Bleibt stehen:"
|
||||
if weitere_dieser_anwendung; then
|
||||
info "sudoers und Dienstbenutzer '$APP' -- eine weitere Installation braucht sie"
|
||||
else
|
||||
info "- (sudoers und Dienstbenutzer '$APP' werden entfernt)"
|
||||
fi
|
||||
if weitere_ueberhaupt; then
|
||||
info "Helfer unter $LIB_DIR -- andere Installationen brauchen ihn"
|
||||
else
|
||||
info "- (Helfer unter $LIB_DIR wird entfernt)"
|
||||
fi
|
||||
|
||||
if [[ $DRY_RUN -eq 1 ]]; then
|
||||
echo
|
||||
info "Probelauf -- es wurde nichts geaendert."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ $ASSUME_YES -eq 0 ]]; then
|
||||
echo
|
||||
read -r -p "Fortfahren? [j/N] " antwort
|
||||
[[ "$antwort" =~ ^[jJyY]$ ]] || fail "abgebrochen"
|
||||
fi
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Ausfuehren
|
||||
# --------------------------------------------------------------------------- #
|
||||
step "Daten"
|
||||
if [[ -d "$DATEN_DIR" && $PURGE -eq 0 ]]; then
|
||||
ARCHIV="$SRV_DIR/$NAME-daten-$(date +%Y%m%d-%H%M%S).tar.gz"
|
||||
tar -czf "$ARCHIV" -C "$(dirname "$DATEN_DIR")" "$(basename "$DATEN_DIR")"
|
||||
chmod 600 "$ARCHIV"
|
||||
info "gesichert: $ARCHIV"
|
||||
elif [[ -d "$DATEN_DIR" ]]; then
|
||||
info "wird mit dem Verzeichnis geloescht (--purge)"
|
||||
else
|
||||
info "nichts zu sichern"
|
||||
fi
|
||||
|
||||
step "Dienste"
|
||||
for unit in "${UNITS[@]}"; do
|
||||
[[ -f "$UNIT_DIR/$unit" ]] || continue
|
||||
systemctl disable --now "$unit" >/dev/null 2>&1 || true
|
||||
rm -f "$UNIT_DIR/$unit"
|
||||
# Ohne das bleibt eine entfernte Unit als "not-found" im Fehlerzustand
|
||||
# stehen und faerbt "systemctl --failed" rot.
|
||||
systemctl reset-failed "$unit" >/dev/null 2>&1 || true
|
||||
info "entfernt: $unit"
|
||||
done
|
||||
systemctl daemon-reload >/dev/null 2>&1 || true
|
||||
|
||||
step "Webserver"
|
||||
if [[ -e "$NGINX_DIR/sites-enabled/$NAME" || -e "$NGINX_DIR/sites-available/$NAME" ]]; then
|
||||
rm -f "$NGINX_DIR/sites-enabled/$NAME" "$NGINX_DIR/sites-available/$NAME"
|
||||
# Drei Faelle, und sie bedeuten Verschiedenes: kein nginx da (dann gibt es
|
||||
# nichts neu zu laden), nginx da und zufrieden, nginx da und unzufrieden.
|
||||
# Sie in einen Zweig zu werfen hiesse, auf einer Maschine ohne nginx eine
|
||||
# Warnung ueber eine kaputte Konfiguration auszugeben, die es nicht gibt.
|
||||
if ! command -v nginx >/dev/null 2>&1; then
|
||||
info "Site entfernt (nginx ist hier nicht installiert)"
|
||||
elif nginx -t >/dev/null 2>&1; then
|
||||
systemctl reload nginx >/dev/null 2>&1 || true
|
||||
info "Site entfernt, nginx neu geladen"
|
||||
else
|
||||
warn "Site entfernt -- nginx beanstandet die Konfiguration, bitte 'nginx -t' ansehen"
|
||||
fi
|
||||
else
|
||||
info "keine Site vorhanden"
|
||||
fi
|
||||
|
||||
step "Dateien"
|
||||
for pfad in "${DATEIEN[@]}"; do
|
||||
[[ -e "$pfad" ]] && { rm -f "$pfad"; info "entfernt: $pfad"; }
|
||||
done
|
||||
for pfad in "${VERZEICHNISSE[@]}"; do
|
||||
[[ -d "$pfad" ]] && { rm -rf "$pfad"; info "entfernt: $pfad"; }
|
||||
done
|
||||
if [[ -d "$ROOT" ]]; then
|
||||
rm -rf "$ROOT"
|
||||
info "entfernt: $ROOT"
|
||||
fi
|
||||
if [[ $PURGE -eq 1 ]]; then
|
||||
# Die Rueckwege aelterer Aktualisierungen. Sie enthalten dieselben Daten wie
|
||||
# die Installation -- deshalb nur hier, nie im Normalfall.
|
||||
for rest in "$SRV_DIR/$NAME-backup-"* "$SRV_DIR/$NAME-failed-"*; do
|
||||
[[ -d "$rest" ]] && { rm -rf "$rest"; info "entfernt: $rest"; }
|
||||
done
|
||||
fi
|
||||
|
||||
step "Geteiltes"
|
||||
if weitere_dieser_anwendung; then
|
||||
info "sudoers und Dienstbenutzer '$APP' bleiben -- eine weitere Installation braucht sie"
|
||||
else
|
||||
rm -f "$SUDOERS_DIR/$APP"
|
||||
info "entfernt: $SUDOERS_DIR/$APP"
|
||||
if getent passwd "$APP" >/dev/null 2>&1; then
|
||||
# Ein laufender Prozess haelt den Benutzer fest. Das ist kein Grund,
|
||||
# den Rueckbau scheitern zu lassen -- der Dienst ist oben schon gestoppt,
|
||||
# und was sonst noch unter diesem Namen laeuft, gehoert angesehen.
|
||||
if userdel "$APP" >/dev/null 2>&1; then
|
||||
info "entfernt: Dienstbenutzer $APP"
|
||||
else
|
||||
warn "Dienstbenutzer $APP liess sich nicht entfernen -- laeuft noch etwas unter ihm?"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
if weitere_ueberhaupt; then
|
||||
info "Helfer unter $LIB_DIR bleibt -- andere Installationen brauchen ihn"
|
||||
else
|
||||
rm -rf "$LIB_DIR"
|
||||
rmdir "$ETC_TESM/certs" "$ETC_TESM" >/dev/null 2>&1 || true
|
||||
info "entfernt: $LIB_DIR"
|
||||
fi
|
||||
|
||||
step "Fertig"
|
||||
info "'$NAME' ist zurueckgebaut."
|
||||
if [[ $PURGE -eq 0 && -n "${ARCHIV:-}" ]]; then
|
||||
info "Die Daten liegen unter $ARCHIV -- dort und sonst nirgends."
|
||||
fi
|
||||
+197
-26
@@ -1,56 +1,227 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# Bootstrapper: laedt ein Release herunter und ruft dessen install.sh auf.
|
||||
# Aktualisierung: laedt das aktuelle Release und ruft dessen install.sh auf.
|
||||
# Enthaelt selbst keinerlei Installationslogik.
|
||||
#
|
||||
# Zwei Fallen des Vorgaengers sind hier bewusst vermieden:
|
||||
# * Der Tag "latest" wird ueber die API aufgeloest, nie direkt in die
|
||||
# Download-URL geschrieben. Gitea liefert sonst mit HTTP 200 ein
|
||||
# automatisch erzeugtes Quell-Snapshot statt des echten Release-Assets.
|
||||
# * Nach dem Entpacken wird geprueft, dass ein install.sh existiert -- faengt
|
||||
# denselben Fehler ein zweites Mal ab.
|
||||
# Eine einzelne Datei genuegt -- sie holt sich alles Weitere:
|
||||
#
|
||||
# curl -fsSLO https://gitea.int.eertmoed.net/alientim/tesm/raw/branch/main/deploy/update.sh
|
||||
# sudo bash update.sh tesm
|
||||
#
|
||||
# Auf einer bestehenden Installation liegt sie schon bereit:
|
||||
#
|
||||
# sudo bash /srv/tesm/deploy/update.sh tesm
|
||||
#
|
||||
# Laeuft hier die Master-Ausgabe des Lizenzservers, kommt das Release aus dem
|
||||
# privaten Repositorium tesm-license-master -- dafuer braucht es einen
|
||||
# Zugriffstoken in TESM_RELEASE_TOKEN oder in /etc/tesm-release.token.
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
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-test" eine
|
||||
# zweite Installation namens "tesm" -- mit eigener Datenbank, eigener
|
||||
# nginx-Site und demselben Port.
|
||||
shift $(( $# > 2 ? 2 : $# ))
|
||||
EXTRA=("$@")
|
||||
|
||||
[[ "$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 test" >&2
|
||||
echo "Ohne Rueckfrage: --yes anhaengen (ohne Terminal zwingend)." >&2
|
||||
echo "Laeuft die Fassung schon, endet der Aufruf ohne Arbeit; --force setzt" >&2
|
||||
echo "sie trotzdem neu auf." >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
WORK="$(mktemp -d /tmp/tesm-update-XXXXXX)"
|
||||
trap 'rm -rf "$WORK"' EXIT # anders als im Vorgaenger wird aufgeraeumt
|
||||
# Repositorium: die Master-Ausgabe des Lizenzservers liegt in einem eigenen,
|
||||
# privaten Repositorium, heisst als Installation aber weiter "tesm-license".
|
||||
# Erkannt wird sie an der Installation und nicht an einem Schalter -- wer
|
||||
# aktualisiert, soll nicht erst wissen muessen, welche Ausgabe hier laeuft.
|
||||
# Sonst holte der Aufruf die Ausgabe fuer Sub-Lizenzserver, und der Installer
|
||||
# muesste sie abweisen.
|
||||
REPO="$APP"
|
||||
if [[ "$APP" == "tesm-license" ]]; then
|
||||
NAME="$APP"
|
||||
for ((i = 0; i < ${#EXTRA[@]}; i++)); do
|
||||
[[ "${EXTRA[i]}" == "--instance" ]] && NAME="$APP-${EXTRA[i + 1]:-}"
|
||||
done
|
||||
# Die Wurzel ist ueberschreibbar, damit ein Test die Erkennung wirklich
|
||||
# ausfuehren kann und nicht nur den Quelltext liest.
|
||||
if [[ -d "${TESM_SRV_ROOT:-/srv}/$NAME/apps/tesm-license/src/tesm_license/master" ]]; then
|
||||
REPO="tesm-license-master"
|
||||
echo "==> Master-Ausgabe erkannt -- Release aus $REPO."
|
||||
fi
|
||||
fi
|
||||
|
||||
# --8<-- Bootstrap: Release holen --------------------------------------------
|
||||
# Wortgleich in update.sh und upgrade.sh; ein Hygienetest vergleicht die beiden
|
||||
# Bloecke. Zwei eigenstaendige Skripte koennen sich keine Datei teilen, und
|
||||
# beide sollen als **einzelne Datei** genuegen: auf das Zielsystem legen,
|
||||
# ausfuehren, fertig -- auf einer 1.2.8 liegt nichts von diesem Projekt.
|
||||
#
|
||||
# Erwartet: $APP und $TAG, wahlweise $REPO (Vorgabe: $APP). Setzt: $TAG
|
||||
# (aufgeloest) und $SRC (entpacktes Release). Raeumt sein Arbeitsverzeichnis
|
||||
# beim Beenden auf.
|
||||
#
|
||||
# $REPO gibt es, weil Installationsname und Repositorium auseinanderfallen
|
||||
# koennen: die Master-Ausgabe des Lizenzservers liegt in
|
||||
# "tesm-license-master", die Installation heisst weiter "tesm-license".
|
||||
#
|
||||
# Zwei Fallen des Vorgaengers sind hier vermieden:
|
||||
# * Der Tag "latest" wird ueber die API aufgeloest, nie direkt in die
|
||||
# Download-URL geschrieben. Gitea liefert sonst mit HTTP 200 ein
|
||||
# automatisch erzeugtes Quell-Snapshot statt des echten Release-Anhangs.
|
||||
# * Nach dem Entpacken wird geprueft, dass ein install.sh existiert.
|
||||
BASE="${TESM_RELEASE_BASE:-https://gitea.int.eertmoed.net}"
|
||||
OWNER="${TESM_RELEASE_OWNER:-alientim}"
|
||||
|
||||
# Ein privates Repositorium -- tesm-license-master -- gibt sein Release nur mit
|
||||
# Zugriffstoken heraus. Reihenfolge: Umgebungsvariable, dann eine Datei, die
|
||||
# der Betreiber selbst anlegt. Der Installer legt sie nie an: ob ein Token auf
|
||||
# dem Server liegt, ist keine Entscheidung eines Skripts.
|
||||
TOKEN="${TESM_RELEASE_TOKEN:-}"
|
||||
if [[ -z "$TOKEN" && -r /etc/tesm-release.token ]]; then
|
||||
TOKEN="$(tr -d '[:space:]' < /etc/tesm-release.token)"
|
||||
fi
|
||||
|
||||
# Ein Wagenruecklauf im Paket macht jedes Shell-Skript auf dem Ziel unbrauchbar
|
||||
# ("bad interpreter"). Die Pruefung darf dabei nicht selbst von einem
|
||||
# Zusatzpaket abhaengen: mit "file" -- das auf einem schlanken Server fehlt --
|
||||
# endete sie in "command not found", grep bekam nichts zu sehen, und das Skript
|
||||
# machte weiter, als waere alles in Ordnung.
|
||||
hat_crlf() {
|
||||
local cr
|
||||
cr="$(printf '\r')"
|
||||
# -U (binaer lesen) ist unter Linux ohne Wirkung, auf einer
|
||||
# Windows-Buildmaschine aber entscheidend: dort entfernt grep den
|
||||
# Wagenruecklauf beim Lesen und findet deshalb nie einen.
|
||||
LC_ALL=C grep -qU "$cr" "$1"
|
||||
}
|
||||
|
||||
BOOTSTRAP_WORK=""
|
||||
bootstrap_aufraeumen() {
|
||||
# Den Ausstiegscode zuerst festhalten: alles danach ueberschreibt ihn.
|
||||
local code=$?
|
||||
[[ -n "$BOOTSTRAP_WORK" ]] && rm -rf "$BOOTSTRAP_WORK"
|
||||
# Wer einen eigenen Abschluss braucht -- etwa einen Rueckweg nach einem
|
||||
# Fehlschlag --, definiert "beim_beenden". Ein zweiter "trap ... EXIT" waere
|
||||
# der falsche Weg: er wuerde diesen hier ersetzen, und das entpackte Release
|
||||
# bliebe unter /tmp liegen.
|
||||
declare -F beim_beenden >/dev/null 2>&1 && beim_beenden "$code"
|
||||
return "$code"
|
||||
}
|
||||
trap bootstrap_aufraeumen EXIT
|
||||
|
||||
release_holen() {
|
||||
local repo="${TESM_RELEASE_REPO:-${REPO:-$APP}}" asset
|
||||
# Leeres Array und "set -u" vertragen sich nur mit dieser Schreibweise.
|
||||
local -a kopf=()
|
||||
[[ -n "$TOKEN" ]] && kopf=(-H "Authorization: token $TOKEN")
|
||||
for werkzeug in curl tar python3 find; do
|
||||
command -v "$werkzeug" >/dev/null 2>&1 || {
|
||||
echo "Auf diesem System fehlt $werkzeug -- ohne das geht es nicht." >&2
|
||||
return 1
|
||||
}
|
||||
done
|
||||
BOOTSTRAP_WORK="$(mktemp -d /tmp/tesm-release-XXXXXX)"
|
||||
|
||||
if [[ "$TAG" == "latest" ]]; then
|
||||
echo "==> Ermittle aktuellen Release-Tag ..."
|
||||
TAG="$(curl -fsS "$BASE/api/v1/repos/$OWNER/$REPO/releases/latest" \
|
||||
| python3 -c 'import json,sys; print(json.load(sys.stdin)["tag_name"])')"
|
||||
TAG="$(curl -fsS ${kopf[0]+"${kopf[@]}"} "$BASE/api/v1/repos/$OWNER/$repo/releases/latest" \
|
||||
| python3 -c 'import json,sys; print(json.load(sys.stdin)["tag_name"])')" || {
|
||||
echo "Kein Release gefunden unter $BASE/$OWNER/$repo." >&2
|
||||
[[ -z "$TOKEN" ]] &&
|
||||
echo "Ist das Repositorium privat? Dann TESM_RELEASE_TOKEN setzen." >&2
|
||||
echo "Andere Quelle: TESM_RELEASE_BASE, TESM_RELEASE_OWNER, TESM_RELEASE_REPO." >&2
|
||||
return 1
|
||||
}
|
||||
echo " $TAG"
|
||||
fi
|
||||
|
||||
ASSET="$REPO-$TAG.tar.gz"
|
||||
echo "==> Lade $ASSET ..."
|
||||
curl -fsSL -o "$WORK/$ASSET" "$BASE/$OWNER/$REPO/releases/download/$TAG/$ASSET"
|
||||
asset="$repo-$TAG.tar.gz"
|
||||
echo "==> Lade $asset ..."
|
||||
curl -fsSL ${kopf[0]+"${kopf[@]}"} -o "$BOOTSTRAP_WORK/$asset" \
|
||||
"$BASE/$OWNER/$repo/releases/download/$TAG/$asset" || {
|
||||
echo "Download fehlgeschlagen: $BASE/$OWNER/$repo/releases/download/$TAG/$asset" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
echo "==> Entpacke ..."
|
||||
tar -xzf "$WORK/$ASSET" -C "$WORK"
|
||||
SRC="$(find "$WORK" -maxdepth 2 -name install.sh -path '*/deploy/*' -printf '%h/..\n' | head -1)"
|
||||
[[ -n "$SRC" ]] || { echo "Im Paket fehlt deploy/install.sh -- Download unbrauchbar." >&2; exit 1; }
|
||||
tar -xzf "$BOOTSTRAP_WORK/$asset" -C "$BOOTSTRAP_WORK"
|
||||
# Drei Ebenen, nicht zwei: release.sh packt mit dem Praefix "<name>/", also
|
||||
# liegt das Skript unter <name>/deploy/install.sh. Mit "-maxdepth 2" fand die
|
||||
# Suche nie etwas -- und die Fehlermeldung schob es auf den Download.
|
||||
SRC="$(find "$BOOTSTRAP_WORK" -maxdepth 3 -name install.sh -path '*/deploy/*' \
|
||||
-printf '%h/..\n' | head -1)"
|
||||
[[ -n "$SRC" ]] || {
|
||||
echo "Im Paket fehlt deploy/install.sh -- Download unbrauchbar." >&2
|
||||
return 1
|
||||
}
|
||||
SRC="$(cd "$SRC" && pwd)"
|
||||
|
||||
echo "==> Pruefe auf CRLF (Windows-Buildmaschinen) ..."
|
||||
if 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
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
# --8<-- Ende Bootstrap ------------------------------------------------------
|
||||
|
||||
release_holen
|
||||
|
||||
# Schon installiert? Dann gar nicht erst fragen.
|
||||
#
|
||||
# Dieselbe Pruefung steht in install.sh und gilt dort endgueltig (sie
|
||||
# vergleicht auch den Kern). Hier steht sie noch einmal, weil ein Update, das
|
||||
# ohne Wirkung bleibt, gar nicht erst nach einer Bestaetigung fragen soll --
|
||||
# und weil "es lief durch" sonst wie eine Aktualisierung aussieht.
|
||||
ERZWINGEN=0
|
||||
for ((i = 0; i < ${#EXTRA[@]}; i++)); do
|
||||
[[ "${EXTRA[i]}" == "--force" ]] && ERZWINGEN=1
|
||||
done
|
||||
INSTALLATION="$APP"
|
||||
for ((i = 0; i < ${#EXTRA[@]}; i++)); do
|
||||
[[ "${EXTRA[i]}" == "--instance" ]] && INSTALLATION="$APP-${EXTRA[i + 1]:-}"
|
||||
done
|
||||
MODUL="tesm"
|
||||
[[ "$APP" == "tesm-license" ]] && MODUL="tesm_license"
|
||||
if [[ $ERZWINGEN -eq 0 && -x "/srv/$INSTALLATION/venv/bin/python" ]]; then
|
||||
LAEUFT="$("/srv/$INSTALLATION/venv/bin/python" -c \
|
||||
"import $MODUL; print($MODUL.__version__)" 2>/dev/null || true)"
|
||||
if [[ -n "$LAEUFT" && "v$LAEUFT" == "$TAG" ]]; then
|
||||
echo "==> $APP $LAEUFT laeuft hier bereits -- nichts zu tun."
|
||||
echo " Trotzdem neu einrichten: --force"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
read -r -p "Version $TAG fuer $APP installieren? [j/N] " answer
|
||||
[[ "$answer" =~ ^[jJyY]$ ]] || exit 0
|
||||
# --yes darf auch hier stehen, nicht nur bei install.sh und upgrade.sh. Es
|
||||
# bleibt in EXTRA: install.sh bekommt es ohnehin, doppelt schadet nicht.
|
||||
OHNE_RUECKFRAGE=0
|
||||
for ((i = 0; i < ${#EXTRA[@]}; i++)); do
|
||||
[[ "${EXTRA[i]}" == "--yes" || "${EXTRA[i]}" == "-y" ]] && OHNE_RUECKFRAGE=1
|
||||
done
|
||||
|
||||
exec sudo bash "$SRC/deploy/install.sh" --app "$APP" --yes
|
||||
if [[ $OHNE_RUECKFRAGE -eq 1 ]]; then
|
||||
echo "==> Version $TAG wird ohne Rueckfrage installiert."
|
||||
elif [[ -t 0 ]]; then
|
||||
read -r -p "Version $TAG fuer $APP${EXTRA[0]+ (${EXTRA[*]})} installieren? [j/N] " answer
|
||||
[[ "$answer" =~ ^[jJyY]$ ]] || exit 0
|
||||
else
|
||||
# Ohne Terminal las "read" hier EOF, die Antwort blieb leer, und das Skript
|
||||
# endete mit 0 -- wer es aus einem anderen Skript aufrief, hielt das Update
|
||||
# fuer erledigt. Lieber laut abbrechen.
|
||||
echo "Kein Terminal fuer die Rueckfrage, und --yes fehlt -- es wurde nichts" >&2
|
||||
echo "installiert. Aufruf mit --yes wiederholen." >&2
|
||||
exit 3
|
||||
fi
|
||||
|
||||
# Kein exec: sonst laeuft die Aufraeumfalle nie, und jedes Update liesse ein
|
||||
# entpacktes Release unter /tmp liegen.
|
||||
bash "$SRC/deploy/install.sh" --app "$APP" --yes ${EXTRA[0]+"${EXTRA[@]}"}
|
||||
@@ -0,0 +1,687 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# Upgrade vom Stand SONNET5 (Version 1.x) auf diese Fassung -- mit Datenuebernahme.
|
||||
#
|
||||
# Eine einzelne Datei genuegt -- sie holt sich das Release selbst:
|
||||
#
|
||||
# curl -fsSLO https://gitea.int.eertmoed.net/alientim/tesm/raw/branch/main/deploy/upgrade.sh
|
||||
# sudo bash upgrade.sh --app tesm
|
||||
#
|
||||
# Auf einer 1.2.8 liegt nichts von diesem Projekt; alles andere waere die
|
||||
# falsche Voraussetzung. Liegt das Skript in einem entpackten Release, wird
|
||||
# dessen install.sh benutzt statt eines Downloads:
|
||||
#
|
||||
# sudo bash deploy/upgrade.sh --app tesm
|
||||
# sudo bash deploy/upgrade.sh --app tesm --instance test --source /srv/tesm
|
||||
#
|
||||
# Der Ablauf ist die Vorgabe und braucht keine Schalter:
|
||||
#
|
||||
# 1. **Sichern.** Vollstaendige Kopie der alten Installation nach
|
||||
# /srv/<name>-upgrade-<zeitstempel>/alt. Erst danach wird irgendetwas
|
||||
# angefasst -- und aus dieser Kopie stammen auch alle weiteren Schritte.
|
||||
# 2. **Altes System abschalten.** Dienst und Timer werden gestoppt und
|
||||
# deaktiviert, damit sie nach einem Neustart nicht zurueckkommen.
|
||||
# 3. **Neu installieren.** install.sh dieses Verzeichnisses.
|
||||
# 4. **Exportieren.** Aus der Kopie entsteht eine Exportdatei im **alten**
|
||||
# Format -- genau die, die der Vorgaenger selbst geschrieben haette.
|
||||
# 5. **Uebersetzen.** Daraus eine Datei im neuen Format.
|
||||
# 6. **Einspielen.** Diese Datei geht durch denselben Import wie eine
|
||||
# Sicherung.
|
||||
#
|
||||
# Warum die Sicherung vor dem Export steht und nicht danach: laeuft das
|
||||
# Upgrade **in dasselbe Verzeichnis** (also ohne --instance), ueberschreibt
|
||||
# install.sh die alte Installation. Wer erst exportiert und dann sichert,
|
||||
# sichert im schlechtesten Fall nichts mehr. Gelesen wird ausschliesslich aus
|
||||
# der Kopie -- das Ergebnis ist dasselbe, die Reihenfolge ist die sichere.
|
||||
#
|
||||
# Der Lizenzserver kennt Schritt 4 und 5 nicht: seine Uebernahme liest die alte
|
||||
# Datenbank unmittelbar und **signiert jede Lizenz neu**. Dabei wird der alte
|
||||
# Signaturschluessel uebernommen -- sonst muesste jeder Kunde einen
|
||||
# Ausstellerwechsel bestaetigen. Wer das nicht will: --new-key. Dort ist die
|
||||
# Kopie aus Schritt 1 der Export.
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
APP=""
|
||||
INSTANCE=""
|
||||
SOURCE=""
|
||||
PASSPHRASE=""
|
||||
ASSUME_YES=0
|
||||
DRY_RUN=0
|
||||
WITH_DATA=1
|
||||
STOP_OLD=1
|
||||
ADOPT_KEY=1
|
||||
TAG="latest"
|
||||
ROLLBACK=""
|
||||
|
||||
#: Steht die Sicherung schon, und ist der Lauf durch? Daran entscheidet
|
||||
#: die EXIT-Falle, ob sie zurueckrollt.
|
||||
SICHERUNG_STEHT=0
|
||||
UPGRADE_FERTIG=0
|
||||
#: Namenszusatz, unter dem ``rename.sh`` eine verdraengte Installation der
|
||||
#: Fassung 1.x ablegt. Beide Skripte muessen denselben Wert benutzen; ein
|
||||
#: Hygienetest vergleicht sie.
|
||||
#:
|
||||
#: Auf einer echten 1.2.8 gibt es das **nicht** -- dort liegt die Installation
|
||||
#: unter ihrem Hauptnamen. Der Zusatz entsteht erst, wenn ``rename.sh`` den
|
||||
#: Hauptnamen fuer die neue Fassung braucht. Deshalb ist er der zweite Ort, an
|
||||
#: dem gesucht wird, nicht der erste.
|
||||
ARCHIV_SUFFIX="sonnet5"
|
||||
|
||||
#: Nur fuer die Ausgabe: wurde die Quelle selbst gefunden oder angegeben?
|
||||
info_quelle=""
|
||||
EXTRA=()
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
|
||||
COLOR_OK=$'\033[32m'; COLOR_WARN=$'\033[33m'; COLOR_ERR=$'\033[31m'; COLOR_OFF=$'\033[0m'
|
||||
step() { printf '\n%s==>%s %s\n' "$COLOR_OK" "$COLOR_OFF" "$*"; }
|
||||
warn() { printf '%s !!%s %s\n' "$COLOR_WARN" "$COLOR_OFF" "$*"; }
|
||||
fail() { printf '%s !!%s %s\n' "$COLOR_ERR" "$COLOR_OFF" "$*" >&2; exit 1; }
|
||||
info() { printf ' %s\n' "$*"; }
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Verwendung: upgrade.sh --app <tesm|tesm-license> [Optionen]
|
||||
|
||||
--app <name> Welche Anwendung (Pflicht)
|
||||
--source <pfad> Verzeichnis der alten Installation
|
||||
(Vorgabe: /srv/tesm bzw. /srv/tesm-license)
|
||||
--instance <name> Neue Installation als benannte Instanz anlegen, statt
|
||||
das alte Verzeichnis zu ueberschreiben. Damit bleibt die
|
||||
alte Installation liegen und ist zurueckholbar.
|
||||
--passphrase <text> Passphrase der Zwischendateien. Ohne Angabe wird eine
|
||||
erzeugt und in der Sicherung abgelegt.
|
||||
--no-data Nur installieren, keine Datenuebernahme
|
||||
--keep-old-running Alten Dienst nicht stoppen (nur mit --instance sinnvoll)
|
||||
--new-key Nur Lizenzserver: einen neuen Signaturschluessel
|
||||
erzeugen, statt den alten zu uebernehmen. Dann muessen
|
||||
alle Kunden den Ausstellerwechsel bestaetigen -- die
|
||||
Vorgabe ist deshalb die Uebernahme.
|
||||
--tag <vX.Y.Z> Bestimmte Version installieren (Vorgabe: latest).
|
||||
Wird nur beachtet, wenn das Release geladen wird.
|
||||
--rollback [pfad] Einen fehlgeschlagenen Lauf zurueckdrehen: die alte
|
||||
Installation aus der Sicherung wiederherstellen und
|
||||
ihren Dienst wieder einschalten. Ohne Pfad wird die
|
||||
neueste Sicherung dieses Namens genommen.
|
||||
--dry-run Nur zeigen, was geschehen wuerde
|
||||
--yes Keine Rueckfrage
|
||||
-h, --help Diese Hilfe
|
||||
|
||||
Alles weitere wird unveraendert an install.sh weitergegeben, etwa --https
|
||||
oder --domain.
|
||||
|
||||
Beispiele:
|
||||
# In dasselbe Verzeichnis, alte Installation wird ersetzt:
|
||||
sudo bash deploy/upgrade.sh --app tesm
|
||||
|
||||
# Daneben, zum Vergleich, alte Installation bleibt unangetastet:
|
||||
sudo bash deploy/upgrade.sh --app tesm --instance test --keep-old-running
|
||||
USAGE
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--app) APP="${2:?}"; shift 2 ;;
|
||||
--source) SOURCE="${2:?}"; shift 2 ;;
|
||||
--instance) INSTANCE="${2:?}"; shift 2 ;;
|
||||
--passphrase) PASSPHRASE="${2:?}"; shift 2 ;;
|
||||
--no-data) WITH_DATA=0; shift ;;
|
||||
--keep-old-running) STOP_OLD=0; shift ;;
|
||||
--new-key) ADOPT_KEY=0; shift ;;
|
||||
--adopt-key) ADOPT_KEY=1; shift ;; # Vorgabe, hier nur zur Deutlichkeit
|
||||
--tag) TAG="${2:?}"; shift 2 ;;
|
||||
--rollback)
|
||||
# Der Pfad ist optional: allein genommen heisst es "die neueste".
|
||||
if [[ -n "${2:-}" && "${2:-}" != --* ]]; then ROLLBACK="$2"; shift 2
|
||||
else ROLLBACK="auto"; shift; fi
|
||||
;;
|
||||
--dry-run) DRY_RUN=1; shift ;;
|
||||
--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"
|
||||
|
||||
NAME="$APP"
|
||||
[[ -n "$INSTANCE" ]] && NAME="$APP-$INSTANCE"
|
||||
ADMIN="$NAME-admin"
|
||||
NEW_ROOT="/srv/$NAME"
|
||||
STAMP="$(date +%Y%m%d-%H%M%S)"
|
||||
BACKUP="/srv/$NAME-upgrade-$STAMP"
|
||||
ALT_KOPIE="$BACKUP/alt"
|
||||
SYSTEM_KOPIE="$BACKUP/system"
|
||||
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.
|
||||
# -- Rueckweg ---------------------------------------------------------------- #
|
||||
# Alles, was install.sh unter demselben Namen ueberschreiben wuerde. Bei einem
|
||||
# Upgrade in dasselbe Verzeichnis sind das die Dateien der alten Fassung.
|
||||
SYSTEMDATEIEN=(
|
||||
"/etc/systemd/system/$NAME.service"
|
||||
"/etc/systemd/system/$NAME-monitor.service"
|
||||
"/etc/nginx/sites-available/$NAME"
|
||||
"/etc/logrotate.d/$NAME"
|
||||
"/etc/update-motd.d/50-$NAME"
|
||||
"/etc/tesm/$NAME.env"
|
||||
)
|
||||
|
||||
zurueckrollen() {
|
||||
local sicherung="$1" grund="${2:-}"
|
||||
local alt="$sicherung/alt" system="$sicherung/system"
|
||||
[[ -d "$alt" ]] || fail "In $sicherung fehlt das Verzeichnis 'alt' -- das ist keine Sicherung dieses Skripts."
|
||||
|
||||
# Quelle und Art des Umzugs stehen im Manifest. Aus dem laufenden Aufruf
|
||||
# abzuleiten waere falsch: ein Rueckweg kann Tage spaeter und mit anderen
|
||||
# Schaltern aufgerufen werden -- und beim automatischen Rueckweg steht
|
||||
# IN_PLACE zwar fest, beim spaeteren --rollback aber nicht.
|
||||
local ziel="${SOURCE:-}" gleiches_verzeichnis="${IN_PLACE:-0}" vermerkt
|
||||
if [[ -f "$system/manifest" ]]; then
|
||||
vermerkt="$(awk '$1=="quelle" {print $2}' "$system/manifest" | head -1)"
|
||||
[[ -n "$vermerkt" ]] && ziel="$vermerkt"
|
||||
vermerkt="$(awk '$1=="in-place" {print $2}' "$system/manifest" | head -1)"
|
||||
[[ -n "$vermerkt" ]] && gleiches_verzeichnis="$vermerkt"
|
||||
fi
|
||||
[[ -n "$ziel" ]] || fail "Im Manifest fehlt die Quelle -- bitte --source angeben."
|
||||
|
||||
[[ -n "$grund" ]] && warn "$grund"
|
||||
step "Rueckweg: alten Stand wiederherstellen"
|
||||
|
||||
# Der neue Dienst zuerst -- er haelt sonst Dateien offen, die gleich ersetzt
|
||||
# werden.
|
||||
for unit in "$NAME.service" "$NAME-monitor.service"; do
|
||||
if systemctl list-unit-files "$unit" --no-legend 2>/dev/null | grep -q .; then
|
||||
systemctl disable --now "$unit" >/dev/null 2>&1 || true
|
||||
info "$unit gestoppt"
|
||||
fi
|
||||
done
|
||||
|
||||
# Verzeichnis: nur bei gleichem Ziel -- bei einer eigenen Instanz war die
|
||||
# alte Installation nie angefasst.
|
||||
if [[ "$gleiches_verzeichnis" == "1" ]]; then
|
||||
rm -rf "$ziel"
|
||||
mkdir -p "$ziel"
|
||||
tar -C "$alt" -cf - . | tar -C "$ziel" -xf -
|
||||
info "$ziel aus der Sicherung wiederhergestellt"
|
||||
[[ -d "$ziel/venv" ]] || warn "Im Backup war kein venv -- der alte Dienst braucht dann noch 'python3 -m venv venv' und seine requirements.txt."
|
||||
else
|
||||
info "$ziel war nicht betroffen (eigene Instanz) -- bleibt, wie es ist"
|
||||
if [[ -d "$NEW_ROOT" && "$NEW_ROOT" != "$ziel" ]]; then
|
||||
rm -rf "$NEW_ROOT"
|
||||
info "$NEW_ROOT entfernt"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Systemdateien zurueck, alles andere entfernen, was der Lauf angelegt hat.
|
||||
local site_wieder=0
|
||||
if [[ -f "$system/manifest" ]]; then
|
||||
while read -r zeile; do
|
||||
case "$zeile" in
|
||||
unit-enabled\ *|site-enabled\ *) continue ;;
|
||||
esac
|
||||
local name; name="$(basename "$zeile")"
|
||||
if [[ -e "$system/$name" ]]; then
|
||||
cp -a "$system/$name" "$zeile"
|
||||
info "wiederhergestellt: $zeile"
|
||||
[[ "$zeile" == /etc/nginx/* ]] && site_wieder=1
|
||||
fi
|
||||
done < <(grep -v '^\(unit\|site\)-enabled ' "$system/manifest" || true)
|
||||
fi
|
||||
|
||||
systemctl daemon-reload
|
||||
if [[ $site_wieder -eq 1 ]] || [[ -e "/etc/nginx/sites-enabled/$NAME" ]]; then
|
||||
nginx -t >/dev/null 2>&1 && systemctl reload nginx >/dev/null 2>&1 \
|
||||
&& info "nginx neu geladen" || warn "nginx meldet einen Fehler -- bitte 'nginx -t' ansehen"
|
||||
fi
|
||||
|
||||
# Und die Dienste wieder einschalten, die vorher liefen.
|
||||
if [[ -f "$system/manifest" ]]; then
|
||||
while read -r _ unit; do
|
||||
systemctl enable --now "$unit" >/dev/null 2>&1 \
|
||||
&& info "$unit wieder eingeschaltet" || warn "$unit liess sich nicht starten"
|
||||
done < <(grep '^unit-enabled ' "$system/manifest" || true)
|
||||
fi
|
||||
|
||||
step "Zurueckgerollt"
|
||||
info "Sicherung bleibt liegen: $sicherung"
|
||||
# Nicht ueber ALTE_UNITS: die Liste entsteht erst nach der Quellensuche, und
|
||||
# bei "--rollback" laeuft diese Funktion vorher. Das Manifest weiss es.
|
||||
local hinweis
|
||||
hinweis="$(awk '$1=="unit-enabled" {print $2; exit}' "$system/manifest" 2>/dev/null || true)"
|
||||
info "Zustand pruefen: systemctl status ${hinweis:-$APP.service}"
|
||||
}
|
||||
|
||||
# Wird von der EXIT-Falle des Bootstraps aufgerufen -- mit dem Ausstiegscode.
|
||||
# Damit gilt der Rueckweg fuer **jeden** Fehlschlag und nicht nur fuer den, an
|
||||
# den ich gedacht habe: Export, Uebersetzen, Einspielen, nginx, ein Ctrl-C.
|
||||
beim_beenden() {
|
||||
local code="${1:-0}"
|
||||
[[ "$code" == "0" ]] && return 0
|
||||
[[ $UPGRADE_FERTIG -eq 1 ]] && return 0
|
||||
if [[ $SICHERUNG_STEHT -eq 0 ]]; then
|
||||
warn "Abgebrochen (Code $code), bevor eine Sicherung stand -- nichts zurueckzurollen."
|
||||
return 0
|
||||
fi
|
||||
zurueckrollen "$BACKUP" "Abgebrochen (Code $code)."
|
||||
}
|
||||
|
||||
neueste_sicherung() {
|
||||
local kandidat
|
||||
kandidat="$(ls -1dt /srv/"$NAME"-upgrade-*/ 2>/dev/null | head -1)"
|
||||
[[ -n "$kandidat" ]] || return 1
|
||||
printf '%s' "${kandidat%/}"
|
||||
}
|
||||
|
||||
if [[ -n "$ROLLBACK" ]]; then
|
||||
if [[ "$ROLLBACK" == "auto" ]]; then
|
||||
ROLLBACK="$(neueste_sicherung)" || fail "Keine Sicherung unter /srv/$NAME-upgrade-* gefunden. Pfad angeben."
|
||||
info "Neueste Sicherung: $ROLLBACK"
|
||||
fi
|
||||
[[ -d "$ROLLBACK" ]] || fail "$ROLLBACK gibt es nicht"
|
||||
if [[ $DRY_RUN -eq 1 ]]; then
|
||||
step "--dry-run: es wuerde $ROLLBACK zurueckgerollt."
|
||||
exit 0
|
||||
fi
|
||||
if [[ $ASSUME_YES -eq 0 ]]; then
|
||||
read -r -p "Stand aus $ROLLBACK wiederherstellen? [j/N] " answer
|
||||
[[ "$answer" =~ ^[jJyY]$ ]] || { echo "Abgebrochen."; exit 0; }
|
||||
fi
|
||||
zurueckrollen "$ROLLBACK"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Ab hier wird wirklich installiert -- ein Rueckweg hat oben schon
|
||||
# beendet. Erst jetzt braucht es ein Release und eine Quelle.
|
||||
|
||||
# --8<-- Bootstrap: Release holen --------------------------------------------
|
||||
# Wortgleich in update.sh und upgrade.sh; ein Hygienetest vergleicht die beiden
|
||||
# Bloecke. Zwei eigenstaendige Skripte koennen sich keine Datei teilen, und
|
||||
# beide sollen als **einzelne Datei** genuegen: auf das Zielsystem legen,
|
||||
# ausfuehren, fertig -- auf einer 1.2.8 liegt nichts von diesem Projekt.
|
||||
#
|
||||
# Erwartet: $APP und $TAG, wahlweise $REPO (Vorgabe: $APP). Setzt: $TAG
|
||||
# (aufgeloest) und $SRC (entpacktes Release). Raeumt sein Arbeitsverzeichnis
|
||||
# beim Beenden auf.
|
||||
#
|
||||
# $REPO gibt es, weil Installationsname und Repositorium auseinanderfallen
|
||||
# koennen: die Master-Ausgabe des Lizenzservers liegt in
|
||||
# "tesm-license-master", die Installation heisst weiter "tesm-license".
|
||||
#
|
||||
# Zwei Fallen des Vorgaengers sind hier vermieden:
|
||||
# * Der Tag "latest" wird ueber die API aufgeloest, nie direkt in die
|
||||
# Download-URL geschrieben. Gitea liefert sonst mit HTTP 200 ein
|
||||
# automatisch erzeugtes Quell-Snapshot statt des echten Release-Anhangs.
|
||||
# * Nach dem Entpacken wird geprueft, dass ein install.sh existiert.
|
||||
BASE="${TESM_RELEASE_BASE:-https://gitea.int.eertmoed.net}"
|
||||
OWNER="${TESM_RELEASE_OWNER:-alientim}"
|
||||
|
||||
# Ein privates Repositorium -- tesm-license-master -- gibt sein Release nur mit
|
||||
# Zugriffstoken heraus. Reihenfolge: Umgebungsvariable, dann eine Datei, die
|
||||
# der Betreiber selbst anlegt. Der Installer legt sie nie an: ob ein Token auf
|
||||
# dem Server liegt, ist keine Entscheidung eines Skripts.
|
||||
TOKEN="${TESM_RELEASE_TOKEN:-}"
|
||||
if [[ -z "$TOKEN" && -r /etc/tesm-release.token ]]; then
|
||||
TOKEN="$(tr -d '[:space:]' < /etc/tesm-release.token)"
|
||||
fi
|
||||
|
||||
# Ein Wagenruecklauf im Paket macht jedes Shell-Skript auf dem Ziel unbrauchbar
|
||||
# ("bad interpreter"). Die Pruefung darf dabei nicht selbst von einem
|
||||
# Zusatzpaket abhaengen: mit "file" -- das auf einem schlanken Server fehlt --
|
||||
# endete sie in "command not found", grep bekam nichts zu sehen, und das Skript
|
||||
# machte weiter, als waere alles in Ordnung.
|
||||
hat_crlf() {
|
||||
local cr
|
||||
cr="$(printf '\r')"
|
||||
# -U (binaer lesen) ist unter Linux ohne Wirkung, auf einer
|
||||
# Windows-Buildmaschine aber entscheidend: dort entfernt grep den
|
||||
# Wagenruecklauf beim Lesen und findet deshalb nie einen.
|
||||
LC_ALL=C grep -qU "$cr" "$1"
|
||||
}
|
||||
|
||||
BOOTSTRAP_WORK=""
|
||||
bootstrap_aufraeumen() {
|
||||
# Den Ausstiegscode zuerst festhalten: alles danach ueberschreibt ihn.
|
||||
local code=$?
|
||||
[[ -n "$BOOTSTRAP_WORK" ]] && rm -rf "$BOOTSTRAP_WORK"
|
||||
# Wer einen eigenen Abschluss braucht -- etwa einen Rueckweg nach einem
|
||||
# Fehlschlag --, definiert "beim_beenden". Ein zweiter "trap ... EXIT" waere
|
||||
# der falsche Weg: er wuerde diesen hier ersetzen, und das entpackte Release
|
||||
# bliebe unter /tmp liegen.
|
||||
declare -F beim_beenden >/dev/null 2>&1 && beim_beenden "$code"
|
||||
return "$code"
|
||||
}
|
||||
trap bootstrap_aufraeumen EXIT
|
||||
|
||||
release_holen() {
|
||||
local repo="${TESM_RELEASE_REPO:-${REPO:-$APP}}" asset
|
||||
# Leeres Array und "set -u" vertragen sich nur mit dieser Schreibweise.
|
||||
local -a kopf=()
|
||||
[[ -n "$TOKEN" ]] && kopf=(-H "Authorization: token $TOKEN")
|
||||
for werkzeug in curl tar python3 find; do
|
||||
command -v "$werkzeug" >/dev/null 2>&1 || {
|
||||
echo "Auf diesem System fehlt $werkzeug -- ohne das geht es nicht." >&2
|
||||
return 1
|
||||
}
|
||||
done
|
||||
BOOTSTRAP_WORK="$(mktemp -d /tmp/tesm-release-XXXXXX)"
|
||||
|
||||
if [[ "$TAG" == "latest" ]]; then
|
||||
echo "==> Ermittle aktuellen Release-Tag ..."
|
||||
TAG="$(curl -fsS ${kopf[0]+"${kopf[@]}"} "$BASE/api/v1/repos/$OWNER/$repo/releases/latest" \
|
||||
| python3 -c 'import json,sys; print(json.load(sys.stdin)["tag_name"])')" || {
|
||||
echo "Kein Release gefunden unter $BASE/$OWNER/$repo." >&2
|
||||
[[ -z "$TOKEN" ]] &&
|
||||
echo "Ist das Repositorium privat? Dann TESM_RELEASE_TOKEN setzen." >&2
|
||||
echo "Andere Quelle: TESM_RELEASE_BASE, TESM_RELEASE_OWNER, TESM_RELEASE_REPO." >&2
|
||||
return 1
|
||||
}
|
||||
echo " $TAG"
|
||||
fi
|
||||
|
||||
asset="$repo-$TAG.tar.gz"
|
||||
echo "==> Lade $asset ..."
|
||||
curl -fsSL ${kopf[0]+"${kopf[@]}"} -o "$BOOTSTRAP_WORK/$asset" \
|
||||
"$BASE/$OWNER/$repo/releases/download/$TAG/$asset" || {
|
||||
echo "Download fehlgeschlagen: $BASE/$OWNER/$repo/releases/download/$TAG/$asset" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
echo "==> Entpacke ..."
|
||||
tar -xzf "$BOOTSTRAP_WORK/$asset" -C "$BOOTSTRAP_WORK"
|
||||
# Drei Ebenen, nicht zwei: release.sh packt mit dem Praefix "<name>/", also
|
||||
# liegt das Skript unter <name>/deploy/install.sh. Mit "-maxdepth 2" fand die
|
||||
# Suche nie etwas -- und die Fehlermeldung schob es auf den Download.
|
||||
SRC="$(find "$BOOTSTRAP_WORK" -maxdepth 3 -name install.sh -path '*/deploy/*' \
|
||||
-printf '%h/..\n' | head -1)"
|
||||
[[ -n "$SRC" ]] || {
|
||||
echo "Im Paket fehlt deploy/install.sh -- Download unbrauchbar." >&2
|
||||
return 1
|
||||
}
|
||||
SRC="$(cd "$SRC" && pwd)"
|
||||
|
||||
echo "==> Pruefe auf CRLF (Windows-Buildmaschinen) ..."
|
||||
if hat_crlf "$SRC/deploy/install.sh"; then
|
||||
echo "Das Paket enthaelt CRLF-Zeilenenden. Beim Paketieren fehlte" >&2
|
||||
echo "'git -c core.autocrlf=false archive'." >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
# --8<-- Ende Bootstrap ------------------------------------------------------
|
||||
|
||||
# Woher kommt install.sh? Liegt dieses Skript in einem entpackten Release,
|
||||
# nimm dessen Verzeichnis -- wer ein Paket ausgepackt hat, will nicht, dass
|
||||
# ungefragt ein anderes geladen wird. Sonst hol das Release.
|
||||
if [[ -f "$SCRIPT_DIR/install.sh" ]]; then
|
||||
SRC="$REPO_DIR"
|
||||
RELEASE_HERKUNFT="$SRC (entpacktes Release neben diesem Skript)"
|
||||
else
|
||||
release_holen || fail "Das Release liess sich nicht holen."
|
||||
RELEASE_HERKUNFT="$TAG von $BASE/$OWNER"
|
||||
fi
|
||||
|
||||
# -- Wo liegt die alte Fassung? ---------------------------------------------- #
|
||||
# Zwei Orte kommen in Frage, und der zweite ist der Grund fuer diesen Block:
|
||||
# ``rename.sh`` schiebt eine Installation der Fassung 1.x nach
|
||||
# ``/srv/<app>-sonnet5``, sobald der Hauptname fuer die neue gebraucht wird.
|
||||
# Danach liegt unter ``/srv/<app>`` die **laufende** Installation, und die
|
||||
# Vorgabe zeigte ins Leere. Erkannt wird an ``sqlite.db`` im Wurzelverzeichnis
|
||||
# -- die neue Fassung legt ihre Datenbank unter ``data/app.db`` ab.
|
||||
ist_alte_fassung() { [[ -f "$1/sqlite.db" ]]; }
|
||||
|
||||
if [[ -z "$SOURCE" ]]; then
|
||||
for kandidat in "/srv/$APP" "/srv/$APP-$ARCHIV_SUFFIX"; do
|
||||
if ist_alte_fassung "$kandidat"; then SOURCE="$kandidat"; break; fi
|
||||
done
|
||||
if [[ -z "$SOURCE" ]]; then
|
||||
warn "Keine Installation der Fassung 1.x gefunden. Gesucht wurde in:"
|
||||
warn " /srv/$APP (dort liegt sie normalerweise)"
|
||||
warn " /srv/$APP-$ARCHIV_SUFFIX (dorthin verschiebt rename.sh sie, falls"
|
||||
warn " der Hauptname schon vergeben war)"
|
||||
[[ -f "/srv/$APP/data/app.db" ]] && warn "Unter /srv/$APP liegt die laufende Installation dieser Fassung."
|
||||
fail "Bitte --source setzen."
|
||||
fi
|
||||
info_quelle="gefunden: $SOURCE"
|
||||
fi
|
||||
[[ -d "$SOURCE" ]] || fail "Die alte Installation $SOURCE gibt es nicht (--source setzen)"
|
||||
ist_alte_fassung "$SOURCE" || fail "In $SOURCE liegt keine sqlite.db -- das ist keine Installation der Vorgaengerfassung"
|
||||
|
||||
IN_PLACE=0
|
||||
[[ "$(cd "$SOURCE" && pwd)" == "$NEW_ROOT" ]] && IN_PLACE=1
|
||||
|
||||
# Die Units der alten Fassung erkennt man daran, dass sie in genau dieses
|
||||
# Verzeichnis zeigen -- nicht am Namen der Anwendung. Nach einem rename.sh
|
||||
# heisst die *laufende* Installation "tesm", und eine Namensliste haette sie
|
||||
# gestoppt und deaktiviert, sobald jemand die archivierte 1.x in eine eigene
|
||||
# Instanz hochzieht.
|
||||
#
|
||||
# Timer und Hilfsdienste tragen keinen Pfad (der Restart-Timer ruft nur
|
||||
# "systemctl restart ..."). Sie kommen ueber den Namen der gefundenen Unit mit.
|
||||
alte_units_finden() {
|
||||
local quelle="$1" gefunden=() datei name runde
|
||||
shopt -s nullglob
|
||||
for datei in /etc/systemd/system/*.service; do
|
||||
grep -qs -- "$quelle/" "$datei" && gefunden+=("$(basename "$datei")")
|
||||
done
|
||||
# Zwei Runden: erst Timer und Helfer, die eine gefundene Unit nennen, dann
|
||||
# noch einmal fuer deren eigene Timer.
|
||||
for runde in 1 2; do
|
||||
for datei in /etc/systemd/system/*.service /etc/systemd/system/*.timer; do
|
||||
name="$(basename "$datei")"
|
||||
[[ " ${gefunden[*]-} " == *" $name "* ]] && continue
|
||||
for treffer in ${gefunden[*]-}; do
|
||||
if grep -qs -- "$treffer" "$datei"; then gefunden+=("$name"); break; fi
|
||||
done
|
||||
done
|
||||
done
|
||||
shopt -u nullglob
|
||||
printf '%s\n' ${gefunden[*]-}
|
||||
}
|
||||
|
||||
mapfile -t ALTE_UNITS < <(alte_units_finden "$SOURCE")
|
||||
if [[ ${#ALTE_UNITS[@]} -eq 0 ]]; then
|
||||
if [[ "$SOURCE" == "/srv/$APP" ]]; then
|
||||
# Kein Fund, aber die Quelle ist der Hauptpfad: die historischen Namen der
|
||||
# Fassung 1.x als Rueckfall.
|
||||
if [[ "$APP" == "tesm" ]]; then
|
||||
ALTE_UNITS=(tesm.service tesm-check.timer tesm-check.service
|
||||
tesm-check-restart.timer tesm-check-restart.service)
|
||||
else
|
||||
ALTE_UNITS=(tesm-license.service)
|
||||
fi
|
||||
else
|
||||
ALTE_UNITS=()
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
step "Vorhaben"
|
||||
info "Anwendung : $APP${INSTANCE:+ (Instanz $INSTANCE)}"
|
||||
info "Neue Fassung : $RELEASE_HERKUNFT"
|
||||
info "Alt : $SOURCE${info_quelle:+ (automatisch gefunden)}"
|
||||
info "Neu : $NEW_ROOT$([[ $IN_PLACE -eq 1 ]] && echo ' << dasselbe Verzeichnis')"
|
||||
info "Sicherung : $ALT_KOPIE"
|
||||
info "Datenuebernahme: $([[ $WITH_DATA -eq 1 ]] && echo ja || echo nein)"
|
||||
info "Altes System : $([[ $STOP_OLD -eq 1 ]] && echo 'stoppen und deaktivieren' || echo 'laeuft weiter')"
|
||||
if [[ $IN_PLACE -eq 1 && $STOP_OLD -eq 0 ]]; then
|
||||
warn "--keep-old-running bei gleichem Verzeichnis: der alte Dienst laeuft auf Dateien,"
|
||||
warn "die gleich ersetzt werden. Das geht schief. Bitte --instance verwenden."
|
||||
fi
|
||||
|
||||
if [[ $DRY_RUN -eq 1 ]]; then
|
||||
step "--dry-run: hier ist Schluss."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ $ASSUME_YES -eq 0 ]]; then
|
||||
read -r -p "Fortfahren? [j/N] " answer
|
||||
[[ "$answer" =~ ^[jJyY]$ ]] || { echo "Abgebrochen."; exit 0; }
|
||||
fi
|
||||
|
||||
# -- 1. Sichern -------------------------------------------------------------- #
|
||||
step "Sichern"
|
||||
mkdir -p "$ALT_KOPIE" "$SYSTEM_KOPIE"
|
||||
chmod 700 "$BACKUP"
|
||||
|
||||
# Das venv kommt nur mit, wenn in dasselbe Verzeichnis installiert wird -- dort
|
||||
# ersetzt install.sh es, und ohne das alte startet der alte Dienst nach einem
|
||||
# Rueckweg nicht mehr. Bei einer eigenen Instanz bleibt die alte Installation
|
||||
# unangetastet, dann waere es nur Ballast (und seine Shebangs zeigen auf einen
|
||||
# anderen Pfad).
|
||||
if [[ $IN_PLACE -eq 1 ]]; then
|
||||
tar -C "$SOURCE" --exclude=__pycache__ -cf - . | tar -C "$ALT_KOPIE" -xf -
|
||||
info "$(du -sh "$ALT_KOPIE" | cut -f1) kopiert (mit venv, weil dasselbe Verzeichnis)"
|
||||
else
|
||||
tar -C "$SOURCE" --exclude=venv --exclude=__pycache__ -cf - . | tar -C "$ALT_KOPIE" -xf -
|
||||
info "$(du -sh "$ALT_KOPIE" | cut -f1) kopiert (ohne venv)"
|
||||
fi
|
||||
[[ -f "$ALT_KOPIE/sqlite.db" ]] || fail "In der Kopie fehlt die Datenbank -- Abbruch"
|
||||
|
||||
# Und die Systemdateien: bei gleichem Verzeichnis schreibt install.sh Unit,
|
||||
# nginx-Site, logrotate und Banner neu. Ohne Kopie liesse sich der alte Stand
|
||||
# nicht wiederherstellen -- das Verzeichnis allein genuegt nicht.
|
||||
: > "$SYSTEM_KOPIE/manifest"
|
||||
# Woher kam der Stand, und ging es in dasselbe Verzeichnis? Ein spaeterer
|
||||
# Rueckweg darf das nicht raten muessen -- er laeuft womoeglich Tage danach
|
||||
# und mit anderen Schaltern.
|
||||
printf 'quelle %s\n' "$SOURCE" >> "$SYSTEM_KOPIE/manifest"
|
||||
printf 'in-place %s\n' "$IN_PLACE" >> "$SYSTEM_KOPIE/manifest"
|
||||
for pfad in "${SYSTEMDATEIEN[@]}"; do
|
||||
[[ -e "$pfad" ]] || continue
|
||||
cp -a "$pfad" "$SYSTEM_KOPIE/$(basename "$pfad")"
|
||||
printf '%s\n' "$pfad" >> "$SYSTEM_KOPIE/manifest"
|
||||
done
|
||||
# Welche Units liefen, und war die Site eingeschaltet?
|
||||
for unit in "${ALTE_UNITS[@]}"; do
|
||||
systemctl is-enabled "$unit" >/dev/null 2>&1 && printf 'unit-enabled %s\n' "$unit" >> "$SYSTEM_KOPIE/manifest"
|
||||
done
|
||||
[[ -L "/etc/nginx/sites-enabled/$NAME" ]] && printf 'site-enabled %s\n' "$NAME" >> "$SYSTEM_KOPIE/manifest"
|
||||
info "$(grep -c . "$SYSTEM_KOPIE/manifest") Systemdatei(en) und Zustaende vermerkt"
|
||||
SICHERUNG_STEHT=1
|
||||
|
||||
# -- 2. Altes System abschalten ---------------------------------------------- #
|
||||
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")
|
||||
# Kein eigener Fehlerzweig mehr: die EXIT-Falle rollt bei jedem Fehlschlag
|
||||
# zurueck -- hier, beim Export, beim Einspielen oder bei einem Ctrl-C.
|
||||
bash "$SRC/deploy/install.sh" "${INSTALL_ARGS[@]}" ${EXTRA[0]+"${EXTRA[@]}"}
|
||||
|
||||
if [[ $WITH_DATA -eq 0 ]]; then
|
||||
UPGRADE_FERTIG=1
|
||||
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 --------------------------------------------------------------- #
|
||||
UPGRADE_FERTIG=1
|
||||
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
|
||||
+375
-66
@@ -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,170 @@ 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`
|
||||
|
||||
`update.sh` genügt als **einzelne Datei**. Auf einem System, auf dem noch nichts
|
||||
von diesem Projekt liegt:
|
||||
|
||||
```bash
|
||||
curl -fsSLO https://gitea.int.eertmoed.net/alientim/tesm/raw/branch/main/deploy/update.sh
|
||||
sudo bash update.sh tesm
|
||||
```
|
||||
|
||||
Auf einer bestehenden Installation liegt sie schon bereit:
|
||||
|
||||
```bash
|
||||
sudo bash /srv/tesm/deploy/update.sh tesm
|
||||
sudo bash /srv/tesm-license/deploy/update.sh tesm-license
|
||||
```
|
||||
|
||||
**Läuft die Fassung schon, passiert nichts.** `update.sh` vergleicht den
|
||||
aufgelösten Tag mit dem, was installiert ist, und endet mit `0`, bevor es eine
|
||||
Rückfrage stellt; `install.sh` prüft es noch einmal genauer (Anwendung **und**
|
||||
Kern) und bricht vor dem ersten Eingriff ab. Ein zweiter Lauf auf dieselbe
|
||||
Fassung baut sonst die Python-Umgebung neu, kopiert dieselben Dateien und
|
||||
startet den Dienst neu -- ein paar Sekunden Ausfall ohne jede Wirkung. Wer das
|
||||
trotzdem will (eine beschädigte Installation etwa), hängt `--force` an.
|
||||
|
||||
Alles nach dem Tag geht unverändert an `install.sh` weiter -- das ist der Weg,
|
||||
eine **benannte Instanz** zu aktualisieren:
|
||||
|
||||
```bash
|
||||
sudo bash /srv/tesm/deploy/update.sh tesm
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
Auch `upgrade.sh` genügt als **einzelne Datei** -- und das ist hier der
|
||||
Regelfall: auf einer 1.2.8 liegt nichts von dieser Fassung.
|
||||
|
||||
```bash
|
||||
curl -fsSLO https://gitea.int.eertmoed.net/alientim/tesm/raw/branch/main/deploy/upgrade.sh
|
||||
sudo bash upgrade.sh --app tesm
|
||||
```
|
||||
|
||||
Sie lädt das aktuelle Release, entpackt es und benutzt dessen `install.sh`.
|
||||
Liegt das Skript in einem entpackten Release, wird dieses benutzt statt eines
|
||||
Downloads -- wer ein Paket ausgepackt hat, will nicht, dass ungefragt ein
|
||||
anderes geladen wird:
|
||||
|
||||
```bash
|
||||
sudo bash deploy/upgrade.sh --app tesm
|
||||
sudo bash deploy/upgrade.sh --app tesm-license
|
||||
```
|
||||
|
||||
Eine bestimmte Version statt `latest`: `--tag v2.0.8`.
|
||||
|
||||
**Wenn etwas schiefgeht.** Schlägt `install.sh` fehl, dreht `upgrade.sh` den
|
||||
Lauf selbst zurück: der neue Dienst wird gestoppt, das Verzeichnis aus der
|
||||
Sicherung wiederhergestellt und der alte Dienst wieder eingeschaltet. Das
|
||||
Verzeichnis allein genügt dafür nicht -- bei einem Upgrade in dasselbe
|
||||
Verzeichnis schreibt `install.sh` auch systemd-Unit, nginx-Site, logrotate und
|
||||
Banner neu und ersetzt das venv. Die Sicherung enthält deshalb alles davon,
|
||||
und ein Manifest hält fest, woher der Stand kam.
|
||||
|
||||
Später von Hand zurückdrehen, etwa nach einem Lauf, der durchlief aber nicht
|
||||
gefallen hat:
|
||||
|
||||
```bash
|
||||
sudo bash upgrade.sh --app tesm --rollback # neueste Sicherung
|
||||
sudo bash upgrade.sh --app tesm --rollback /srv/tesm-upgrade-20260908-225428
|
||||
```
|
||||
|
||||
Die Sicherung bleibt danach liegen; gelöscht wird nichts.
|
||||
|
||||
Ohne `--source` wird die alte Fassung an zwei Stellen gesucht: unter
|
||||
`/srv/<app>` und unter `/srv/<app>-sonnet5`. Der zweite Ort ist der, an den
|
||||
`rename.sh` sie verschiebt, sobald der Hauptname für die neue Installation
|
||||
gebraucht wird -- danach liegt unter `/srv/<app>` die **laufende** Installation.
|
||||
Erkannt wird eine 1.x an der `sqlite.db` im Wurzelverzeichnis; die neue Fassung
|
||||
legt ihre Datenbank unter `data/app.db` ab.
|
||||
|
||||
Der Ablauf ist die Vorgabe und braucht keine Schalter:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| 1. Sichern | Vollständige Kopie der alten Installation nach `/srv/<name>-upgrade-<zeitstempel>/alt` (ohne `venv`, Verzeichnis 0700) |
|
||||
| 2. Abschalten | Alter Dienst **und seine Timer** werden gestoppt und deaktiviert |
|
||||
| 3. Installieren | `install.sh` dieses Verzeichnisses |
|
||||
| 4. Exportieren | Aus der Kopie entsteht `export-alt.json` im **alten** Format |
|
||||
| 5. Übersetzen | Daraus `import-neu.json` im neuen Format |
|
||||
| 6. Einspielen | Diese Datei geht durch denselben Import wie eine Sicherung |
|
||||
|
||||
Warum die Sicherung **vor** dem Export steht: ohne `--instance` installiert
|
||||
`install.sh` in dasselbe Verzeichnis und ersetzt die alte Installation. Wer
|
||||
erst exportiert und dann sichert, sichert im schlechtesten Fall nichts mehr.
|
||||
Gelesen wird ausschliesslich aus der Kopie -- die alte Installation wird nie
|
||||
verändert.
|
||||
|
||||
Die beiden Zwischendateien bleiben liegen; ihre Passphrase steht daneben in
|
||||
`passphrase.txt` (0600, Verzeichnis 0700). `export-alt.json` ist ein echter
|
||||
Export des Vorgängers und taugt deshalb auch als Rückweg.
|
||||
|
||||
**Daneben statt darüber.** Mit `--instance` entsteht eine zweite Installation,
|
||||
und die alte bleibt vollständig liegen:
|
||||
|
||||
```bash
|
||||
sudo bash deploy/upgrade.sh --app tesm --instance test --keep-old-running --port 5100 --http-port 8080
|
||||
```
|
||||
|
||||
**Was nicht mitkommt** -- und warum:
|
||||
|
||||
* **Lokale Konten.** Der alte Hash ist bcrypt, hier gilt Argon2id. Ein Hash
|
||||
ist nicht umrechenbar; das ist sein Zweck. Erstes Konto mit
|
||||
`tesm-admin create-admin`.
|
||||
* **Gruppen und Rechte.** Die Rechteschlüssel wurden neu geschnitten.
|
||||
* **LDAP, Webserver, Zertifikat.** Von Hand nachziehen.
|
||||
|
||||
Beim **Lizenzserver** gibt es Schritt 4 und 5 nicht: die Übernahme liest die
|
||||
alte Datenbank unmittelbar, holt Endpunkt und Anbieterdaten aus ihren
|
||||
Einstellungen und **signiert jede Lizenz neu**. Der alte Signaturschlüssel wird
|
||||
dabei übernommen -- sonst müsste jeder Kunde einen Ausstellerwechsel
|
||||
bestätigen; `--new-key` erzwingt das Gegenteil. Der Bericht nennt namentlich,
|
||||
wer eine neue Lizenzdatei braucht, denn die Bindung an eine Maschine gilt nur
|
||||
für die alte Datei.
|
||||
|
||||
Nur die Anwendung, ohne Daten: `--no-data`. Nur zeigen, was geschähe:
|
||||
`--dry-run`.
|
||||
|
||||
---
|
||||
|
||||
## 4. HTTPS
|
||||
@@ -114,7 +265,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 +275,200 @@ 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-test
|
||||
```
|
||||
|
||||
Der Aufruf im Banner ist mit `timeout 3` und `|| true` abgesichert: ein
|
||||
hängender oder fehlschlagender Bannerlauf darf eine Anmeldung nie blockieren.
|
||||
Ein Banner der Vorgängerfassung (`50-tesm`, das `motd_banner.py` aufruft) wird
|
||||
entfernt -- nur dieses, nichts anderes in dem Verzeichnis.
|
||||
|
||||
---
|
||||
|
||||
## 4b. Installation umbenennen
|
||||
|
||||
Aus einer Instanz die Hauptinstallation machen (oder umgekehrt):
|
||||
|
||||
```bash
|
||||
sudo bash deploy/rename.sh --app tesm --from tesm-test --to tesm
|
||||
sudo bash deploy/rename.sh --app tesm-license --from tesm-license-test --to tesm-license
|
||||
```
|
||||
|
||||
Der Name steckt an mehr Stellen als im Verzeichnisnamen, und drei davon sind
|
||||
leicht zu übersehen:
|
||||
|
||||
* **`SITE_KEY`** in der Umgebungsdatei -- daraus entsteht der Name des
|
||||
Sitzungscookies. Bleibt er alt, heisst das Cookie weiter wie die alte
|
||||
Instanz.
|
||||
* **`web_cert_path`, `web_key_path`, `web_acme_webroot`** in der Datenbank. Sie
|
||||
enthalten den Namen; ein Zertifikat unter dem alten Pfad findet nginx nach
|
||||
dem Umzug nicht mehr.
|
||||
* **`server_name`** der nginx-Site. `install.sh` schreibt die Datei aus seiner
|
||||
Vorlage neu, sobald sich die `alias`-Pfade ändern -- und die ändern sich hier
|
||||
zwangsläufig. Ohne den Namen aus der Datenbank stünden danach zwei Sites mit
|
||||
`server_name _` auf Port 80, und die erste gewinnt.
|
||||
|
||||
Ausserdem wird das **venv verworfen und neu gebaut**: seine Konsolenskripte
|
||||
tragen den absoluten Pfad im Shebang, und nach dem Umzug zeigt
|
||||
`venv/bin/gunicorn` auf einen Interpreter, den es nicht mehr gibt. systemd
|
||||
meldet dann `No such file or directory` -- für eine Datei, die sichtbar da ist.
|
||||
|
||||
Belegt der Zielname noch eine Installation der Fassung 1.x, wandert **alles**,
|
||||
was ihn belegt, in ein Archiv `/srv/<ziel>-sonnet5`: das Verzeichnis, die
|
||||
Protokolle unter `/var/log/<ziel>`, der ACME-Pfad, die logrotate-Datei, und die
|
||||
systemd-Units samt nginx-Site unter `systemd-alt/`. Gelöscht wird nichts.
|
||||
|
||||
Ports und HTTPS bleiben, wie sie waren; ein vorhandenes Zertifikat zieht mit
|
||||
und wird weiterverwendet. **Alle angemeldeten Benutzer werden abgemeldet** --
|
||||
das Sitzungscookie heisst danach anders.
|
||||
|
||||
Danach ist auch das Aktualisieren einfacher, weil der Instanzname wegfällt:
|
||||
|
||||
```bash
|
||||
sudo bash /srv/tesm/deploy/update.sh tesm
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4bb. Release-Upgrade des Betriebssystems
|
||||
|
||||
Ein `do-release-upgrade` tauscht die Minor-Fassung von Python aus (24.04 bringt
|
||||
3.12, 26.04 bringt 3.14). Der venv einer Installation hängt daran: seine Pakete
|
||||
liegen unter `lib/python3.12`, während `venv/bin/python3` auf `/usr/bin/python3`
|
||||
zeigt — also nach dem Upgrade auf den **neuen** Interpreter. Er ist ausführbar
|
||||
und trotzdem tot; jeder Dienst scheitert mit `No module named gunicorn`, nginx
|
||||
meldet 502.
|
||||
|
||||
**Das repariert TESM seit 2.8.33 von selbst.** Vor jedem Dienststart läuft
|
||||
|
||||
```
|
||||
ExecStartPre=+/usr/local/lib/tesm/tesm-venv /srv/tesm tesm
|
||||
```
|
||||
|
||||
Ist die Umgebung heil, kostet das Millisekunden. Sonst wird sie neu gebaut und
|
||||
mit denselben Paketen bestückt, die auch die Installation einspielt — es ist
|
||||
dasselbe Programm, `install.sh` ruft es mit `--immer` auf. Nach dem Neustart,
|
||||
der auf jedes Release-Upgrade folgt, kommt der Dienst also von allein zurück;
|
||||
im Live-Protokoll steht dann eine Zeile als Hinweis.
|
||||
|
||||
Von Hand prüfen oder anstoßen:
|
||||
|
||||
```bash
|
||||
/usr/local/lib/tesm/tesm-venv /srv/tesm tesm --pruefen # 0 heil, 1 kaputt
|
||||
/usr/local/lib/tesm/tesm-venv /srv/tesm tesm # reparieren
|
||||
```
|
||||
|
||||
**Grenzen.** Der Neuaufbau lädt Pakete und braucht Zugriff auf das
|
||||
Paketverzeichnis. Ohne Netz kann er nicht gelingen: dann wird **ein** Versuch je
|
||||
Systemstart unternommen (Merker unter `/run`), und es bleibt bei einer klaren
|
||||
Zeile im Journal und im Live-Protokoll. Ohne diese Bremse startete
|
||||
`Restart=always` alle fünf Sekunden einen neuen `pip`-Lauf.
|
||||
|
||||
Die alten Pakete unter `lib/python3.<alt>` werden nicht übernommen —
|
||||
`cryptography`, `argon2-cffi` und `bcrypt` sind kompiliert und für die alte
|
||||
Fassung gebaut.
|
||||
|
||||
Empfohlene Reihenfolge bleibt trotzdem: erst TESM aktualisieren, dann
|
||||
`do-release-upgrade`, dann prüfen.
|
||||
|
||||
---
|
||||
|
||||
## 4c. Installation entfernen
|
||||
|
||||
Das Gegenstück zu `install.sh`:
|
||||
|
||||
```bash
|
||||
sudo bash /srv/tesm/deploy/uninstall.sh --app tesm --dry-run
|
||||
sudo bash /srv/tesm/deploy/uninstall.sh --app tesm
|
||||
sudo bash /srv/tesm/deploy/uninstall.sh --app tesm-license --instance test --yes
|
||||
```
|
||||
|
||||
Entfernt werden Dienste (`.service`, `-monitor`, `-cleanup` samt Timer), die
|
||||
nginx-Site, der Begrüßungshaken, der Shim unter `/usr/local/bin`, die
|
||||
Umgebungsdatei, das Zertifikatsverzeichnis, das Protokollverzeichnis und die
|
||||
Installation selbst.
|
||||
|
||||
**Die Daten bleiben.** Datenbank, Schlüssel und Lizenz gehen vorher in ein
|
||||
Archiv (`/srv/<name>-daten-<zeit>.tar.gz`, nur root). Sie sind das einzige an
|
||||
einer Installation, was sich nicht wiederherstellen lässt. Wer sie wirklich
|
||||
los sein will, nimmt `--purge` — das löscht zusätzlich die Rückwege älterer
|
||||
Aktualisierungen (`/srv/<name>-backup-*`).
|
||||
|
||||
**Geteiltes bleibt, solange es jemand braucht.** Die sudoers-Datei und der
|
||||
Dienstbenutzer tragen den Namen der *Anwendung*, nicht den der Instanz; der
|
||||
Helfer unter `/usr/local/lib/tesm` gehört allen Installationen beider
|
||||
Anwendungen. Sie verschwinden erst mit der letzten. Sonst legte der Rückbau
|
||||
einer Testinstanz die Produktion daneben lahm.
|
||||
|
||||
`--dry-run` zeigt den Plan, ohne etwas zu ändern. Ein zweiter Lauf auf eine
|
||||
bereits entfernte Installation sagt das und endet mit 0.
|
||||
|
||||
Warum es das gibt: wer eine Installation von Hand löscht (`rm -rf /srv/…`),
|
||||
lässt Unit, Begrüßungshaken, Shim, sudoers-Datei und nginx-Site zurück.
|
||||
systemd versucht die Unit danach in einer Dauerschleife zu starten, und der
|
||||
Haken druckt bei jeder Anmeldung seinen Banner. Auf dem Testserver standen so
|
||||
neun tote Dienste und vierzehn Banner.
|
||||
|
||||
---
|
||||
|
||||
## 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 +488,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 +500,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 +523,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`
|
||||
|
||||
---
|
||||
|
||||
@@ -226,21 +535,21 @@ Unverzichtbar:
|
||||
**Die Anwendung antwortet nicht.**
|
||||
```bash
|
||||
journalctl -u tesm -n 200 --no-pager
|
||||
curl -s http://127.0.0.1:5000/gesundheit
|
||||
curl -s http://127.0.0.1:5000/health
|
||||
```
|
||||
Antwortet gunicorn, aber der Browser nicht, liegt es an nginx.
|
||||
|
||||
**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 +559,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 +599,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.
|
||||
+61
-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,36 @@ 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 |
|
||||
| Rechte | Eine Zeile je Funktion; das Administratorkennzeichen gibt den Verwaltungsbereich, nicht alles |
|
||||
| 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 +46,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 +70,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 +146,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 +167,15 @@ 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.
|
||||
Ein echter Administrator darf sich Funktionsrechte über eine Gruppe geben --
|
||||
das ist gewollt und hinterlässt eine Zeile im Änderungsprotokoll. Was es
|
||||
nicht gibt, ist der stille Weg: ein Kennzeichen, das ohne Eintrag alles
|
||||
aufschließt.
|
||||
* **Kein Hostname im Lizenz-Fingerabdruck** -- Umbenennen darf die Bindung
|
||||
nicht brechen.
|
||||
* **Keine von Hand gepflegte nginx-Site.** Sie wird immer neu erzeugt.
|
||||
@@ -179,5 +184,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.
|
||||
+181
@@ -0,0 +1,181 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# Installation -- der kurze Weg.
|
||||
#
|
||||
# Diese Datei liegt oben im Paket und ruft den Installer unter deploy/ auf:
|
||||
#
|
||||
# tar xzf tesm-vX.Y.Z.tar.gz && cd tesm
|
||||
# sudo bash install.sh --app tesm --https
|
||||
#
|
||||
# Sie genuegt aber auch **allein**: liegt kein Paket um sie herum, holt sie
|
||||
# sich das aktuelle Release selbst.
|
||||
#
|
||||
# curl -fsSLO https://gitea.int.eertmoed.net/alientim/tesm/raw/branch/main/install.sh
|
||||
# sudo bash install.sh --app tesm --https
|
||||
#
|
||||
# Die Master-Ausgabe des Lizenzservers liegt im privaten Repositorium
|
||||
# tesm-license-master. Allein-Betrieb dafuer:
|
||||
#
|
||||
# export TESM_RELEASE_REPO=tesm-license-master
|
||||
# export TESM_RELEASE_TOKEN=<Gitea-Token>
|
||||
# sudo -E bash install.sh --app tesm-license --https
|
||||
#
|
||||
# Aus einem entpackten Paket braucht es das nicht: dort entscheidet, welches
|
||||
# Paket es ist. Die Master-Ausgabe legt beim Installieren Signaturschluessel
|
||||
# und eigene Master-Lizenz selbst an.
|
||||
#
|
||||
# Alle Argumente gehen unveraendert an deploy/install.sh weiter; dessen Hilfe:
|
||||
#
|
||||
# bash install.sh --help
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
# --app wird nur gelesen, nicht verbraucht: der Bootstrap braucht es fuer den
|
||||
# Namen des Repositoriums, der Installer bekommt die Argumente unveraendert.
|
||||
APP=""
|
||||
TAG="latest"
|
||||
ARGUMENTE=("$@")
|
||||
for ((i = 0; i < $#; i++)); do
|
||||
case "${ARGUMENTE[i]}" in
|
||||
--app) APP="${ARGUMENTE[i + 1]:-}" ;;
|
||||
--tag) TAG="${ARGUMENTE[i + 1]:-latest}" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Ohne Paket drumherum brauchen wir den Namen, um das richtige Release zu
|
||||
# finden -- die beiden Anwendungen liegen in getrennten Repositorien.
|
||||
if [[ ! -f "$SCRIPT_DIR/deploy/install.sh" ]]; then
|
||||
[[ "$APP" == "tesm" || "$APP" == "tesm-license" ]] || {
|
||||
echo "Diese Datei liegt allein hier und muss sich das Release holen --" >&2
|
||||
echo "dafuer braucht sie --app <tesm|tesm-license>." >&2
|
||||
echo "Verwendung: install.sh --app <tesm|tesm-license> [weitere Optionen]" >&2
|
||||
exit 2
|
||||
}
|
||||
fi
|
||||
|
||||
# --8<-- Bootstrap: Release holen --------------------------------------------
|
||||
# Wortgleich in update.sh und upgrade.sh; ein Hygienetest vergleicht die beiden
|
||||
# Bloecke. Zwei eigenstaendige Skripte koennen sich keine Datei teilen, und
|
||||
# beide sollen als **einzelne Datei** genuegen: auf das Zielsystem legen,
|
||||
# ausfuehren, fertig -- auf einer 1.2.8 liegt nichts von diesem Projekt.
|
||||
#
|
||||
# Erwartet: $APP und $TAG, wahlweise $REPO (Vorgabe: $APP). Setzt: $TAG
|
||||
# (aufgeloest) und $SRC (entpacktes Release). Raeumt sein Arbeitsverzeichnis
|
||||
# beim Beenden auf.
|
||||
#
|
||||
# $REPO gibt es, weil Installationsname und Repositorium auseinanderfallen
|
||||
# koennen: die Master-Ausgabe des Lizenzservers liegt in
|
||||
# "tesm-license-master", die Installation heisst weiter "tesm-license".
|
||||
#
|
||||
# Zwei Fallen des Vorgaengers sind hier vermieden:
|
||||
# * Der Tag "latest" wird ueber die API aufgeloest, nie direkt in die
|
||||
# Download-URL geschrieben. Gitea liefert sonst mit HTTP 200 ein
|
||||
# automatisch erzeugtes Quell-Snapshot statt des echten Release-Anhangs.
|
||||
# * Nach dem Entpacken wird geprueft, dass ein install.sh existiert.
|
||||
BASE="${TESM_RELEASE_BASE:-https://gitea.int.eertmoed.net}"
|
||||
OWNER="${TESM_RELEASE_OWNER:-alientim}"
|
||||
|
||||
# Ein privates Repositorium -- tesm-license-master -- gibt sein Release nur mit
|
||||
# Zugriffstoken heraus. Reihenfolge: Umgebungsvariable, dann eine Datei, die
|
||||
# der Betreiber selbst anlegt. Der Installer legt sie nie an: ob ein Token auf
|
||||
# dem Server liegt, ist keine Entscheidung eines Skripts.
|
||||
TOKEN="${TESM_RELEASE_TOKEN:-}"
|
||||
if [[ -z "$TOKEN" && -r /etc/tesm-release.token ]]; then
|
||||
TOKEN="$(tr -d '[:space:]' < /etc/tesm-release.token)"
|
||||
fi
|
||||
|
||||
# Ein Wagenruecklauf im Paket macht jedes Shell-Skript auf dem Ziel unbrauchbar
|
||||
# ("bad interpreter"). Die Pruefung darf dabei nicht selbst von einem
|
||||
# Zusatzpaket abhaengen: mit "file" -- das auf einem schlanken Server fehlt --
|
||||
# endete sie in "command not found", grep bekam nichts zu sehen, und das Skript
|
||||
# machte weiter, als waere alles in Ordnung.
|
||||
hat_crlf() {
|
||||
local cr
|
||||
cr="$(printf '\r')"
|
||||
# -U (binaer lesen) ist unter Linux ohne Wirkung, auf einer
|
||||
# Windows-Buildmaschine aber entscheidend: dort entfernt grep den
|
||||
# Wagenruecklauf beim Lesen und findet deshalb nie einen.
|
||||
LC_ALL=C grep -qU "$cr" "$1"
|
||||
}
|
||||
|
||||
BOOTSTRAP_WORK=""
|
||||
bootstrap_aufraeumen() {
|
||||
# Den Ausstiegscode zuerst festhalten: alles danach ueberschreibt ihn.
|
||||
local code=$?
|
||||
[[ -n "$BOOTSTRAP_WORK" ]] && rm -rf "$BOOTSTRAP_WORK"
|
||||
# Wer einen eigenen Abschluss braucht -- etwa einen Rueckweg nach einem
|
||||
# Fehlschlag --, definiert "beim_beenden". Ein zweiter "trap ... EXIT" waere
|
||||
# der falsche Weg: er wuerde diesen hier ersetzen, und das entpackte Release
|
||||
# bliebe unter /tmp liegen.
|
||||
declare -F beim_beenden >/dev/null 2>&1 && beim_beenden "$code"
|
||||
return "$code"
|
||||
}
|
||||
trap bootstrap_aufraeumen EXIT
|
||||
|
||||
release_holen() {
|
||||
local repo="${TESM_RELEASE_REPO:-${REPO:-$APP}}" asset
|
||||
# Leeres Array und "set -u" vertragen sich nur mit dieser Schreibweise.
|
||||
local -a kopf=()
|
||||
[[ -n "$TOKEN" ]] && kopf=(-H "Authorization: token $TOKEN")
|
||||
for werkzeug in curl tar python3 find; do
|
||||
command -v "$werkzeug" >/dev/null 2>&1 || {
|
||||
echo "Auf diesem System fehlt $werkzeug -- ohne das geht es nicht." >&2
|
||||
return 1
|
||||
}
|
||||
done
|
||||
BOOTSTRAP_WORK="$(mktemp -d /tmp/tesm-release-XXXXXX)"
|
||||
|
||||
if [[ "$TAG" == "latest" ]]; then
|
||||
echo "==> Ermittle aktuellen Release-Tag ..."
|
||||
TAG="$(curl -fsS ${kopf[0]+"${kopf[@]}"} "$BASE/api/v1/repos/$OWNER/$repo/releases/latest" \
|
||||
| python3 -c 'import json,sys; print(json.load(sys.stdin)["tag_name"])')" || {
|
||||
echo "Kein Release gefunden unter $BASE/$OWNER/$repo." >&2
|
||||
[[ -z "$TOKEN" ]] &&
|
||||
echo "Ist das Repositorium privat? Dann TESM_RELEASE_TOKEN setzen." >&2
|
||||
echo "Andere Quelle: TESM_RELEASE_BASE, TESM_RELEASE_OWNER, TESM_RELEASE_REPO." >&2
|
||||
return 1
|
||||
}
|
||||
echo " $TAG"
|
||||
fi
|
||||
|
||||
asset="$repo-$TAG.tar.gz"
|
||||
echo "==> Lade $asset ..."
|
||||
curl -fsSL ${kopf[0]+"${kopf[@]}"} -o "$BOOTSTRAP_WORK/$asset" \
|
||||
"$BASE/$OWNER/$repo/releases/download/$TAG/$asset" || {
|
||||
echo "Download fehlgeschlagen: $BASE/$OWNER/$repo/releases/download/$TAG/$asset" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
echo "==> Entpacke ..."
|
||||
tar -xzf "$BOOTSTRAP_WORK/$asset" -C "$BOOTSTRAP_WORK"
|
||||
# Drei Ebenen, nicht zwei: release.sh packt mit dem Praefix "<name>/", also
|
||||
# liegt das Skript unter <name>/deploy/install.sh. Mit "-maxdepth 2" fand die
|
||||
# Suche nie etwas -- und die Fehlermeldung schob es auf den Download.
|
||||
SRC="$(find "$BOOTSTRAP_WORK" -maxdepth 3 -name install.sh -path '*/deploy/*' \
|
||||
-printf '%h/..\n' | head -1)"
|
||||
[[ -n "$SRC" ]] || {
|
||||
echo "Im Paket fehlt deploy/install.sh -- Download unbrauchbar." >&2
|
||||
return 1
|
||||
}
|
||||
SRC="$(cd "$SRC" && pwd)"
|
||||
|
||||
echo "==> Pruefe auf CRLF (Windows-Buildmaschinen) ..."
|
||||
if hat_crlf "$SRC/deploy/install.sh"; then
|
||||
echo "Das Paket enthaelt CRLF-Zeilenenden. Beim Paketieren fehlte" >&2
|
||||
echo "'git -c core.autocrlf=false archive'." >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
# --8<-- Ende Bootstrap ------------------------------------------------------
|
||||
|
||||
if [[ -f "$SCRIPT_DIR/deploy/install.sh" ]]; then
|
||||
# Im Paket: der Installer liegt daneben.
|
||||
exec bash "$SCRIPT_DIR/deploy/install.sh" ${ARGUMENTE[0]+"${ARGUMENTE[@]}"}
|
||||
fi
|
||||
|
||||
# Allein: Release holen und dessen Installer benutzen. Kein exec -- sonst
|
||||
# laeuft die Aufraeumfalle nie und das entpackte Release bliebe unter /tmp.
|
||||
release_holen || { echo "Das Release liess sich nicht holen." >&2; exit 1; }
|
||||
bash "$SRC/deploy/install.sh" ${ARGUMENTE[0]+"${ARGUMENTE[@]}"}
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "tesm-core"
|
||||
version = "2.0.0"
|
||||
version = "2.8.18"
|
||||
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
|
||||
|
||||
@@ -13,10 +13,12 @@ import secrets
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Sequence
|
||||
|
||||
from flask import Blueprint, Flask, g, render_template, request
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from flask import Blueprint, Flask, g, redirect, render_template, request
|
||||
from werkzeug.exceptions import HTTPException
|
||||
|
||||
from . import audit, logging_setup, ui
|
||||
from . import audit, legacy_urls, logging_setup, ui
|
||||
from .config import CoreConfig
|
||||
from .core_schema import CORE_MIGRATIONS
|
||||
from .db import Database
|
||||
@@ -31,6 +33,7 @@ from .security import csrf
|
||||
from .security import sessions as session_store
|
||||
from .security.sessions import SqliteSessionInterface
|
||||
from .settings_store import SettingSpec, SettingsStore
|
||||
from . import uploads
|
||||
from .sysops import SysOps
|
||||
from .auth import directory
|
||||
from .auth import routes as auth_routes
|
||||
@@ -43,6 +46,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"),
|
||||
@@ -51,11 +59,34 @@ CORE_SETTINGS: tuple[SettingSpec, ...] = (
|
||||
30,
|
||||
"int",
|
||||
"Aufbewahrung im Papierkorb (Tage)",
|
||||
hint="Nach dieser Frist werden Eintraege beim naechsten Aufruf endgueltig entfernt.",
|
||||
hint="Nach dieser Frist werden Einträge beim nächsten Aufruf endgültig entfernt.",
|
||||
section="system",
|
||||
minimum=1,
|
||||
maximum=3650,
|
||||
),
|
||||
SettingSpec(
|
||||
"session_absolute_hours",
|
||||
12,
|
||||
"int",
|
||||
"Längstmögliche Sitzungsdauer (Stunden)",
|
||||
hint="Danach ist Schluss, auch bei ständiger Arbeit – eine Sitzung soll nicht "
|
||||
"ewig leben. Die eingebundenen Dateifreigaben enden mit ihr.",
|
||||
section="system",
|
||||
minimum=1,
|
||||
maximum=168,
|
||||
),
|
||||
SettingSpec(
|
||||
"session_idle_minutes",
|
||||
30,
|
||||
"int",
|
||||
"Automatische Abmeldung nach Leerlauf (Minuten)",
|
||||
hint="Jede Anfrage setzt den Zähler zurück – gezählt wird nur Untätigkeit. "
|
||||
"Gilt auch für 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(
|
||||
@@ -71,6 +102,14 @@ CORE_SETTINGS: tuple[SettingSpec, ...] = (
|
||||
),
|
||||
# -- Webserver/TLS (Host-Ebene, in beiden Anwendungen gleich) ----------- #
|
||||
# Lizenzserver: eigenes Zertifikat statt abgeschalteter Pruefung.
|
||||
SettingSpec(
|
||||
"modules_disabled",
|
||||
[],
|
||||
"json",
|
||||
"Vor Ort abgeschaltete Module",
|
||||
hint="Die Lizenz sagt, was erlaubt ist; das hier sagt, was benutzt wird.",
|
||||
section="system",
|
||||
),
|
||||
SettingSpec(
|
||||
"license_server_ca",
|
||||
"",
|
||||
@@ -83,8 +122,8 @@ CORE_SETTINGS: tuple[SettingSpec, ...] = (
|
||||
SettingSpec("web_hsts_enabled", False, "bool", "HSTS senden", section="web"),
|
||||
SettingSpec("web_redirect_http", True, "bool", "HTTP auf HTTPS umleiten", section="web"),
|
||||
SettingSpec("web_cert_path", "", "str", "Zertifikat (Pfad)", section="web"),
|
||||
SettingSpec("web_key_path", "", "str", "Privater Schluessel (Pfad)", section="web"),
|
||||
SettingSpec("web_acme_email", "", "str", "E-Mail fuer Let's Encrypt", section="web"),
|
||||
SettingSpec("web_key_path", "", "str", "Privater Schlüssel (Pfad)", section="web"),
|
||||
SettingSpec("web_acme_email", "", "str", "E-Mail für Let's Encrypt", section="web"),
|
||||
SettingSpec("web_acme_webroot", "", "str", "ACME-Webroot", section="web"),
|
||||
# Abweichende Ports braucht nur eine Testinstanz neben der Produktion --
|
||||
# im Regelbetrieb bleibt es bei 80 und 443.
|
||||
@@ -95,7 +134,7 @@ CORE_SETTINGS: tuple[SettingSpec, ...] = (
|
||||
"web_https_port", 443, "int", "HTTPS-Port", section="web", minimum=1, maximum=65535
|
||||
),
|
||||
SettingSpec(
|
||||
"web_max_body_mb", 128, "int", "Maximale Anfragegroesse (MB)", section="web",
|
||||
"web_max_body_mb", 128, "int", "Maximale Anfragegröße (MB)", section="web",
|
||||
minimum=1, maximum=8192,
|
||||
),
|
||||
*directory.SETTINGS,
|
||||
@@ -113,11 +152,15 @@ 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",
|
||||
#: Beide Anwendungen geben ihre eigene Paketversion mit -- und die laeuft
|
||||
#: absichtlich auseinander: was sich nicht geaendert hat, bekommt keine
|
||||
#: neue Nummer. Eine feste Zahl an dieser Stelle wuerde irgendwann eine
|
||||
#: falsche Version in die Fussnote schreiben.
|
||||
app_version: str = "unbekannt",
|
||||
built_in_trusted_keys: tuple[str, ...] = (),
|
||||
enable_license: bool = True,
|
||||
template_folder: str | None = None,
|
||||
@@ -129,6 +172,10 @@ def create_base_app(
|
||||
static_folder=static_folder or "static",
|
||||
static_url_path="/assets",
|
||||
)
|
||||
# Umlaute bleiben Umlaute -- auch in JSON. Flask wuerde sonst nach ASCII
|
||||
# ausweichen ("ü"), und genau darin stehen die Flash-Meldungen in der
|
||||
# Seite: eine Meldung mit "ue" war lesbar, eine mit "ü" wurde zu Zahlen.
|
||||
app.json.ensure_ascii = False
|
||||
|
||||
# Templates und Design-System des Kerns liegen im Paket ``tesm_core`` und
|
||||
# werden ueber einen eigenen Blueprint bereitgestellt. Dadurch kann jede
|
||||
@@ -146,6 +193,10 @@ def create_base_app(
|
||||
config.data_dir.mkdir(parents=True, exist_ok=True)
|
||||
config.log_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Die Groessengrenze einer Anfrage kommt aus der Einstellung, nicht aus
|
||||
# der Konfiguration beim Start.
|
||||
app.request_class = uploads.UploadLimitRequest
|
||||
|
||||
app.config.update(
|
||||
SECRET_KEY=load_or_create_secret_key(config.secret_key_file),
|
||||
# site_key statt app_key: Cookies unterscheiden keine Ports. Zwei
|
||||
@@ -155,6 +206,9 @@ def create_base_app(
|
||||
SESSION_COOKIE_HTTPONLY=True,
|
||||
SESSION_COOKIE_SECURE=config.security.cookie_secure,
|
||||
SESSION_COOKIE_SAMESITE=config.security.cookie_samesite,
|
||||
# Nur die aeussere Schranke aus der Umgebung. Die eigentliche Grenze
|
||||
# steht in der Einstellung und wird pro Anfrage gelesen (uploads.py) --
|
||||
# sonst braeuchte jede Aenderung einen Neustart aller Arbeiter.
|
||||
MAX_CONTENT_LENGTH=config.security.max_content_length,
|
||||
JSON_SORT_KEYS=False,
|
||||
TRAP_HTTP_EXCEPTIONS=False,
|
||||
@@ -192,11 +246,34 @@ 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
|
||||
|
||||
def _gesamtdauer(conn: Any) -> int:
|
||||
"""Gesamtdauer aus den Einstellungen -- ueber dieselbe offene Verbindung."""
|
||||
try:
|
||||
wert = int(settings.get(conn, "session_absolute_hours") or 0)
|
||||
except (TypeError, ValueError):
|
||||
wert = 0
|
||||
return wert if wert > 0 else config.security.session_absolute_hours
|
||||
|
||||
app.session_interface = SqliteSessionInterface(
|
||||
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,
|
||||
absolute_lookup=_gesamtdauer,
|
||||
)
|
||||
|
||||
# -- Schema und Startzustand ------------------------------------------ #
|
||||
@@ -228,6 +305,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 +352,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,
|
||||
@@ -285,6 +374,26 @@ def create_base_app(
|
||||
# -- Fehlerseiten ------------------------------------------------------ #
|
||||
@app.errorhandler(HTTPException)
|
||||
def _http_error(error: HTTPException): # type: ignore[no-untyped-def]
|
||||
if error.code == 404:
|
||||
# Vor der Fehlerseite: war das eine Adresse aus der Zeit vor 2.5.0?
|
||||
# Erst hier gefragt, damit die Tabelle nie eine bestehende Route
|
||||
# verdeckt -- auf dem Lizenzserver ist /lizenz/<ticket> echt.
|
||||
ziel = legacy_urls.neue_adresse(request.path)
|
||||
if ziel is not None:
|
||||
parameter = legacy_urls.neue_parameter(request.args.to_dict(flat=True))
|
||||
if parameter:
|
||||
ziel = ziel + "?" + urlencode(parameter)
|
||||
return redirect(ziel)
|
||||
if error.code == 413:
|
||||
# "Request Entity Too Large" allein hilft niemandem weiter: die
|
||||
# Zahl steht in einer Einstellung, und wer hochlaedt, soll sie
|
||||
# sehen. Vorher stand hier nur der nackte Status.
|
||||
grenze = uploads.limit_mb()
|
||||
error.description = (
|
||||
f"Diese Datei ist größer als erlaubt. Zurzeit sind {grenze} MB je Upload "
|
||||
"eingestellt; ändern kann das ein Administrator unter "
|
||||
'"Einstellungen -> Webserver und TLS".'
|
||||
)
|
||||
if _wants_json():
|
||||
return {
|
||||
"error": error.name,
|
||||
@@ -338,7 +447,7 @@ def create_base_app(
|
||||
with app.app_context():
|
||||
trash_views.register_core_providers()
|
||||
|
||||
app.add_url_rule("/gesundheit", "health", _health, methods=["GET"])
|
||||
app.add_url_rule("/health", "health", _health, methods=["GET"])
|
||||
|
||||
return app
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ import sqlite3
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
from typing import Any, Collection, Iterable, Mapping, Sequence
|
||||
|
||||
from .db import Database
|
||||
|
||||
@@ -116,6 +116,49 @@ def log(
|
||||
return int(cursor.lastrowid or 0)
|
||||
|
||||
|
||||
def diff(
|
||||
vorher: Mapping[str, Any] | None,
|
||||
nachher: Mapping[str, Any] | None,
|
||||
*,
|
||||
felder: Sequence[tuple[str, str]] | None = None,
|
||||
geheim: Collection[str] = (),
|
||||
) -> str:
|
||||
"""Was sich geaendert hat: ``Feld: alt -> neu``, mit Komma getrennt.
|
||||
|
||||
Damit steht im Protokoll nicht nur *dass* etwas geaendert wurde, sondern
|
||||
auch *von was auf was* -- der Weg zurueck steht damit in der Zeile selbst.
|
||||
|
||||
``felder`` gibt Auswahl und Beschriftung vor (``[("ssh_port", "SSH-Port")]``);
|
||||
ohne Angabe werden alle gemeinsamen Felder verglichen. Namen in ``geheim``
|
||||
werden nie im Klartext geschrieben -- dort steht nur, dass sie sich
|
||||
geaendert haben.
|
||||
"""
|
||||
if vorher is None or nachher is None:
|
||||
return ""
|
||||
paare = felder or [(name, name) for name in sorted(set(vorher) & set(nachher))]
|
||||
teile: list[str] = []
|
||||
for name, beschriftung in paare:
|
||||
if name not in vorher or name not in nachher:
|
||||
continue
|
||||
alt, neu = vorher[name], nachher[name]
|
||||
if alt == neu:
|
||||
continue
|
||||
if name in geheim:
|
||||
teile.append(f"{beschriftung}: geaendert")
|
||||
continue
|
||||
teile.append(f"{beschriftung}: {_wert(alt)} -> {_wert(neu)}")
|
||||
return ", ".join(teile)
|
||||
|
||||
|
||||
def _wert(wert: Any) -> str:
|
||||
if wert is None or wert == "":
|
||||
return "(leer)"
|
||||
if isinstance(wert, bool):
|
||||
return "ein" if wert else "aus"
|
||||
text = " ".join(str(wert).split())
|
||||
return text if len(text) <= 80 else text[:79] + "…"
|
||||
|
||||
|
||||
def verify_chain(conn: sqlite3.Connection, *, limit: int | None = None) -> dict[str, Any]:
|
||||
"""Prueft die Hash-Kette. Rueckgabe: ``{"ok", "checked", "broken_at", "reason"}``."""
|
||||
sql = "SELECT * FROM audit_log ORDER BY id"
|
||||
@@ -133,14 +176,14 @@ def verify_chain(conn: sqlite3.Connection, *, limit: int | None = None) -> dict[
|
||||
"ok": False,
|
||||
"checked": checked,
|
||||
"broken_at": data["id"],
|
||||
"reason": "Vorgaenger-Hash passt nicht -- eine Zeile wurde entfernt oder eingefuegt.",
|
||||
"reason": "Vorgänger-Hash passt nicht – eine Zeile wurde entfernt oder eingefügt.",
|
||||
}
|
||||
if compute_hash(str(data["prev_hash"]), data) != data["hash"]:
|
||||
return {
|
||||
"ok": False,
|
||||
"checked": checked,
|
||||
"broken_at": data["id"],
|
||||
"reason": "Zeileninhalt passt nicht zum gespeicherten Hash -- Eintrag geaendert.",
|
||||
"reason": "Zeileninhalt passt nicht zum gespeicherten Hash | Eintrag geändert.",
|
||||
}
|
||||
expected_prev = str(data["hash"])
|
||||
checked += 1
|
||||
|
||||
@@ -26,6 +26,7 @@ import re
|
||||
import socket
|
||||
import sqlite3
|
||||
import ssl
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Literal
|
||||
@@ -42,7 +43,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"]
|
||||
@@ -59,20 +60,20 @@ SETTINGS: tuple[SettingSpec, ...] = (
|
||||
section="ldap",
|
||||
choices=(("ldaps", "LDAPS (Port 636)"), ("starttls", "StartTLS (Port 389)"), ("none", "Unverschluesselt")),
|
||||
),
|
||||
SettingSpec("ldap_verify_cert", True, "bool", "Serverzertifikat pruefen", section="ldap"),
|
||||
SettingSpec("ldap_verify_cert", True, "bool", "Serverzertifikat prüfen", section="ldap"),
|
||||
SettingSpec("ldap_ca_file", "", "str", "CA-Zertifikat (Pfad)", section="ldap"),
|
||||
SettingSpec(
|
||||
"ldap_allow_insecure",
|
||||
False,
|
||||
"bool",
|
||||
"Unverschluesselte Verbindung ausdruecklich erlauben",
|
||||
hint="Nur fuer Testumgebungen. Das Passwort geht dabei im Klartext ueber das Netz.",
|
||||
"Unverschlüsselte Verbindung ausdrücklich erlauben",
|
||||
hint="Nur für Testumgebungen. Das Passwort geht dabei im Klartext über das Netz.",
|
||||
section="ldap",
|
||||
),
|
||||
SettingSpec("ldap_base_dn", "", "str", "Basis-DN (leer = automatisch)", section="ldap"),
|
||||
SettingSpec("ldap_user_attribute", "sAMAccountName", "str", "Anmeldeattribut", section="ldap"),
|
||||
SettingSpec("ldap_email_attribute", "userPrincipalName", "str", "E-Mail-Attribut", section="ldap"),
|
||||
SettingSpec("ldap_group_sync", True, "bool", "Gruppen aus dem Verzeichnis uebernehmen", section="ldap"),
|
||||
SettingSpec("ldap_group_sync", True, "bool", "Gruppen aus dem Verzeichnis übernehmen", section="ldap"),
|
||||
SettingSpec("ldap_timeout", 8, "int", "Zeitlimit (Sekunden)", section="ldap", minimum=1, maximum=60),
|
||||
)
|
||||
|
||||
@@ -123,7 +124,7 @@ def bind_credentials(conn: sqlite3.Connection) -> tuple[str, str]:
|
||||
try:
|
||||
secret = core().keystore.decrypt(encrypted, aad=AAD_LDAP_BIND)
|
||||
except Exception: # noqa: BLE001 - Schluessel gewechselt/Datei kaputt
|
||||
logger.error("Bind-Passwort des Verzeichnisdienstes konnte nicht entschluesselt werden")
|
||||
logger.error("Bind-Passwort des Verzeichnisdienstes konnte nicht entschlüsselt werden")
|
||||
return str(row.get("username") or ""), ""
|
||||
return str(row.get("username") or ""), secret
|
||||
|
||||
@@ -162,19 +163,82 @@ def _tls_object(config: dict[str, Any]):
|
||||
|
||||
|
||||
def _server(config: dict[str, Any]):
|
||||
"""Das Server-Objekt -- so sparsam wie moeglich.
|
||||
|
||||
``get_info`` entscheidet, was ldap3 beim Verbinden **zusaetzlich** holt.
|
||||
Mit ``ALL`` ist das die Beschreibung des Verzeichnisses *und sein ganzes
|
||||
Schema*: bei einem Active Directory sind das Tausende Attribut- und
|
||||
Klassendefinitionen, mehrere Megabyte, bei jeder Verbindung neu. Fuer eine
|
||||
Anmeldung braucht davon nichts -- gelesen wird hoechstens der
|
||||
``defaultNamingContext``, wenn kein Basis-DN eingetragen ist, und dafuer
|
||||
genuegt ``DSA`` (nur die Serverinformation, ein Datensatz).
|
||||
|
||||
Liegt der Domain-Controller in einem anderen Netz, ist genau das der
|
||||
Unterschied zwischen "geht sofort" und "dauert manchmal".
|
||||
"""
|
||||
from ldap3 import Server
|
||||
|
||||
security = str(config["ldap_security"])
|
||||
braucht_basis = not str(config.get("ldap_base_dn") or "").strip()
|
||||
return Server(
|
||||
str(config["ldap_host"]),
|
||||
port=int(config["ldap_port"]),
|
||||
use_ssl=security == "ldaps",
|
||||
tls=_tls_object(config) if security in {"ldaps", "starttls"} else None,
|
||||
get_info="ALL",
|
||||
get_info="DSA" if braucht_basis else "NO_INFO",
|
||||
connect_timeout=int(config["ldap_timeout"]),
|
||||
)
|
||||
|
||||
|
||||
#: Fehler, die nur "gerade jetzt nicht" bedeuten -- nicht "falsches Passwort".
|
||||
#:
|
||||
#: Die Liste stammt aus der Fassung 1.2.8, wo dieses Problem schon einmal
|
||||
#: geloest war. Der Kommentar dort beschreibt es genauer, als ich es koennte:
|
||||
#: ein Domain-Controller in einem anderen, langsameren Netzsegment -- die
|
||||
#: allererste Verbindung dorthin ist oft spuerbar langsamer als jede folgende,
|
||||
#: und der erste Login schlug mit "Benutzer/Passwort ungueltig" fehl, waehrend
|
||||
#: ein sofortiger zweiter Versuch klappte.
|
||||
TRANSIENTE_FEHLER: tuple[type[Exception], ...] = ()
|
||||
|
||||
|
||||
def _transiente_fehler() -> tuple[type[Exception], ...]:
|
||||
"""Die Ausnahmeklassen von ldap3, die einen Wiederholversuch verdienen."""
|
||||
global TRANSIENTE_FEHLER
|
||||
if TRANSIENTE_FEHLER:
|
||||
return TRANSIENTE_FEHLER
|
||||
from ldap3.core.exceptions import (
|
||||
LDAPResponseTimeoutError,
|
||||
LDAPSocketOpenError,
|
||||
LDAPSocketReceiveError,
|
||||
LDAPSocketSendError,
|
||||
)
|
||||
|
||||
TRANSIENTE_FEHLER = (
|
||||
LDAPSocketOpenError,
|
||||
LDAPSocketReceiveError,
|
||||
LDAPSocketSendError,
|
||||
LDAPResponseTimeoutError,
|
||||
)
|
||||
return TRANSIENTE_FEHLER
|
||||
|
||||
|
||||
def mit_wiederholung(aufbau: Any) -> Any:
|
||||
"""Genau ein zweiter Versuch -- und nur bei einem Netzfehler.
|
||||
|
||||
Nicht bei einer abgelehnten Anmeldung: ein Tippfehler im Passwort wuerde
|
||||
sonst den Sperrzaehler des Kontos zweimal statt einmal hochzaehlen. Das
|
||||
ist die Regel aus 1.2.8, und sie ist richtig.
|
||||
"""
|
||||
try:
|
||||
return aufbau()
|
||||
except _transiente_fehler() as fehler:
|
||||
logger.info(
|
||||
"Verzeichnisdienst antwortete nicht (%s) -- ein zweiter Versuch",
|
||||
type(fehler).__name__,
|
||||
)
|
||||
return aufbau()
|
||||
|
||||
|
||||
def _connect(config: dict[str, Any], user: str, password: str):
|
||||
"""Baut eine Verbindung auf und bindet. Wirft bei Netz-/TLS-Problemen."""
|
||||
from ldap3 import Connection
|
||||
@@ -182,7 +246,7 @@ def _connect(config: dict[str, Any], user: str, password: str):
|
||||
security = str(config["ldap_security"])
|
||||
if security == "none" and not config["ldap_allow_insecure"]:
|
||||
raise PermissionError(
|
||||
"Unverschluesselte Verzeichnisverbindungen sind deaktiviert. "
|
||||
"Unverschlüsselte Verzeichnisverbindungen sind deaktiviert. "
|
||||
"Nutzen Sie LDAPS oder StartTLS."
|
||||
)
|
||||
connection = Connection(
|
||||
@@ -192,6 +256,12 @@ def _connect(config: dict[str, Any], user: str, password: str):
|
||||
auto_bind=False,
|
||||
raise_exceptions=False,
|
||||
receive_timeout=int(config["ldap_timeout"]),
|
||||
# Ein Active Directory antwortet auf eine Suche ab der Domaenenwurzel
|
||||
# mit Verweisen auf andere Controller. ldap3 folgt ihnen von sich aus
|
||||
# -- und baut dafuer neue Verbindungen auf, womoeglich in ein anderes
|
||||
# Netz. Fuer eine Anmeldung ist das nicht nur unnoetig, sondern die
|
||||
# Quelle genau der Wartezeiten, die niemand erklaeren kann.
|
||||
auto_referrals=False,
|
||||
)
|
||||
if security == "starttls":
|
||||
if not connection.open():
|
||||
@@ -249,12 +319,23 @@ def authenticate(conn: sqlite3.Connection, username: str, password: str) -> Dire
|
||||
bind_user, bind_password = bind_credentials(conn)
|
||||
warnings: list[str] = []
|
||||
if str(config["ldap_security"]) == "none":
|
||||
warnings.append("Unverschluesselte Verzeichnisverbindung in Benutzung")
|
||||
warnings.append("Unverschlüsselte Verzeichnisverbindung in Benutzung")
|
||||
if not config["ldap_verify_cert"]:
|
||||
warnings.append("Serverzertifikat des Verzeichnisdienstes wird nicht geprueft")
|
||||
warnings.append("Serverzertifikat des Verzeichnisdienstes wird nicht geprüft")
|
||||
|
||||
begonnen = time.monotonic()
|
||||
zeiten: list[str] = []
|
||||
|
||||
def gemessen(name: str, seit: float) -> float:
|
||||
"""Haelt eine Stufe mit ihrer Dauer fest und gibt die neue Marke."""
|
||||
jetzt = time.monotonic()
|
||||
zeiten.append(f"{name} {int((jetzt - seit) * 1000)}ms")
|
||||
return jetzt
|
||||
|
||||
try:
|
||||
service_conn, bound = _connect(config, bind_user, bind_password)
|
||||
service_conn, bound = mit_wiederholung(
|
||||
lambda: _connect(config, bind_user, bind_password)
|
||||
)
|
||||
except (PermissionError, ConnectionError) as exc:
|
||||
logger.error("Verzeichnisdienst: %s", exc)
|
||||
return DirectoryResult("unavailable", message=str(exc), warnings=tuple(warnings))
|
||||
@@ -271,8 +352,10 @@ def authenticate(conn: sqlite3.Connection, username: str, password: str) -> Dire
|
||||
warnings=tuple(warnings),
|
||||
)
|
||||
|
||||
marke = gemessen("Dienstbindung", begonnen)
|
||||
try:
|
||||
base = _base_dn(service_conn, config)
|
||||
marke = gemessen("Basis-DN", marke)
|
||||
if not base:
|
||||
return DirectoryResult(
|
||||
"unavailable", message="Basis-DN konnte nicht ermittelt werden.", warnings=tuple(warnings)
|
||||
@@ -288,8 +371,10 @@ def authenticate(conn: sqlite3.Connection, username: str, password: str) -> Dire
|
||||
attributes=list(dict.fromkeys(wanted)),
|
||||
size_limit=2,
|
||||
)
|
||||
marke = gemessen("Benutzersuche", marke)
|
||||
entries = list(service_conn.entries)
|
||||
if len(entries) != 1:
|
||||
logger.info("Verzeichnisanmeldung %s: %s", username, ", ".join(zeiten))
|
||||
return DirectoryResult("invalid", warnings=tuple(warnings))
|
||||
|
||||
entry = entries[0]
|
||||
@@ -300,9 +385,18 @@ def authenticate(conn: sqlite3.Connection, username: str, password: str) -> Dire
|
||||
|
||||
# Zweite, unabhaengige Verbindung mit den Benutzerdaten -- die eigentliche
|
||||
# Passwortpruefung. Niemals ueber die Dienstkonto-Verbindung rebinden.
|
||||
user_conn, user_bound = _connect(config, dn, password)
|
||||
#
|
||||
# Mit Wiederholversuch, und genau hier zeigte sich das Symptom: bei
|
||||
# einem Domain-Controller in einem anderen Netz schlug der erste
|
||||
# Versuch mit "Benutzer/Passwort ungueltig" fehl, der zweite gleich
|
||||
# darauf klappte. Wiederholt wird nur bei einem Netzfehler -- eine
|
||||
# abgelehnte Anmeldung bleibt abgelehnt, sonst zaehlt ein Tippfehler
|
||||
# den Sperrzaehler des Kontos zweimal hoch.
|
||||
user_conn, user_bound = mit_wiederholung(lambda: _connect(config, dn, password))
|
||||
marke = gemessen("Passwortpruefung", marke)
|
||||
try:
|
||||
if not user_bound:
|
||||
logger.info("Verzeichnisanmeldung %s: %s", username, ", ".join(zeiten))
|
||||
return DirectoryResult("invalid", warnings=tuple(warnings))
|
||||
finally:
|
||||
try:
|
||||
@@ -313,6 +407,7 @@ def authenticate(conn: sqlite3.Connection, username: str, password: str) -> Dire
|
||||
group_dns: tuple[str, ...] = ()
|
||||
if config["ldap_group_sync"]:
|
||||
group_dns = _resolve_groups(service_conn, base, dn)
|
||||
marke = gemessen("Gruppen", marke)
|
||||
|
||||
profile = DirectoryProfile(
|
||||
username=_attr(entry, attribute) or username,
|
||||
@@ -322,6 +417,15 @@ def authenticate(conn: sqlite3.Connection, username: str, password: str) -> Dire
|
||||
email=_attr(entry, email_attribute),
|
||||
group_dns=group_dns,
|
||||
)
|
||||
# Eine Zeile je Anmeldung mit den Dauern der Stufen. Aus "dauert
|
||||
# manchmal laenger" wird damit eine Zahl -- und man sieht, ob es die
|
||||
# Verbindung ist, die Suche oder die Gruppen.
|
||||
logger.info(
|
||||
"Verzeichnisanmeldung %s: %s, gesamt %dms",
|
||||
username,
|
||||
", ".join(zeiten),
|
||||
int((time.monotonic() - begonnen) * 1000),
|
||||
)
|
||||
return DirectoryResult("ok", profile=profile, warnings=tuple(warnings))
|
||||
finally:
|
||||
try:
|
||||
@@ -349,7 +453,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,
|
||||
@@ -423,18 +527,24 @@ def _apply_group_mappings(
|
||||
zugewiesene Gruppen bleiben erhalten, wenn sie kein Verzeichnis-Mapping
|
||||
haben -- sonst waere jede lokale Zuweisung nach dem naechsten Login weg.
|
||||
"""
|
||||
if not group_dns:
|
||||
return
|
||||
mappings = Database.all(
|
||||
conn, "SELECT ad_group_dn, app_group_id FROM ldap_group_mappings"
|
||||
conn, "SELECT ad_group_dn, app_group_id, grants_admin FROM ldap_group_mappings"
|
||||
)
|
||||
if not mappings:
|
||||
return
|
||||
mapped_group_ids = {int(row["app_group_id"]) for row in mappings}
|
||||
lowered = {dn.lower() for dn in group_dns}
|
||||
# Das Kennzeichen zuerst -- es haengt an keiner Gruppe und muss auch dann
|
||||
# ausgewertet werden, wenn das Konto in keiner zugeordneten Gruppe steht.
|
||||
_kennzeichen_setzen(conn, user_id, mappings, lowered)
|
||||
if not group_dns:
|
||||
return
|
||||
gruppenzuordnungen = [row for row in mappings if row["app_group_id"] is not None]
|
||||
if not gruppenzuordnungen:
|
||||
return
|
||||
mapped_group_ids = {int(row["app_group_id"]) for row in gruppenzuordnungen}
|
||||
wanted = {
|
||||
int(row["app_group_id"])
|
||||
for row in mappings
|
||||
for row in gruppenzuordnungen
|
||||
if str(row["ad_group_dn"]).lower() in lowered
|
||||
}
|
||||
|
||||
@@ -452,6 +562,71 @@ def _apply_group_mappings(
|
||||
)
|
||||
|
||||
|
||||
def _kennzeichen_setzen(
|
||||
conn: sqlite3.Connection,
|
||||
user_id: int,
|
||||
mappings: list[dict[str, Any]],
|
||||
gruppen: set[str],
|
||||
) -> None:
|
||||
"""Setzt oder entzieht das Administratorkennzeichen nach dem Verzeichnis.
|
||||
|
||||
Wirkung wie bei Gruppen: **ersetzend**. Wer aus der zugeordneten
|
||||
AD-Gruppe herausfaellt, verliert das Kennzeichen bei der naechsten
|
||||
Anmeldung -- sonst waere ein Austritt im Verzeichnis eine Aenderung, die
|
||||
hier nie ankommt.
|
||||
|
||||
Zwei Grenzen, beide absichtlich:
|
||||
|
||||
* Nur **Verzeichniskonten**. Ein lokales Konto (etwa das, das
|
||||
``tesm-admin create-admin`` anlegt) bleibt unberuehrt -- es ist der
|
||||
Notausgang, wenn das Verzeichnis nicht erreichbar ist.
|
||||
* Nie den letzten entsperrten Administrator herunterstufen. Ein
|
||||
Tippfehler im DN wuerde sonst beim naechsten Login die Anlage
|
||||
verschliessen, und niemand koennte ihn korrigieren.
|
||||
"""
|
||||
zuordnungen = [row for row in mappings if int(row["grants_admin"] or 0) == 1]
|
||||
if not zuordnungen:
|
||||
return
|
||||
soll = any(str(row["ad_group_dn"]).lower() in gruppen for row in zuordnungen)
|
||||
zeile = Database.one(
|
||||
conn, "SELECT is_admin, auth_source FROM users WHERE id=?", (user_id,)
|
||||
)
|
||||
if zeile is None or str(zeile["auth_source"]) != "ldap":
|
||||
return
|
||||
ist = bool(zeile["is_admin"])
|
||||
if ist == soll:
|
||||
return
|
||||
if not soll:
|
||||
uebrig = Database.value(
|
||||
conn,
|
||||
"SELECT COUNT(*) FROM users WHERE is_admin=1 AND is_locked=0 "
|
||||
"AND deleted_at IS NULL AND id<>?",
|
||||
(user_id,),
|
||||
default=0,
|
||||
)
|
||||
if not uebrig:
|
||||
logger.warning(
|
||||
"Administratorkennzeichen bleibt bei Benutzer %s: es waere das letzte.",
|
||||
user_id,
|
||||
)
|
||||
return
|
||||
conn.execute(
|
||||
"UPDATE users SET is_admin=?, updated_at=? WHERE id=?", (int(soll), _now(), user_id)
|
||||
)
|
||||
audit.log(
|
||||
conn,
|
||||
actor="system",
|
||||
action="auth.admin_flag_changed",
|
||||
target=str(user_id),
|
||||
detail=(
|
||||
"Administrator: "
|
||||
+ ("nein -> ja" if soll else "ja -> nein")
|
||||
+ " (aus der Verzeichniszuordnung)"
|
||||
),
|
||||
severity="warning",
|
||||
)
|
||||
|
||||
|
||||
def _user_group_ids(conn: sqlite3.Connection, user_id: int) -> list[dict[str, Any]]:
|
||||
return Database.all(conn, "SELECT group_id AS id FROM user_groups WHERE user_id=?", (user_id,))
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -76,25 +77,40 @@ class User:
|
||||
|
||||
@property
|
||||
def effective_permissions(self) -> frozenset[str]:
|
||||
"""Rechte nach Anwendung der Bereichs-Kill-Switches."""
|
||||
"""Rechte nach Anwendung der Bereichs-Kill-Switches.
|
||||
|
||||
Ein echter Administrator hat den Verwaltungsbereich immer dabei -- auch
|
||||
ohne Gruppe und ohne dass jemand ihn angehakt haette. Sonst koennte
|
||||
sich das letzte Administratorkonto aus der eigenen Anlage aussperren,
|
||||
indem es seine Gruppe verliert.
|
||||
"""
|
||||
if self._effective is None:
|
||||
tree = self._tree
|
||||
self._effective = (
|
||||
frozenset(tree.effective(self.granted)) if tree is not None else frozenset()
|
||||
)
|
||||
if tree is None:
|
||||
self._effective = frozenset()
|
||||
else:
|
||||
eigene = set(tree.effective(self.granted))
|
||||
if self.is_admin:
|
||||
eigene |= set(tree.verwaltungsrechte())
|
||||
self._effective = frozenset(eigene)
|
||||
return self._effective
|
||||
|
||||
def has(self, *permissions: str) -> bool:
|
||||
"""True, wenn **alle** angegebenen Rechte greifen. Admins duerfen immer alles."""
|
||||
if self.is_admin:
|
||||
return True
|
||||
"""True, wenn **alle** angegebenen Rechte greifen.
|
||||
|
||||
Das Administratorkennzeichen gibt seit 2.8.0 **den Verwaltungsbereich**
|
||||
frei und nicht mehr jede Pruefung: Benutzer, Gruppen, Einstellungen,
|
||||
Sicherung, Papierkorb, Diagnose. Funktionsrechte -- Geraete, Neustarts,
|
||||
DHCP, Freigaben -- kommen ueber Gruppen, und die darf sich ein
|
||||
Administrator selbst zuweisen. Der Unterschied ist nicht, *ob* er sie
|
||||
bekommen kann, sondern dass es dann eine Zeile im
|
||||
Aenderungsprotokoll gibt und in der Matrix zu sehen ist.
|
||||
"""
|
||||
if not permissions:
|
||||
return True
|
||||
return all(permission in self.effective_permissions for permission in permissions)
|
||||
|
||||
def has_any(self, permissions: Iterable[str]) -> bool:
|
||||
if self.is_admin:
|
||||
return True
|
||||
effective = self.effective_permissions
|
||||
return any(permission in effective for permission in permissions)
|
||||
|
||||
@@ -189,9 +205,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)
|
||||
|
||||
@@ -206,6 +235,66 @@ def login_required(view: Callable[..., Any]) -> Callable[..., Any]:
|
||||
return wrapper
|
||||
|
||||
|
||||
def _verlangt_wiederanmeldung(permission: str) -> bool:
|
||||
"""Fragt das Rechtemodell -- ausserhalb einer Anwendung nie."""
|
||||
try:
|
||||
from ..extension import core
|
||||
|
||||
return bool(core().permissions.verlangt_wiederanmeldung(permission))
|
||||
except Exception: # noqa: BLE001 - ohne Modell keine zusaetzliche Huerde
|
||||
return False
|
||||
|
||||
|
||||
def _wiederanmeldung_frisch() -> bool:
|
||||
from .service import reauth_is_fresh # zyklischen Import vermeiden
|
||||
|
||||
return reauth_is_fresh()
|
||||
|
||||
|
||||
def _bitte_bestaetigen(permission: str) -> Any:
|
||||
"""Zur Passwortbestaetigung schicken -- oder es einem Skript sagen.
|
||||
|
||||
Ein Aufruf per fetch bekaeme sonst die Anmeldeseite als Antwort und wuesste
|
||||
nichts damit anzufangen.
|
||||
"""
|
||||
if is_automatic(request) or (
|
||||
request.accept_mimetypes.accept_json and not request.accept_mimetypes.accept_html
|
||||
):
|
||||
abort(403, description="Bitte bestätigen Sie zur Sicherheit Ihr Passwort.")
|
||||
session["_after_reauth"] = request.full_path if request.query_string else request.path
|
||||
flash("Bitte bestätigen Sie zur Sicherheit Ihr Passwort.", "info")
|
||||
return redirect(url_for("auth.reauth"))
|
||||
|
||||
|
||||
def _heikel(permissions: tuple[str, ...], mode: str) -> str:
|
||||
"""Welches Recht verlangt hier eine frische Bestaetigung? Sonst leer.
|
||||
|
||||
Bei ``mode="all"`` ist die Antwort einfach: jedes genannte Recht wird
|
||||
gebraucht, also zaehlt jedes heikle darunter.
|
||||
|
||||
Bei ``mode="any"`` nicht. Diese Seiten nennen mehrere **unabhaengige**
|
||||
Wege hinein, und welcher genommen wird, entscheidet das Konto. Der
|
||||
Protokollverlauf etwa nennt "logs_history.read" und "logs_history.export":
|
||||
lesen ist harmlos, mitnehmen nicht. Gezaehlt wurde aber ueber alle
|
||||
genannten Rechte -- und so verlangte das blosse **Ansehen** des Verlaufs
|
||||
nach fuenfzehn Minuten eine Passworteingabe, mit dem Hinweis, man greife
|
||||
auf einen Datenexport zu. Das stimmte nicht, und wer es dreimal liest,
|
||||
glaubt die Meldung beim vierten Mal auch dann nicht, wenn sie zutrifft.
|
||||
|
||||
Gefragt wird deshalb: haette dieses Konto die Seite auch ohne das heikle
|
||||
Recht geoeffnet? Dann gibt es nichts zu bestaetigen. Die Ausfuhr selbst
|
||||
haengt an ihrer eigenen Route, und die nennt nur ein Recht.
|
||||
"""
|
||||
heikle = [p for p in permissions if _verlangt_wiederanmeldung(p)]
|
||||
if not heikle:
|
||||
return ""
|
||||
if mode == "any":
|
||||
harmlos = [p for p in permissions if p not in heikle]
|
||||
if any(current_user.has(p) for p in harmlos):
|
||||
return ""
|
||||
return heikle[0]
|
||||
|
||||
|
||||
def permission_required(*permissions: str, mode: str = "all") -> Callable[..., Any]:
|
||||
"""Erzwingt Rechte serverseitig.
|
||||
|
||||
@@ -223,7 +312,15 @@ def permission_required(*permissions: str, mode: str = "all") -> Callable[..., A
|
||||
else current_user.has(*permissions)
|
||||
)
|
||||
if not ok:
|
||||
return _deny("Fuer diese Seite fehlt Ihnen die Berechtigung.")
|
||||
return _deny("Für diese Seite fehlt Ihnen die Berechtigung.")
|
||||
# Geheimnisse und Exporte verlangen eine frische Bestaetigung --
|
||||
# das steht im Rechtemodell und wird deshalb hier abgefragt, nicht
|
||||
# in jeder Route einzeln. Vorher hing es daran, dass jemand an den
|
||||
# Dekorator dachte: dieselbe Lizenz herunterzuladen fragte nach,
|
||||
# sie per Mail zu verschicken nicht.
|
||||
heikel = _heikel(permissions, mode)
|
||||
if heikel and not _wiederanmeldung_frisch():
|
||||
return _bitte_bestaetigen(heikel)
|
||||
return view(*args, **kwargs)
|
||||
|
||||
wrapper._required_permissions = permissions # type: ignore[attr-defined]
|
||||
@@ -265,7 +362,7 @@ def reauth_required(view: Callable[..., Any]) -> Callable[..., Any]:
|
||||
return _deny("Anmeldung erforderlich.")
|
||||
if not reauth_is_fresh():
|
||||
session["_after_reauth"] = request.full_path if request.query_string else request.path
|
||||
flash("Bitte bestaetigen Sie zur Sicherheit Ihr Passwort.", "info")
|
||||
flash("Bitte bestätigen Sie zur Sicherheit Ihr Passwort.", "info")
|
||||
return redirect(url_for("auth.reauth"))
|
||||
return view(*args, **kwargs)
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ def login():
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/login/zweiter-faktor", methods=["GET", "POST"])
|
||||
@bp.route("/login/second-factor", methods=["GET", "POST"])
|
||||
def second_factor():
|
||||
conn = db()
|
||||
user_row = service.pending_totp_user(conn)
|
||||
@@ -116,7 +116,7 @@ def logout_get():
|
||||
return redirect(url_for("auth.login"))
|
||||
|
||||
|
||||
@bp.route("/bestaetigen", methods=["GET", "POST"])
|
||||
@bp.route("/confirm", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def reauth():
|
||||
target = _safe_next(session.get("_after_reauth"))
|
||||
|
||||
@@ -151,7 +151,7 @@ def authenticate(
|
||||
if ip_verdict.blocked:
|
||||
return AuthOutcome(
|
||||
"rate_limited",
|
||||
"Zu viele Anmeldeversuche von dieser Adresse. Bitte spaeter erneut versuchen.",
|
||||
"Zu viele Anmeldeversuche von dieser Adresse. Bitte später erneut versuchen.",
|
||||
retry_after=ip_verdict.retry_after_seconds,
|
||||
)
|
||||
|
||||
@@ -165,7 +165,7 @@ def authenticate(
|
||||
if user_verdict.blocked:
|
||||
return AuthOutcome(
|
||||
"rate_limited",
|
||||
"Dieses Konto ist nach mehreren Fehlversuchen voruebergehend gesperrt.",
|
||||
"Dieses Konto ist nach mehreren Fehlversuchen vorübergehend gesperrt.",
|
||||
retry_after=user_verdict.retry_after_seconds,
|
||||
)
|
||||
|
||||
@@ -248,7 +248,7 @@ def _verify_credentials(
|
||||
return AuthOutcome(
|
||||
"directory_unavailable",
|
||||
"Der Verzeichnisdienst ist derzeit nicht erreichbar. "
|
||||
"Bitte versuchen Sie es spaeter erneut.",
|
||||
"Bitte versuchen Sie es später erneut.",
|
||||
)
|
||||
if result.status != "ok" or result.profile is None:
|
||||
return AuthOutcome("invalid", GENERIC_FAILURE)
|
||||
@@ -257,6 +257,24 @@ def _verify_credentials(
|
||||
return AuthOutcome("ok", user_row=user_row, group_dns=result.profile.group_dns)
|
||||
|
||||
|
||||
|
||||
def _ist_erstanmeldung(user_row: dict[str, Any]) -> bool:
|
||||
"""Meldet sich dieses Konto zum ersten Mal ueberhaupt an?
|
||||
|
||||
Das Aenderungsprotokoll soll Aenderungen zeigen. Eine taegliche Anmeldung
|
||||
ist keine -- sie erzeugte aber jedes Mal eine Zeile und schob damit echte
|
||||
Eintraege aus dem Blickfeld. Interessant ist genau die erste: mit ihr
|
||||
entsteht das Konto aus dem Verzeichnisdienst und betritt jemand die
|
||||
Anwendung, der vorher nicht darin war.
|
||||
|
||||
Nicht betroffen: fehlgeschlagene Anmeldungen und Sperren. Die stehen
|
||||
weiterhin **jedes Mal** im Protokoll -- dort liegt der sicherheitsrelevante
|
||||
Teil, und Haeufung ist dort das Signal. Wer wann zuletzt angemeldet war,
|
||||
steht ausserdem unveraendert am Konto und in der Sitzungsuebersicht.
|
||||
"""
|
||||
return not str(user_row.get("last_login_at") or "").strip()
|
||||
|
||||
|
||||
def complete_login(
|
||||
conn: sqlite3.Connection,
|
||||
user_row: dict[str, Any],
|
||||
@@ -275,20 +293,25 @@ def complete_login(
|
||||
"""
|
||||
from ..security import csrf
|
||||
|
||||
# Vor dem UPDATE lesen -- danach steht ueberall ein Zeitstempel.
|
||||
erstanmeldung = _ist_erstanmeldung(user_row)
|
||||
|
||||
with core().database.transaction(conn):
|
||||
session_store.rotate(conn, session, reason="login") # type: ignore[arg-type]
|
||||
conn.execute(
|
||||
"UPDATE users SET last_login_at=? WHERE id=?", (_iso(_now()), user_row["id"])
|
||||
)
|
||||
if erstanmeldung:
|
||||
audit.log(
|
||||
conn,
|
||||
actor=str(user_row["username"]),
|
||||
actor_id=int(user_row["id"]),
|
||||
action="auth.login",
|
||||
action="auth.first_login",
|
||||
target=str(user_row["username"]),
|
||||
detail=f"Quelle: {user_row.get('auth_source') or 'local'}",
|
||||
detail=f"Erste Anmeldung | Quelle: {user_row.get('auth_source') or 'local'}",
|
||||
ip=ip,
|
||||
request_id=getattr(g, "request_id", ""),
|
||||
severity="notice",
|
||||
)
|
||||
|
||||
session.clear()
|
||||
@@ -336,15 +359,11 @@ def logout(conn: sqlite3.Connection) -> None:
|
||||
current_app.logger.exception(
|
||||
"Abmeldehaken %r fehlgeschlagen", getattr(hook, "__name__", hook)
|
||||
)
|
||||
with core().database.transaction(conn):
|
||||
audit.log(
|
||||
conn,
|
||||
actor=user.username,
|
||||
actor_id=user.id,
|
||||
action="auth.logout",
|
||||
target=user.username,
|
||||
ip=client_ip(),
|
||||
)
|
||||
# Kein Protokolleintrag fuer das Abmelden: eine Abmeldung aendert
|
||||
# nichts. Sie stand als "Info" in jeder zweiten Zeile und verdraengte
|
||||
# die Eintraege, um die es geht. Wer wissen will, wer wann da war,
|
||||
# liest die Sitzungsuebersicht; fehlgeschlagene Anmeldungen und
|
||||
# Sperren stehen weiterhin jedes Mal im Protokoll.
|
||||
session.destroy() if hasattr(session, "destroy") else session.clear() # type: ignore[attr-defined]
|
||||
g.user = ANONYMOUS
|
||||
|
||||
|
||||
@@ -42,6 +42,8 @@ def _config_from_settings(extension: Any, conn: Any) -> webserver.WebConfig:
|
||||
],
|
||||
)
|
||||
return webserver.WebConfig(
|
||||
# Die neue Schreibweise, sobald der Webserver sie kennt.
|
||||
http2_directive=webserver.nginx_kann_http2(),
|
||||
app_key=extension.config.site_key,
|
||||
upstream=str(extension.registry.get("upstream", "127.0.0.1:5000")),
|
||||
static_root=str(
|
||||
@@ -83,7 +85,7 @@ def run_web_setup(app: Any, extension: Any, args: argparse.Namespace) -> int:
|
||||
port = getattr(args, flag, None)
|
||||
if port:
|
||||
if not 1 <= int(port) <= 65535:
|
||||
print(f"Ungueltiger Port: {port}", file=sys.stderr)
|
||||
print(f"Ungültiger Port: {port}", file=sys.stderr)
|
||||
return 2
|
||||
values[key] = int(port)
|
||||
|
||||
@@ -93,11 +95,11 @@ def run_web_setup(app: Any, extension: Any, args: argparse.Namespace) -> int:
|
||||
|
||||
# -- Zertifikat --------------------------------------------------- #
|
||||
config = _config_from_settings(extension, conn)
|
||||
cert: dict[str, Any] = {"ok": True, "message": "Zertifikat unveraendert."}
|
||||
cert: dict[str, Any] = {"ok": True, "message": "Zertifikat unverändert."}
|
||||
|
||||
if args.acme:
|
||||
if sysops is None or not sysops.available:
|
||||
print("Der Systemhelfer ist nicht verfuegbar.", file=sys.stderr)
|
||||
print("Der Systemhelfer ist nicht verfügbar.", file=sys.stderr)
|
||||
return 2
|
||||
cert = webserver.request_certificate(
|
||||
sysops,
|
||||
@@ -118,7 +120,7 @@ def run_web_setup(app: Any, extension: Any, args: argparse.Namespace) -> int:
|
||||
|
||||
if args.self_signed and not args.acme:
|
||||
if sysops is None or not sysops.available:
|
||||
print("Der Systemhelfer ist nicht verfuegbar.", file=sys.stderr)
|
||||
print("Der Systemhelfer ist nicht verfügbar.", file=sys.stderr)
|
||||
return 2
|
||||
suggested = webserver.local_names()
|
||||
cert = webserver.create_self_signed(
|
||||
@@ -144,7 +146,7 @@ def run_web_setup(app: Any, extension: Any, args: argparse.Namespace) -> int:
|
||||
if args.https:
|
||||
if not (config.cert_path and config.key_path):
|
||||
print(
|
||||
"HTTPS verlangt Zertifikat und Schluessel -- bitte --self-signed "
|
||||
"HTTPS verlangt Zertifikat und Schlüssel – bitte --self-signed "
|
||||
"oder --acme angeben.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
@@ -172,7 +174,7 @@ def run_web_setup(app: Any, extension: Any, args: argparse.Namespace) -> int:
|
||||
return 0
|
||||
|
||||
if sysops is None or not sysops.available:
|
||||
print("Der Systemhelfer ist nicht verfuegbar.", file=sys.stderr)
|
||||
print("Der Systemhelfer ist nicht verfügbar.", file=sys.stderr)
|
||||
return 2
|
||||
result = webserver.apply_config(sysops, rendered)
|
||||
print(result["message"])
|
||||
@@ -194,7 +196,7 @@ def add_web_setup(sub: Any, runner: Any) -> None:
|
||||
help="nginx und TLS einrichten (Port 80/443)",
|
||||
description=(
|
||||
"Setzt die Webserver-Einstellungen und erzeugt daraus die nginx-Site. "
|
||||
"Die Datei wird immer vollstaendig neu geschrieben -- eine von Hand "
|
||||
"Die Datei wird immer vollständig neu geschrieben – eine von Hand "
|
||||
"bearbeitete Site-Datei gibt es in diesem Projekt bewusst nicht."
|
||||
),
|
||||
)
|
||||
@@ -207,20 +209,20 @@ def add_web_setup(sub: Any, runner: Any) -> None:
|
||||
parser.add_argument(
|
||||
"--acme",
|
||||
action="store_true",
|
||||
help="Zertifikat per Let's Encrypt anfordern (Domain muss oeffentlich zeigen)",
|
||||
help="Zertifikat per Let's Encrypt anfordern (Domain muss öffentlich zeigen)",
|
||||
)
|
||||
parser.add_argument("--domain", help="Oeffentlicher Name dieser Installation")
|
||||
parser.add_argument("--acme-email", help="Kontaktadresse fuer Let's Encrypt")
|
||||
parser.add_argument("--domain", help="Öffentlicher Name dieser Installation")
|
||||
parser.add_argument("--acme-email", help="Kontaktadresse für Let's Encrypt")
|
||||
parser.add_argument("--hsts", action="store_true", help="HSTS-Header setzen")
|
||||
parser.add_argument(
|
||||
"--no-redirect",
|
||||
action="store_true",
|
||||
help="Port 80 nicht auf HTTPS umleiten (Sonderfall)",
|
||||
)
|
||||
parser.add_argument("--max-body-mb", type=int, help="Obergrenze fuer Uploads")
|
||||
parser.add_argument("--max-body-mb", type=int, help="Obergrenze für Uploads")
|
||||
parser.add_argument("--http-port", type=int, help="Abweichender HTTP-Port (Standard 80)")
|
||||
parser.add_argument("--https-port", type=int, help="Abweichender HTTPS-Port (Standard 443)")
|
||||
parser.add_argument("--apply", action="store_true", help="nginx schreiben, pruefen, neu laden")
|
||||
parser.add_argument("--apply", action="store_true", help="nginx schreiben, prüfen, neu laden")
|
||||
parser.add_argument(
|
||||
"--print-config", action="store_true", help="Nur die erzeugte Datei ausgeben"
|
||||
)
|
||||
|
||||
@@ -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.
|
||||
@@ -76,7 +76,7 @@ class CoreConfig:
|
||||
"""Technischer Kurzname, z. B. ``tesm`` -- Namensraum fuer Pfade und Units."""
|
||||
|
||||
site_key: str
|
||||
"""Name *dieser Installation* auf dem Host, z. B. ``tesm-opus``.
|
||||
"""Name *dieser Installation* auf dem Host, z. B. ``tesm-test``.
|
||||
|
||||
Weicht vom ``app_key`` ab, sobald neben der Hauptinstallation eine zweite
|
||||
Instanz laeuft. Alles, was ausserhalb von ``/srv/<name>`` liegt und damit
|
||||
@@ -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),
|
||||
@@ -175,11 +175,11 @@ def load_core_config(
|
||||
|
||||
bind = (_env(env_prefix, "BIND") or default_bind).strip()
|
||||
if not re.fullmatch(r"[0-9A-Za-z_.\[\]:-]{1,80}:\d{1,5}", bind):
|
||||
raise ValueError(f"Ungueltige Bind-Adresse: {bind!r}")
|
||||
raise ValueError(f"Ungültige Bind-Adresse: {bind!r}")
|
||||
|
||||
site_key = (_env(env_prefix, "SITE_KEY") or app_key).strip().lower()
|
||||
if not re.fullmatch(r"[a-z0-9][a-z0-9-]{0,39}", site_key):
|
||||
raise ValueError(f"Ungueltiger SITE_KEY: {site_key!r}")
|
||||
raise ValueError(f"Ungültiger SITE_KEY: {site_key!r}")
|
||||
|
||||
return CoreConfig(
|
||||
app_key=app_key,
|
||||
|
||||
@@ -141,11 +141,16 @@ CREATE TABLE IF NOT EXISTS service_accounts (
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Eine Verzeichnisgruppe zeigt entweder auf eine lokale Gruppe **oder** auf
|
||||
-- das Administratorkennzeichen (grants_admin). Darum ist app_group_id
|
||||
-- nullbar: "Domänen-Admins" soll echte Administratoren erzeugen koennen und
|
||||
-- nicht nur Mitglieder der Gruppe "Administration".
|
||||
CREATE TABLE IF NOT EXISTS ldap_group_mappings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ad_group_dn TEXT NOT NULL UNIQUE,
|
||||
ad_group_name TEXT NOT NULL,
|
||||
app_group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||
app_group_id INTEGER REFERENCES groups(id) ON DELETE CASCADE,
|
||||
grants_admin INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
"""
|
||||
@@ -186,8 +191,145 @@ CREATE INDEX IF NOT EXISTS idx_jobs_kind_state ON jobs(kind, state);
|
||||
CREATE INDEX IF NOT EXISTS idx_jobs_created ON jobs(created_at);
|
||||
"""
|
||||
|
||||
def benenne_rechte_um(conn) -> None: # type: ignore[no-untyped-def]
|
||||
"""Schreibt bestehende Gruppenrechte auf das neue Vokabular um.
|
||||
|
||||
``devices.view`` wird ``devices.read``, ``devices.edit`` wird
|
||||
``devices.write``. Ohne diesen Schritt stehen in den Gruppen nur noch
|
||||
Schluessel, die der Rechtebaum nicht kennt -- und
|
||||
:meth:`PermissionTree.sanitize` wirft Unbekanntes **still** weg. Nach dem
|
||||
Update waeren alle Gruppen leer und niemand kaeme mehr an seine Seiten:
|
||||
kein Fehler, den man sieht, sondern einer, den man erst merkt, wenn jemand
|
||||
anruft.
|
||||
|
||||
Im Kern und nicht in den Anwendungen, weil die Tabelle beiden gehoert.
|
||||
Idempotent: was schon neu heisst, wird nicht angefasst.
|
||||
"""
|
||||
from .rbac.legacy_permissions import UMBENANNTE_AKTIONEN
|
||||
|
||||
for alt, neu in UMBENANNTE_AKTIONEN.items():
|
||||
# Erst die Dubletten weg, die beim Umschreiben entstehen wuerden: haette
|
||||
# eine Gruppe (aus welchem Grund auch immer) beide Schluessel, brach der
|
||||
# eindeutige Index. Lieber vorher aufraeumen als in der Migration
|
||||
# scheitern.
|
||||
conn.execute(
|
||||
"DELETE FROM group_permissions WHERE permission LIKE ? AND EXISTS ("
|
||||
" SELECT 1 FROM group_permissions AS andere"
|
||||
" WHERE andere.group_id = group_permissions.group_id"
|
||||
" AND andere.permission = substr(group_permissions.permission, 1,"
|
||||
" length(group_permissions.permission) - ?) || ?)",
|
||||
(f"%.{alt}", len(alt), neu),
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE group_permissions"
|
||||
" SET permission = substr(permission, 1, length(permission) - ?) || ?"
|
||||
" WHERE permission LIKE ?",
|
||||
(len(alt), neu, f"%.{alt}"),
|
||||
)
|
||||
|
||||
|
||||
def zuordnung_auf_kennzeichen(conn) -> None: # type: ignore[no-untyped-def]
|
||||
"""Erlaubt einer Verzeichnisgruppe, das Administratorkennzeichen zu setzen.
|
||||
|
||||
Bisher konnte eine Zuordnung nur auf eine lokale Gruppe zeigen. "Domänen-
|
||||
Admins" landete deshalb in der Gruppe *Administration* -- und wurde damit
|
||||
gerade **kein** echter Administrator: die Gruppe pflegt Benutzer und
|
||||
Rechte, hat aber kein Kennzeichen und kann sich keines geben.
|
||||
|
||||
Zwei Aenderungen an der Tabelle, und SQLite kann Spalten nicht
|
||||
umdefinieren: also neu anlegen und umkopieren. ``app_group_id`` wird
|
||||
nullbar (eine Zuordnung auf das Kennzeichen zeigt auf keine Gruppe), und
|
||||
``grants_admin`` sagt, dass genau das gemeint ist.
|
||||
"""
|
||||
spalten = {
|
||||
str(zeile[1]) for zeile in conn.execute("PRAGMA table_info(ldap_group_mappings)")
|
||||
}
|
||||
if "grants_admin" in spalten:
|
||||
return
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE ldap_group_mappings_neu (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ad_group_dn TEXT NOT NULL UNIQUE,
|
||||
ad_group_name TEXT NOT NULL,
|
||||
app_group_id INTEGER REFERENCES groups(id) ON DELETE CASCADE,
|
||||
grants_admin INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO ldap_group_mappings_neu "
|
||||
"(id, ad_group_dn, ad_group_name, app_group_id, grants_admin, created_at) "
|
||||
"SELECT id, ad_group_dn, ad_group_name, app_group_id, 0, created_at "
|
||||
" FROM ldap_group_mappings"
|
||||
)
|
||||
conn.execute("DROP TABLE ldap_group_mappings")
|
||||
conn.execute("ALTER TABLE ldap_group_mappings_neu RENAME TO ldap_group_mappings")
|
||||
|
||||
|
||||
def teile_rechte_auf(conn) -> None: # type: ignore[no-untyped-def]
|
||||
"""Verteilt ein altes Sammelrecht auf die Zeilen, die daraus wurden.
|
||||
|
||||
Additiv: wer ``dhcp.edit`` hatte, bekommt alle fuenf DHCP-Zeilen, und das
|
||||
alte Recht verschwindet. Ohne diesen Schritt stuende in der Gruppe ein
|
||||
Schluessel, den der Baum nicht kennt -- und weg waere er still, denn genau
|
||||
das tut :meth:`PermissionTree.sanitize` mit Unbekanntem.
|
||||
|
||||
Laeuft nach ``benenne_rechte_um``: die Tabelle nennt beide Schreibweisen,
|
||||
wo es darauf ankommt, und ein zweiter Lauf findet nichts mehr zu tun.
|
||||
"""
|
||||
from .rbac.legacy_permissions import (
|
||||
ERWEITERTE_RECHTE,
|
||||
GETEILTE_RECHTE,
|
||||
neue_schluessel,
|
||||
)
|
||||
|
||||
def gruppen_mit(recht: str) -> list[int]:
|
||||
return [
|
||||
int(zeile[0])
|
||||
for zeile in conn.execute(
|
||||
"SELECT DISTINCT group_id FROM group_permissions WHERE permission=?",
|
||||
(recht,),
|
||||
).fetchall()
|
||||
]
|
||||
|
||||
# Geteilt: das alte Recht gibt es nicht mehr, also ersetzen.
|
||||
for altes, neue in GETEILTE_RECHTE.items():
|
||||
gruppen = gruppen_mit(altes)
|
||||
if not gruppen:
|
||||
continue
|
||||
conn.executemany(
|
||||
"INSERT OR IGNORE INTO group_permissions (group_id, permission) VALUES (?,?)",
|
||||
[(gid, recht) for gid in gruppen for recht in neue],
|
||||
)
|
||||
conn.execute("DELETE FROM group_permissions WHERE permission=?", (altes,))
|
||||
|
||||
# Erweitert: das Recht bleibt und bekommt dazu, was bisher an ihm hing.
|
||||
# Loeschen waere hier falsch -- es ist keines von gestern.
|
||||
for recht, dazu in ERWEITERTE_RECHTE.items():
|
||||
gruppen = gruppen_mit(recht)
|
||||
if not gruppen:
|
||||
continue
|
||||
conn.executemany(
|
||||
"INSERT OR IGNORE INTO group_permissions (group_id, permission) VALUES (?,?)",
|
||||
[(gid, weiteres) for gid in gruppen for weiteres in dazu],
|
||||
)
|
||||
assert neue_schluessel # in derselben Tabelle nachgeschlagen wie beim Import
|
||||
|
||||
|
||||
CORE_MIGRATIONS: tuple[Migration, ...] = (
|
||||
Migration(version=1, name="baseline", sql=V1_BASELINE),
|
||||
Migration(
|
||||
version=1,
|
||||
name="baseline",
|
||||
sql=V1_BASELINE,
|
||||
geaendert_weil=(
|
||||
"Das Grundschema legt ldap_group_mappings jetzt mit grants_admin und nullbarer app_group_id an -- eine neue Installation soll nicht erst core/6 brauchen, um die Tabelle wieder umzubauen."
|
||||
),
|
||||
),
|
||||
Migration(version=2, name="trusted-issuer-keys", sql=V2_TRUSTED_KEYS),
|
||||
Migration(version=3, name="jobs", sql=V3_JOBS),
|
||||
Migration(version=4, name="rechte-read-write", fn=benenne_rechte_um),
|
||||
Migration(version=5, name="rechte-je-funktion", fn=teile_rechte_auf),
|
||||
Migration(version=6, name="ldap-kennzeichen", fn=zuordnung_auf_kennzeichen),
|
||||
)
|
||||
@@ -80,8 +80,8 @@ class Database:
|
||||
"""Oeffnet eine neue Verbindung. Nicht aufrufen, solange eine Transaktion laeuft."""
|
||||
if self._depth() > 0:
|
||||
raise NestedConnectionError(
|
||||
"Es laeuft bereits eine Schreibtransaktion auf dieser Datenbank. "
|
||||
"Reiche die bestehende Verbindung durch, statt eine neue zu oeffnen "
|
||||
"Es läuft bereits eine Schreibtransaktion auf dieser Datenbank. "
|
||||
"Reiche die bestehende Verbindung durch, statt eine neue zu öffnen "
|
||||
"(sonst blockiert SQLite sich selbst)."
|
||||
)
|
||||
return self._raw_connect()
|
||||
@@ -119,6 +119,29 @@ class Database:
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@contextmanager
|
||||
def dauerhaft(self, conn: sqlite3.Connection) -> Iterator[sqlite3.Connection]:
|
||||
"""Fuer Schreibvorgaenge, die einen Stromausfall ueberstehen muessen.
|
||||
|
||||
Im Alltag laeuft die Datenbank mit ``synchronous=NORMAL``: schnell, und
|
||||
bei einem Stromausfall gehen hoechstens die letzten Sekunden verloren --
|
||||
die Datei bleibt heil. Fuer einen Messwert ist das genau richtig.
|
||||
|
||||
Fuer eine Unterschrift nicht. Dort liegt die Datei anschliessend auf der
|
||||
Freigabe, und fehlte der Eintrag dazu, waere sie "unbekannt" statt
|
||||
nachweisbar. Ein fsync kostet ein paar Millisekunden -- bei ein paar
|
||||
Unterschriften am Tag ist das keine Diskussion wert.
|
||||
|
||||
Umschliesst die Transaktion, nicht umgekehrt: ``PRAGMA synchronous``
|
||||
wirkt auf den naechsten Commit, und gesetzt werden kann es nur
|
||||
ausserhalb einer laufenden Transaktion.
|
||||
"""
|
||||
conn.execute("PRAGMA synchronous=FULL")
|
||||
try:
|
||||
yield conn
|
||||
finally:
|
||||
conn.execute("PRAGMA synchronous=NORMAL")
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Transaktionen
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
@@ -79,7 +79,7 @@ def core(app: Flask | None = None) -> TesmCore:
|
||||
try:
|
||||
return target.extensions["tesm"] # type: ignore[return-value]
|
||||
except KeyError as exc: # pragma: no cover - Programmierfehler
|
||||
raise RuntimeError("tesm_core ist fuer diese Anwendung nicht initialisiert") from exc
|
||||
raise RuntimeError("tesm_core ist für diese Anwendung nicht initialisiert") from exc
|
||||
|
||||
|
||||
def db() -> sqlite3.Connection:
|
||||
|
||||
@@ -78,7 +78,7 @@ def check_private_file(path: Path) -> list[str]:
|
||||
except OSError:
|
||||
return problems
|
||||
if mode & (stat.S_IRGRP | stat.S_IROTH | stat.S_IWGRP | stat.S_IWOTH):
|
||||
problems.append(f"{path} ist fuer Gruppe/Andere lesbar oder schreibbar (erwartet 0600)")
|
||||
problems.append(f"{path} ist für Gruppe/Andere lesbar oder schreibbar (erwartet 0600)")
|
||||
return problems
|
||||
|
||||
|
||||
@@ -110,11 +110,11 @@ class Keystore:
|
||||
created_at=str(entry.get("created_at", "")),
|
||||
)
|
||||
if len(key.material) != _KEY_BYTES:
|
||||
raise ValueError(f"Schluessel {key.key_id} hat die falsche Laenge")
|
||||
raise ValueError(f"Schlüssel {key.key_id} hat die falsche Länge")
|
||||
self._keys[key.key_id] = key
|
||||
self._active = str(raw.get("active") or "")
|
||||
if self._active not in self._keys:
|
||||
raise ValueError("Aktiver Schluessel fehlt im Schluesselbund")
|
||||
raise ValueError("Aktiver Schlüssel fehlt im Schlüsselbund")
|
||||
return
|
||||
self._active = "1"
|
||||
self._keys = {
|
||||
@@ -160,7 +160,7 @@ class Keystore:
|
||||
def drop_key(self, key_id: str) -> None:
|
||||
"""Entfernt einen alten Schluessel -- erst nach vollstaendiger Neuverschluesselung."""
|
||||
if key_id == self._active:
|
||||
raise ValueError("Der aktive Schluessel kann nicht entfernt werden")
|
||||
raise ValueError("Der aktive Schlüssel kann nicht entfernt werden")
|
||||
self._keys.pop(key_id, None)
|
||||
self._persist()
|
||||
|
||||
@@ -183,14 +183,14 @@ class Keystore:
|
||||
raise DecryptionError(f"Unbekannte Chiffrat-Version: {prefix!r}")
|
||||
key = self._keys.get(key_id)
|
||||
if key is None:
|
||||
raise DecryptionError(f"Schluessel {key_id} ist nicht im Schluesselbund")
|
||||
raise DecryptionError(f"Schlüssel {key_id} ist nicht im Schlüsselbund")
|
||||
try:
|
||||
plain = AESGCM(key.material).decrypt(
|
||||
_b64d(nonce_b64), _b64d(ct_b64), aad.encode("utf-8")
|
||||
)
|
||||
except (InvalidTag, ValueError) as exc:
|
||||
raise DecryptionError(
|
||||
"Chiffrat konnte nicht entschluesselt werden (falscher Schluessel, "
|
||||
"Chiffrat konnte nicht entschlüsselt werden (falscher Schlüssel, "
|
||||
"manipulierte Daten oder falscher Verwendungszweck)"
|
||||
) from exc
|
||||
return plain.decode("utf-8")
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
"""Alte, deutsche Adressen auf die neuen englischen umlenken.
|
||||
|
||||
Mit 2.5.0 folgen die Adressen der Navigation und sind englisch:
|
||||
``/monitoring/host-keys`` statt ``/hostschluessel``. Das ist eine gute
|
||||
Aenderung und eine unangenehme zugleich -- Lesezeichen, offene Browsertabs und
|
||||
ein Link in einer Mail zeigen weiter auf die alte Adresse. Ein offener Tab ist
|
||||
dabei der unangenehmste Fall: die Statusuebersicht fragt alle 20 Sekunden ihre
|
||||
Kacheln ab und bekam nach dem Update jedes Mal einen 404.
|
||||
|
||||
Deshalb diese Tabelle. Sie greift **nur, wenn sonst ein 404 herauskaeme** --
|
||||
also nie vor einer bestehenden Route. Das ist keine Feinheit: auf dem
|
||||
Lizenzserver ist ``/lizenz/<ticket>`` eine echte, weiterhin gueltige Adresse
|
||||
(sie steht in verschickten Mails), waehrend dieselbe Wurzel in TESM zur
|
||||
Lizenzseite fuehrte. Wer zuerst das Routing fragt, muss sich nicht zwischen
|
||||
beidem entscheiden.
|
||||
|
||||
Die Tabelle ist bewusst vollstaendig und nicht nur auf Wurzeln gekuerzt: eine
|
||||
Umleitung von ``/dateifreigaben/x/vorschau`` auf eine Uebersichtsseite waere
|
||||
zwar kein 404 mehr, aber auch nicht das, was jemand angeklickt hat.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Mapping
|
||||
|
||||
#: Alte Wurzel -> (neue Wurzel, Uebersetzung der Segmente darunter).
|
||||
WURZELN: dict[str, tuple[str, Mapping[str, str]]] = {
|
||||
"/dateifreigaben": (
|
||||
"/fileshares",
|
||||
{
|
||||
"herunterladen": "download",
|
||||
"vorschau": "preview",
|
||||
"hochladen": "upload",
|
||||
"formular": "form",
|
||||
"ordner": "folder",
|
||||
"umbenennen": "rename",
|
||||
"loeschen": "delete",
|
||||
"freigeben": "release",
|
||||
"zuordnungen": "mappings",
|
||||
},
|
||||
),
|
||||
"/hostschluessel": (
|
||||
"/monitoring/host-keys",
|
||||
{"pruefen": "probe", "freigeben": "trust", "verwerfen": "forget", "verwaist": "orphaned"},
|
||||
),
|
||||
"/zugangsdaten": (
|
||||
"/monitoring/credentials",
|
||||
{"neu": "new", "loeschen": "delete", "anzeigen": "reveal"},
|
||||
),
|
||||
"/switche": (
|
||||
"/monitoring/switches",
|
||||
{
|
||||
"neu": "new",
|
||||
"loeschen": "delete",
|
||||
"hostkey": "host-key",
|
||||
"pruefen": "probe",
|
||||
"freigeben": "trust",
|
||||
"verwerfen": "forget",
|
||||
"kommando": "command",
|
||||
"konsole": "console",
|
||||
},
|
||||
),
|
||||
"/clients": (
|
||||
"/monitoring/clients",
|
||||
{
|
||||
"neu": "new",
|
||||
"aktiv": "active",
|
||||
"loeschen": "delete",
|
||||
"neustart": "restart",
|
||||
"hostkey": "host-key",
|
||||
"pruefen": "probe",
|
||||
"freigeben": "trust",
|
||||
"verwerfen": "forget",
|
||||
"konsole": "console",
|
||||
},
|
||||
),
|
||||
"/wartung": (
|
||||
"/maintenance",
|
||||
{
|
||||
"paket-nachinstallieren": "install-package",
|
||||
"auftrag": "job",
|
||||
"ausgabe": "output",
|
||||
"aktualisieren": "update",
|
||||
"neustarten": "restart",
|
||||
},
|
||||
),
|
||||
"/protokolle": (
|
||||
"/logs",
|
||||
{
|
||||
"kea": "dhcp",
|
||||
"neustarts": "restarts",
|
||||
"laufend": "live",
|
||||
"inhalt": "content",
|
||||
"verlauf": "history",
|
||||
"datei": "file",
|
||||
"archiv": "archive",
|
||||
"archiv-export": "archive-export",
|
||||
"archiv-loeschen": "archive-delete",
|
||||
"aenderungen": "audit",
|
||||
},
|
||||
),
|
||||
"/verwaltung": (
|
||||
"/administration",
|
||||
{
|
||||
"benutzer": "users",
|
||||
"neu": "new",
|
||||
"loeschen": "delete",
|
||||
"sitzungen-beenden": "end-sessions",
|
||||
"verzeichnis-suche": "directory-search",
|
||||
"verzeichnis-uebernehmen": "directory-import",
|
||||
"gruppen": "groups",
|
||||
"vorlage-anwenden": "apply-preset",
|
||||
"sitzungen": "sessions",
|
||||
"beenden": "end",
|
||||
},
|
||||
),
|
||||
"/einstellungen": (
|
||||
"/administration",
|
||||
{
|
||||
"zeitzone": "timezone",
|
||||
"pruefung-jetzt": "check-now",
|
||||
"netzwerk": "network",
|
||||
"bestaetigen": "confirm",
|
||||
"webserver": "web",
|
||||
"uebernehmen": "apply",
|
||||
"zertifikat": "certificate",
|
||||
"selbstsigniert": "self-signed",
|
||||
"verzeichnisdienst": "directory",
|
||||
"gruppensuche": "group-search",
|
||||
"zuordnung": "mapping",
|
||||
"loeschen": "delete",
|
||||
"anbieter": "vendor",
|
||||
"logo-entfernen": "remove-logo",
|
||||
},
|
||||
),
|
||||
"/sicherung": (
|
||||
"/administration/backup",
|
||||
{"vorschau": "preview", "uebernehmen": "apply", "verwerfen": "discard"},
|
||||
),
|
||||
"/papierkorb": (
|
||||
"/administration/trash",
|
||||
{"wiederherstellen": "restore", "endgueltig-loeschen": "purge", "leeren": "empty"},
|
||||
),
|
||||
"/diagnose": (
|
||||
"/administration/diagnostics",
|
||||
{"aufraeumen": "cleanup", "schluessel-rotieren": "rotate-keys"},
|
||||
),
|
||||
"/eigene-lizenz": (
|
||||
"/own-license",
|
||||
{
|
||||
"einspielen": "install",
|
||||
"aktivieren": "activate",
|
||||
"deaktivieren": "deactivate",
|
||||
"offline-antwort": "offline-response",
|
||||
"signaturschluessel": "signing-key",
|
||||
},
|
||||
),
|
||||
"/lizenzen": (
|
||||
"/licenses",
|
||||
{
|
||||
"aus-ticket": "from-ticket",
|
||||
"datei": "file",
|
||||
"neu-signieren": "resign",
|
||||
"widerrufen": "revoke",
|
||||
"loeschen": "delete",
|
||||
"versenden": "send",
|
||||
"sub-lizenzserver": "sub-servers",
|
||||
},
|
||||
),
|
||||
"/lizenz": (
|
||||
"/administration/license",
|
||||
{
|
||||
"einspielen": "install",
|
||||
"aktivieren": "activate",
|
||||
"deaktivieren": "deactivate",
|
||||
"offline-antwort": "offline-response",
|
||||
"serverzertifikat": "server-certificate",
|
||||
"aussteller": "issuer",
|
||||
"entfernen": "remove",
|
||||
},
|
||||
),
|
||||
"/kunden": ("/customers", {"neu": "new", "loeschen": "delete"}),
|
||||
"/konto": (
|
||||
"/account",
|
||||
{
|
||||
"stammdaten": "profile",
|
||||
"profilbild": "avatar",
|
||||
"sicherheit": "security",
|
||||
"passwort": "password",
|
||||
"zwei-faktor": "two-factor",
|
||||
"starten": "start",
|
||||
"bestaetigen": "confirm",
|
||||
"deaktivieren": "disable",
|
||||
"codes-erneuern": "recovery-codes",
|
||||
"sitzungen": "sessions",
|
||||
"beenden": "end",
|
||||
"alle-beenden": "end-all",
|
||||
},
|
||||
),
|
||||
"/uebersicht": ("/overview", {"kacheln": "tiles"}),
|
||||
"/gesundheit": ("/health", {}),
|
||||
"/bestaetigen": ("/confirm", {}),
|
||||
}
|
||||
|
||||
#: Abfrageparameter, die mit umbenannt wurden.
|
||||
PARAMETER = {
|
||||
"freigabe": "share",
|
||||
"pfad": "path",
|
||||
"aktion": "action",
|
||||
"benutzer": "actor",
|
||||
"stufe": "severity",
|
||||
"seite": "page",
|
||||
"methode": "method",
|
||||
"kunde": "customer",
|
||||
"freischalten": "unlocked",
|
||||
}
|
||||
|
||||
|
||||
#: Wurzeln, unter denen eine unbekannte Unterseite **nicht** umgelenkt wird.
|
||||
#: ``/lizenz/<ticket>`` ist auf dem Lizenzserver eine echte Adresse; ein
|
||||
#: abgelaufenes oder falsches Ticket gehoert dort auf die Fehlerseite und nicht
|
||||
#: in die Verwaltung.
|
||||
STRENG = {"/lizenz"}
|
||||
|
||||
|
||||
def neue_adresse(pfad: str) -> str | None:
|
||||
"""Uebersetzt eine alte Adresse -- oder ``None``, wenn es keine ist."""
|
||||
for alt, (neu, segmente) in WURZELN.items():
|
||||
if pfad != alt and not pfad.startswith(alt + "/"):
|
||||
continue
|
||||
rest = pfad[len(alt) :]
|
||||
teile = rest.split("/")
|
||||
if alt in STRENG:
|
||||
erstes = next((teil for teil in teile if teil), "")
|
||||
if erstes and erstes not in segmente:
|
||||
return None
|
||||
return neu + "/".join(segmente.get(teil, teil) for teil in teile)
|
||||
return None
|
||||
|
||||
|
||||
def neue_parameter(args: Mapping[str, str]) -> dict[str, str]:
|
||||
return {PARAMETER.get(name, name): wert for name, wert in args.items()}
|
||||
@@ -34,7 +34,7 @@ import urllib.request
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
from typing import Any, Callable, Iterable
|
||||
|
||||
import tesm_licensing as lic
|
||||
|
||||
@@ -48,8 +48,27 @@ 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"
|
||||
|
||||
#: Module, die der Betreiber vor Ort abgeschaltet hat (Liste von Namen).
|
||||
#:
|
||||
#: Die Lizenz sagt, *was erlaubt ist*; sie sagt nicht, was ein Haus auch
|
||||
#: benutzen will. Wer kein DHCP ueber TESM fahren will, weil ein anderer Server
|
||||
#: das tut, soll den Punkt nicht wegdruecken muessen -- und vor allem soll
|
||||
#: niemand versehentlich eine fremde Kea-Konfiguration ueberschreiben koennen.
|
||||
#: Abschalten ist deshalb mehr als Aufraeumen der Navigation: die Sperre sitzt
|
||||
#: an derselben Stelle wie die Lizenzpruefung, also vor jeder Route.
|
||||
#: Bewusst **ohne** das Praefix ``license_``: Sicherungen lassen alles mit
|
||||
#: diesem Praefix draussen, weil Lizenz- und Aktivierungszustand zur Maschine
|
||||
#: gehoeren und nicht in ein Backup. Der Schalter ist aber keine Lizenzangabe,
|
||||
#: sondern eine Entscheidung des Hauses -- die soll eine Wiederherstellung
|
||||
#: mitbringen.
|
||||
SETTING_MODULES_OFF = "modules_disabled"
|
||||
|
||||
STATUS_INACTIVE = "inactive"
|
||||
STATUS_PENDING = "pending"
|
||||
STATUS_ACTIVE = "active"
|
||||
@@ -64,10 +83,10 @@ API_PATHS = {
|
||||
#: die einzige Wahrheit -- Templates lesen sie, Routen erzwingen sie.
|
||||
LICENSED_FEATURES: dict[str, str] = {
|
||||
"directory": "Verzeichnisanmeldung (LDAP/AD) konfigurieren",
|
||||
"nginx": "Webserver-Einstellungen aendern",
|
||||
"nginx": "Webserver-Einstellungen ändern",
|
||||
"export": "Daten exportieren",
|
||||
"trash_restore": "Papierkorb wiederherstellen und endgueltig loeschen",
|
||||
"monitor_interval": "Pruefintervall der Ueberwachung aendern",
|
||||
"trash_restore": "Papierkorb wiederherstellen und endgültig löschen",
|
||||
"monitor_interval": "Prüfintervall der Überwachung ändern",
|
||||
"auto_poe_restart": "Automatischer PoE-Neustart bei Ausfall",
|
||||
}
|
||||
|
||||
@@ -81,7 +100,7 @@ def describe_transport_error(exc: BaseException, endpoint: str) -> str:
|
||||
"""
|
||||
text = str(exc)
|
||||
if "CERTIFICATE_VERIFY_FAILED" in text or "certificate verify failed" in text:
|
||||
detail = "Das Zertifikat konnte nicht geprueft werden"
|
||||
detail = "Das Zertifikat konnte nicht geprüft werden"
|
||||
if "self-signed" in text or "self signed" in text:
|
||||
detail = "Der Lizenzserver benutzt ein selbstsigniertes Zertifikat"
|
||||
return (
|
||||
@@ -90,7 +109,7 @@ def describe_transport_error(exc: BaseException, endpoint: str) -> str:
|
||||
"einer anerkannten Stelle."
|
||||
)
|
||||
if "Name or service not known" in text or "nodename nor servname" in text:
|
||||
return f"Der Name im Endpunkt ({endpoint}) laesst sich nicht aufloesen."
|
||||
return f"Der Name im Endpunkt ({endpoint}) lässt sich nicht auflösen."
|
||||
if "Connection refused" in text:
|
||||
return f"{endpoint} nimmt keine Verbindung an."
|
||||
if "timed out" in text or "timeout" in text.lower():
|
||||
@@ -117,6 +136,8 @@ class LicenseState:
|
||||
last_heartbeat_at: str = ""
|
||||
heartbeat_error: str = ""
|
||||
pending_action: str = ""
|
||||
#: Vor Ort abgeschaltet -- unabhaengig davon, was die Lizenz erlaubt.
|
||||
modules_off: frozenset[str] = frozenset()
|
||||
|
||||
@property
|
||||
def role(self) -> str:
|
||||
@@ -124,8 +145,14 @@ class LicenseState:
|
||||
|
||||
@property
|
||||
def modules(self) -> frozenset[str]:
|
||||
"""Was die Lizenz freischaltet -- ohne Ruecksicht auf den Schalter."""
|
||||
return self.status.modules if self.status else frozenset()
|
||||
|
||||
@property
|
||||
def modules_in_use(self) -> frozenset[str]:
|
||||
"""Was tatsaechlich laeuft: lizenziert **und** nicht abgeschaltet."""
|
||||
return self.modules - self.modules_off
|
||||
|
||||
@property
|
||||
def customer_name(self) -> str:
|
||||
return str((self.status.customer if self.status else {}).get("name") or "")
|
||||
@@ -175,6 +202,7 @@ class LicenseManager:
|
||||
"""Liest Lizenzdatei und Status neu ein."""
|
||||
with self._lock:
|
||||
state = LicenseState()
|
||||
state.modules_off = self.abgeschaltete_module(conn)
|
||||
state.last_heartbeat_at = str(self.settings.get(conn, SETTING_LAST_HEARTBEAT) or "")
|
||||
state.heartbeat_error = str(self.settings.get(conn, SETTING_HEARTBEAT_ERROR) or "")
|
||||
pending = self._pending(conn)
|
||||
@@ -235,9 +263,51 @@ class LicenseManager:
|
||||
)
|
||||
|
||||
def module_active(self, name: str) -> bool:
|
||||
"""Ist ein Vollmodul (dhcp/fileshare/maintenance) freigeschaltet?"""
|
||||
"""Ist ein Vollmodul (dhcp/fileshare/maintenance) in Betrieb?
|
||||
|
||||
Zwei Bedingungen, und beide muessen gelten: die Lizenz muss es
|
||||
freischalten, und der Betreiber darf es nicht abgeschaltet haben. Diese
|
||||
eine Funktion fragen die Navigation, jede Route und die Hintergrund-
|
||||
aufgaben -- darum sitzt der Schalter hier und nicht in einer Vorlage.
|
||||
"""
|
||||
return (
|
||||
self.active()
|
||||
and name in self._state.modules
|
||||
and name not in self._state.modules_off
|
||||
)
|
||||
|
||||
def module_licensed(self, name: str) -> bool:
|
||||
"""Schaltet die Lizenz das Modul frei? (Ohne den Schalter vor Ort.)"""
|
||||
return self.active() and name in self._state.modules
|
||||
|
||||
def abgeschaltete_module(self, conn: sqlite3.Connection) -> frozenset[str]:
|
||||
"""Liest die Liste aus den Einstellungen."""
|
||||
gespeichert = self.settings.get(conn, SETTING_MODULES_OFF) or []
|
||||
if isinstance(gespeichert, str): # Altbestand: ein einzelner Name
|
||||
gespeichert = [gespeichert]
|
||||
return frozenset(str(name) for name in gespeichert)
|
||||
|
||||
def module_abschalten(
|
||||
self, conn: sqlite3.Connection, namen: Iterable[str], *, actor: str = ""
|
||||
) -> tuple[frozenset[str], frozenset[str]]:
|
||||
"""Setzt die Liste der abgeschalteten Module neu.
|
||||
|
||||
Gibt ``(eingeschaltet, abgeschaltet)`` zurueck -- also nur das, was sich
|
||||
wirklich geaendert hat; der Aufrufer schreibt daraus seinen
|
||||
Protokolleintrag.
|
||||
|
||||
Der Zustand wird hier nicht neu eingelesen, sondern nur an dieser einen
|
||||
Stelle nachgezogen: ein voller Neuaufbau wuerde die Lizenzdatei erneut
|
||||
pruefen, obwohl sich an ihr nichts geaendert hat -- und der Schalter ist
|
||||
das Einzige, was jetzt anders ist.
|
||||
"""
|
||||
vorher = self.abgeschaltete_module(conn)
|
||||
nachher = frozenset(str(name) for name in namen)
|
||||
self.settings.set(conn, SETTING_MODULES_OFF, sorted(nachher), actor=actor)
|
||||
with self._lock:
|
||||
self._state.modules_off = nachher
|
||||
return vorher - nachher, nachher - vorher
|
||||
|
||||
def feature_allowed(self, feature: str) -> bool:
|
||||
"""Lizenzpflichtige Einzelfunktion.
|
||||
|
||||
@@ -253,8 +323,8 @@ class LicenseManager:
|
||||
def require(self, feature: str) -> None:
|
||||
if not self.feature_allowed(feature):
|
||||
raise LicenseError(
|
||||
f"{LICENSED_FEATURES.get(feature, feature)} ist ohne gueltige, "
|
||||
"aktivierte Lizenz nicht moeglich."
|
||||
f"{LICENSED_FEATURES.get(feature, feature)} ist ohne gültige, "
|
||||
"aktivierte Lizenz nicht möglich."
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
@@ -280,7 +350,7 @@ class LicenseManager:
|
||||
try:
|
||||
payload = json.loads(text)
|
||||
except ValueError as exc:
|
||||
raise LicenseError("Die Datei ist kein gueltiges JSON.") from exc
|
||||
raise LicenseError("Die Datei ist kein gültiges JSON.") from exc
|
||||
|
||||
try:
|
||||
document, client_key = lic.split_bundle(payload)
|
||||
@@ -290,20 +360,20 @@ class LicenseManager:
|
||||
try:
|
||||
lic.verify_license(document) # innere Konsistenz
|
||||
except lic.LicensingError as exc:
|
||||
raise LicenseError(f"Die Lizenzdatei ist ungueltig: {exc}") from exc
|
||||
raise LicenseError(f"Die Lizenzdatei ist ungültig: {exc}") from exc
|
||||
|
||||
master_pubkey = str(document.get("master_pubkey") or "")
|
||||
known = self.trusted_keys(conn)
|
||||
if known and master_pubkey not in set(known) and not allow_new_issuer:
|
||||
raise LicenseError(
|
||||
"Diese Lizenz wurde von einem anderen Lizenzserver ausgestellt "
|
||||
f"(Schluessel {lic.key_id(master_pubkey)}). Ein Administrator muss den "
|
||||
"Wechsel des Ausstellers ausdruecklich bestaetigen."
|
||||
f"(Schlüssel {lic.key_id(master_pubkey)}). Ein Administrator muss den "
|
||||
"Wechsel des Ausstellers ausdrücklich bestätigen."
|
||||
)
|
||||
|
||||
if not client_key and not self.key_path.is_file():
|
||||
raise LicenseError(
|
||||
"Der Datei fehlt der Client-Schluessel. Bitte das vollstaendige "
|
||||
"Der Datei fehlt der Client-Schlüssel. Bitte das vollständige "
|
||||
"Lizenz-Bundle des Lizenzservers verwenden."
|
||||
)
|
||||
|
||||
@@ -328,7 +398,7 @@ class LicenseManager:
|
||||
actor=actor,
|
||||
action="license.issuer_pinned",
|
||||
target=lic.key_id(master_pubkey),
|
||||
detail="Trust on first use" if not known else "Aussteller-Wechsel bestaetigt",
|
||||
detail="Trust on first use" if not known else "Aussteller-Wechsel bestätigt",
|
||||
severity="notice",
|
||||
)
|
||||
|
||||
@@ -393,11 +463,11 @@ class LicenseManager:
|
||||
self, action: str, *, hostname: str = "", report: dict[str, Any] | None = None
|
||||
) -> dict[str, Any]:
|
||||
if not self._state.valid:
|
||||
raise LicenseError("Es ist keine gueltige Lizenz installiert.")
|
||||
raise LicenseError("Es ist keine gültige Lizenz installiert.")
|
||||
key = self.client_key()
|
||||
if not key:
|
||||
raise LicenseError(
|
||||
"Der Client-Schluessel dieser Lizenz fehlt. Bitte die Lizenzdatei erneut importieren."
|
||||
"Der Client-Schlüssel dieser Lizenz fehlt. Bitte die Lizenzdatei erneut importieren."
|
||||
)
|
||||
return lic.build_client_request(
|
||||
action, self._state.document, key, hostname=hostname, report=report
|
||||
@@ -438,8 +508,8 @@ class LicenseManager:
|
||||
)
|
||||
else:
|
||||
reason = (
|
||||
"In dieser Lizenz ist kein Lizenzserver hinterlegt -- sie kann sich nur "
|
||||
"ueber den Offline-Weg aktivieren."
|
||||
"In dieser Lizenz ist kein Lizenzserver hinterlegt – sie kann sich nur "
|
||||
"über den Offline-Weg aktivieren."
|
||||
)
|
||||
|
||||
# Offline-Weg: Anfrage merken -- **mit** dem Code. Zwischen Anfrage und
|
||||
@@ -471,7 +541,7 @@ class LicenseManager:
|
||||
"code": code,
|
||||
"message": (
|
||||
(reason + " " if reason else "")
|
||||
+ "Der Offline-Weg steht offen: uebermitteln Sie den angezeigten Code an "
|
||||
+ "Der Offline-Weg steht offen: übermitteln Sie den angezeigten Code an "
|
||||
"Ihren Ansprechpartner und tragen Sie die Antwort hier ein."
|
||||
),
|
||||
}
|
||||
@@ -523,7 +593,7 @@ class LicenseManager:
|
||||
current_seq=current_seq,
|
||||
)
|
||||
except lic.LicensingError as exc:
|
||||
raise LicenseError(f"Die Antwort des Lizenzservers ist nicht gueltig: {exc}") from exc
|
||||
raise LicenseError(f"Die Antwort des Lizenzservers ist nicht gültig: {exc}") from exc
|
||||
|
||||
status = str(response.get("status") or "")
|
||||
detail = str(response.get("detail") or "")
|
||||
@@ -593,9 +663,17 @@ class LicenseManager:
|
||||
actor=actor,
|
||||
)
|
||||
self.settings.set(conn, SETTING_HEARTBEAT_ERROR, "", actor=actor)
|
||||
message = "Verbindung zum Lizenzserver bestaetigt."
|
||||
message = "Verbindung zum Lizenzserver bestätigt."
|
||||
|
||||
self.settings.set(conn, SETTING_PENDING, "", actor=actor)
|
||||
# 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,
|
||||
@@ -636,7 +714,7 @@ class LicenseManager:
|
||||
url: str, payload: dict[str, Any], *, timeout: int, ca_pem: str = ""
|
||||
) -> dict[str, Any]:
|
||||
if not url.startswith(("https://", "http://")):
|
||||
raise ValueError("Ungueltiger Endpunkt")
|
||||
raise ValueError("Ungültiger Endpunkt")
|
||||
data = json.dumps(payload).encode("utf-8")
|
||||
request = urllib.request.Request( # noqa: S310 - Schema oben geprueft
|
||||
url,
|
||||
@@ -704,6 +782,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)}
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
@@ -723,7 +814,7 @@ class LicenseManager:
|
||||
}
|
||||
if not state.valid:
|
||||
return {
|
||||
"label": "Lizenz ungueltig",
|
||||
"label": "Lizenz ungültig",
|
||||
"css_class": "status-pill--danger",
|
||||
"dot": "1",
|
||||
"dot_class": "status-pill__dot--pulse",
|
||||
@@ -748,18 +839,29 @@ class LicenseManager:
|
||||
"dot": "1",
|
||||
"dot_class": "status-pill__dot--pulse",
|
||||
"title": (
|
||||
"Die Kulanzfrist laeuft noch, Funktionen bleiben vorerst verfuegbar."
|
||||
"Die Kulanzfrist läuft noch, Funktionen bleiben vorerst verfügbar."
|
||||
if status.grace_active
|
||||
else "Lizenzpflichtige Funktionen sind abgeschaltet."
|
||||
),
|
||||
}
|
||||
if status.expiring_soon:
|
||||
return {
|
||||
"label": f"Lizenz laeuft in {status.days_left} Tagen ab",
|
||||
"label": f"Lizenz läuft in {status.days_left} Tagen ab",
|
||||
"css_class": "status-pill--warning",
|
||||
"dot": "1",
|
||||
"dot_class": "status-pill__dot--pulse",
|
||||
"title": "Bitte rechtzeitig verlaengern.",
|
||||
"title": "Bitte rechtzeitig verlängern.",
|
||||
}
|
||||
# Zwischen der Warnschwelle und 90 Tagen: gruen, rein als Hinweis. Wer
|
||||
# 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 läuft in {status.days_left} Tagen ab",
|
||||
"css_class": "",
|
||||
"dot": "1",
|
||||
"dot_class": "",
|
||||
"title": "Nur ein Hinweis – es ist nichts eingeschränkt.",
|
||||
}
|
||||
if state.last_heartbeat_at:
|
||||
try:
|
||||
@@ -773,6 +875,6 @@ class LicenseManager:
|
||||
"css_class": "status-pill--warning",
|
||||
"dot": "1",
|
||||
"dot_class": "",
|
||||
"title": "Rein informativ -- es wird nichts abgeschaltet.",
|
||||
"title": "Rein informativ – es wird nichts abgeschaltet.",
|
||||
}
|
||||
return None
|
||||
@@ -94,7 +94,7 @@ def init_app(app: Flask, *, log_dir: Path, debug: bool, json_logs: bool = False)
|
||||
except OSError:
|
||||
pass
|
||||
except OSError as exc: # pragma: no cover - z. B. fehlende Rechte
|
||||
root.warning("Konnte app.log nicht oeffnen: %s", exc)
|
||||
root.warning("Konnte app.log nicht öffnen: %s", exc)
|
||||
|
||||
# Zugriffsprotokoll separat: eine Zeile pro Anfrage, ohne Stacktraces.
|
||||
access = logging.getLogger(ACCESS_LOGGER)
|
||||
|
||||
@@ -50,6 +50,12 @@ class Migration:
|
||||
sql: str = ""
|
||||
fn: MigrationFn | None = None
|
||||
allow_integrity_error: bool = False
|
||||
#: Begruendung, falls der Text einer bereits angewendeten Migration
|
||||
#: absichtlich geaendert wurde -- etwa weil eine frische Installation
|
||||
#: mit der neuen Schreibweise starten muss. Ohne Begruendung warnt der
|
||||
#: Lauf, und das soll er auch: ein stiller Umbau einer angewendeten
|
||||
#: Migration ist fast immer ein Versehen.
|
||||
geaendert_weil: str = ""
|
||||
tags: tuple[str, ...] = field(default=())
|
||||
|
||||
@property
|
||||
@@ -175,10 +181,26 @@ class MigrationRunner:
|
||||
record = known.get(migration.version)
|
||||
if record is not None:
|
||||
if record["checksum"] != migration.checksum:
|
||||
if migration.geaendert_weil:
|
||||
# Mit Begruendung ist es eine Notiz, keine Warnung:
|
||||
# manchmal muss der Text einer laengst angewendeten
|
||||
# Migration mit, damit eine **frische** Installation
|
||||
# denselben Stand erreicht. Wer das Protokoll liest,
|
||||
# soll den Unterschied zu einem Versehen sehen.
|
||||
logger.info(
|
||||
"Migration %s/%s (%s) wurde absichtlich geändert: %s "
|
||||
"Auf dieser Installation ist sie längst angewendet und "
|
||||
"wird nicht erneut ausgeführt.",
|
||||
component,
|
||||
migration.version,
|
||||
migration.name,
|
||||
migration.geaendert_weil,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"Migration %s/%s (%s) wurde nachtraeglich geaendert "
|
||||
"(Pruefsumme %s != %s). Bereits angewendete Migrationen werden "
|
||||
"nicht erneut ausgefuehrt -- neue Aenderungen brauchen eine neue "
|
||||
"Migration %s/%s (%s) wurde nachträglich geändert "
|
||||
"(Prüfsumme %s != %s). Bereits angewendete Migrationen werden "
|
||||
"nicht erneut ausgeführt – neue Änderungen brauchen eine neue "
|
||||
"Versionsnummer.",
|
||||
component,
|
||||
migration.version,
|
||||
@@ -210,7 +232,7 @@ class MigrationRunner:
|
||||
raise
|
||||
logger.error(
|
||||
"Migration %s/%s (%s) konnte wegen bestehender Altdaten nicht "
|
||||
"angewendet werden. Der Start laeuft weiter; die Daten muessen "
|
||||
"angewendet werden. Der Start läuft weiter; die Daten müssen "
|
||||
"manuell bereinigt werden.",
|
||||
component,
|
||||
migration.version,
|
||||
|
||||
@@ -16,10 +16,10 @@ A = Action
|
||||
USERS = Resource(
|
||||
key="users",
|
||||
label="Benutzer",
|
||||
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE),
|
||||
actions=(A.READ, A.CREATE, A.WRITE, A.DELETE),
|
||||
hint="Konten anlegen, sperren, Gruppen zuordnen",
|
||||
action_hints={
|
||||
A.EDIT: "Stammdaten, Sperre und Gruppenzuordnung aendern.",
|
||||
A.WRITE: "Stammdaten, Sperre und Gruppenzuordnung ändern.",
|
||||
A.DELETE: "Konto in den Papierkorb legen (Verzeichniskonten sofort entfernen).",
|
||||
},
|
||||
)
|
||||
@@ -27,35 +27,46 @@ USERS = Resource(
|
||||
GROUPS = Resource(
|
||||
key="groups",
|
||||
label="Gruppen und Rechte",
|
||||
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE),
|
||||
actions=(A.READ, A.CREATE, A.WRITE, A.DELETE),
|
||||
hint="Rechtematrix pflegen",
|
||||
action_hints={
|
||||
A.EDIT: "Rechte vergeben -- nur Rechte, die man selbst besitzt.",
|
||||
A.WRITE: "Rechte vergeben – nur Rechte, die man selbst besitzt.",
|
||||
},
|
||||
)
|
||||
|
||||
SETTINGS_SYSTEM = Resource(
|
||||
key="settings_system",
|
||||
label="Systemeinstellungen",
|
||||
actions=(A.VIEW, A.EDIT, A.EXECUTE),
|
||||
hint="Hostname, Zeitzone, Protokollrotation, Navigation",
|
||||
action_hints={A.EXECUTE: "Systemaktionen ausloesen (Dienste, Netzwerk uebernehmen)."},
|
||||
actions=(A.READ, A.WRITE, A.EXECUTE),
|
||||
hint="Hostname, Zeitzone, Protokollrotation, Sitzungsdauer",
|
||||
action_hints={A.EXECUTE: "Systemaktionen auslösen (Dienste, Netzwerk übernehmen)."},
|
||||
)
|
||||
|
||||
#: Die Reihenfolge des Menues lief unter "Systemaktionen auslösen" mit -- also
|
||||
#: unter demselben Recht wie das Aendern des Hostnamens. Es ist aber das
|
||||
#: harmloseste, was man in der Verwaltung tun kann, und das haeufigste.
|
||||
SETTINGS_NAV = Resource(
|
||||
key="settings_nav",
|
||||
parent="settings_system",
|
||||
label="Navigation",
|
||||
actions=(A.READ, A.WRITE),
|
||||
hint="Reihenfolge der Menüpunkte und ihrer Unterpunkte",
|
||||
)
|
||||
|
||||
SETTINGS_LICENSE = Resource(
|
||||
key="settings_license",
|
||||
label="Lizenz",
|
||||
actions=(A.VIEW, A.EDIT),
|
||||
actions=(A.READ, A.WRITE),
|
||||
hint="Lizenzdatei einspielen, aktivieren, deaktivieren",
|
||||
)
|
||||
|
||||
SETTINGS_DIRECTORY = Resource(
|
||||
key="settings_directory",
|
||||
label="Verzeichnisdienst (LDAP/AD)",
|
||||
actions=(A.VIEW, A.EDIT, A.EXECUTE, A.SECRETS),
|
||||
actions=(A.READ, A.WRITE, A.EXECUTE, A.SECRETS),
|
||||
hint="Anbindung und Gruppen-Zuordnung",
|
||||
action_hints={
|
||||
A.EXECUTE: "Verbindungstest ausfuehren.",
|
||||
A.EXECUTE: "Verbindungstest ausführen.",
|
||||
A.SECRETS: "Bind-Passwort des Dienstkontos setzen.",
|
||||
},
|
||||
)
|
||||
@@ -63,67 +74,78 @@ SETTINGS_DIRECTORY = Resource(
|
||||
SETTINGS_WEB = Resource(
|
||||
key="settings_web",
|
||||
label="Webserver und TLS",
|
||||
actions=(A.VIEW, A.EDIT, A.EXECUTE),
|
||||
actions=(A.READ, A.WRITE, A.EXECUTE),
|
||||
hint="Domain, Zertifikat, HSTS",
|
||||
action_hints={A.EXECUTE: "Konfiguration uebernehmen, Zertifikat anfordern."},
|
||||
action_hints={A.EXECUTE: "Konfiguration übernehmen, Zertifikat anfordern."},
|
||||
)
|
||||
|
||||
SETTINGS_NETWORK = Resource(
|
||||
key="settings_network",
|
||||
label="Netzwerkeinstellungen",
|
||||
actions=(A.VIEW, A.EDIT),
|
||||
actions=(A.READ, A.WRITE),
|
||||
hint="IP-Adresse, Gateway, DNS des Hosts",
|
||||
)
|
||||
|
||||
BACKUP = Resource(
|
||||
key="backup",
|
||||
label="Sicherung und Uebertragung",
|
||||
actions=(A.VIEW, A.EXECUTE, A.EXPORT, A.SECRETS),
|
||||
hint="Verschluesselter Export und Import",
|
||||
#: Sichern und Einspielen sind zwei Zeilen, weil es zwei Vorgaenge mit sehr
|
||||
#: verschiedenen Folgen sind: eine Sicherung zu erzeugen aendert nichts, eine
|
||||
#: einzuspielen aendert alles. Sie liefen unter einem Recht, und wer eine
|
||||
#: Sicherung ziehen durfte, durfte damit auch eine fremde einspielen.
|
||||
BACKUP_EXPORT = Resource(
|
||||
key="backup_export",
|
||||
label="Sicherung erstellen",
|
||||
actions=(A.READ, A.EXPORT, A.SECRETS),
|
||||
hint="Verschlüsselter Export des Datenstands",
|
||||
action_hints={
|
||||
A.EXECUTE: "Eine Sicherung einspielen.",
|
||||
A.EXPORT: "Sicherung erzeugen und herunterladen.",
|
||||
A.SECRETS: "Zugangsdaten in die Sicherung einschliessen.",
|
||||
A.SECRETS: "Zugangsdaten in die Sicherung einschließen.",
|
||||
},
|
||||
)
|
||||
|
||||
BACKUP_IMPORT = Resource(
|
||||
key="backup_import",
|
||||
label="Sicherung einspielen",
|
||||
actions=(A.READ, A.EXECUTE),
|
||||
hint="Prüfen, übernehmen, verwerfen",
|
||||
action_hints={A.EXECUTE: "Eine geprüfte Sicherung anwenden."},
|
||||
)
|
||||
|
||||
TRASH = Resource(
|
||||
key="trash",
|
||||
label="Papierkorb",
|
||||
actions=(A.VIEW, A.EDIT, A.DELETE),
|
||||
hint="Geloeschtes wiederherstellen oder endgueltig entfernen",
|
||||
actions=(A.READ, A.WRITE, A.DELETE),
|
||||
hint="Gelöschtes wiederherstellen oder endgültig entfernen",
|
||||
action_hints={
|
||||
A.EDIT: "Eintraege wiederherstellen.",
|
||||
A.DELETE: "Eintraege endgueltig entfernen.",
|
||||
A.WRITE: "Einträge wiederherstellen.",
|
||||
A.DELETE: "Einträge endgültig entfernen.",
|
||||
},
|
||||
)
|
||||
|
||||
DIAGNOSTICS = Resource(
|
||||
key="diagnostics",
|
||||
label="Diagnose",
|
||||
actions=(A.VIEW,),
|
||||
hint="Schemastand, Protokollintegritaet, Schluesselrechte",
|
||||
actions=(A.READ,),
|
||||
hint="Schemastand, Protokollintegrität, Schlüsselrechte",
|
||||
)
|
||||
|
||||
LOGS_LIVE = Resource(
|
||||
key="logs_live",
|
||||
label="Laufendes Protokoll",
|
||||
actions=(A.VIEW,),
|
||||
label="Live-Protokoll",
|
||||
actions=(A.READ,),
|
||||
)
|
||||
|
||||
LOGS_HISTORY = Resource(
|
||||
key="logs_history",
|
||||
label="Protokollverlauf",
|
||||
actions=(A.VIEW, A.DELETE, A.EXPORT),
|
||||
actions=(A.READ, A.DELETE, A.EXPORT),
|
||||
hint="Rotierte und archivierte Protokolle",
|
||||
action_hints={A.DELETE: "Archivierte Tage vom Server entfernen."},
|
||||
)
|
||||
|
||||
LOGS_AUDIT = Resource(
|
||||
key="logs_audit",
|
||||
label="Aenderungsprotokoll",
|
||||
actions=(A.VIEW, A.EXPORT),
|
||||
hint="Wer hat wann was geaendert",
|
||||
label="Änderungsprotokoll",
|
||||
actions=(A.READ, A.EXPORT),
|
||||
hint="Wer hat wann was geändert",
|
||||
)
|
||||
|
||||
|
||||
@@ -139,11 +161,13 @@ def administration_area(*extra: Resource) -> Area:
|
||||
USERS,
|
||||
GROUPS,
|
||||
SETTINGS_SYSTEM,
|
||||
SETTINGS_NAV,
|
||||
SETTINGS_LICENSE,
|
||||
SETTINGS_DIRECTORY,
|
||||
SETTINGS_WEB,
|
||||
SETTINGS_NETWORK,
|
||||
BACKUP,
|
||||
BACKUP_EXPORT,
|
||||
BACKUP_IMPORT,
|
||||
TRASH,
|
||||
DIAGNOSTICS,
|
||||
*extra,
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
"""Alte Rechteschluessel auf neue -- an einer Stelle, nicht an dreien.
|
||||
|
||||
Mit 2.8.0 heisst das Aktionsvokabular durchgehend wie das, was es tut:
|
||||
``view`` wurde ``read``, ``edit`` wurde ``write``. Danach steht in der Matrix
|
||||
"Write" und im Aenderungsprotokoll ``write``, nicht zweierlei.
|
||||
|
||||
Drei Stellen brauchen die Zuordnung, und darum steht sie hier und nicht dort:
|
||||
|
||||
* die **Migration**, die bestehende Gruppenzeilen umschreibt,
|
||||
* der **Import**, dem eine Sicherung aus 2.7.x untergeschoben wird -- ohne die
|
||||
Zuordnung landen dort lauter unbekannte Schluessel, und
|
||||
:meth:`PermissionTree.sanitize` wirft Unbekanntes absichtlich **still** weg.
|
||||
Aus einer Sicherung wuerden Gruppen ohne Rechte.
|
||||
* der **Test**, der prueft, dass kein alter Schluessel mehr im Code steht.
|
||||
|
||||
Warum nicht dreimal dieselbe Ersetzung tippen: weil die dritte Kopie beim
|
||||
naechsten Umbau vergessen wird. Genau so entstand das Muster bei den Adressen
|
||||
(:mod:`tesm_core.legacy_urls`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
#: Aktion alt -> neu. Die Ressource bleibt, wie sie heisst.
|
||||
UMBENANNTE_AKTIONEN: dict[str, str] = {
|
||||
"view": "read",
|
||||
"edit": "write",
|
||||
}
|
||||
|
||||
#: Ein altes Recht, das in mehrere neue zerfaellt -- ein Recht je Funktion.
|
||||
#:
|
||||
#: Die Aufteilung ist **additiv**: wer das alte Recht hatte, bekommt alle
|
||||
#: neuen. Niemand verliert etwas; ab jetzt laesst sich jedes Stueck einzeln
|
||||
#: vergeben. Der umgekehrte Weg waere auch denkbar gewesen -- nur das
|
||||
#: Harmloseste uebernehmen --, waere aber ein Update, das Leuten Rechte
|
||||
#: wegnimmt, ohne dass jemand es entschieden hat.
|
||||
#:
|
||||
#: Die Schluessel stehen fuer beide Anwendungen in einer Tabelle. Auf einer
|
||||
#: TESM-Datenbank tut die Zeile fuer ``licenses.write`` nichts, weil sie dort
|
||||
#: in keiner Gruppe steht -- und umgekehrt. Zwei getrennte Tabellen waeren
|
||||
#: zwei Stellen, die man pflegen muss.
|
||||
GETEILTE_RECHTE: dict[str, tuple[str, ...]] = {
|
||||
# Drei Neustartwege mit drei verschiedenen Folgen.
|
||||
"devices.execute": (
|
||||
"device_restart_poe.read",
|
||||
"device_restart_poe.execute",
|
||||
"device_restart_ssh.read",
|
||||
"device_restart_ssh.execute",
|
||||
"device_restart_rpc.read",
|
||||
"device_restart_rpc.execute",
|
||||
),
|
||||
# Die Switch-Konsole war "Switch ausfuehren".
|
||||
"switches.execute": ("switch_console.read", "switch_console.execute"),
|
||||
# Vier Funktionen: aktualisieren, neu starten, Werkzeug installieren --
|
||||
# und die Client-Konsole, die jetzt bei den Clients steht.
|
||||
"maintenance.read": (
|
||||
"servicing_update.read",
|
||||
"servicing_reboot.read",
|
||||
"servicing_tool.read",
|
||||
"device_console.read",
|
||||
),
|
||||
"maintenance.execute": (
|
||||
"servicing_update.read",
|
||||
"servicing_update.execute",
|
||||
"servicing_reboot.read",
|
||||
"servicing_reboot.execute",
|
||||
"servicing_tool.read",
|
||||
"servicing_tool.execute",
|
||||
"device_console.read",
|
||||
"device_console.execute",
|
||||
),
|
||||
# Elf Funktionen an einem Recht.
|
||||
"dhcp.read": (
|
||||
"dhcp_subnets.read",
|
||||
"dhcp_options.read",
|
||||
"dhcp_reservations.read",
|
||||
"dhcp_config.read",
|
||||
"dhcp_service.read",
|
||||
),
|
||||
"dhcp.write": (
|
||||
"dhcp_subnets.read",
|
||||
"dhcp_subnets.create",
|
||||
"dhcp_subnets.write",
|
||||
"dhcp_subnets.delete",
|
||||
"dhcp_options.read",
|
||||
"dhcp_options.write",
|
||||
"dhcp_reservations.read",
|
||||
"dhcp_reservations.create",
|
||||
"dhcp_reservations.write",
|
||||
"dhcp_reservations.delete",
|
||||
"dhcp_config.read",
|
||||
"dhcp_config.execute",
|
||||
),
|
||||
"dhcp.execute": ("dhcp_service.read", "dhcp_service.execute"),
|
||||
# Sichern und Einspielen.
|
||||
"backup.read": ("backup_export.read", "backup_import.read"),
|
||||
"backup.export": ("backup_export.read", "backup_export.export"),
|
||||
"backup.secrets": ("backup_export.read", "backup_export.secrets"),
|
||||
"backup.execute": ("backup_import.read", "backup_import.execute"),
|
||||
# Lizenzserver: "aendern" hiess widerrufen **oder** erneuern -- zwei
|
||||
# Eingriffe mit entgegengesetzter Wirkung unter einem Haken.
|
||||
"licenses.create": ("license_issue.read", "license_issue.execute"),
|
||||
"licenses.write": (
|
||||
"license_renew.read",
|
||||
"license_renew.execute",
|
||||
"license_revoke.read",
|
||||
"license_revoke.execute",
|
||||
),
|
||||
"licenses.execute": ("license_activation.read", "license_activation.execute"),
|
||||
}
|
||||
|
||||
|
||||
#: Ein Recht, das **bleibt** und bisher noch etwas anderes mitgab.
|
||||
#:
|
||||
#: Der Unterschied zu :data:`GETEILTE_RECHTE` ist keine Formsache: dort ist der
|
||||
#: alte Schluessel verschwunden und muss ersetzt werden, hier gilt er weiter
|
||||
#: und bekommt nur Gesellschaft. Beides in eine Tabelle zu werfen hiesse, in
|
||||
#: der Migration ein Recht zu loeschen, das es noch gibt -- und im Test nicht
|
||||
#: mehr pruefen zu koennen, welcher Fall gemeint war.
|
||||
ERWEITERTE_RECHTE: dict[str, tuple[str, ...]] = {
|
||||
# Die Navigationsreihenfolge lief unter "Systemaktionen ausloesen" mit.
|
||||
"settings_system.execute": ("settings_nav.read", "settings_nav.write"),
|
||||
# Einen Host-Schluessel freizugeben hing an den Stammdaten.
|
||||
"devices.write": ("hostkeys.read", "hostkeys.execute"),
|
||||
"switches.write": ("hostkeys.read", "hostkeys.execute"),
|
||||
# Umbenennen war auch "Freigaben zuordnen".
|
||||
"fileshare.write": (
|
||||
"fileshare_mappings.read",
|
||||
"fileshare_mappings.create",
|
||||
"fileshare_mappings.write",
|
||||
"fileshare_mappings.delete",
|
||||
),
|
||||
# Ausgefuellt ablegen war auch "unterschreiben".
|
||||
"fileshare_pdf.create": ("fileshare_signature.read", "fileshare_signature.execute"),
|
||||
}
|
||||
|
||||
|
||||
def neue_schluessel(alt: str) -> tuple[str, ...]:
|
||||
"""Was aus einem alten Rechteschluessel wird -- eins oder mehrere.
|
||||
|
||||
Unbekanntes kommt unveraendert zurueck: ob ein Schluessel gilt, entscheidet
|
||||
der Rechtebaum, nicht diese Tabelle. Sie benennt nur um.
|
||||
"""
|
||||
if alt in GETEILTE_RECHTE:
|
||||
return GETEILTE_RECHTE[alt]
|
||||
ressource, punkt, aktion = alt.rpartition(".")
|
||||
if not punkt:
|
||||
return (alt,)
|
||||
neu = UMBENANNTE_AKTIONEN.get(aktion)
|
||||
selbst = f"{ressource}.{neu}" if neu else alt
|
||||
# Ein Recht, das bleibt, nimmt mit, was es bisher mitgab -- sonst verliert
|
||||
# jemand beim Update genau die Funktion, die vorher an diesem Haken hing.
|
||||
return (selbst, *ERWEITERTE_RECHTE.get(selbst, ()))
|
||||
|
||||
|
||||
def umgeschrieben(rechte: object) -> list[str]:
|
||||
"""Eine ganze Liste umschreiben, ohne Dubletten und in fester Reihenfolge."""
|
||||
if not isinstance(rechte, (list, tuple, set, frozenset)):
|
||||
return []
|
||||
ergebnis: set[str] = set()
|
||||
for recht in rechte:
|
||||
ergebnis.update(neue_schluessel(str(recht)))
|
||||
return sorted(ergebnis)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"UMBENANNTE_AKTIONEN",
|
||||
"GETEILTE_RECHTE",
|
||||
"ERWEITERTE_RECHTE",
|
||||
"neue_schluessel",
|
||||
"umgeschrieben",
|
||||
]
|
||||
@@ -1,6 +1,14 @@
|
||||
"""Rechtemodell: Bereiche, Ressourcen, Aktionen.
|
||||
|
||||
Das Vorgaengermodell kannte fuenf Spalten (R/W/E/D/X), wobei ``E`` ("Aendern")
|
||||
Das Vokabular heisst seit 2.8.0 durchgehend wie das, was es tut: **Read,
|
||||
Create, Write, Delete, Execute, Export, Secrets**. Bis dahin hiessen zwei
|
||||
Aktionen anders (``view``, ``edit``), waehrend die Matrix "Bearbeiten" zeigte
|
||||
und die Kurzfassung "U" -- dieselbe Aktion in drei Schreibweisen. Der
|
||||
Kill-Switch eines Bereichs heisst entsprechend ``<bereich>.read``.
|
||||
:mod:`tesm_core.rbac.legacy_permissions` schreibt alte Schluessel um; die
|
||||
Migration ``core/4`` tut das fuer bestehende Gruppen.
|
||||
|
||||
Das Modell vor 2.0 kannte fuenf Spalten (R/W/E/D/X), wobei ``E`` ("Aendern")
|
||||
das Loeschen **mit abdeckte** und ``D``/``X`` je nach Zeile etwas voellig anderes
|
||||
bedeuteten (Dashboard-Neustart bzw. Export). Das war kompakt, aber:
|
||||
|
||||
@@ -15,7 +23,7 @@ Aktionen eine Ressource ueberhaupt kennt, entscheidet die Ressource; die Matrix
|
||||
rendert nur die Spalten, die im jeweiligen Bereich wirklich vorkommen.
|
||||
|
||||
Beibehalten wird der bewaehrte **Kill-Switch**: jeder Bereich hat ein
|
||||
``<bereich>.view``-Recht. Ohne dieses greift kein einziges Recht innerhalb des
|
||||
``<bereich>.read``-Recht. Ohne dieses greift kein einziges Recht innerhalb des
|
||||
Bereichs -- unabhaengig davon, was einzeln angehakt ist. Das erlaubt es, einer
|
||||
Gruppe einen ganzen Bereich mit einem Klick zu entziehen, ohne die fein
|
||||
eingestellten Einzelrechte zu verlieren.
|
||||
@@ -29,38 +37,68 @@ from typing import Iterable, Mapping, Sequence
|
||||
|
||||
|
||||
class Action(str, Enum):
|
||||
"""Aktionsvokabular. Bedeutung ist ueberall dieselbe -- keine Ausnahmen."""
|
||||
"""Aktionsvokabular. Bedeutung ist ueberall dieselbe -- keine Ausnahmen.
|
||||
|
||||
VIEW = "view"
|
||||
Die Namen sind die englischen, weil sie in jedem Rechtesystem dieselben
|
||||
sind und keiner Uebersetzung bedarf: wer ``write`` liest, weiss es. Zwei
|
||||
hiessen bis 2.7.x anders (``view``, ``edit``) -- in der Matrix stand
|
||||
"Bearbeiten" und im Protokoll ``edit``, also zweierlei fuer dasselbe.
|
||||
:mod:`tesm_core.rbac.legacy_permissions` schreibt die alten Schluessel um.
|
||||
"""
|
||||
|
||||
READ = "read"
|
||||
CREATE = "create"
|
||||
EDIT = "edit"
|
||||
WRITE = "write"
|
||||
DELETE = "delete"
|
||||
EXECUTE = "execute"
|
||||
EXPORT = "export"
|
||||
SECRETS = "secrets"
|
||||
|
||||
|
||||
#: Kuerzel und Erklaerung je Aktion -- Kopfzeile der Rechtematrix.
|
||||
ACTION_META: dict[Action, tuple[str, str, str]] = {
|
||||
Action.VIEW: ("R", "Ansehen", "Datensaetze und Seiten dieses Bereichs lesen."),
|
||||
Action.CREATE: ("C", "Anlegen", "Neue Datensaetze erstellen."),
|
||||
Action.EDIT: ("U", "Bearbeiten", "Bestehende Datensaetze aendern -- ohne Loeschen."),
|
||||
Action.DELETE: ("D", "Loeschen", "Datensaetze loeschen bzw. endgueltig entfernen."),
|
||||
Action.EXECUTE: ("X", "Ausfuehren", "Aktionen ausloesen (Neustart, Dienste, Jobs, Import)."),
|
||||
Action.EXPORT: ("E", "Exportieren", "Daten aus dem System heraustragen (Datei-Download)."),
|
||||
#: Kuerzel, englischer Name, deutsche Beschriftung, Erklaerung.
|
||||
#:
|
||||
#: Beides hat seinen Ort: die Matrix fuehrt die Spalte unter ihrem englischen
|
||||
#: Namen (das ist das Vokabular, und es steht so auch im Schluessel), die
|
||||
#: Kleinschrift darunter und jeder Satz in der Oberflaeche bleiben deutsch.
|
||||
ACTION_META: dict[Action, tuple[str, str, str, str]] = {
|
||||
Action.READ: ("R", "Read", "ansehen", "Datensätze und Seiten dieses Bereichs ansehen."),
|
||||
Action.CREATE: ("C", "Create", "anlegen", "Neue Datensätze anlegen."),
|
||||
Action.WRITE: (
|
||||
"W",
|
||||
"Write",
|
||||
"ändern",
|
||||
"Bestehende Datensätze ändern – ohne Anlegen, ohne Löschen.",
|
||||
),
|
||||
Action.DELETE: ("D", "Delete", "löschen", "Datensätze löschen bzw. endgültig entfernen."),
|
||||
Action.EXECUTE: (
|
||||
"X",
|
||||
"Execute",
|
||||
"auslösen",
|
||||
"Aktionen auslösen (Neustart, Dienste, Aufträge, Import).",
|
||||
),
|
||||
Action.EXPORT: (
|
||||
"E",
|
||||
"Export",
|
||||
"mitnehmen",
|
||||
"Daten aus dem System heraustragen (Datei-Download).",
|
||||
),
|
||||
Action.SECRETS: (
|
||||
"S",
|
||||
"Geheimnisse",
|
||||
"Passwoerter und Schluessel im Klartext sehen oder mitnehmen.",
|
||||
"Secrets",
|
||||
"Klartext",
|
||||
"Passwörter und Schlüssel im Klartext sehen oder mitnehmen.",
|
||||
),
|
||||
}
|
||||
|
||||
#: Aktionen, die zusaetzlich eine frische Passworteingabe verlangen (sudo-Modus).
|
||||
SENSITIVE_ACTIONS: frozenset[Action] = frozenset({Action.SECRETS, Action.EXPORT})
|
||||
|
||||
#: ``view`` ist Voraussetzung fuer alles andere in derselben Zeile.
|
||||
#: ``read`` ist Voraussetzung fuer alles andere in derselben Zeile. Umgekehrt
|
||||
#: gilt nichts: wer anlegen darf, darf damit nicht aendern, und wer aendern
|
||||
#: darf, nicht anlegen. Das ist die Trennung, die man in einer
|
||||
#: Verwaltungsanwendung am haeufigsten braucht.
|
||||
IMPLIED_BY: dict[Action, tuple[Action, ...]] = {
|
||||
action: (Action.VIEW,) for action in Action if action is not Action.VIEW
|
||||
action: (Action.READ,) for action in Action if action is not Action.READ
|
||||
}
|
||||
|
||||
|
||||
@@ -74,6 +112,32 @@ class Resource:
|
||||
hint: str = ""
|
||||
#: Abweichende Erklaerung einzelner Aktionen (z. B. was "Ausfuehren" hier meint).
|
||||
action_hints: Mapping[Action, str] = field(default_factory=dict)
|
||||
#: Zu welcher Zeile diese gehoert -- rein fuer die Darstellung.
|
||||
#:
|
||||
#: Aus einer Zeile "Clients" sind sechs geworden (Stammdaten, drei
|
||||
#: Neustartwege, Konsole, Host-Schluessel). Untereinander und alle gleich
|
||||
#: eingerueckt liest man sie als sechs unabhaengige Dinge; eingerueckt
|
||||
#: unter ihrer Hauptzeile sieht man, dass sie zusammengehoeren. Auf die
|
||||
#: Rechte hat das keine Wirkung: jede Zeile bleibt einzeln vergebbar, und
|
||||
#: die Hauptzeile ist **nicht** Voraussetzung fuer ihre Unterzeilen.
|
||||
parent: str = ""
|
||||
#: Aktionen, die hier **keine** frische Passwortbestaetigung verlangen,
|
||||
#: obwohl sie in SENSITIVE_ACTIONS stehen. Bewusst hier und nicht als
|
||||
#: stilles Weglassen in der Route: eine Ausnahme von einer
|
||||
#: Sicherheitsregel gehoert dorthin, wo man sie sieht und begruenden muss.
|
||||
ohne_wiederanmeldung: tuple[Action, ...] = ()
|
||||
|
||||
@property
|
||||
def short_label(self) -> str:
|
||||
"""Beschriftung ohne den Namen der Hauptzeile.
|
||||
|
||||
Unter "Clients" steht "Neustart über PoE" und nicht "Clients: Neustart
|
||||
über PoE" -- das Wort stand sonst siebenmal untereinander. Ausserhalb
|
||||
der Matrix (Protokoll, Meldungen) bleibt der vollstaendige Name.
|
||||
"""
|
||||
if self.parent and ": " in self.label:
|
||||
return self.label.split(": ", 1)[1]
|
||||
return self.label
|
||||
|
||||
def permission(self, action: Action) -> str:
|
||||
return f"{self.key}.{action.value}"
|
||||
@@ -96,8 +160,9 @@ class Area:
|
||||
hint: str = ""
|
||||
|
||||
@property
|
||||
def view_key(self) -> str:
|
||||
return f"{self.key}.view"
|
||||
def read_key(self) -> str:
|
||||
"""Der Kill-Switch des Bereichs. Ohne ihn greift keine Zeile darunter."""
|
||||
return f"{self.key}.read"
|
||||
|
||||
def columns(self) -> tuple[Action, ...]:
|
||||
"""Nur die Aktionen, die in diesem Bereich tatsaechlich vorkommen."""
|
||||
@@ -123,45 +188,92 @@ class PermissionTree:
|
||||
self._parent: dict[str, str] = {}
|
||||
self._all: list[str] = []
|
||||
self._resources: dict[str, tuple[Area, Resource]] = {}
|
||||
self._verwaltung: frozenset[str] | None = None
|
||||
|
||||
seen_keys: set[str] = set()
|
||||
for area in self.areas:
|
||||
if area.key in seen_keys:
|
||||
raise ValueError(f"Doppelter Bereichsschluessel: {area.key}")
|
||||
raise ValueError(f"Doppelter Bereichsschlüssel: {area.key}")
|
||||
seen_keys.add(area.key)
|
||||
self._by_key[area.key] = area
|
||||
self._all.append(area.view_key)
|
||||
self._all.append(area.read_key)
|
||||
for resource in area.resources:
|
||||
if resource.key in seen_keys:
|
||||
raise ValueError(f"Doppelter Ressourcenschluessel: {resource.key}")
|
||||
raise ValueError(f"Doppelter Ressourcenschlüssel: {resource.key}")
|
||||
seen_keys.add(resource.key)
|
||||
if Action.VIEW not in resource.actions:
|
||||
if Action.READ not in resource.actions:
|
||||
raise ValueError(
|
||||
f"Ressource {resource.key} muss die Aktion 'view' anbieten -- "
|
||||
"sie ist Voraussetzung fuer jede andere Aktion."
|
||||
f"Ressource {resource.key} muss die Aktion 'read' anbieten – "
|
||||
"sie ist Voraussetzung für jede andere Aktion."
|
||||
)
|
||||
self._resources[resource.key] = (area, resource)
|
||||
for permission in resource.permissions():
|
||||
self._all.append(permission)
|
||||
self._parent[permission] = area.view_key
|
||||
self._parent[permission] = area.read_key
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def all_keys(self) -> tuple[str, ...]:
|
||||
return tuple(self._all)
|
||||
|
||||
#: Der Bereich, den ein echter Administrator immer ganz hat.
|
||||
#:
|
||||
#: Nicht "alles": das Kennzeichen ``is_admin`` gab bisher jede Pruefung
|
||||
#: frei, auch Geraete, DHCP und Dateifreigaben. Ein Administrator ist aber
|
||||
#: jemand, der die **Anlage** verwaltet -- Benutzer, Rechte,
|
||||
#: Einstellungen --, und nicht automatisch jemand, der Maschinen neu
|
||||
#: startet oder in Freigaben liest. Diese Rechte holt er sich ueber eine
|
||||
#: Gruppe, und dann steht es im Aenderungsprotokoll.
|
||||
VERWALTUNGSBEREICH = "administration"
|
||||
|
||||
def verwaltungsrechte(self) -> frozenset[str]:
|
||||
"""Alle Rechte des Verwaltungsbereichs, samt seinem Kill-Switch.
|
||||
|
||||
Einmal gerechnet und gemerkt: die Antwort haengt nur am Baum, und der
|
||||
entsteht beim Import.
|
||||
"""
|
||||
if self._verwaltung is None:
|
||||
bereich = self._by_key.get(self.VERWALTUNGSBEREICH)
|
||||
if bereich is None:
|
||||
self._verwaltung = frozenset()
|
||||
else:
|
||||
rechte = {bereich.read_key}
|
||||
for resource in bereich.resources:
|
||||
rechte.update(resource.permissions())
|
||||
self._verwaltung = frozenset(rechte)
|
||||
return self._verwaltung
|
||||
|
||||
def area(self, key: str) -> Area | None:
|
||||
return self._by_key.get(key)
|
||||
|
||||
def parent_view_key(self, permission: str) -> str | None:
|
||||
def parent_read_key(self, permission: str) -> str | None:
|
||||
return self._parent.get(permission)
|
||||
|
||||
def resource(self, key: str) -> tuple[Area, Resource] | None:
|
||||
return self._resources.get(key)
|
||||
|
||||
def verlangt_wiederanmeldung(self, permission: str) -> bool:
|
||||
"""Braucht dieses Recht eine frische Passwortbestaetigung?
|
||||
|
||||
Die Antwort steht im Modell (``SENSITIVE_ACTIONS``) und nicht in der
|
||||
Route -- sonst vergisst sie die naechste, die dazukommt.
|
||||
"""
|
||||
resource_key, _, action_value = permission.rpartition(".")
|
||||
entry = self._resources.get(resource_key)
|
||||
if entry is None:
|
||||
return False
|
||||
_, resource = entry
|
||||
try:
|
||||
action = Action(action_value)
|
||||
except ValueError:
|
||||
return False
|
||||
if action in resource.ohne_wiederanmeldung:
|
||||
return False
|
||||
return action in SENSITIVE_ACTIONS
|
||||
|
||||
def label_for(self, permission: str) -> str:
|
||||
"""Menschenlesbare Beschreibung eines Rechteschluessels -- fuer Audit-Logs."""
|
||||
if permission.endswith(".view") and permission[:-5] in self._by_key:
|
||||
if permission.endswith(".read") and permission[:-5] in self._by_key:
|
||||
return f"Bereich {self._by_key[permission[:-5]].label}: Zugang"
|
||||
resource_key, _, action_value = permission.rpartition(".")
|
||||
entry = self._resources.get(resource_key)
|
||||
@@ -172,7 +284,10 @@ class PermissionTree:
|
||||
action = Action(action_value)
|
||||
except ValueError:
|
||||
return permission
|
||||
return f"{resource.label}: {ACTION_META[action][1]}"
|
||||
# Die deutsche Beschriftung, nicht der englische Name: dieser Text
|
||||
# landet in Saetzen ("Sie koennen kein Recht vergeben ...") und im
|
||||
# Aenderungsprotokoll, und die sind deutsch.
|
||||
return f"{resource.label}: {ACTION_META[action][2]}"
|
||||
|
||||
def sanitize(self, permissions: Iterable[str]) -> list[str]:
|
||||
"""Wirft unbekannte Rechte weg und ergaenzt implizierte.
|
||||
@@ -180,7 +295,7 @@ class PermissionTree:
|
||||
Regeln:
|
||||
* Ein Recht, das nicht im Baum steht, wird verworfen (kein stilles
|
||||
Weiterschleppen von Altrechten nach einem Refactoring).
|
||||
* Jede Aktion impliziert ``view`` derselben Zeile.
|
||||
* Jede Aktion impliziert ``read`` derselben Zeile -- aber nur diese.
|
||||
* Existiert irgendein Recht eines Bereichs, wird der Kill-Switch des
|
||||
Bereichs mitgesetzt -- sonst waere die Auswahl wirkungslos und der
|
||||
Bearbeiter wuerde sich wundern.
|
||||
@@ -200,13 +315,13 @@ class PermissionTree:
|
||||
result.add(candidate)
|
||||
for area in self.areas:
|
||||
if any(p in result for resource in area.resources for p in resource.permissions()):
|
||||
result.add(area.view_key)
|
||||
result.add(area.read_key)
|
||||
return sorted(result)
|
||||
|
||||
def effective(self, permissions: Iterable[str]) -> set[str]:
|
||||
"""Rechte, die nach Anwendung der Kill-Switches tatsaechlich greifen."""
|
||||
granted = set(permissions)
|
||||
active = {key for key in granted if key.endswith(".view") and key[:-5] in self._by_key}
|
||||
active = {key for key in granted if key.endswith(".read") and key[:-5] in self._by_key}
|
||||
return {
|
||||
key
|
||||
for key in granted
|
||||
|
||||
@@ -29,7 +29,7 @@ from typing import Any, Iterable, Sequence
|
||||
|
||||
from ..db import Database
|
||||
from ..security import passwords
|
||||
from .model import Action, PermissionTree, RolePreset
|
||||
from .model import ACTION_META, Action, PermissionTree, RolePreset
|
||||
|
||||
|
||||
class PermissionDenied(Exception):
|
||||
@@ -124,7 +124,20 @@ def group_permissions(conn: sqlite3.Connection, group_id: int) -> list[str]:
|
||||
def guard_no_amplification(
|
||||
actor: Any, tree: PermissionTree, added: Iterable[str], *, what: str = "Recht"
|
||||
) -> None:
|
||||
"""Verhindert, dass jemand Rechte vergibt, die er selbst nicht besitzt."""
|
||||
"""Verhindert, dass jemand Rechte vergibt, die er selbst nicht besitzt.
|
||||
|
||||
Ein echter Administrator darf es -- und das ist seit 2.8.0 die bewusst
|
||||
offene Tuer: sein Kennzeichen gibt ihm den Verwaltungsbereich, die
|
||||
Funktionen holt er sich ueber eine Gruppe, auch fuer sich selbst. Der
|
||||
Unterschied zu vorher ist nicht, *ob* er an die Funktionen kommt, sondern
|
||||
dass es dann eine Zeile im Aenderungsprotokoll gibt und in der Matrix zu
|
||||
sehen ist, statt unsichtbar im Kennzeichen zu stecken.
|
||||
|
||||
Fuer alle anderen bleibt die Regel dicht -- auch fuer die Gruppe
|
||||
"Administration", die Benutzer und Gruppen pflegt: sie kann keiner Gruppe
|
||||
ein Recht geben, das sie nicht selbst hat, und sich damit nicht ueber eine
|
||||
neue Gruppe hochstufen.
|
||||
"""
|
||||
if getattr(actor, "is_admin", False):
|
||||
return
|
||||
missing = sorted(p for p in added if not actor.has(p))
|
||||
@@ -132,7 +145,7 @@ def guard_no_amplification(
|
||||
labels = ", ".join(tree.label_for(p) for p in missing[:6])
|
||||
more = "" if len(missing) <= 6 else f" (und {len(missing) - 6} weitere)"
|
||||
raise PermissionDenied(
|
||||
f"Sie koennen kein {what} vergeben, das Sie selbst nicht besitzen: {labels}{more}."
|
||||
f"Sie können kein {what} vergeben, das Sie selbst nicht besitzen: {labels}{more}."
|
||||
)
|
||||
|
||||
|
||||
@@ -160,7 +173,7 @@ def create_group(
|
||||
if existing["deleted_at"]:
|
||||
raise ValidationError(
|
||||
f"Eine Gruppe namens {clean_name!r} liegt im Papierkorb. "
|
||||
"Stellen Sie sie wieder her oder loeschen Sie sie endgueltig."
|
||||
"Stellen Sie sie wieder her oder löschen Sie sie endgültig."
|
||||
)
|
||||
raise ValidationError(f"Eine Gruppe namens {clean_name!r} existiert bereits.")
|
||||
|
||||
@@ -205,7 +218,7 @@ def update_group(
|
||||
|
||||
if name is not None and name.strip() != group.name:
|
||||
if group.is_system:
|
||||
raise PermissionDenied("Systemgruppen koennen nicht umbenannt werden.")
|
||||
raise PermissionDenied("Systemgruppen können nicht umbenannt werden.")
|
||||
clean = name.strip()
|
||||
if not clean:
|
||||
raise ValidationError("Der Gruppenname darf nicht leer sein.")
|
||||
@@ -224,13 +237,13 @@ def update_group(
|
||||
"UPDATE groups SET description=?, updated_at=? WHERE id=?",
|
||||
(description.strip(), _now(), group_id),
|
||||
)
|
||||
changes.append("Beschreibung geaendert")
|
||||
changes.append("Beschreibung geändert")
|
||||
|
||||
if permissions is not None:
|
||||
if group.is_system and not unlock_system:
|
||||
raise PermissionDenied(
|
||||
"Die Rechte dieser Systemgruppe sind gesperrt. "
|
||||
"Sie muessen sie zuerst ausdruecklich freischalten."
|
||||
"Sie müssen sie zuerst ausdrücklich freischalten."
|
||||
)
|
||||
wanted = tree.sanitize(permissions)
|
||||
added = sorted(set(wanted) - set(group.permissions))
|
||||
@@ -240,7 +253,7 @@ def update_group(
|
||||
_write_permissions(conn, group_id, wanted)
|
||||
conn.execute("UPDATE groups SET updated_at=? WHERE id=?", (_now(), group_id))
|
||||
if added:
|
||||
changes.append("Rechte hinzugefuegt: " + ", ".join(tree.label_for(p) for p in added))
|
||||
changes.append("Rechte hinzugefügt: " + ", ".join(tree.label_for(p) for p in added))
|
||||
if removed:
|
||||
changes.append("Rechte entfernt: " + ", ".join(tree.label_for(p) for p in removed))
|
||||
|
||||
@@ -252,7 +265,7 @@ def soft_delete_group(conn: sqlite3.Connection, group_id: int) -> None:
|
||||
if group is None:
|
||||
raise ValidationError("Diese Gruppe existiert nicht.")
|
||||
if group.is_system:
|
||||
raise PermissionDenied("Systemgruppen koennen nicht geloescht werden.")
|
||||
raise PermissionDenied("Systemgruppen können nicht gelöscht werden.")
|
||||
conn.execute("UPDATE groups SET deleted_at=? WHERE id=?", (_now(), group_id))
|
||||
|
||||
|
||||
@@ -315,6 +328,113 @@ def ensure_presets(
|
||||
return created
|
||||
|
||||
|
||||
def unberuehrte_vorlagen_entfernen(
|
||||
conn: sqlite3.Connection, tree: PermissionTree, presets: Sequence[RolePreset]
|
||||
) -> list[str]:
|
||||
"""Legt Vorlagengruppen in den Papierkorb -- aber nur unberuehrte.
|
||||
|
||||
Wird gebraucht, wenn eine Vorlage aus der Auslieferung verschwindet. Auf
|
||||
einer frischen Installation entsteht sie dann gar nicht erst; auf einer
|
||||
bestehenden steht sie in der Datenbank, und eine Migration, die sie einfach
|
||||
loescht, nimmt einem Kunden womoeglich eine Gruppe weg, mit der er
|
||||
arbeitet.
|
||||
|
||||
Darum diese fuenf Bedingungen, und alle fuenf muessen zutreffen:
|
||||
|
||||
* Die Gruppe stammt **aus der Auslieferung**: ihr ``preset_key`` ist der der
|
||||
Vorlage. Diese Kennung setzt nur :func:`ensure_presets`; eine selbst
|
||||
angelegte Gruppe hat sie nicht, auch wenn sie zufaellig denselben Namen
|
||||
traegt. Ueber den Namen wird bewusst *nicht* gesucht -- sonst raeumte
|
||||
dieser Schritt jemandem seine eigene Gruppe "Betrieb" weg.
|
||||
* Sie heisst noch so, wie sie ausgeliefert wurde. Ein anderer Name heisst:
|
||||
jemand hat sie uebernommen.
|
||||
* Die Gruppe hat **keine Mitglieder**. Wer Mitglieder hat, wird benutzt.
|
||||
* Ihre Rechte sind **unveraendert** die der Vorlage. Jede Abweichung heisst:
|
||||
jemand hat sie angepasst, also ist sie seine, nicht unsere.
|
||||
* Sie ist keine Systemgruppe.
|
||||
|
||||
Geloescht wird weich: die Gruppe liegt im Papierkorb und kommt mit einem
|
||||
Klick zurueck. Gibt die Namen der einsortierten Gruppen zurueck.
|
||||
"""
|
||||
entfernt: list[str] = []
|
||||
gruppen = list_groups(conn)
|
||||
for preset in presets:
|
||||
gruppe = next(
|
||||
(
|
||||
g
|
||||
for g in gruppen
|
||||
if g.preset_key
|
||||
and g.preset_key == preset.key
|
||||
and g.name.lower() == preset.name.lower()
|
||||
),
|
||||
None,
|
||||
)
|
||||
if gruppe is None or gruppe.is_system or gruppe.member_count:
|
||||
continue
|
||||
if set(gruppe.permissions) != set(tree.expand_preset(preset)):
|
||||
continue
|
||||
conn.execute("UPDATE groups SET deleted_at=? WHERE id=?", (_now(), gruppe.id))
|
||||
entfernt.append(gruppe.name)
|
||||
return entfernt
|
||||
|
||||
|
||||
def funktionsgruppe_anlegen(
|
||||
conn: sqlite3.Connection, tree: PermissionTree, preset: RolePreset
|
||||
) -> int:
|
||||
"""Legt die Vollzugriffsgruppe an, falls sie fehlt, und gibt ihre Nummer.
|
||||
|
||||
Getrennt von :func:`funktionsgruppe_fuer_administratoren`, weil das zwei
|
||||
verschiedene Dinge sind: hier entsteht nur die Gruppe, dort bekommt sie
|
||||
**jeder** Administrator. Wer ein einzelnes Konto aufnehmen will -- etwa
|
||||
das, das an der Kommandozeile entsteht --, braucht das erste ohne das
|
||||
zweite; sonst faengt sich ein Administrator, der die Gruppe bewusst
|
||||
abgelegt hat, sie beim naechsten Aufruf wieder ein.
|
||||
"""
|
||||
zeile = Database.one(
|
||||
conn, "SELECT id FROM groups WHERE name=? COLLATE NOCASE", (preset.name,)
|
||||
)
|
||||
if zeile is not None:
|
||||
return int(zeile["id"])
|
||||
cursor = conn.execute(
|
||||
"INSERT INTO groups (name, description, is_system, is_default, preset_key, "
|
||||
"created_at, updated_at) VALUES (?,?,0,0,?,?,?)",
|
||||
(preset.name, preset.description, preset.key, _now(), _now()),
|
||||
)
|
||||
gruppe = int(cursor.lastrowid or 0)
|
||||
_write_permissions(conn, gruppe, tree.expand_preset(preset))
|
||||
return gruppe
|
||||
|
||||
|
||||
def funktionsgruppe_fuer_administratoren(
|
||||
conn: sqlite3.Connection, tree: PermissionTree, preset: RolePreset
|
||||
) -> int:
|
||||
"""Legt die Vollzugriffsgruppe an und gibt sie allen Administratoren.
|
||||
|
||||
Der Uebergang zu 2.8.0: bis dahin gab das Kennzeichen ``is_admin`` jede
|
||||
Pruefung frei. Danach gibt es den Verwaltungsbereich, und alles andere
|
||||
kommt ueber Gruppen. Ohne diesen Schritt fehlt einem bestehenden
|
||||
Administrator nach dem Update die halbe Anwendung -- und er haelt das fuer
|
||||
einen Fehler, nicht fuer eine Absicht.
|
||||
|
||||
Auf einer frischen Installation gibt es beim Migrieren noch kein Konto;
|
||||
dann entsteht nur die Gruppe, und wer sie will, weist sie sich zu. Genau so
|
||||
ist die neue Trennung gedacht.
|
||||
|
||||
Gibt die Nummer der Gruppe zurueck.
|
||||
"""
|
||||
gruppe = funktionsgruppe_anlegen(conn, tree, preset)
|
||||
conn.executemany(
|
||||
"INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?,?)",
|
||||
[
|
||||
(int(row["id"]), gruppe)
|
||||
for row in Database.all(
|
||||
conn, "SELECT id FROM users WHERE is_admin=1 AND deleted_at IS NULL"
|
||||
)
|
||||
],
|
||||
)
|
||||
return gruppe
|
||||
|
||||
|
||||
def default_group_id(conn: sqlite3.Connection) -> int | None:
|
||||
value = Database.value(
|
||||
conn, "SELECT id FROM groups WHERE is_default=1 AND deleted_at IS NULL ORDER BY id LIMIT 1"
|
||||
@@ -410,7 +530,7 @@ def guard_target_account(actor: Any, target: dict[str, Any]) -> None:
|
||||
"""Ein Administratorkonto darf nur ein echter Administrator anfassen."""
|
||||
if bool(target.get("is_admin")) and not getattr(actor, "is_admin", False):
|
||||
raise PermissionDenied(
|
||||
"Administratorkonten koennen nur von Administratoren bearbeitet werden."
|
||||
"Administratorkonten können nur von Administratoren bearbeitet werden."
|
||||
)
|
||||
|
||||
|
||||
@@ -420,8 +540,8 @@ def guard_last_admin(conn: sqlite3.Connection, user_id: int, *, action: str) ->
|
||||
return
|
||||
if count_active_admins(conn, exclude_id=user_id) == 0:
|
||||
raise PermissionDenied(
|
||||
f"{action} nicht moeglich: es muss mindestens ein entsperrtes "
|
||||
"Administratorkonto uebrig bleiben."
|
||||
f"{action} nicht möglich: es muss mindestens ein entsperrtes "
|
||||
"Administratorkonto übrig bleiben."
|
||||
)
|
||||
|
||||
|
||||
@@ -482,12 +602,12 @@ def create_user(
|
||||
if existing["deleted_at"]:
|
||||
raise ValidationError(
|
||||
f"Der Benutzername {clean!r} liegt im Papierkorb. Stellen Sie das Konto "
|
||||
"wieder her oder loeschen Sie es endgueltig."
|
||||
"wieder her oder löschen Sie es endgültig."
|
||||
)
|
||||
raise ValidationError(f"Der Benutzername {clean!r} ist bereits vergeben.")
|
||||
|
||||
if is_admin and not getattr(actor, "is_admin", False):
|
||||
raise PermissionDenied("Nur Administratoren koennen weitere Administratoren anlegen.")
|
||||
raise PermissionDenied("Nur Administratoren können weitere Administratoren anlegen.")
|
||||
|
||||
if auth_source == "local":
|
||||
problems = policy.check(password, username=clean)
|
||||
@@ -552,24 +672,30 @@ def update_user(
|
||||
# ueberschrieben -- die Bearbeitung waere eine Illusion.
|
||||
raise ValidationError(
|
||||
"Name und E-Mail eines Verzeichniskontos werden bei jeder Anmeldung "
|
||||
"aus dem Verzeichnis uebernommen und koennen hier nicht geaendert werden."
|
||||
"aus dem Verzeichnis übernommen und können hier nicht geändert werden."
|
||||
)
|
||||
|
||||
fields: dict[str, Any] = {}
|
||||
# Mit Werten, nicht nur "geaendert": aus dem Protokoll soll man den
|
||||
# vorherigen Stand ablesen koennen, ohne eine Sicherung auszupacken.
|
||||
beschriftungen = {"first_name": "Vorname", "last_name": "Nachname", "email": "E-Mail"}
|
||||
for column, value in (
|
||||
("first_name", first_name),
|
||||
("last_name", last_name),
|
||||
("email", email),
|
||||
):
|
||||
if value is not None and value.strip() != str(target.get(column) or ""):
|
||||
alt = str(target.get(column) or "")
|
||||
if value is not None and value.strip() != alt:
|
||||
fields[column] = value.strip()
|
||||
changes.append(f"{column} geaendert")
|
||||
changes.append(
|
||||
f"{beschriftungen[column]}: {alt or '(leer)'} -> {value.strip() or '(leer)'}"
|
||||
)
|
||||
|
||||
if is_admin is not None and bool(is_admin) != bool(target["is_admin"]):
|
||||
if not getattr(actor, "is_admin", False):
|
||||
raise PermissionDenied("Das Admin-Kennzeichen kann nur ein Administrator aendern.")
|
||||
raise PermissionDenied("Das Admin-Kennzeichen kann nur ein Administrator ändern.")
|
||||
if int(target["id"]) == int(getattr(actor, "id", 0)):
|
||||
raise PermissionDenied("Sie koennen Ihr eigenes Admin-Kennzeichen nicht aendern.")
|
||||
raise PermissionDenied("Sie können Ihr eigenes Admin-Kennzeichen nicht ändern.")
|
||||
if not is_admin:
|
||||
guard_last_admin(conn, user_id, action="Herabstufen")
|
||||
fields["is_admin"] = int(bool(is_admin))
|
||||
@@ -577,7 +703,7 @@ def update_user(
|
||||
|
||||
if is_locked is not None and bool(is_locked) != bool(target["is_locked"]):
|
||||
if int(target["id"]) == int(getattr(actor, "id", 0)):
|
||||
raise PermissionDenied("Sie koennen Ihr eigenes Konto nicht sperren.")
|
||||
raise PermissionDenied("Sie können Ihr eigenes Konto nicht sperren.")
|
||||
if is_locked:
|
||||
guard_last_admin(conn, user_id, action="Sperren")
|
||||
fields["is_locked"] = int(bool(is_locked))
|
||||
@@ -595,7 +721,7 @@ def update_user(
|
||||
after = {int(gid) for gid in group_ids}
|
||||
if before != after:
|
||||
set_user_groups(conn, user_id, sorted(after), actor=actor, tree=tree)
|
||||
changes.append("Gruppenzuordnung geaendert")
|
||||
changes.append("Gruppenzuordnung geändert")
|
||||
|
||||
return changes
|
||||
|
||||
@@ -613,7 +739,7 @@ def set_password(
|
||||
raise ValidationError("Dieses Konto existiert nicht.")
|
||||
if str(row.get("auth_source")) == "ldap":
|
||||
raise ValidationError(
|
||||
"Verzeichniskonten haben hier kein Passwort -- es wird im Verzeichnisdienst gepflegt."
|
||||
"Verzeichniskonten haben hier kein Passwort – es wird im Verzeichnisdienst gepflegt."
|
||||
)
|
||||
problems = policy.check(new_password, username=str(row["username"]))
|
||||
if problems:
|
||||
@@ -631,7 +757,7 @@ def soft_delete_user(conn: sqlite3.Connection, user_id: int, *, actor: Any) -> s
|
||||
raise ValidationError("Dieses Konto existiert nicht (mehr).")
|
||||
guard_target_account(actor, target)
|
||||
if int(target["id"]) == int(getattr(actor, "id", 0)):
|
||||
raise PermissionDenied("Sie koennen Ihr eigenes Konto nicht loeschen.")
|
||||
raise PermissionDenied("Sie können Ihr eigenes Konto nicht löschen.")
|
||||
guard_last_admin(conn, user_id, action="Loeschen")
|
||||
|
||||
if str(target.get("auth_source")) == "ldap":
|
||||
@@ -677,7 +803,7 @@ def matrix_state(
|
||||
granted = set(permissions)
|
||||
state: dict[str, dict[str, bool]] = {}
|
||||
for area in tree.areas:
|
||||
state[area.key] = {"view": area.view_key in granted}
|
||||
state[area.key] = {"read": area.read_key in granted}
|
||||
for resource in area.resources:
|
||||
state[resource.key] = {
|
||||
action.value: resource.permission(action) in granted for action in resource.actions
|
||||
@@ -690,7 +816,7 @@ def summarize_permissions(tree: PermissionTree, permissions: Iterable[str]) -> l
|
||||
granted = set(permissions)
|
||||
lines: list[str] = []
|
||||
for area in tree.areas:
|
||||
if area.view_key not in granted:
|
||||
if area.read_key not in granted:
|
||||
continue
|
||||
parts: list[str] = []
|
||||
for resource in area.resources:
|
||||
@@ -706,12 +832,9 @@ def summarize_permissions(tree: PermissionTree, permissions: Iterable[str]) -> l
|
||||
return lines
|
||||
|
||||
|
||||
#: Abgeleitet und nicht abgeschrieben: die Buchstaben stehen in ACTION_META.
|
||||
#: Die zweite Liste hier hatte "U" fuer Bearbeiten, waehrend die Matrix "W"
|
||||
#: zeigte -- dieselbe Aktion, zwei Kuerzel, je nachdem wo man hinsah.
|
||||
ACTION_LETTERS: dict[Action, str] = {
|
||||
Action.VIEW: "R",
|
||||
Action.CREATE: "C",
|
||||
Action.EDIT: "U",
|
||||
Action.DELETE: "D",
|
||||
Action.EXECUTE: "X",
|
||||
Action.EXPORT: "E",
|
||||
Action.SECRETS: "S",
|
||||
action: meta[0] for action, meta in ACTION_META.items()
|
||||
}
|
||||
@@ -112,7 +112,7 @@ def protect() -> None:
|
||||
if not expected or not provided:
|
||||
abort(403, description="Das Sicherheitstoken des Formulars fehlt. Bitte Seite neu laden.")
|
||||
if not hmac.compare_digest(str(expected), str(provided)):
|
||||
abort(403, description="Das Sicherheitstoken des Formulars ist ungueltig. Bitte Seite neu laden.")
|
||||
abort(403, description="Das Sicherheitstoken des Formulars ist ungültig. Bitte Seite neu laden.")
|
||||
|
||||
|
||||
def exempt(view):
|
||||
|
||||
@@ -65,10 +65,10 @@ class PasswordPolicy:
|
||||
if classes < self.require_classes:
|
||||
problems.append(
|
||||
f"Mindestens {self.require_classes} verschiedene Zeichenarten "
|
||||
"(Klein-/Grossbuchstaben, Ziffern, Sonderzeichen)."
|
||||
"(Klein-/Großbuchstaben, Ziffern, Sonderzeichen)."
|
||||
)
|
||||
if normalized.lower() in _FORBIDDEN:
|
||||
problems.append("Dieses Passwort ist zu haeufig und damit unsicher.")
|
||||
problems.append("Dieses Passwort ist zu häufig und damit unsicher.")
|
||||
if username and len(username) >= 3 and username.lower() in normalized.lower():
|
||||
problems.append("Das Passwort darf den Benutzernamen nicht enthalten.")
|
||||
if len(set(normalized)) <= 3 and normalized:
|
||||
|
||||
@@ -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,35 @@ class SqliteSessionInterface(SessionInterface):
|
||||
cookie_name: str,
|
||||
idle_minutes: int,
|
||||
absolute_hours: int,
|
||||
idle_lookup: Any = None,
|
||||
absolute_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
|
||||
self._absolute_lookup = absolute_lookup
|
||||
|
||||
def _absolute(self, conn: Any) -> int:
|
||||
"""Gesamtdauer in Stunden -- einstellbar wie die Leerlaufgrenze."""
|
||||
if self._absolute_lookup is None:
|
||||
return self.absolute_hours
|
||||
try:
|
||||
return int(self._absolute_lookup(conn)) or self.absolute_hours
|
||||
except Exception: # pragma: no cover - Einstellung unlesbar
|
||||
return self.absolute_hours
|
||||
|
||||
def _idle(self, conn: Any) -> int:
|
||||
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 +220,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 +244,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
|
||||
|
||||
@@ -179,6 +291,9 @@ class SqliteSessionInterface(SessionInterface):
|
||||
payload = json.dumps(dict(session), separators=(",", ":"), sort_keys=True)
|
||||
with self.database.session() as conn, self.database.transaction(conn):
|
||||
if session.new or not session.sid:
|
||||
# Eine vorhandene ID nicht wegwerfen: nach einem Wechsel beim
|
||||
# Anmelden steht sie schon, und andere haben sie sich gemerkt.
|
||||
if not session.sid:
|
||||
session.sid = secrets.token_urlsafe(SESSION_ID_BYTES)
|
||||
conn.execute(
|
||||
"INSERT INTO user_sessions "
|
||||
@@ -190,14 +305,39 @@ class SqliteSessionInterface(SessionInterface):
|
||||
session.get("user_id"),
|
||||
_iso(now),
|
||||
_iso(now),
|
||||
_iso(now + timedelta(minutes=self.idle_minutes)),
|
||||
_iso(now + timedelta(hours=self.absolute_hours)),
|
||||
_iso(now + timedelta(minutes=self._idle(conn))),
|
||||
_iso(now + timedelta(hours=self._absolute(conn))),
|
||||
session.get("_ip", ""),
|
||||
session.get("_ua", ""),
|
||||
payload,
|
||||
),
|
||||
)
|
||||
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 +346,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,
|
||||
),
|
||||
@@ -241,7 +385,12 @@ def rotate(conn: sqlite3.Connection, session: ServerSession, *, reason: str = "p
|
||||
"UPDATE user_sessions SET revoked_at=?, revoked_reason=? WHERE id=?",
|
||||
(_iso(_now()), reason, old_sid),
|
||||
)
|
||||
session.sid = ""
|
||||
# Die neue ID entsteht **jetzt** und nicht erst beim Speichern der Antwort.
|
||||
# Zwischen beidem laufen die Anmeldehaken, und einer davon bindet die
|
||||
# Dateifreigaben ein und schreibt sich die Sitzung dazu. Mit einer leeren
|
||||
# Sitzung hielt der Aufraeumer diese Einbindungen fuer verwaist und loeste
|
||||
# sie binnen Minuten wieder.
|
||||
session.sid = secrets.token_urlsafe(SESSION_ID_BYTES)
|
||||
session.new = True
|
||||
session.modified = True
|
||||
return old_sid
|
||||
|
||||
@@ -58,6 +58,30 @@ class SettingSpec:
|
||||
return str(raw)
|
||||
|
||||
def serialize(self, value: Any) -> str:
|
||||
"""Ein Wert als Text fuer die Datenbank.
|
||||
|
||||
Der Wert kann als Python-Wert kommen (aus einem Formular, aus dem
|
||||
Code) -- oder als die Zeichenkette, die schon in der Datenbank stand.
|
||||
Genau so liest eine **Sicherung** ihn wieder ein, und genau dort lag
|
||||
ein stiller Fehler: ``serialize("0")`` ergab ``"1"``, weil jede nicht
|
||||
leere Zeichenkette wahr ist. Aus jedem abgeschalteten Schalter wurde
|
||||
beim Einspielen ein eingeschalteter -- auch aus
|
||||
``ldap_allow_insecure``. Ein JSON-Feld wurde ein zweites Mal kodiert,
|
||||
und aus der Navigationsreihenfolge damit eine Zeichenkette.
|
||||
|
||||
Deshalb: eine Zeichenkette erst lesen. Was hier herauskommt, muss
|
||||
durch ``serialize`` unveraendert hindurchgehen -- sonst ueberlebt kein
|
||||
Wert eine Sicherung.
|
||||
"""
|
||||
if isinstance(value, str):
|
||||
if self.kind == "bool":
|
||||
value = self.parse(value)
|
||||
elif self.kind == "json":
|
||||
try:
|
||||
value = json.loads(value)
|
||||
except ValueError:
|
||||
# Kein JSON-Text, also ein Wert: dann wird er kodiert.
|
||||
pass
|
||||
if self.kind == "bool":
|
||||
return "1" if value else "0"
|
||||
if self.kind == "json":
|
||||
@@ -67,6 +91,45 @@ class SettingSpec:
|
||||
return "" if value is None else str(value)
|
||||
|
||||
|
||||
#: So lang darf ein Wert im Protokoll werden -- danach wird gekuerzt. Ein
|
||||
#: JSON-Feld mit der halben Navigationsreihenfolge macht die Zeile unlesbar.
|
||||
WERT_GRENZE = 80
|
||||
|
||||
|
||||
class Aenderung(str):
|
||||
"""Eine geaenderte Einstellung, lesbar als ``schluessel: alt -> neu``.
|
||||
|
||||
Eine Zeichenkette und kein eigener Typ, weil die Aufrufer die Liste
|
||||
weiterhin mit ``", ".join(...)`` in einen Protokolleintrag schreiben --
|
||||
so gibt es keine Stelle, die man beim Umbau vergessen kann.
|
||||
"""
|
||||
|
||||
key: str
|
||||
alt: str
|
||||
neu: str
|
||||
|
||||
def __new__(cls, key: str, alt: Any, neu: Any, *, geheim: bool = False) -> "Aenderung":
|
||||
alt_text = "(gesetzt)" if geheim and alt else _kurz(alt)
|
||||
neu_text = "(geaendert)" if geheim else _kurz(neu)
|
||||
selbst = super().__new__(cls, f"{key}: {alt_text} -> {neu_text}")
|
||||
selbst.key = key
|
||||
selbst.alt = alt_text
|
||||
selbst.neu = neu_text
|
||||
return selbst
|
||||
|
||||
|
||||
def _kurz(wert: Any) -> str:
|
||||
"""Ein Wert als kurzer Text -- leer wird sichtbar, lang wird gekuerzt."""
|
||||
if wert is None or wert == "":
|
||||
return "(leer)"
|
||||
if isinstance(wert, bool):
|
||||
return "ein" if wert else "aus"
|
||||
text = " ".join(str(wert).split())
|
||||
if len(text) > WERT_GRENZE:
|
||||
return text[: WERT_GRENZE - 1] + "…"
|
||||
return text
|
||||
|
||||
|
||||
class SettingsStore:
|
||||
"""Zugriff auf ``settings``. Alle Methoden nehmen die bestehende Verbindung."""
|
||||
|
||||
@@ -140,16 +203,24 @@ class SettingsStore:
|
||||
|
||||
def set_many(
|
||||
self, conn: sqlite3.Connection, values: dict[str, Any], *, actor: str = ""
|
||||
) -> list[str]:
|
||||
"""Setzt mehrere Werte, gibt die tatsaechlich geaenderten Schluessel zurueck."""
|
||||
changed: list[str] = []
|
||||
) -> list["Aenderung"]:
|
||||
"""Setzt mehrere Werte und gibt die Aenderungen zurueck.
|
||||
|
||||
Jede Aenderung ist eine Zeichenkette der Form ``schluessel: alt -> neu``
|
||||
-- und traegt die Einzelteile als Attribute. Damit steht im
|
||||
Aenderungsprotokoll, was sich geaendert hat, ohne dass jede
|
||||
Einstellungsseite es einzeln zusammenbauen muss. Vorher stand dort nur
|
||||
der Schluessel, und die Frage "was war vorher eingestellt?" liess sich
|
||||
aus dem Protokoll nicht beantworten.
|
||||
"""
|
||||
changed: list[Aenderung] = []
|
||||
current = self.get_many(conn, list(values))
|
||||
for key, value in values.items():
|
||||
spec = self.spec(key)
|
||||
if spec.serialize(current.get(key)) == spec.serialize(value):
|
||||
continue
|
||||
self.set(conn, key, value, actor=actor)
|
||||
changed.append(key)
|
||||
changed.append(Aenderung(key, current.get(key), value, geheim=spec.kind == "secret"))
|
||||
return changed
|
||||
|
||||
def delete(self, conn: sqlite3.Connection, key: str) -> None:
|
||||
|
||||
@@ -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,210 @@
|
||||
}
|
||||
.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. */
|
||||
/* Zwei Klassen am selben Element: diese hier muss gewinnen, obwohl
|
||||
.modal__body weiter unten steht. Mit der doppelten Klasse haengt das
|
||||
nicht mehr an der Reihenfolge im Stylesheet. */
|
||||
.modal__body.modal__body--flush { padding: 0; max-height: none; overflow: visible; }
|
||||
.modal__caption {
|
||||
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);
|
||||
}
|
||||
|
||||
/* -- Betrachter: Bild, PDF, Rahmen ------------------------------------- */
|
||||
/* Eine feste Hoehe fuer alle drei Anzeigen: sonst springt der Dialog beim
|
||||
Wechsel zwischen Foto und PDF. */
|
||||
.viewer {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* Was der Dialog uebrig laesst: Kopf, Dateiname und Fusszeile brauchen
|
||||
zusammen rund 190 Pixel. Ein fester Anteil der Fensterhoehe schob die
|
||||
Zoomleiste auf kleinen Fenstern unter den Rand. */
|
||||
height: min(calc(100dvh - 240px), 760px);
|
||||
min-height: 220px;
|
||||
background: var(--surface-1);
|
||||
}
|
||||
/* Hochkant -- Tablet oder Monitor im Hochformat -- gibt es unter dem Dialog
|
||||
viel Platz, den die Obergrenze verschenkt. Beim Ausfuellen eines
|
||||
Formulars zaehlt jede sichtbare Zeile, also nehmen wir dort die Hoehe,
|
||||
die da ist. Der Rest (Kopf, Dateiname, Fusszeile) braucht rund 200 Pixel;
|
||||
die 240 lassen etwas Luft. */
|
||||
@media (orientation: portrait) {
|
||||
.viewer {
|
||||
height: calc(100dvh - 240px);
|
||||
}
|
||||
}
|
||||
.viewer > .file-preview {
|
||||
flex: 1;
|
||||
height: auto;
|
||||
}
|
||||
.viewer__stage {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
/* Ohne min-height waechst die Buehne mit dem Bild, und dessen
|
||||
max-height:100% haengt dann an sich selbst: ein 4032x3024-Foto stand
|
||||
36 Pixel ueber den Rand hinaus. */
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
/* Die Seite darf nicht mitwandern, wenn im Bild geschoben wird. */
|
||||
touch-action: none;
|
||||
cursor: zoom-in;
|
||||
}
|
||||
.viewer__stage--gezogen {
|
||||
cursor: grab;
|
||||
}
|
||||
.viewer__img {
|
||||
/* Der Ausgangszustand ist "passt in die Flaeche" -- genau das fehlte:
|
||||
ein Foto vom iPad ist 4032 Pixel breit und wurde im Rahmen 1:1
|
||||
gezeigt, also als Ausschnitt aus der Mitte. */
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
/* Zoomen soll nicht unscharf wirken, solange nicht vergroessert wird. */
|
||||
transform-origin: center center;
|
||||
will-change: transform;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
/* pdf.js verlangt einen absolut positionierten, scrollenden Behaelter --
|
||||
der liegt deshalb *innen*. Aussen ein gewoehnlicher Flex-Kasten, damit
|
||||
Zoomleiste und Speicherzeile ihren Platz behalten. Vorher rechnete der
|
||||
Bereich mit "bottom: var(--sp-10)", also mit genau einer Leiste; die
|
||||
zweite Zeile lag dann unter dem Dialogrand. */
|
||||
.viewer__pdf {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
/* Ein PDF ist weisses Papier. pdf.js malt Formularfelder mit Systemfarben
|
||||
und ueberlaesst Ankreuzfelder dem Browser -- im dunklen Design heisst
|
||||
CanvasText weiss, und die Haken waren auf dem Papier unsichtbar. Fuer
|
||||
diesen Ausschnitt gilt deshalb immer das helle Schema. */
|
||||
color-scheme: light;
|
||||
}
|
||||
/* pdf.js rechnet fuer die Seite mit dem Standard "content-box": ihr
|
||||
9-Pixel-Rahmen liegt *ausserhalb* der angegebenen Breite. Unser globales
|
||||
"border-box" rechnete ihn hinein -- die Leinwand wurde 18 Pixel schmaler
|
||||
als die Anmerkungsebene, und die Formularfelder wanderten nach rechts
|
||||
unten, immer weiter je weiter unten sie lagen. */
|
||||
.viewer__pdf .pdfViewer .page {
|
||||
box-sizing: content-box;
|
||||
}
|
||||
.viewer__pdfscroll {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
.viewer__bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-2) var(--sp-4);
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--surface-2, var(--surface-1));
|
||||
}
|
||||
.viewer__hint {
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
/* Bildfeld eines Formulars: der Kasten zeigt das gewaehlte Foto, die
|
||||
Schaltflaeche liegt darin. Acrobats eigener Knopf tut im Browser nichts --
|
||||
das hier ist der Ersatz. */
|
||||
.viewer__bildfeld [data-viewer-bildvorschau] {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
background: #fff;
|
||||
}
|
||||
.viewer__bildwahl {
|
||||
position: absolute;
|
||||
inset: auto 0 4px 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
.viewer__bildwahl > span {
|
||||
padding: 4px 10px;
|
||||
border-radius: var(--r-sm, 6px);
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: 600;
|
||||
box-shadow: var(--shadow-sm, 0 1px 3px rgb(0 0 0 / 0.3));
|
||||
}
|
||||
.viewer__bildfeld--belegt .viewer__bildwahl > span {
|
||||
opacity: 0.85;
|
||||
}
|
||||
.viewer__save {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: var(--sp-3);
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--surface-2, var(--surface-1));
|
||||
}
|
||||
.viewer__save .viewer__feld {
|
||||
flex: 1 1 12rem;
|
||||
margin: 0;
|
||||
}
|
||||
/* Die Entscheidung steht rechts und der Haken darueber: er gehoert zum
|
||||
Speichern, nicht zu den Feldern davor. */
|
||||
.viewer__saveaktionen {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
.viewer__saveknoepfe {
|
||||
display: flex;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
/* Nach "gibt es schon": der Haken ist die Antwort -- also faellt er auf. */
|
||||
.viewer__saveaktionen .check.is-gefragt {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 3px;
|
||||
border-radius: var(--r-xs);
|
||||
}
|
||||
/* Eigener Name: ".field--inline" gibt es schon und steht dort fuer
|
||||
"waagerecht" -- diese Regel haette sie app-weit umgestellt. */
|
||||
.viewer__feld {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
/* Der Dateiname ist ein Knopf (er oeffnet die Vorschau), soll aber wie ein
|
||||
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;
|
||||
@@ -744,7 +1010,25 @@
|
||||
text-align: center;
|
||||
}
|
||||
.matrix thead th:first-child { text-align: left; }
|
||||
.matrix td:not(:first-child), .matrix th:not(:first-child) { width: 62px; text-align: center; }
|
||||
/* Kuerzel, englischer Name, deutsche Beschriftung -- untereinander. Nur das
|
||||
Kuerzel allein war eine Spalte, die man nachschlagen musste; die Legende
|
||||
steht unter einer Tabelle, die man dafuer erst zu Ende scrollt. */
|
||||
.matrix__spalte { display: flex; flex-direction: column; align-items: center; line-height: 1.15; }
|
||||
.matrix__spalte b {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--text);
|
||||
font-weight: 650;
|
||||
}
|
||||
.matrix__spalte span { font-size: var(--fs-xs); color: var(--text-muted); text-transform: none; }
|
||||
.matrix__spalte small {
|
||||
font-size: 10px;
|
||||
color: var(--text-faint);
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
font-weight: 400;
|
||||
}
|
||||
.matrix td:not(:first-child), .matrix th:not(:first-child) { width: 72px; text-align: center; }
|
||||
.matrix tr.matrix__area > td {
|
||||
background: var(--surface-2);
|
||||
font-weight: 640;
|
||||
@@ -759,7 +1043,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;
|
||||
@@ -767,6 +1054,46 @@
|
||||
}
|
||||
.matrix__resource-label { display: flex; flex-direction: column; gap: 1px; }
|
||||
.matrix__resource-label small { color: var(--text-faint); font-size: var(--fs-xs); }
|
||||
.matrix__zeile { display: flex; align-items: baseline; gap: var(--sp-2); }
|
||||
.matrix__zeile b { font-weight: 600; }
|
||||
|
||||
/* Unterzeile: gehoert zur Zeile darueber. Ohne Einrueckung liest man sechs
|
||||
unabhaengige Dinge, wo sechs Teile eines Ganzen stehen. */
|
||||
.matrix tr.matrix__unterzeile > td:first-child { padding-left: var(--sp-6); }
|
||||
.matrix tr.matrix__unterzeile > td:first-child .matrix__zeile b { font-weight: 500; }
|
||||
|
||||
/* Was eine Aktion in dieser Zeile bedeutet -- in der Zeile, nicht im
|
||||
Titelattribut: mit dem Finger gibt es kein "hover". */
|
||||
.matrix__bedeutung { color: var(--text-faint); font-size: var(--fs-xs); }
|
||||
.matrix__bedeutung b {
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.matrix__alle {
|
||||
border: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
border-radius: var(--r-sm);
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
padding: 2px 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.matrix__alle:hover { color: var(--accent); border-color: var(--accent); }
|
||||
|
||||
.matrix__filter {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--sp-3);
|
||||
margin-bottom: var(--sp-3);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.matrix__filter-hint { color: var(--text-faint); font-size: var(--fs-xs); }
|
||||
/* Eine ausgefilterte Zeile ist versteckt, nicht entfernt: ihre Kaestchen
|
||||
bleiben im Formular, sonst loescht ein Filter beim Speichern Rechte. */
|
||||
.matrix tr[hidden] { display: none; }
|
||||
.matrix__legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -806,6 +1133,16 @@
|
||||
}
|
||||
.log-view mark { background: var(--warning-soft); color: var(--warning); border-radius: 3px; }
|
||||
|
||||
/* Stufen im laufenden Protokoll. Die Zuordnung der Farbrollen ist Absicht:
|
||||
--warning ist bernsteinfarben und traegt den HINWEIS, --danger ist rot und
|
||||
traegt die WARNUNG. Wer das nach den Namen "aufraeumt", dreht die Bedeutung
|
||||
um: ein Geraet, das nicht antwortet, waere dann orange und eines, das neu
|
||||
startet, rot. */
|
||||
.log-view__zeile--info { color: var(--success); }
|
||||
.log-view__zeile--hinweis { color: var(--warning); }
|
||||
.log-view__zeile--warnung { color: var(--danger); }
|
||||
.log-view__zeile--fehler { color: var(--danger); font-weight: 600; }
|
||||
|
||||
.kv { display: grid; grid-template-columns: auto 1fr; gap: var(--sp-2) var(--sp-4); font-size: var(--fs-base); }
|
||||
.kv dt { color: var(--text-faint); white-space: nowrap; }
|
||||
.kv dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
|
||||
@@ -853,9 +1190,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.
|
||||
@@ -875,6 +1218,12 @@
|
||||
--tile-accent: var(--text-faint);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
/* Spalte, damit die Fusszeile unten haengen kann (siehe .tile__actions).
|
||||
Die Kacheln eines Rasters sind gleich hoch, ihr Inhalt aber nicht: mal
|
||||
fehlt der Switch, mal der Standort. Ohne diesen Halt stuenden die Knoepfe
|
||||
in jeder Kachel auf einer anderen Hoehe -- und das Auge sucht sie. */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: var(--sp-4);
|
||||
padding-left: calc(var(--sp-4) + 3px);
|
||||
border: 1px solid var(--border-subtle);
|
||||
@@ -936,24 +1285,102 @@
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
.auth-page { position: relative; overflow: hidden; align-content: center; }
|
||||
/* Mitlesen auf der Wartungsseite: die Ausgabe eines laufenden Auftrags,
|
||||
direkt unter seinem Geraet. Dunkler Grund wie in jeder Konsole -- was
|
||||
dort steht, kommt von einer fremden Maschine und nicht aus dieser
|
||||
Oberflaeche. */
|
||||
.job-output {
|
||||
margin: 0;
|
||||
max-height: 220px;
|
||||
overflow: auto;
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
background: #101216;
|
||||
color: #d7dbe0;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-xs);
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------- #
|
||||
Bildschirmsitzung (VNC)
|
||||
|
||||
Die Leinwand bekommt einen dunklen Grund -- unabhaengig vom Thema der
|
||||
Oberflaeche: was darauf erscheint, ist der Bildschirm eines fremden
|
||||
Geraets, und ein heller Rahmen darum laesst ihn wie einen Teil dieser
|
||||
Seite aussehen. Er ist keiner.
|
||||
---------------------------------------------------------------------- */
|
||||
.screen {
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--r-md);
|
||||
background: #101216;
|
||||
overflow: hidden;
|
||||
}
|
||||
.screen__canvas {
|
||||
width: 100%;
|
||||
/* Genug Hoehe, dass ein Bildschirm erkennbar ist, ohne die Seite zu
|
||||
sprengen; noVNC skaliert das Bild hinein. */
|
||||
height: min(72vh, 760px);
|
||||
}
|
||||
/* Im Fenster ist weniger Platz -- und das Fenster soll nicht ueber den
|
||||
Bildschirmrand hinauswachsen, nur weil das Geraet hochaufloesend ist. */
|
||||
dialog.modal .screen__canvas { height: min(62vh, 620px); }
|
||||
.screen__status {
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--text-faint);
|
||||
}
|
||||
.screen__status--ok { color: var(--success); }
|
||||
.screen__status--warnung { color: var(--warning); }
|
||||
.screen__status--fehler { color: var(--danger); }
|
||||
|
||||
/* Fusszeile der Kachel: links der Bildschirm, rechts der Neustart.
|
||||
``margin-top: auto`` schiebt sie an den unteren Rand -- unabhaengig
|
||||
davon, wie viel darueber steht. Die beiden Eingriffe stehen damit in
|
||||
jeder Kachel an derselben Stelle, und der eine liegt nicht dort, wo eben
|
||||
noch der andere war. */
|
||||
.tile__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
margin-top: auto;
|
||||
padding-top: var(--sp-3);
|
||||
}
|
||||
.tile__actions > :last-child { margin-left: auto; }
|
||||
/* Steht nur ein Knopf da, bleibt er links -- sonst spraenge der Neustart bei
|
||||
einem Geraet ohne Bildschirmzugang an die Stelle, an der nebenan VNC
|
||||
steht. Das Fuellstueck haelt den Platz. */
|
||||
.tile__spacer { flex: 1 1 auto; }
|
||||
|
||||
/* 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 +1428,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; }
|
||||
@@ -1038,3 +1475,340 @@
|
||||
.hide-sm { display: none !important; }
|
||||
}
|
||||
}
|
||||
|
||||
/* -- Sortierbare Liste (Navigationsreihenfolge) --------------------------- */
|
||||
/* Symbol und Beschriftung stehen in einer Zeile -- so wie im Menue selbst.
|
||||
Vorher brach die Beschriftung unter das Symbol, und die Liste sah aus wie
|
||||
etwas anderes als das, was sie sortiert. */
|
||||
|
||||
.reorder {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.reorder__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--surface-2);
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.reorder__row > .flex-1 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.reorder__row.is-dragging {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.reorder__grip {
|
||||
color: var(--text-muted);
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.reorder__pos {
|
||||
min-width: 1.6em;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--fs-xs);
|
||||
}
|
||||
|
||||
.reorder__moves {
|
||||
display: inline-flex;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.reorder__group {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.reorder--child {
|
||||
margin: 4px 0 4px 28px;
|
||||
}
|
||||
|
||||
.reorder--child .reorder__row {
|
||||
background: transparent;
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
/* -- Unterschrift --------------------------------------------------------- */
|
||||
/* Die Zeichenflaeche braucht "touch-action: none": sonst scrollt ein iPad die
|
||||
Seite, statt einen Strich zu zeichnen -- und es sieht aus, als reagierte das
|
||||
Feld nicht. */
|
||||
|
||||
.viewer__sign {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.viewer__signfelder {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.viewer__signpad {
|
||||
width: 100%;
|
||||
height: 160px;
|
||||
border: 1px dashed var(--border-strong, var(--border));
|
||||
border-radius: var(--r-sm);
|
||||
background: #fff;
|
||||
touch-action: none;
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.viewer__signaktionen {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.viewer__pdfscroll--setzen {
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.viewer__pdfscroll--setzen .page {
|
||||
outline: 2px dashed var(--accent, #c8791f);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
/* Die gesetzte Unterschrift liegt auf der Seite -- in Prozent, damit sie beim
|
||||
Zoomen an ihrer Stelle bleibt. Sie nimmt Zeiger an: verschieben mit dem
|
||||
Block, Groesse mit dem Griff unten rechts, entfernen mit dem Kreuz.
|
||||
"touch-action: none" ist auf dem iPad die Bedingung dafuer, dass ein Finger
|
||||
zieht statt die Seite zu scrollen. */
|
||||
/* Die Marke ist genau die Zeichnung: ihre Unterkante liegt auf dem
|
||||
Ankerpunkt -- dieselbe Kante, an der pdf-lib das Bild setzt. Der Text
|
||||
haengt darunter und darf die Hoehe nicht mitbestimmen. */
|
||||
.viewer__unterschrift {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
/* "outline" statt "border": ein Rahmen haette die Marke um zwei Pixel
|
||||
groesser gemacht und die Bildkante vom Ankerpunkt weggeschoben. */
|
||||
outline: 1px dashed transparent;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.viewer__unterschrift:hover,
|
||||
.viewer__unterschrift.is-fassend {
|
||||
outline-color: var(--accent);
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.viewer__unterschrift img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Dieselbe Rechnung wie im PDF (Breite durch 18): auf dem Schirm steht, was
|
||||
spaeter in der Datei steht. */
|
||||
/* "width: max-content": der Strich ist so lang wie der Text darunter -- so
|
||||
wie im PDF. Waere er so breit wie der Block, zeigte die Vorschau etwas
|
||||
anderes als die Datei. */
|
||||
.viewer__unterschrifttext {
|
||||
/* Unter der Zeichnung, nicht in ihr: "top: 100%" setzt die Oberkante des
|
||||
Strichs auf die Bildkante -- genau dort zieht ihn auch pdf-lib. Abstand,
|
||||
Strichstaerke und Innenabstand rechnet das Skript in Punkten. */
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
/* Helvetica: dieselbe Schrift, die das PDF benutzt (StandardFonts.Helvetica).
|
||||
Mit der Hausschrift sah die Vorschau anders aus als das Ergebnis. Die
|
||||
Groesse setzt das Skript je Unterschrift -- hier steht nur ein Rueckfall. */
|
||||
font-family: Helvetica, Arial, sans-serif;
|
||||
/* Die tatsaechliche Groesse setzt das Skript in Pixeln (Punkte mal
|
||||
Pixel-je-Punkt der Seite). Dieser Wert gilt nur, solange sie noch nicht
|
||||
gerechnet wurde -- geerbt wuerde sonst die Schriftgroesse der Seite. */
|
||||
font-size: 8px;
|
||||
line-height: 1.15;
|
||||
color: #111;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.85);
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
.viewer__unterschriftgriff,
|
||||
.viewer__unterschriftweg {
|
||||
position: absolute;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 3px;
|
||||
background: var(--surface-1);
|
||||
color: var(--accent);
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
opacity: 0;
|
||||
cursor: nwse-resize;
|
||||
}
|
||||
|
||||
.viewer__unterschriftweg {
|
||||
cursor: pointer;
|
||||
top: -8px;
|
||||
right: -8px;
|
||||
}
|
||||
|
||||
.viewer__unterschriftgriff {
|
||||
right: -8px;
|
||||
bottom: -8px;
|
||||
}
|
||||
|
||||
.viewer__unterschrift:hover .viewer__unterschriftgriff,
|
||||
.viewer__unterschrift:hover .viewer__unterschriftweg,
|
||||
.viewer__unterschrift.is-fassend .viewer__unterschriftgriff {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Auf einem Tastbildschirm gibt es kein "hover" -- dort stehen die Knoepfe
|
||||
immer, sonst kaeme man nie an sie heran. */
|
||||
@media (hover: none) {
|
||||
.viewer__unterschriftgriff,
|
||||
.viewer__unterschriftweg {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* -- Ergebnis der Signaturpruefung ---------------------------------------- */
|
||||
/* Der Schleier liegt ueber der Seite und nimmt keine Klicks an: ein
|
||||
unterschriebenes Dokument ist flach, aber bei einem Irrtum soll die
|
||||
Kennzeichnung nicht auch noch die Bedienung blockieren. */
|
||||
|
||||
.viewer__pdf--unveraendert {
|
||||
box-shadow: inset 0 0 0 3px var(--success);
|
||||
}
|
||||
|
||||
.viewer__pdf--unveraendert::after,
|
||||
.viewer__pdf--veraendert::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 4;
|
||||
}
|
||||
|
||||
.viewer__pdf--unveraendert::after {
|
||||
background: rgba(47, 158, 68, 0.1);
|
||||
}
|
||||
|
||||
.viewer__pdf--veraendert {
|
||||
box-shadow: inset 0 0 0 3px var(--danger);
|
||||
}
|
||||
|
||||
.viewer__pdf--veraendert::after {
|
||||
background: rgba(201, 42, 42, 0.08);
|
||||
}
|
||||
|
||||
.viewer__pruefung {
|
||||
margin: 0;
|
||||
padding: 6px 12px;
|
||||
font-size: var(--fs-xs);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.viewer__pruefung--gut {
|
||||
color: var(--success);
|
||||
background: rgba(47, 158, 68, 0.12);
|
||||
}
|
||||
|
||||
.viewer__pruefung--schlecht {
|
||||
color: var(--danger);
|
||||
background: rgba(201, 42, 42, 0.12);
|
||||
}
|
||||
|
||||
/* -- Tabelle, die schmal zu Bloecken wird -------------------------------- */
|
||||
/* Eine Protokollzeile besteht aus sieben Angaben, von denen eine lang ist:
|
||||
das Detail. Auf einem breiten Schirm passt das nebeneinander. Wird es eng,
|
||||
ist eine Spalte von zwei Zentimetern fuer eine Pruefsumme keine Loesung --
|
||||
dann steht jede Angabe auf ihrer eigenen Zeile, beschriftet. */
|
||||
|
||||
.table--stapelbar .td--detail {
|
||||
/* Zeilenumbrueche aus dem Eintrag bleiben stehen: die Pruefsumme steht
|
||||
unter dem lesbaren Teil, nicht mitten darin. Und ein Hash ohne Trennstelle
|
||||
darf notfalls hart umbrechen. */
|
||||
white-space: pre-line;
|
||||
overflow-wrap: anywhere;
|
||||
max-width: 52ch;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.table--stapelbar thead {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.table--stapelbar,
|
||||
.table--stapelbar tbody,
|
||||
.table--stapelbar tr,
|
||||
.table--stapelbar td {
|
||||
display: block;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.table--stapelbar tr {
|
||||
padding: var(--sp-3) 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.table--stapelbar td {
|
||||
display: grid;
|
||||
grid-template-columns: 8.5rem 1fr;
|
||||
gap: var(--sp-2);
|
||||
align-items: baseline;
|
||||
border: 0;
|
||||
padding: 2px var(--sp-3);
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.table--stapelbar td::before {
|
||||
content: attr(data-spalte);
|
||||
color: var(--text-muted);
|
||||
font-size: var(--fs-xs);
|
||||
}
|
||||
|
||||
.table--stapelbar .td--detail {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
/* Leere Zellen brauchen keine Zeile. */
|
||||
.table--stapelbar td:empty {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* -- Dateiliste: Symbol und Name in einer Zeile -------------------------- */
|
||||
/* Das Symbol ist ein Block-SVG; ohne diese Regel stand der Name darunter und
|
||||
eine Ordnerzeile sah doppelt so hoch aus wie eine Dateizeile. */
|
||||
|
||||
.file-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.file-cell > svg {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -86,7 +109,7 @@ export function showToast(message, type = "info", { timeout = 6000 } = {}) {
|
||||
const close = document.createElement("button");
|
||||
close.type = "button";
|
||||
close.className = "toast__close";
|
||||
close.setAttribute("aria-label", "Meldung schliessen");
|
||||
close.setAttribute("aria-label", "Meldung schließen");
|
||||
close.textContent = "×";
|
||||
toast.append(body, close);
|
||||
toastStack.appendChild(toast);
|
||||
@@ -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);
|
||||
@@ -262,7 +293,7 @@ function initConfirmations() {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
pending = trigger;
|
||||
titleNode.textContent = trigger.dataset.confirmTitle || "Bitte bestaetigen";
|
||||
titleNode.textContent = trigger.dataset.confirmTitle || "Bitte bestätigen";
|
||||
textNode.textContent = trigger.dataset.confirm;
|
||||
okButton.textContent = trigger.dataset.confirmOk || "Fortfahren";
|
||||
okButton.className = `btn btn--${trigger.dataset.confirmStyle || "danger"}`;
|
||||
@@ -409,6 +440,56 @@ function initPermissionMatrix() {
|
||||
});
|
||||
};
|
||||
|
||||
/* -- Ganze Zeile, ganze Spalte, Filter -----------------------------
|
||||
Bei 37 Zeilen und bis zu sieben Spalten je Bereich sind das ueber
|
||||
zweihundert Kaestchen. Ohne diese drei Hilfen setzt man oben den
|
||||
Bereichsschalter und laesst es dabei -- und die feine Aufteilung
|
||||
haette nichts gebracht. */
|
||||
// Nur was man sieht: eine vom Filter ausgeblendete Zeile darf ein
|
||||
// Sammelknopf nicht mitnehmen. Sonst setzt man "alle Write" waehrend nach
|
||||
// "Neustart" gefiltert ist und aendert dabei Zeilen, die gar nicht auf dem
|
||||
// Schirm stehen -- und merkt es erst nach dem Speichern.
|
||||
const sichtbar = (box) => !box.disabled && !box.closest("tr").hidden;
|
||||
const felder = (bereich) =>
|
||||
$$('input[type="checkbox"][name][data-action]', bereich).filter(sichtbar);
|
||||
|
||||
table.addEventListener("click", (event) => {
|
||||
const ziel = event.target;
|
||||
if (!(ziel instanceof HTMLElement)) return;
|
||||
|
||||
const zeilenknopf = ziel.closest("[data-matrix-row]");
|
||||
if (zeilenknopf) {
|
||||
event.preventDefault();
|
||||
const row = zeilenknopf.closest("tr");
|
||||
if (!row) return;
|
||||
const kaestchen = felder(row);
|
||||
// Erst wenn alles gesetzt ist, nimmt der Knopf weg -- sonst waere
|
||||
// ein zweiter Klick auf eine halb gesetzte Zeile ein Ruecksprung.
|
||||
const alleGesetzt = kaestchen.length > 0 && kaestchen.every((box) => box.checked);
|
||||
kaestchen.forEach((box) => {
|
||||
box.checked = !alleGesetzt;
|
||||
box.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const spaltenknopf = ziel.closest("[data-matrix-column]");
|
||||
if (spaltenknopf) {
|
||||
event.preventDefault();
|
||||
const aktion = spaltenknopf.dataset.matrixColumn;
|
||||
const bereich = spaltenknopf.dataset.area;
|
||||
const kaestchen = $$(
|
||||
`tr[data-area="${bereich}"] input[data-action="${aktion}"][name]`,
|
||||
table
|
||||
).filter(sichtbar);
|
||||
const alleGesetzt = kaestchen.length > 0 && kaestchen.every((box) => box.checked);
|
||||
kaestchen.forEach((box) => {
|
||||
box.checked = !alleGesetzt;
|
||||
box.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
$$("[data-area-switch]", table).forEach((master) => {
|
||||
const areaKey = master.dataset.areaSwitch;
|
||||
syncArea(areaKey);
|
||||
@@ -420,8 +501,12 @@ function initPermissionMatrix() {
|
||||
if (!(box instanceof HTMLInputElement) || !box.dataset.action) return;
|
||||
const row = box.closest("tr");
|
||||
if (!row) return;
|
||||
const viewBox = $('input[data-action="view"]', row);
|
||||
if (box.dataset.action === "view") {
|
||||
// "read", nicht "view": die Aktion heisst seit 2.8.0 wie das, was sie
|
||||
// tut. Mit dem alten Namen fand diese Zeile das Kaestchen nicht mehr --
|
||||
// die Matrix haette sich weiter bedienen lassen und dabei die Regel
|
||||
// "jede Aktion setzt Ansehen voraus" stillschweigend fallen gelassen.
|
||||
const viewBox = $('input[data-action="read"]', row);
|
||||
if (box.dataset.action === "read") {
|
||||
// "Ansehen" abwaehlen entfernt alles andere dieser Zeile.
|
||||
if (!box.checked) {
|
||||
$$('input[type="checkbox"][name]', row).forEach((other) => {
|
||||
@@ -438,6 +523,30 @@ function initPermissionMatrix() {
|
||||
master.dispatchEvent(new Event("change"));
|
||||
}
|
||||
});
|
||||
|
||||
const suche = $("[data-matrix-filter]");
|
||||
const zaehler = $("[data-matrix-count]");
|
||||
if (suche) {
|
||||
const zeilen = $$("tr[data-suchtext]", table);
|
||||
const filtern = () => {
|
||||
const begriff = suche.value.trim().toLowerCase();
|
||||
let sichtbar = 0;
|
||||
zeilen.forEach((row) => {
|
||||
const passt = !begriff || (row.dataset.suchtext || "").includes(begriff);
|
||||
// "hidden" und nicht aus dem Formular nehmen: ein Filter, der
|
||||
// Kaestchen entfernt, loescht beim Speichern Rechte.
|
||||
row.hidden = !passt;
|
||||
if (passt) sichtbar += 1;
|
||||
});
|
||||
if (zaehler) {
|
||||
zaehler.textContent = begriff
|
||||
? `${sichtbar} von ${zeilen.length} Zeilen`
|
||||
: `${zeilen.length} Zeilen`;
|
||||
}
|
||||
};
|
||||
suche.addEventListener("input", filtern);
|
||||
filtern();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -466,14 +575,14 @@ function initAutoRefresh() {
|
||||
nameField.value = entry.name || "";
|
||||
dnField.value = entry.dn || "";
|
||||
results.hidden = true;
|
||||
show(`Uebernommen: ${entry.name || entry.dn}`);
|
||||
show(`Übernommen: ${entry.name || entry.dn}`);
|
||||
};
|
||||
|
||||
const search = async () => {
|
||||
const term = query.value.trim();
|
||||
if (term.length < 2) { show("Bitte mindestens zwei Zeichen eingeben."); return; }
|
||||
button.disabled = true;
|
||||
show("Suche laeuft ...");
|
||||
show("Suche läuft ...");
|
||||
results.innerHTML = "";
|
||||
try {
|
||||
const url = `${picker.dataset.searchUrl}?q=${encodeURIComponent(term)}`;
|
||||
@@ -501,10 +610,10 @@ function initAutoRefresh() {
|
||||
results.appendChild(row);
|
||||
});
|
||||
results.hidden = false;
|
||||
show(`${entries.length} Treffer -- zum Uebernehmen anklicken.`);
|
||||
show(`${entries.length} Treffer – zum Übernehmen anklicken.`);
|
||||
} catch (error) {
|
||||
results.hidden = true;
|
||||
show("Die Suche ist fehlgeschlagen. Laeuft die Verbindung zum Verzeichnis?");
|
||||
show("Die Suche ist fehlgeschlagen. Läuft die Verbindung zum Verzeichnis?");
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
@@ -548,7 +657,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 +675,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 +692,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();
|
||||
@@ -676,7 +798,7 @@ function initClipboard() {
|
||||
timeout: 2500,
|
||||
});
|
||||
} catch {
|
||||
showToast("Kopieren nicht moeglich -- bitte manuell markieren.", "warning");
|
||||
showToast("Kopieren nicht möglich – bitte manuell markieren.", "warning");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -812,8 +934,102 @@ function initTileFilters() {
|
||||
});
|
||||
}
|
||||
|
||||
/* -- Reihenfolge aendern -------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Eine Liste, deren Reihenfolge man aendern kann -- ohne Bibliothek.
|
||||
*
|
||||
* Die Reihenfolge steckt in der Reihenfolge der versteckten Felder: das
|
||||
* Formular schickt sie als wiederholtes Feld, und der Server liest sie als
|
||||
* Liste. Verschiebt man einen Eintrag, verschiebt sich das Feld mit ihm.
|
||||
*
|
||||
* Zwei Wege, weil es zwei Arten von Geraeten gibt: Ziehen mit der Maus und
|
||||
* zwei Knoepfe je Zeile. Die Knoepfe sind nicht die Notloesung -- auf einem
|
||||
* Tablet sind sie der einzige Weg, der zuverlaessig funktioniert, und mit der
|
||||
* Tastatur ebenso.
|
||||
*/
|
||||
function initReorder() {
|
||||
$$('[data-behavior="reorder"]').forEach((liste) => {
|
||||
// Nur die eigenen Zeilen: Gruppen enthalten wiederum eine sortierbare
|
||||
// Liste, und deren Zeilen gehoeren der inneren Liste, nicht dieser.
|
||||
const zeilen = () => Array.from(liste.querySelectorAll(":scope > [data-reorder-item]"));
|
||||
|
||||
const nummerieren = () => {
|
||||
zeilen().forEach((zeile, index) => {
|
||||
const nummer = zeile.querySelector("[data-reorder-position-text]");
|
||||
if (nummer) nummer.textContent = String(index + 1);
|
||||
const hoch = zeile.querySelector('[data-reorder-move="up"]');
|
||||
const runter = zeile.querySelector('[data-reorder-move="down"]');
|
||||
if (hoch) hoch.disabled = index === 0;
|
||||
if (runter) runter.disabled = index === zeilen().length - 1;
|
||||
});
|
||||
};
|
||||
|
||||
liste.addEventListener("click", (event) => {
|
||||
const knopf = event.target.closest("[data-reorder-move]");
|
||||
if (!knopf) return;
|
||||
const zeile = knopf.closest("[data-reorder-item]");
|
||||
if (!zeile || zeile.parentElement !== liste) return;
|
||||
event.preventDefault();
|
||||
if (knopf.dataset.reorderMove === "up") {
|
||||
const davor = zeile.previousElementSibling;
|
||||
if (davor) liste.insertBefore(zeile, davor);
|
||||
} else {
|
||||
const danach = zeile.nextElementSibling;
|
||||
if (danach) liste.insertBefore(danach, zeile);
|
||||
}
|
||||
nummerieren();
|
||||
zeile.querySelector(`[data-reorder-move="${knopf.dataset.reorderMove}"]`)?.focus();
|
||||
});
|
||||
|
||||
let gezogen = null;
|
||||
liste.addEventListener("dragstart", (event) => {
|
||||
const zeile = event.target.closest("[data-reorder-item]");
|
||||
if (!zeile || zeile.parentElement !== liste) return;
|
||||
gezogen = zeile;
|
||||
gezogen.classList.add("is-dragging");
|
||||
if (event.dataTransfer) event.dataTransfer.effectAllowed = "move";
|
||||
});
|
||||
liste.addEventListener("dragend", () => {
|
||||
if (gezogen) gezogen.classList.remove("is-dragging");
|
||||
gezogen = null;
|
||||
nummerieren();
|
||||
});
|
||||
liste.addEventListener("dragover", (event) => {
|
||||
if (!gezogen) return;
|
||||
event.preventDefault();
|
||||
const ziel = event.target.closest("[data-reorder-item]");
|
||||
if (!ziel || ziel === gezogen || ziel.parentElement !== liste) return;
|
||||
const kasten = ziel.getBoundingClientRect();
|
||||
const obereHaelfte = event.clientY < kasten.top + kasten.height / 2;
|
||||
liste.insertBefore(gezogen, obereHaelfte ? ziel : ziel.nextElementSibling);
|
||||
});
|
||||
|
||||
nummerieren();
|
||||
});
|
||||
}
|
||||
|
||||
/* -- Start ---------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* 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,7 +1046,23 @@ function boot() {
|
||||
initPalette();
|
||||
initSubmitGuards();
|
||||
initTileFilters();
|
||||
initReorder();
|
||||
initLogTails();
|
||||
unsavedGuard.init();
|
||||
initViewer();
|
||||
}
|
||||
|
||||
/* Der Dateibetrachter kommt nur auf Seiten mit Vorschau -- und er zieht bei
|
||||
einem PDF noch pdf.js nach. Beides erst dann, wenn es gebraucht wird. */
|
||||
function initViewer() {
|
||||
if (!document.querySelector("[data-viewer]")) return;
|
||||
// Dieselbe Version wie diese Datei: app.js wird als ".../app.js?v=<Zeit>"
|
||||
// eingebunden, und nginx liefert /core-assets/ mit "expires 7d". Ohne das
|
||||
// Anhaengsel bliebe der alte Betrachter nach einem Update im Cache liegen.
|
||||
const version = new URL(import.meta.url).search;
|
||||
import(`./viewer.js${version}`)
|
||||
.then((modul) => modul.initFilePreview())
|
||||
.catch((fehler) => console.error("Dateibetrachter", fehler));
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
|
||||
@@ -0,0 +1,468 @@
|
||||
// Bildschirmsitzung im Browser: noVNC an die WebSocket-Strecke von TESM.
|
||||
//
|
||||
// Der Client handelt das RFB-Protokoll unmittelbar mit dem Geraet aus; TESM
|
||||
// reicht nur Bytes durch. Alles, was diese Datei tut, ist: verbinden, den
|
||||
// Zustand in Worte fassen und nach Zugangsdaten fragen, wenn keine hinterlegt
|
||||
// sind.
|
||||
//
|
||||
// Die Meldungen sind der eigentliche Wert. Eine Sitzung, die schwarz bleibt,
|
||||
// laesst offen, ob es an der Berechtigung, am Netz, am Server oder am
|
||||
// Passwort liegt -- und wer das nicht sieht, sucht den Fehler in TESM.
|
||||
//
|
||||
// **Ein Bauteil, zwei Orte.** Dieselbe Sitzung laeuft auf der Bildschirmseite
|
||||
// und im Fenster der Statusuebersicht. Sie holt alles, was sie braucht, aus
|
||||
// einer JSON-Auskunft des Servers -- damit gibt es keine zweite Fassung, die
|
||||
// man beim naechsten Fund vergisst nachzuziehen.
|
||||
|
||||
import RFB from "../vendor/novnc/core/rfb.js";
|
||||
|
||||
// Drei Eingriffe in private Methoden der mitgelieferten Bibliothek. Bewusst
|
||||
// hier und nicht in der Bibliothek selbst: eine neue Fassung von noVNC laesst
|
||||
// sich dann austauschen, ohne eine Aenderung nachzupflegen -- faellt eine
|
||||
// Methode weg, entfaellt nur die Auskunft, nicht die Sitzung.
|
||||
//
|
||||
// Zwei davon holen nur Auskunft (_fail, _socketClose). Der dritte greift in
|
||||
// die Wahl des Sicherheitsverfahrens ein -- aber erst, **nachdem** eines
|
||||
// nachweislich nicht getragen hat, oder weil eines schon einmal getragen hat.
|
||||
//
|
||||
// Die Vorgeschichte gehoert dazu, damit niemand den Schritt zurueckgeht: hier
|
||||
// stand einmal eine Vorauswahl, die ohne hinterlegte Zugangsdaten ein
|
||||
// Verfahren ohne Anmeldung erzwang, weil RealVNC RA2ne zuerst nennt. Das war
|
||||
// geraten und falsch. Was jetzt hier steht, ist das Gegenteil einer Vermutung:
|
||||
// ein Befund aus einer vorherigen Sitzung.
|
||||
const istUnterstuetzt = RFB.prototype._isSupportedSecurityType;
|
||||
|
||||
//: Den *Grund* eines Fehlschlags kennt nur ``_fail`` -- das Ereignis
|
||||
//: "disconnect" traegt ihn nicht, und der Weg ueber die Protokollausgabe
|
||||
//: funktioniert nicht: die Bibliothek bindet ``console.error`` beim Laden,
|
||||
//: also bevor eine Seite etwas daran aendern koennte.
|
||||
let letzterGrund = "";
|
||||
const originalFail = RFB.prototype._fail;
|
||||
RFB.prototype._fail = function (details) {
|
||||
letzterGrund = String(details);
|
||||
return originalFail.call(this, details);
|
||||
};
|
||||
|
||||
//: Und der Abschluss der Leitung selbst: der Schliesscode sagt, ob TESM
|
||||
//: abgewiesen hat (1008), ob es ein Serverfehler war (1011) -- oder ob die
|
||||
//: Verbindung nie zustande kam (1006).
|
||||
let letzterCode = 0;
|
||||
const originalSocketClose = RFB.prototype._socketClose;
|
||||
RFB.prototype._socketClose = function (ereignis) {
|
||||
letzterCode = (ereignis && ereignis.code) || 0;
|
||||
if (ereignis && ereignis.reason) {
|
||||
letzterGrund = letzterGrund || String(ereignis.reason);
|
||||
}
|
||||
return originalSocketClose.call(this, ereignis);
|
||||
};
|
||||
|
||||
//: Hoechstens ein zweiter Anlauf. Jeder Versuch ist eine Verbindung, und ein
|
||||
//: RealVNC Server zaehlt abgebrochene Versuche mit -- wer dreimal anklopft,
|
||||
//: sperrt sich aus.
|
||||
const HOECHSTENS = 2;
|
||||
|
||||
//: So lange darf "wird aufgebaut" stehen, bevor die Seite selbst nachsieht.
|
||||
//: Kommt die WebSocket-Verbindung gar nicht erst zustande, wartet noVNC ohne
|
||||
//: jedes Ereignis, und die Seite stuende stumm bis zum Seitenwechsel.
|
||||
const AUFBAU_GRENZE = 12000;
|
||||
|
||||
//: Verfahren, die im Browser keinen Schluesseltausch kosten.
|
||||
//:
|
||||
//: Der Grund steht in der Bibliothek, `core/ra2.js`: fuer RA2ne erzeugt der
|
||||
//: **Browser** bei jeder Sitzung ein eigenes 2048-Bit-RSA-Schluesselpaar, in
|
||||
//: JavaScript. Die Primzahlsuche dafuer dauert ein bis drei Minuten -- genau
|
||||
//: die Zeit, die zwischen Klick und Bild lag. "Ohne Authentifizierung" (1)
|
||||
//: kostet davon nichts.
|
||||
//:
|
||||
//: Deshalb kommt es zuerst, **wenn der Server es anbietet** und wir nicht
|
||||
//: ohnehin schon wissen, was hier traegt. Das ist nicht dieselbe Vorauswahl
|
||||
//: wie in 2.8.14: die war geraten, diese steht auf einer Messung. Traegt sie
|
||||
//: nicht, nimmt der zweite Anlauf die Reihenfolge des Servers -- dann dauert
|
||||
//: es eben.
|
||||
const OHNE_SCHLUESSELTAUSCH = new Set([1]);
|
||||
|
||||
//: RA2ne. Wird es ausgehandelt, ist die Wartezeit erklaerbar und kein Fehler.
|
||||
const RA2NE = 6;
|
||||
|
||||
//: Tastenkombinationen, die der Arbeitsplatz selbst abfaengt und die deshalb
|
||||
//: einen Knopf brauchen. X11-Keysyms.
|
||||
const ALT_LINKS = 0xffe9;
|
||||
const F4 = 0xffc1;
|
||||
const F5 = 0xffc2;
|
||||
|
||||
const schema = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
|
||||
/**
|
||||
* Baut eine Sitzung in diesem Behaelter auf.
|
||||
*
|
||||
* ``quelle`` ist die Adresse der JSON-Auskunft; alles Weitere steht dort.
|
||||
* Rueckgabe: eine Funktion, die die Sitzung wieder abbaut.
|
||||
*/
|
||||
export function starteSitzung(wurzel, quelle, { beiEnde = null } = {}) {
|
||||
const teil = (rolle) => wurzel.querySelector(`[data-rolle="${rolle}"]`);
|
||||
const leinwand = teil("leinwand");
|
||||
const zeile = teil("status");
|
||||
const abfrage = teil("zugangsdaten");
|
||||
const feldBenutzer = teil("benutzer");
|
||||
const feldPasswort = teil("passwort");
|
||||
const knopfSenden = teil("anmelden");
|
||||
const knopfTrennen = teil("trennen");
|
||||
const knoepfeSitzung = [...wurzel.querySelectorAll("[data-rolle^='taste-']"), knopfTrennen];
|
||||
|
||||
const gescheitert = new Set();
|
||||
let rfb = null;
|
||||
let aufbauUhr = 0;
|
||||
let versuche = 0;
|
||||
let stand = false;
|
||||
let daten = null;
|
||||
let letzterZugang = {};
|
||||
let beendet = false;
|
||||
|
||||
function melden(text, art = "") {
|
||||
if (!zeile) return;
|
||||
zeile.textContent = text;
|
||||
zeile.className = `screen__status${art ? " screen__status--" + art : ""}`;
|
||||
}
|
||||
|
||||
function sitzungsknoepfe(sichtbar) {
|
||||
knoepfeSitzung.forEach((knopf) => {
|
||||
if (knopf) knopf.hidden = !sichtbar;
|
||||
});
|
||||
}
|
||||
|
||||
function grund() {
|
||||
const teile = [];
|
||||
if (letzterGrund) {
|
||||
teile.push(letzterGrund.replace(/^Failed (when|while) \w+: /, ""));
|
||||
}
|
||||
if (rfb && rfb._rfbAuthScheme > 0) {
|
||||
teile.push(`gewähltes Verfahren: ${rfb._rfbAuthScheme}`);
|
||||
}
|
||||
if (gescheitert.size) {
|
||||
teile.push(`erfolglos: ${[...gescheitert].join(", ")}`);
|
||||
}
|
||||
return teile.join(" · ");
|
||||
}
|
||||
|
||||
// Steht die WebSocket-Strecke zu TESM überhaupt? Zwei sehr verschiedene
|
||||
// Ursachen sehen von aussen gleich aus: der VNC-Server mag nicht, oder die
|
||||
// Verbindung kommt nie zustande, weil ein Proxy zwischen Browser und TESM
|
||||
// "Upgrade" nicht durchreicht. Diese Probe trennt sie.
|
||||
function streckeTesten() {
|
||||
return new Promise((fertig) => {
|
||||
let beantwortet = false;
|
||||
const pruefling = new WebSocket(`${schema}//${window.location.host}/ws/probe`);
|
||||
const schliessen = (ergebnis) => {
|
||||
if (beantwortet) return;
|
||||
beantwortet = true;
|
||||
try {
|
||||
pruefling.close();
|
||||
} catch (fehler) {
|
||||
/* schon zu */
|
||||
}
|
||||
fertig(ergebnis);
|
||||
};
|
||||
pruefling.onmessage = () => schliessen(true);
|
||||
pruefling.onerror = () => schliessen(false);
|
||||
pruefling.onclose = () => schliessen(false);
|
||||
window.setTimeout(() => schliessen(false), 4000);
|
||||
});
|
||||
}
|
||||
|
||||
// Hat das gewählte Verfahren die Verbindung abreissen lassen? Dann ist das
|
||||
// ein Befund und keine Vermutung -- der naechste Anlauf laesst es aus. Nur
|
||||
// solange nie ein Bild stand: bricht eine **laufende** Sitzung ab, liegt es
|
||||
// nicht am Anmeldeverfahren.
|
||||
function naechsterVersuch() {
|
||||
const verfahren = rfb && rfb._rfbAuthScheme > 0 ? rfb._rfbAuthScheme : 0;
|
||||
if (!verfahren || versuche >= HOECHSTENS || gescheitert.has(verfahren)) {
|
||||
return false;
|
||||
}
|
||||
gescheitert.add(verfahren);
|
||||
melden(
|
||||
`Verfahren ${verfahren} hat die Verbindung nicht getragen – nächster Anlauf ` +
|
||||
"mit dem nächsten, das der Server anbietet …",
|
||||
"warnung",
|
||||
);
|
||||
const zugang = letzterZugang;
|
||||
rfb = null;
|
||||
window.setTimeout(() => verbinden(zugang), 400);
|
||||
return true;
|
||||
}
|
||||
|
||||
function verfahrenMelden(gewaehlt) {
|
||||
if (!gewaehlt || gewaehlt === daten.bewaehrt || !daten.meldeweg) return;
|
||||
fetch(daten.meldeweg, {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Requested-With": "fetch",
|
||||
"X-CSRF-Token": document.body?.dataset.csrf || "",
|
||||
},
|
||||
body: JSON.stringify({ verfahren: gewaehlt }),
|
||||
}).catch(() => {
|
||||
/* Eine Merkhilfe, kein Teil der Sitzung. */
|
||||
});
|
||||
}
|
||||
|
||||
function verbinden(zugang) {
|
||||
if (beendet) return;
|
||||
melden(versuche ? "Nächster Versuch …" : "Verbindung wird aufgebaut …");
|
||||
letzterGrund = "";
|
||||
letzterCode = 0;
|
||||
letzterZugang = zugang;
|
||||
versuche += 1;
|
||||
window.clearTimeout(aufbauUhr);
|
||||
aufbauUhr = window.setTimeout(() => {
|
||||
// Ein ausgehandeltes RA2ne ist kein Fehler, sondern Rechenzeit: der
|
||||
// Browser erzeugt dafuer gerade einen 2048-Bit-Schluessel. Das zu
|
||||
// melden ist das Gegenteil einer Warnung -- es nimmt die Sorge.
|
||||
if (rfb && rfb._rfbAuthScheme === RA2NE) {
|
||||
melden(
|
||||
"Der Server verlangt RA2ne (RSA-AES). Dafür erzeugt der Browser ein " +
|
||||
"2048-Bit-Schlüsselpaar – das dauert ein bis drei Minuten. Bitte warten.",
|
||||
"warnung",
|
||||
);
|
||||
return;
|
||||
}
|
||||
melden("Es kommt keine Antwort. Wird geprüft, woran es liegt …", "warnung");
|
||||
streckeTesten().then((steht) => {
|
||||
melden(
|
||||
steht
|
||||
? "Die WebSocket-Strecke zu TESM steht, aber die Sitzung kommt nicht " +
|
||||
"zustande. Antwortet der VNC-Server auf dem Gerät noch? Ein Neustart " +
|
||||
"des VNC-Dienstes räumt eine Sperre ab."
|
||||
: "Die WebSocket-Verbindung zu TESM kommt nicht zustande – auch eine " +
|
||||
"einfache Probe scheitert. Ein Proxy zwischen Browser und TESM muss " +
|
||||
"die Kopfzeilen Upgrade und Connection durchreichen (nginx: " +
|
||||
"location /ws/ mit proxy_set_header Upgrade $http_upgrade).",
|
||||
"fehler",
|
||||
);
|
||||
});
|
||||
}, AUFBAU_GRENZE);
|
||||
|
||||
// Drei Regeln, alle aus Messung und keine aus Vermutung:
|
||||
//
|
||||
// 1. Was schon einmal getragen hat, kommt zuerst.
|
||||
// 2. Sonst das Verfahren ohne Schluesseltausch -- aber nur, wenn der
|
||||
// Server es nachweislich anbietet (sonst faende noVNC gar keines und
|
||||
// scheiterte sofort). RA2ne kostet sonst ein bis drei Minuten
|
||||
// Rechenzeit im Browser, jedes Mal.
|
||||
// 3. Was abgerissen ist, wird nicht noch einmal gewaehlt.
|
||||
//
|
||||
// Beides gilt nur fuer den **ersten** Anlauf; danach zaehlt die
|
||||
// Reihenfolge des Servers, abzueglich dessen, was schon abgerissen ist.
|
||||
const bewaehrt = daten.bewaehrt || 0;
|
||||
const angeboten = daten.verfahren || [];
|
||||
const schnell = angeboten.filter((typ) => OHNE_SCHLUESSELTAUSCH.has(typ));
|
||||
let bevorzugt = 0;
|
||||
if (versuche === 1) {
|
||||
if (bewaehrt > 0 && !gescheitert.has(bewaehrt)) bevorzugt = bewaehrt;
|
||||
else if (schnell.length && !gescheitert.has(schnell[0])) bevorzugt = schnell[0];
|
||||
}
|
||||
RFB.prototype._isSupportedSecurityType = function (typ) {
|
||||
if (gescheitert.has(typ)) return false;
|
||||
if (bevorzugt) return typ === bevorzugt;
|
||||
return istUnterstuetzt.call(this, typ);
|
||||
};
|
||||
|
||||
rfb = new RFB(leinwand, `${schema}//${window.location.host}${daten.socket}`, {
|
||||
credentials: zugang,
|
||||
// Eine Bildschirmsitzung soll nicht nebenbei die Zwischenablage des
|
||||
// Arbeitsplatzes mit dem Geraet teilen -- das ist eine eigene
|
||||
// Entscheidung, und niemand hat sie hier getroffen.
|
||||
clipboardTarget: null,
|
||||
});
|
||||
rfb.scaleViewport = true;
|
||||
rfb.resizeSession = false;
|
||||
rfb.focusOnClick = true;
|
||||
|
||||
rfb.addEventListener("connect", () => {
|
||||
window.clearTimeout(aufbauUhr);
|
||||
stand = true;
|
||||
verfahrenMelden(rfb && rfb._rfbAuthScheme > 0 ? rfb._rfbAuthScheme : 0);
|
||||
melden(`Verbunden mit ${daten.ziel}`, "ok");
|
||||
if (abfrage) abfrage.hidden = true;
|
||||
leinwand.hidden = false;
|
||||
sitzungsknoepfe(true);
|
||||
});
|
||||
|
||||
rfb.addEventListener("disconnect", (ereignis) => {
|
||||
window.clearTimeout(aufbauUhr);
|
||||
leinwand.hidden = true;
|
||||
sitzungsknoepfe(false);
|
||||
if (ereignis.detail && ereignis.detail.clean) {
|
||||
// Beim sauberen Schluss steht der Grund im Schliessrahmen -- etwa,
|
||||
// dass TESM wegen Untätigkeit getrennt hat.
|
||||
melden(letzterGrund ? `Sitzung beendet – ${letzterGrund}` : "Sitzung beendet.");
|
||||
// Im Fenster gibt es nach dem Trennen nichts mehr zu sehen: ein leerer
|
||||
// Rahmen, den man wegklicken muss. Bei einem **Abbruch** bleibt es
|
||||
// stehen -- dort steht der Grund, und der ist der ganze Zweck.
|
||||
beiEnde?.();
|
||||
} else if (!stand && naechsterVersuch()) {
|
||||
return;
|
||||
} else {
|
||||
const erklaerung = grund();
|
||||
melden(
|
||||
erklaerung
|
||||
? `Verbindung abgebrochen – ${erklaerung}`
|
||||
: "Verbindung abgebrochen. Wird geprüft, woran es liegt …",
|
||||
"fehler",
|
||||
);
|
||||
if (letzterCode === 1006 || !erklaerung) {
|
||||
streckeTesten().then((steht) => {
|
||||
melden(
|
||||
steht
|
||||
? `${erklaerung || "Verbindung abgebrochen"} – die WebSocket-Strecke zu ` +
|
||||
"TESM selbst steht (Probe erfolgreich), der Abbruch kommt also von " +
|
||||
"der VNC-Seite."
|
||||
: "Die WebSocket-Verbindung zu TESM kommt nicht zustande – auch eine " +
|
||||
"einfache Probe scheitert. Ein Proxy zwischen Browser und TESM muss " +
|
||||
"die Kopfzeilen Upgrade und Connection durchreichen (nginx: " +
|
||||
"location /ws/ mit proxy_set_header Upgrade $http_upgrade). Dasselbe " +
|
||||
"gilt für die Browser-Konsole.",
|
||||
"fehler",
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
rfb = null;
|
||||
});
|
||||
|
||||
rfb.addEventListener("credentialsrequired", (ereignis) => {
|
||||
window.clearTimeout(aufbauUhr);
|
||||
const gefragt = ereignis.detail.types || ["password"];
|
||||
if (abfrage) abfrage.hidden = false;
|
||||
if (feldBenutzer) feldBenutzer.closest("label").hidden = !gefragt.includes("username");
|
||||
melden("Der VNC-Server verlangt Zugangsdaten.", "warnung");
|
||||
(gefragt.includes("username") ? feldBenutzer : feldPasswort)?.focus();
|
||||
});
|
||||
|
||||
rfb.addEventListener("securityfailure", (ereignis) => {
|
||||
window.clearTimeout(aufbauUhr);
|
||||
melden(
|
||||
`Anmeldung abgelehnt: ${ereignis.detail.reason || "kein Grund genannt"}`,
|
||||
"fehler",
|
||||
);
|
||||
if (abfrage) abfrage.hidden = false;
|
||||
});
|
||||
}
|
||||
|
||||
knopfSenden?.addEventListener("click", () => {
|
||||
const zugang = {};
|
||||
if (feldBenutzer?.value) zugang.username = feldBenutzer.value;
|
||||
if (feldPasswort?.value) zugang.password = feldPasswort.value;
|
||||
if (rfb) rfb.sendCredentials(zugang);
|
||||
else verbinden(zugang);
|
||||
});
|
||||
|
||||
[feldBenutzer, feldPasswort].forEach((feld) =>
|
||||
feld?.addEventListener("keydown", (ereignis) => {
|
||||
if (ereignis.key === "Enter") {
|
||||
ereignis.preventDefault();
|
||||
knopfSenden?.click();
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
knopfTrennen?.addEventListener("click", () => rfb && rfb.disconnect());
|
||||
teil("taste-cad")?.addEventListener("click", () => rfb && rfb.sendCtrlAltDel());
|
||||
// Alt+F4 kennt noVNC nicht als fertigen Griff: der Arbeitsplatz faengt die
|
||||
// Kombination selbst ab, bevor sie in der Leinwand ankommt. Also einzeln --
|
||||
// Alt halten, F4 tippen, Alt loslassen -- wie es auf der Tastatur auch
|
||||
// geschieht.
|
||||
teil("taste-altf4")?.addEventListener("click", () => {
|
||||
if (!rfb) return;
|
||||
rfb.sendKey(ALT_LINKS, "AltLeft", true);
|
||||
rfb.sendKey(F4, "F4", true);
|
||||
rfb.sendKey(F4, "F4", false);
|
||||
rfb.sendKey(ALT_LINKS, "AltLeft", false);
|
||||
});
|
||||
// F5 faengt der Browser des Arbeitsplatzes ab -- er wuerde diese Seite neu
|
||||
// laden statt das Bild auf dem Geraet.
|
||||
teil("taste-f5")?.addEventListener("click", () => {
|
||||
if (!rfb) return;
|
||||
rfb.sendKey(F5, "F5", true);
|
||||
rfb.sendKey(F5, "F5", false);
|
||||
});
|
||||
|
||||
sitzungsknoepfe(false);
|
||||
melden("Wird vorbereitet …");
|
||||
|
||||
fetch(quelle, { credentials: "same-origin", headers: { "X-Requested-With": "fetch" } })
|
||||
.then((antwort) => antwort.json().then((inhalt) => ({ status: antwort.status, inhalt })))
|
||||
.then(({ status, inhalt }) => {
|
||||
if (beendet) return;
|
||||
if (status === 409 && inhalt.reauth) {
|
||||
melden(
|
||||
"Für den Bildschirm ist eine frische Passwortbestätigung nötig – bitte über " +
|
||||
"die Geräteseite öffnen.",
|
||||
"warnung",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (status !== 200) {
|
||||
melden(inhalt.fehler || "Diese Sitzung lässt sich nicht öffnen.", "fehler");
|
||||
return;
|
||||
}
|
||||
daten = inhalt;
|
||||
if (daten.hinderungsgrund) {
|
||||
melden(daten.hinderungsgrund, "fehler");
|
||||
return;
|
||||
}
|
||||
const zugang = {};
|
||||
if (daten.vorgaben?.username) zugang.username = daten.vorgaben.username;
|
||||
if (daten.vorgaben?.password) zugang.password = daten.vorgaben.password;
|
||||
verbinden(zugang);
|
||||
})
|
||||
.catch(() => melden("Die Sitzungsdaten sind nicht abrufbar.", "fehler"));
|
||||
|
||||
return function beenden() {
|
||||
beendet = true;
|
||||
window.clearTimeout(aufbauUhr);
|
||||
try {
|
||||
rfb?.disconnect();
|
||||
} catch (fehler) {
|
||||
/* schon zu */
|
||||
}
|
||||
rfb = null;
|
||||
};
|
||||
}
|
||||
|
||||
// -- Die beiden Orte -------------------------------------------------------- #
|
||||
|
||||
// 1. Die Bildschirmseite: ein Behaelter, der seine Quelle selbst mitbringt.
|
||||
const seite = document.getElementById("screen");
|
||||
if (seite?.dataset.sitzung) {
|
||||
starteSitzung(seite, seite.dataset.sitzung);
|
||||
}
|
||||
|
||||
// 2. Die Statusuebersicht: ein Fenster, das jede Kachel oeffnen kann. Ein
|
||||
// Dialog fuer alle -- er bekommt beim Oeffnen, welches Geraet gemeint ist.
|
||||
const fenster = document.getElementById("vnc-fenster");
|
||||
if (fenster) {
|
||||
let beenden = null;
|
||||
const behaelter = fenster.querySelector("[data-rolle='behaelter']");
|
||||
const titel = fenster.querySelector("[data-rolle='titel']");
|
||||
|
||||
document.addEventListener("click", (ereignis) => {
|
||||
const knopf = ereignis.target.closest("[data-vnc-oeffnen]");
|
||||
if (!knopf) return;
|
||||
ereignis.preventDefault();
|
||||
beenden?.();
|
||||
titel.textContent = knopf.dataset.vncName || "Bildschirm";
|
||||
if (typeof fenster.showModal === "function") fenster.showModal();
|
||||
else fenster.setAttribute("open", "");
|
||||
beenden = starteSitzung(behaelter, knopf.dataset.vncOeffnen, {
|
||||
beiEnde: () => fenster.close(),
|
||||
});
|
||||
});
|
||||
|
||||
// Zu heisst zu: eine Sitzung, die im Hintergrund weiterlaeuft, haelt die
|
||||
// Verbindung zum Geraet und bei RealVNC den einen Sitzungsplatz.
|
||||
fenster.addEventListener("close", () => {
|
||||
beenden?.();
|
||||
beenden = null;
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,13 @@
|
||||
maintainers:
|
||||
- Samuel Mannehed for Cendio AB (@samhed)
|
||||
- Pierre Ossman for Cendio AB (@CendioOssman)
|
||||
maintainersEmeritus:
|
||||
- Joel Martin (@kanaka)
|
||||
- Solly Ross (@directxman12)
|
||||
- @astrand
|
||||
contributors:
|
||||
# There are a bunch of people that should be here.
|
||||
# If you want to be on this list, feel free send a PR
|
||||
# to add yourself.
|
||||
- jalf <git@jalf.dk>
|
||||
- NTT corp.
|
||||
@@ -0,0 +1,52 @@
|
||||
# noVNC 1.6.0 -- mitgeliefert, nicht nachgeladen
|
||||
|
||||
Quelle: <https://github.com/novnc/noVNC>, Fassung **1.6.0**, uebernommen am
|
||||
13.09.2026. Enthalten sind `core/` (die Bibliothek) und `vendor/pako` (die
|
||||
Entpackroutine, die `core/inflator.js` braucht) -- ohne `app/`, `tests/` und
|
||||
die mitgelieferte Oberflaeche: die Bedienung stellt TESM selbst
|
||||
(`apps/tesm/src/tesm/templates/tesm/screen.html`).
|
||||
|
||||
**Lizenz:** MPL-2.0 fuer die Kernbibliothek, siehe `LICENSE.txt` und `AUTHORS`
|
||||
(beide unveraendert uebernommen). pako steht unter MIT, siehe
|
||||
`vendor/pako/LICENSE`.
|
||||
|
||||
**Warum mitgeliefert und nicht von einem CDN:** dieselbe Regel wie bei pdf.js
|
||||
und pdf-lib nebenan. Die Anwendung laeuft in Netzen ohne Internetzugang, die
|
||||
Inhaltsrichtlinie (CSP) erlaubt ausschliesslich `'self'`, und eine
|
||||
Bibliothek, die sich zur Laufzeit aendern kann, gehoert nicht in einen Pfad,
|
||||
der einen Bildschirm fernsteuert.
|
||||
|
||||
**Unveraendert.** Wird eine Anpassung noetig, gehoert sie hierher in diese
|
||||
Datei -- mit Grund und Fundstelle, damit ein Wechsel auf eine neuere Fassung
|
||||
nicht stillschweigend darueberlaeuft.
|
||||
|
||||
**Was TESM von aussen umbiegt** (also *nicht* in diesen Dateien steht, sondern
|
||||
in `js/screen.js`):
|
||||
|
||||
* `RFB.prototype._fail` und `_socketClose` -- nur, um den Grund eines
|
||||
Abbruchs zu erfahren. noVNC meldet ihn sonst ausschliesslich in die
|
||||
Entwicklerkonsole, und `console.error` mitzulesen traegt nicht: die
|
||||
Bibliothek bindet es beim Laden.
|
||||
* `RFB.prototype._isSupportedSecurityType` -- **erst nachdem** ein Verfahren
|
||||
die Verbindung nachweislich hat abreissen lassen. Dann wird es fuer den
|
||||
zweiten Anlauf ausgelassen, damit noVNC nicht wieder dasselbe zuerst findet.
|
||||
Hoechstens zwei Anlaeufe, denn jeder ist eine Verbindung, und ein RealVNC
|
||||
Server zaehlt abgebrochene mit.
|
||||
|
||||
Hier stand einmal das Gegenteil: eine **Vorauswahl**, die ohne hinterlegte
|
||||
Zugangsdaten ein Verfahren ohne Anmeldung erzwang. Das war geraten und hat
|
||||
eine laufende Sitzung gebrochen. Der Unterschied ist nicht der Code, sondern
|
||||
der Anlass: eine Vermutung vorher gegen einen Befund hinterher.
|
||||
|
||||
Faellt eine dieser Methoden in einer neueren Fassung weg, entfaellt nur die
|
||||
Auskunft bzw. der zweite Anlauf -- die Sitzung laeuft wie ohne sie.
|
||||
|
||||
**Was noVNC an Sicherheitsverfahren spricht** (`core/rfb.js`,
|
||||
`_isSupportedSecurityType`): `1` None, `2` VNC-Passwort, `6` RA2ne (RSA-AES
|
||||
ohne Streckenverschluesselung), `16` Tight, `19` VeNCrypt, `22` xvp, `30` ARD,
|
||||
`113` MS-Logon II. **Nicht** dabei: `5` (RA2 mit Streckenverschluesselung) und
|
||||
alles aus dem RealVNC-eigenen Bereich `7`-`15`. Ein RealVNC Server, der nur
|
||||
`13` und `5` anbietet, kommt mit dieser Bibliothek also nicht zusammen -- er
|
||||
muss `Encryption=AlwaysOff` (dann `6`) oder `Authentication=VncAuth` (dann
|
||||
`2`) angeboten bekommen. TESM misst das und schreibt es auf die Seite, statt
|
||||
den Benutzer vor eine schwarze Flaeche zu setzen.
|
||||
Loaded 100 of 255 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user