Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
No files matched your search
@@ -4,6 +4,13 @@ Ausgabe und Verwaltung der Lizenzen für TESM: Kunde -> Ticket -> Lizenz,
|
|||||||
Aktivierung online und offline, Kundenportal, Selbstbedienung, Versand über
|
Aktivierung online und offline, Kundenportal, Selbstbedienung, Versand über
|
||||||
MS Graph und eine Hierarchie aus Unter-Lizenzservern.
|
MS Graph und eine Hierarchie aus Unter-Lizenzservern.
|
||||||
|
|
||||||
|
> **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
|
**Dieses Repository enthält genau das, was zum Installieren und Betreiben des
|
||||||
Lizenzservers gebraucht wird** -- nichts darüber hinaus:
|
Lizenzservers gebraucht wird** -- nichts darüber hinaus:
|
||||||
|
|
||||||
@@ -21,6 +28,7 @@ Die verwaltete Anwendung liegt hier **nicht** mit drin.
|
|||||||
| Wo sonst noch | |
|
| Wo sonst noch | |
|
||||||
|---|---|
|
|---|---|
|
||||||
| [alientim/tesm](https://gitea.int.eertmoed.net/alientim/tesm) | TESM selbst |
|
| [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 |
|
| [alientim/TESM-DEV](https://gitea.int.eertmoed.net/alientim/TESM-DEV) | Entwicklung: beide Anwendungen, Tests, Architekturdoku |
|
||||||
|
|
||||||
## Zweige
|
## Zweige
|
||||||
@@ -34,16 +42,24 @@ Die verwaltete Anwendung liegt hier **nicht** mit drin.
|
|||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
|
Eine einzelne Datei genügt -- sie lädt das Release selbst:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo bash deploy/install.sh --app tesm-license --https --domain lizenz.firma.de
|
curl -fsSLO https://gitea.int.eertmoed.net/alientim/tesm-license/raw/branch/main/install.sh
|
||||||
sudo -u tesm-license tesm-license-admin init-key
|
sudo bash install.sh --app tesm-license --https --domain lizenz.firma.de
|
||||||
sudo -u tesm-license tesm-license-admin bootstrap
|
|
||||||
sudo -u tesm-license tesm-license-admin create-admin
|
sudo -u tesm-license tesm-license-admin create-admin
|
||||||
```
|
```
|
||||||
|
|
||||||
**`init-key` erzeugt den Signaturschlüssel dieses Servers. Sichern Sie ihn
|
Aus einem entpackten Paket ist es derselbe Aufruf ohne `curl`.
|
||||||
sofort.** Ohne ihn lässt sich keine ausgestellte Lizenz mehr erneuern, und
|
|
||||||
Kunden müssten einen Ausstellerwechsel bestätigen.
|
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
|
Zielzustand: **Port 443 mit TLS, Port 80 leitet dorthin um** -- ausser dem
|
||||||
ACME-Pfad, der erreichbar bleiben muss, sonst kann Let's Encrypt nie erneuern.
|
ACME-Pfad, der erreichbar bleiben muss, sonst kann Let's Encrypt nie erneuern.
|
||||||
@@ -57,7 +73,7 @@ für nginx-Site, logrotate, Zertifikate und Sitzungscookie.
|
|||||||
### Aktualisieren
|
### Aktualisieren
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo bash deploy/update.sh --app tesm-license
|
sudo bash /srv/tesm-license/deploy/update.sh tesm-license
|
||||||
```
|
```
|
||||||
|
|
||||||
Vor jedem Update entsteht ein vollständiges Backup, danach läuft ein
|
Vor jedem Update entsteht ein vollständiges Backup, danach läuft ein
|
||||||
@@ -70,7 +86,6 @@ Health-Check mit automatischem Rollback.
|
|||||||
```bash
|
```bash
|
||||||
sudo -u tesm-license tesm-license-admin status
|
sudo -u tesm-license tesm-license-admin status
|
||||||
sudo -u tesm-license tesm-license-admin init-key # Signaturschlüssel
|
sudo -u tesm-license tesm-license-admin init-key # Signaturschlüssel
|
||||||
sudo -u tesm-license tesm-license-admin bootstrap # Grunddaten
|
|
||||||
sudo -u tesm-license tesm-license-admin create-admin
|
sudo -u tesm-license tesm-license-admin create-admin
|
||||||
sudo -u tesm-license tesm-license-admin migrate-legacy # Altbestand übernehmen
|
sudo -u tesm-license tesm-license-admin migrate-legacy # Altbestand übernehmen
|
||||||
```
|
```
|
||||||
@@ -135,3 +150,19 @@ Entwicklungsrepository, ebenso die Tests.
|
|||||||
* **CSRF-Schutz überhaupt.** Der Vorgänger hatte keinen.
|
* **CSRF-Schutz überhaupt.** Der Vorgänger hatte keinen.
|
||||||
* **Rechte werden serverseitig geprüft**, nicht nur im Template ausgeblendet.
|
* **Rechte werden serverseitig geprüft**, nicht nur im Template ausgeblendet.
|
||||||
* **Verschachtelte Datenbankverbindungen sind ein Fehler**, kein Deadlock.
|
* **Verschachtelte Datenbankverbindungen sind ein Fehler**, kein Deadlock.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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]
|
[project]
|
||||||
name = "tesm-license"
|
name = "tesm-license"
|
||||||
version = "2.0.3"
|
version = "2.8.3"
|
||||||
description = "TESM-Lizenzserver -- Kunden, Tickets, Lizenzen, Aktivierung"
|
description = "TESM-Lizenzserver -- Kunden, Tickets, Lizenzen, Aktivierung"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ musste (und ``licensing.py`` byte-identisch gehalten werden *musste*).
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -23,17 +24,46 @@ from .permissions import PERMISSIONS, ROLE_PRESETS
|
|||||||
from .schema import LICENSE_MIGRATIONS
|
from .schema import LICENSE_MIGRATIONS
|
||||||
from .settings_spec import LICENSE_SERVER_SETTINGS
|
from .settings_spec import LICENSE_SERVER_SETTINGS
|
||||||
|
|
||||||
__version__ = "2.0.2"
|
#: Version aus den Metadaten des installierten Pakets -- die einzige Quelle
|
||||||
|
#: ist ``pyproject.toml``. Vorher stand hier eine feste Zahl, und die
|
||||||
|
#: Fusszeile zeigte vier Releases lang 2.0.2.
|
||||||
|
#:
|
||||||
|
#: Im Quellbaum ohne ``pip install`` gibt es keine Metadaten; dann steht dort
|
||||||
|
#: "unbekannt" statt einer erfundenen Nummer. In einem editierbaren Install
|
||||||
|
#: zeigt sie den Stand des letzten ``pip install -e`` -- also die Version, die
|
||||||
|
#: tatsaechlich installiert ist.
|
||||||
|
try: # pragma: no cover - haengt an der Installationsart
|
||||||
|
from importlib.metadata import PackageNotFoundError, version as _paketversion
|
||||||
|
|
||||||
|
__version__ = _paketversion("tesm-license")
|
||||||
|
except (ImportError, PackageNotFoundError): # pragma: no cover
|
||||||
|
__version__ = "unbekannt"
|
||||||
|
|
||||||
APP_KEY = "tesm-license"
|
APP_KEY = "tesm-license"
|
||||||
APP_NAME = "TESM-Lizenzserver"
|
APP_NAME = "TESM-Lizenzserver"
|
||||||
APP_SHORT = "TL"
|
APP_SHORT = "TL"
|
||||||
APP_EDITION = "Lizenzverwaltung"
|
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_"
|
ENV_PREFIX = "TESM_LICENSE_"
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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:
|
def master_key_path(config: Any) -> Path:
|
||||||
return Path(config.data_dir) / "master_signing_key.json"
|
return Path(config.data_dir) / "master_signing_key.json"
|
||||||
|
|
||||||
@@ -56,6 +86,7 @@ def create_app(**overrides: Any) -> Flask:
|
|||||||
import_name="tesm_license",
|
import_name="tesm_license",
|
||||||
app_short=APP_SHORT,
|
app_short=APP_SHORT,
|
||||||
app_edition=APP_EDITION,
|
app_edition=APP_EDITION,
|
||||||
|
auth_tagline=AUTH_TAGLINE,
|
||||||
app_version=__version__,
|
app_version=__version__,
|
||||||
enable_license=True,
|
enable_license=True,
|
||||||
)
|
)
|
||||||
@@ -149,7 +180,7 @@ def _reencrypt_secrets(conn: Any, keystore: Any) -> int:
|
|||||||
|
|
||||||
|
|
||||||
def _status_pills(user: Any) -> list[dict[str, str]]:
|
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 []
|
return []
|
||||||
from tesm_core.extension import db
|
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:
|
def _startup_tasks(app: Flask) -> None:
|
||||||
extension = core(app)
|
extension = core(app)
|
||||||
if not extension.config.web_process:
|
if not extension.config.web_process:
|
||||||
logger.info("Startaufgaben uebersprungen -- kein Webprozess.")
|
logger.info("Startaufgaben übersprungen – kein Webprozess.")
|
||||||
return
|
return
|
||||||
|
|
||||||
from .services import activation
|
from .services import activation
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ def _handle(action: str):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if request.content_length and request.content_length > MAX_BODY_BYTES:
|
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)
|
payload = request.get_json(silent=True)
|
||||||
if not isinstance(payload, dict):
|
if not isinstance(payload, dict):
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ from tesm_core.extension import core, db, transaction
|
|||||||
from ..services import transfer
|
from ..services import transfer
|
||||||
from .common import log_event
|
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
|
MAX_IMPORT_BYTES = 64 * 1024 * 1024
|
||||||
PREVIEW_SESSION_KEY = "_import_token"
|
PREVIEW_SESSION_KEY = "_import_token"
|
||||||
@@ -24,14 +24,14 @@ def _allowed_categories() -> list[transfer.Category]:
|
|||||||
for category in transfer.CATEGORIES:
|
for category in transfer.CATEGORIES:
|
||||||
if category.admin_only and not current_user.is_admin:
|
if category.admin_only and not current_user.is_admin:
|
||||||
continue
|
continue
|
||||||
if category.sensitive and not current_user.has("backup.secrets"):
|
if category.sensitive and not current_user.has("backup_export.secrets"):
|
||||||
continue
|
continue
|
||||||
allowed.append(category)
|
allowed.append(category)
|
||||||
return allowed
|
return allowed
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/")
|
@bp.get("/")
|
||||||
@permission_required("backup.view", "backup.export", "backup.execute", mode="any")
|
@permission_required("backup_export.read", "backup_import.read", mode="any")
|
||||||
def overview():
|
def overview():
|
||||||
conn = db()
|
conn = db()
|
||||||
preview = None
|
preview = None
|
||||||
@@ -50,25 +50,25 @@ def overview():
|
|||||||
preview=preview,
|
preview=preview,
|
||||||
min_passphrase=transfer.MIN_PASSPHRASE_LENGTH,
|
min_passphrase=transfer.MIN_PASSPHRASE_LENGTH,
|
||||||
preview_ttl=transfer.PREVIEW_TTL_MINUTES,
|
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"]),
|
signing_key_path=str(core().registry["master_key_path"]),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/export")
|
@bp.post("/export")
|
||||||
@permission_required("backup.export")
|
@permission_required("backup_export.export")
|
||||||
@reauth_required
|
# Die frische Bestaetigung verlangt das Recht selbst (SENSITIVE_ACTIONS).
|
||||||
def export():
|
def export():
|
||||||
conn = db()
|
conn = db()
|
||||||
allowed = {category.key for category in _allowed_categories()}
|
allowed = {category.key for category in _allowed_categories()}
|
||||||
chosen = [key for key in request.form.getlist("categories") if key in allowed]
|
chosen = [key for key in request.form.getlist("categories") if key in allowed]
|
||||||
if not chosen:
|
if not chosen:
|
||||||
flash("Es wurde keine Kategorie ausgewaehlt.", "warning")
|
flash("Es wurde keine Kategorie ausgewählt.", "warning")
|
||||||
return redirect(url_for("backup.overview"))
|
return redirect(url_for("backup.overview"))
|
||||||
|
|
||||||
passphrase = request.form.get("passphrase") or ""
|
passphrase = request.form.get("passphrase") or ""
|
||||||
if passphrase != (request.form.get("passphrase_repeat") 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"))
|
return redirect(url_for("backup.overview"))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -94,18 +94,18 @@ def export():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/import/vorschau")
|
@bp.post("/import/preview")
|
||||||
@permission_required("backup.execute")
|
@permission_required("backup_import.execute")
|
||||||
@reauth_required
|
@reauth_required
|
||||||
def import_preview():
|
def import_preview():
|
||||||
conn = db()
|
conn = db()
|
||||||
upload = request.files.get("backup_file")
|
upload = request.files.get("backup_file")
|
||||||
if upload is None or not upload.filename:
|
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"))
|
return redirect(url_for("backup.overview"))
|
||||||
raw = upload.stream.read(MAX_IMPORT_BYTES + 1)
|
raw = upload.stream.read(MAX_IMPORT_BYTES + 1)
|
||||||
if len(raw) > MAX_IMPORT_BYTES:
|
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"))
|
return redirect(url_for("backup.overview"))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -124,28 +124,28 @@ def import_preview():
|
|||||||
scope="sicherung",
|
scope="sicherung",
|
||||||
)
|
)
|
||||||
flash(
|
flash(
|
||||||
f"Datei geoeffnet. Sie haben {transfer.PREVIEW_TTL_MINUTES} Minuten Zeit, die "
|
f"Datei geöffnet. Sie haben {transfer.PREVIEW_TTL_MINUTES} Minuten Zeit, die "
|
||||||
"Uebernahme zu bestaetigen.",
|
"Übernahme zu bestätigen.",
|
||||||
"success",
|
"success",
|
||||||
)
|
)
|
||||||
return redirect(url_for("backup.overview"))
|
return redirect(url_for("backup.overview"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/import/uebernehmen")
|
@bp.post("/import/apply")
|
||||||
@permission_required("backup.execute")
|
@permission_required("backup_import.execute")
|
||||||
@reauth_required
|
@reauth_required
|
||||||
def import_apply():
|
def import_apply():
|
||||||
extension = core()
|
extension = core()
|
||||||
conn = db()
|
conn = db()
|
||||||
token = session.get(PREVIEW_SESSION_KEY, "")
|
token = session.get(PREVIEW_SESSION_KEY, "")
|
||||||
if not token:
|
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"))
|
return redirect(url_for("backup.overview"))
|
||||||
|
|
||||||
allowed = {category.key for category in _allowed_categories()}
|
allowed = {category.key for category in _allowed_categories()}
|
||||||
chosen = [key for key in request.form.getlist("categories") if key in allowed]
|
chosen = [key for key in request.form.getlist("categories") if key in allowed]
|
||||||
if not chosen:
|
if not chosen:
|
||||||
flash("Es wurde keine Kategorie ausgewaehlt.", "warning")
|
flash("Es wurde keine Kategorie ausgewählt.", "warning")
|
||||||
return redirect(url_for("backup.overview"))
|
return redirect(url_for("backup.overview"))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -172,7 +172,7 @@ def import_apply():
|
|||||||
action="backup.imported",
|
action="backup.imported",
|
||||||
detail="; ".join(
|
detail="; ".join(
|
||||||
f"{key}: {counts['created']} neu, {counts['updated']} aktualisiert, "
|
f"{key}: {counts['created']} neu, {counts['updated']} aktualisiert, "
|
||||||
f"{counts['skipped']} uebersprungen"
|
f"{counts['skipped']} übersprungen"
|
||||||
for key, counts in report.items()
|
for key, counts in report.items()
|
||||||
),
|
),
|
||||||
ip=client_ip(),
|
ip=client_ip(),
|
||||||
@@ -182,16 +182,16 @@ def import_apply():
|
|||||||
lines = [
|
lines = [
|
||||||
f"{transfer.CATEGORY_BY_KEY[key].label}: {counts['created']} neu, "
|
f"{transfer.CATEGORY_BY_KEY[key].label}: {counts['created']} neu, "
|
||||||
f"{counts['updated']} aktualisiert"
|
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()
|
for key, counts in report.items()
|
||||||
if key in transfer.CATEGORY_BY_KEY
|
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"))
|
return redirect(url_for("backup.overview"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/import/verwerfen")
|
@bp.post("/import/discard")
|
||||||
@permission_required("backup.execute")
|
@permission_required("backup_import.execute")
|
||||||
def import_discard():
|
def import_discard():
|
||||||
token = session.pop(PREVIEW_SESSION_KEY, "")
|
token = session.pop(PREVIEW_SESSION_KEY, "")
|
||||||
if token:
|
if token:
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from tesm_core.extension import db, transaction
|
|||||||
from ..services import issuance, reporting
|
from ..services import issuance, reporting
|
||||||
from .common import customer_facing_url, log_event
|
from .common import customer_facing_url, log_event
|
||||||
|
|
||||||
bp = Blueprint("customers", __name__, url_prefix="/kunden")
|
bp = Blueprint("customers", __name__, url_prefix="/customers")
|
||||||
|
|
||||||
FIELDS = (
|
FIELDS = (
|
||||||
"name",
|
"name",
|
||||||
@@ -29,13 +29,13 @@ def _form() -> dict[str, str]:
|
|||||||
|
|
||||||
|
|
||||||
@bp.get("/")
|
@bp.get("/")
|
||||||
@permission_required("customers.view")
|
@permission_required("customers.read")
|
||||||
def overview():
|
def overview():
|
||||||
return render_template("license/customers.html", customers=issuance.list_customers(db()))
|
return render_template("license/customers.html", customers=issuance.list_customers(db()))
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/<int:customer_id>")
|
@bp.get("/<int:customer_id>")
|
||||||
@permission_required("customers.view")
|
@permission_required("customers.read")
|
||||||
def detail(customer_id: int):
|
def detail(customer_id: int):
|
||||||
conn = db()
|
conn = db()
|
||||||
customer = issuance.get_customer(conn, customer_id)
|
customer = issuance.get_customer(conn, customer_id)
|
||||||
@@ -48,7 +48,7 @@ def detail(customer_id: int):
|
|||||||
]
|
]
|
||||||
reported = (
|
reported = (
|
||||||
reporting.reported_for_customer(conn, customer_id)
|
reporting.reported_for_customer(conn, customer_id)
|
||||||
if current_user.has("reports.view")
|
if current_user.has("reports.read")
|
||||||
else {"customers": [], "source_license_id": ""}
|
else {"customers": [], "source_license_id": ""}
|
||||||
)
|
)
|
||||||
return render_template(
|
return render_template(
|
||||||
@@ -61,7 +61,7 @@ def detail(customer_id: int):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/neu")
|
@bp.post("/new")
|
||||||
@permission_required("customers.create")
|
@permission_required("customers.create")
|
||||||
def create():
|
def create():
|
||||||
try:
|
try:
|
||||||
@@ -83,7 +83,7 @@ def create():
|
|||||||
|
|
||||||
|
|
||||||
@bp.post("/<int:customer_id>")
|
@bp.post("/<int:customer_id>")
|
||||||
@permission_required("customers.edit")
|
@permission_required("customers.write")
|
||||||
def update(customer_id: int):
|
def update(customer_id: int):
|
||||||
try:
|
try:
|
||||||
with transaction() as conn:
|
with transaction() as conn:
|
||||||
@@ -96,14 +96,14 @@ def update(customer_id: int):
|
|||||||
severity="notice",
|
severity="notice",
|
||||||
scope="kunde",
|
scope="kunde",
|
||||||
)
|
)
|
||||||
flash("Aenderungen gespeichert." if changes else "Keine Aenderungen.",
|
flash("Änderungen gespeichert." if changes else "Keine Änderungen.",
|
||||||
"success" if changes else "info")
|
"success" if changes else "info")
|
||||||
except issuance.IssuanceError as exc:
|
except issuance.IssuanceError as exc:
|
||||||
flash(str(exc), "danger")
|
flash(str(exc), "danger")
|
||||||
return redirect(url_for("customers.detail", customer_id=customer_id))
|
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")
|
@permission_required("customers.delete")
|
||||||
def delete(customer_id: int):
|
def delete(customer_id: int):
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -119,7 +119,7 @@ def delete(customer_id: int):
|
|||||||
severity="warning",
|
severity="warning",
|
||||||
scope="kunde",
|
scope="kunde",
|
||||||
)
|
)
|
||||||
flash("Kunde geloescht.", "success")
|
flash("Kunde gelöscht.", "success")
|
||||||
return redirect(url_for("customers.overview"))
|
return redirect(url_for("customers.overview"))
|
||||||
except issuance.IssuanceError as exc:
|
except issuance.IssuanceError as exc:
|
||||||
flash(str(exc), "danger")
|
flash(str(exc), "danger")
|
||||||
|
|||||||
@@ -16,12 +16,12 @@ from ..services import issuance, reporting
|
|||||||
bp = Blueprint("dashboard", __name__)
|
bp = Blueprint("dashboard", __name__)
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/uebersicht")
|
@bp.get("/overview")
|
||||||
@login_required
|
@login_required
|
||||||
def index():
|
def index():
|
||||||
conn = db()
|
conn = db()
|
||||||
extension = core()
|
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
|
# Leere Huelle statt 403: die Startseite soll fuer jeden erreichbar
|
||||||
# bleiben, auch fuer ein Konto ohne Lizenzrechte.
|
# bleiben, auch fuer ein Konto ohne Lizenzrechte.
|
||||||
return render_template("license/dashboard.html", summary=None, sources=[])
|
return render_template("license/dashboard.html", summary=None, sources=[])
|
||||||
@@ -30,7 +30,7 @@ def index():
|
|||||||
return render_template(
|
return render_template(
|
||||||
"license/dashboard.html",
|
"license/dashboard.html",
|
||||||
summary=summary,
|
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"),
|
own_role=(extension.license.state.role if extension.license else "master"),
|
||||||
type_labels=dict(),
|
type_labels=dict(),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -38,18 +38,18 @@ from .common import (
|
|||||||
vendor_info,
|
vendor_info,
|
||||||
)
|
)
|
||||||
|
|
||||||
bp = Blueprint("licenses", __name__, url_prefix="/lizenzen")
|
bp = Blueprint("licenses", __name__, url_prefix="/licenses")
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/")
|
@bp.get("/")
|
||||||
@permission_required("licenses.view")
|
@permission_required("licenses.read")
|
||||||
def overview():
|
def overview():
|
||||||
conn = db()
|
conn = db()
|
||||||
rows = [
|
rows = [
|
||||||
{**row, **issuance.display_state(row)}
|
{**row, **issuance.display_state(row)}
|
||||||
for row in issuance.list_licenses(
|
for row in issuance.list_licenses(
|
||||||
conn,
|
conn,
|
||||||
customer_id=request.args.get("kunde", type=int),
|
customer_id=request.args.get("customer", type=int),
|
||||||
ticket_id=request.args.get("ticket"),
|
ticket_id=request.args.get("ticket"),
|
||||||
status=request.args.get("status"),
|
status=request.args.get("status"),
|
||||||
)
|
)
|
||||||
@@ -65,7 +65,7 @@ def overview():
|
|||||||
|
|
||||||
|
|
||||||
@bp.get("/<license_id>")
|
@bp.get("/<license_id>")
|
||||||
@permission_required("licenses.view")
|
@permission_required("licenses.read")
|
||||||
def detail(license_id: str):
|
def detail(license_id: str):
|
||||||
conn = db()
|
conn = db()
|
||||||
row = issuance.get_license(conn, license_id)
|
row = issuance.get_license(conn, license_id)
|
||||||
@@ -99,8 +99,8 @@ def detail(license_id: str):
|
|||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/aus-ticket/<ticket_id>")
|
@bp.post("/from-ticket/<ticket_id>")
|
||||||
@permission_required("licenses.create")
|
@permission_required("license_issue.execute")
|
||||||
def issue(ticket_id: str):
|
def issue(ticket_id: str):
|
||||||
conn = db()
|
conn = db()
|
||||||
as_trial = request.form.get("as_trial") == "1"
|
as_trial = request.form.get("as_trial") == "1"
|
||||||
@@ -124,7 +124,7 @@ def issue(ticket_id: str):
|
|||||||
|
|
||||||
if result.already_existed:
|
if result.already_existed:
|
||||||
flash(
|
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.",
|
"zweite auszustellen.",
|
||||||
"info",
|
"info",
|
||||||
)
|
)
|
||||||
@@ -140,9 +140,9 @@ def issue(ticket_id: str):
|
|||||||
return redirect(url_for("licenses.detail", license_id=result.license_id))
|
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")
|
@permission_required("licenses.export")
|
||||||
@reauth_required
|
# Die frische Bestaetigung verlangt das Recht selbst (SENSITIVE_ACTIONS).
|
||||||
def download(license_id: str):
|
def download(license_id: str):
|
||||||
"""Laedt das vollstaendige Auslieferungs-Bundle herunter.
|
"""Laedt das vollstaendige Auslieferungs-Bundle herunter.
|
||||||
|
|
||||||
@@ -166,7 +166,7 @@ def download(license_id: str):
|
|||||||
channel="download",
|
channel="download",
|
||||||
recipient=current_user.username,
|
recipient=current_user.username,
|
||||||
actor=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")
|
log_event("license.downloaded", target=license_id[:12], severity="notice")
|
||||||
return Response(
|
return Response(
|
||||||
@@ -181,8 +181,8 @@ def download(license_id: str):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/<license_id>/neu-signieren")
|
@bp.post("/<license_id>/resign")
|
||||||
@permission_required("licenses.edit")
|
@permission_required("license_renew.execute")
|
||||||
@reauth_required
|
@reauth_required
|
||||||
def resign(license_id: str):
|
def resign(license_id: str):
|
||||||
"""Signiert eine neue Revision -- z. B. nach einer Modulerweiterung.
|
"""Signiert eine neue Revision -- z. B. nach einer Modulerweiterung.
|
||||||
@@ -202,7 +202,7 @@ def resign(license_id: str):
|
|||||||
try:
|
try:
|
||||||
days = int(request.form["extend_days"])
|
days = int(request.form["extend_days"])
|
||||||
except ValueError:
|
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))
|
return redirect(url_for("licenses.detail", license_id=license_id))
|
||||||
if days > 0:
|
if days > 0:
|
||||||
document = json.loads(str(row["license_document"]))
|
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))
|
changes["expires_at"] = lic.iso(base + timedelta(days=days))
|
||||||
|
|
||||||
if not changes:
|
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))
|
return redirect(url_for("licenses.detail", license_id=license_id))
|
||||||
|
|
||||||
key = signing_key()
|
key = signing_key()
|
||||||
@@ -233,15 +233,15 @@ def resign(license_id: str):
|
|||||||
)
|
)
|
||||||
reporting.report_soon(core())
|
reporting.report_soon(core())
|
||||||
flash(
|
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.",
|
"Heartbeat.",
|
||||||
"success",
|
"success",
|
||||||
)
|
)
|
||||||
return redirect(url_for("licenses.detail", license_id=license_id))
|
return redirect(url_for("licenses.detail", license_id=license_id))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/<license_id>/widerrufen")
|
@bp.post("/<license_id>/revoke")
|
||||||
@permission_required("licenses.edit")
|
@permission_required("license_revoke.execute")
|
||||||
def revoke(license_id: str):
|
def revoke(license_id: str):
|
||||||
conn = db()
|
conn = db()
|
||||||
try:
|
try:
|
||||||
@@ -258,14 +258,14 @@ def revoke(license_id: str):
|
|||||||
)
|
)
|
||||||
reporting.report_soon(core())
|
reporting.report_soon(core())
|
||||||
flash(
|
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.",
|
"ausgestellt werden.",
|
||||||
"success",
|
"success",
|
||||||
)
|
)
|
||||||
return redirect(url_for("licenses.detail", license_id=license_id))
|
return redirect(url_for("licenses.detail", license_id=license_id))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/<license_id>/loeschen")
|
@bp.post("/<license_id>/delete")
|
||||||
@permission_required("licenses.delete")
|
@permission_required("licenses.delete")
|
||||||
def delete(license_id: str):
|
def delete(license_id: str):
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -277,7 +277,7 @@ def delete(license_id: str):
|
|||||||
return redirect(url_for("licenses.detail", license_id=license_id))
|
return redirect(url_for("licenses.detail", license_id=license_id))
|
||||||
log_event("license.deleted", target=license_id[:12], severity="warning")
|
log_event("license.deleted", target=license_id[:12], severity="warning")
|
||||||
reporting.report_soon(core())
|
reporting.report_soon(core())
|
||||||
flash("Lizenz geloescht.", "success")
|
flash("Lizenz gelöscht.", "success")
|
||||||
return redirect(url_for("licenses.overview"))
|
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")
|
@permission_required("licenses.export")
|
||||||
def send_mail(license_id: str):
|
def send_mail(license_id: str):
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -295,7 +295,7 @@ def send_mail(license_id: str):
|
|||||||
abort(404)
|
abort(404)
|
||||||
recipient = (request.form.get("recipient") or str(row["contact_email"] or "")).strip()
|
recipient = (request.form.get("recipient") or str(row["contact_email"] or "")).strip()
|
||||||
if not recipient:
|
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))
|
return redirect(url_for("licenses.detail", license_id=license_id))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -315,7 +315,7 @@ def send_mail(license_id: str):
|
|||||||
"",
|
"",
|
||||||
f"Lizenz-ID: {license_id}",
|
f"Lizenz-ID: {license_id}",
|
||||||
f"Typ: {type_labels().get(str(row['type']), str(row['type']))}",
|
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 ""),
|
+ (" (unbefristet)" if row["lifetime"] else ""),
|
||||||
]
|
]
|
||||||
if is_customer and row["ticket_id"]:
|
if is_customer and row["ticket_id"]:
|
||||||
@@ -329,7 +329,7 @@ def send_mail(license_id: str):
|
|||||||
"Ihre Stammdaten pflegen:",
|
"Ihre Stammdaten pflegen:",
|
||||||
customer_facing_url("portal.request_link"),
|
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"):
|
if vendor.get("phone"):
|
||||||
lines.append(f"Telefon: {vendor['phone']}")
|
lines.append(f"Telefon: {vendor['phone']}")
|
||||||
if vendor.get("email"):
|
if vendor.get("email"):
|
||||||
@@ -369,7 +369,7 @@ def send_mail(license_id: str):
|
|||||||
|
|
||||||
|
|
||||||
@bp.route("/offline-code", methods=["GET", "POST"])
|
@bp.route("/offline-code", methods=["GET", "POST"])
|
||||||
@permission_required("licenses.execute")
|
@permission_required("license_activation.execute")
|
||||||
def manual_code():
|
def manual_code():
|
||||||
"""Verarbeitet einen Offline-Code des Kunden.
|
"""Verarbeitet einen Offline-Code des Kunden.
|
||||||
|
|
||||||
@@ -403,7 +403,7 @@ def manual_code():
|
|||||||
if outcome.ok:
|
if outcome.ok:
|
||||||
response_code = lic.encode_code(outcome.response)
|
response_code = lic.encode_code(outcome.response)
|
||||||
ok = True
|
ok = True
|
||||||
message = "Antwortcode erzeugt. Bitte an den Kunden zuruecksenden."
|
message = "Antwortcode erzeugt. Bitte an den Kunden zurücksenden."
|
||||||
log_event(
|
log_event(
|
||||||
f"license.offline_{payload.get('action')}",
|
f"license.offline_{payload.get('action')}",
|
||||||
target=str(payload.get("license_id") or "")[:12],
|
target=str(payload.get("license_id") or "")[:12],
|
||||||
@@ -428,8 +428,8 @@ def manual_code():
|
|||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/sub-lizenzserver")
|
@bp.get("/sub-servers")
|
||||||
@permission_required("reports.view")
|
@permission_required("reports.read")
|
||||||
def reports():
|
def reports():
|
||||||
conn = db()
|
conn = db()
|
||||||
sources = reporting.reporting_sources(conn)
|
sources = reporting.reporting_sources(conn)
|
||||||
@@ -440,8 +440,8 @@ def reports():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/sub-lizenzserver/<license_id>")
|
@bp.get("/sub-servers/<license_id>")
|
||||||
@permission_required("reports.view")
|
@permission_required("reports.read")
|
||||||
def report_detail(license_id: str):
|
def report_detail(license_id: str):
|
||||||
conn = db()
|
conn = db()
|
||||||
row = issuance.get_license(conn, license_id)
|
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
|
import tesm_licensing as lic
|
||||||
|
|
||||||
|
from .. import is_master_edition
|
||||||
from ..services import master_key
|
from ..services import master_key
|
||||||
from .common import can_issue_license_server_licenses, log_event, signing_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
|
MAX_UPLOAD_BYTES = 256 * 1024
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/")
|
@bp.get("/")
|
||||||
@permission_required("settings_license.view")
|
@permission_required("settings_license.read")
|
||||||
def overview():
|
def overview():
|
||||||
extension = core()
|
extension = core()
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -62,12 +63,13 @@ def overview():
|
|||||||
),
|
),
|
||||||
signing_key_health=master_key.health(extension.registry["master_key_path"]),
|
signing_key_health=master_key.health(extension.registry["master_key_path"]),
|
||||||
may_issue_license_server=can_issue_license_server_licenses(),
|
may_issue_license_server=can_issue_license_server_licenses(),
|
||||||
|
master_edition=is_master_edition(),
|
||||||
grace_days=lic.GRACE_PERIOD_DAYS,
|
grace_days=lic.GRACE_PERIOD_DAYS,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/einspielen")
|
@bp.post("/install")
|
||||||
@permission_required("settings_license.edit")
|
@permission_required("settings_license.write")
|
||||||
def upload():
|
def upload():
|
||||||
extension = core()
|
extension = core()
|
||||||
manager = extension.license
|
manager = extension.license
|
||||||
@@ -77,11 +79,11 @@ def upload():
|
|||||||
|
|
||||||
upload_file = request.files.get("license_file")
|
upload_file = request.files.get("license_file")
|
||||||
if upload_file is None or not upload_file.filename:
|
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"))
|
return redirect(url_for("own_license.overview"))
|
||||||
raw = upload_file.stream.read(MAX_UPLOAD_BYTES + 1)
|
raw = upload_file.stream.read(MAX_UPLOAD_BYTES + 1)
|
||||||
if len(raw) > MAX_UPLOAD_BYTES:
|
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"))
|
return redirect(url_for("own_license.overview"))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -127,27 +129,27 @@ def _handshake(action: str):
|
|||||||
return redirect(url_for("own_license.overview"))
|
return redirect(url_for("own_license.overview"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/aktivieren")
|
@bp.post("/activate")
|
||||||
@permission_required("settings_license.edit")
|
@permission_required("settings_license.write")
|
||||||
def activate():
|
def activate():
|
||||||
return _handshake("activate")
|
return _handshake("activate")
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/deaktivieren")
|
@bp.post("/deactivate")
|
||||||
@permission_required("settings_license.edit")
|
@permission_required("settings_license.write")
|
||||||
@reauth_required
|
@reauth_required
|
||||||
def deactivate():
|
def deactivate():
|
||||||
return _handshake("deactivate")
|
return _handshake("deactivate")
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/heartbeat")
|
@bp.post("/heartbeat")
|
||||||
@permission_required("settings_license.edit")
|
@permission_required("settings_license.write")
|
||||||
def heartbeat():
|
def heartbeat():
|
||||||
return _handshake("heartbeat")
|
return _handshake("heartbeat")
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/offline-antwort")
|
@bp.post("/offline-response")
|
||||||
@permission_required("settings_license.edit")
|
@permission_required("settings_license.write")
|
||||||
def offline_response():
|
def offline_response():
|
||||||
manager = core().license
|
manager = core().license
|
||||||
if manager is None:
|
if manager is None:
|
||||||
@@ -164,8 +166,8 @@ def offline_response():
|
|||||||
return redirect(url_for("own_license.overview"))
|
return redirect(url_for("own_license.overview"))
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/signaturschluessel")
|
@bp.get("/signing-key")
|
||||||
@permission_required("settings_license.view")
|
@permission_required("settings_license.read")
|
||||||
def signing_key_info():
|
def signing_key_info():
|
||||||
"""Zeigt den oeffentlichen Signaturschluessel -- zum Pinnen beim Kunden.
|
"""Zeigt den oeffentlichen Signaturschluessel -- zum Pinnen beim Kunden.
|
||||||
|
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ RATE_WINDOW_MINUTES = 15
|
|||||||
|
|
||||||
GENERIC_ANSWER = (
|
GENERIC_ANSWER = (
|
||||||
"Falls diese E-Mail-Adresse bei uns hinterlegt ist, wurde soeben eine Nachricht mit "
|
"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 = (
|
EDITABLE_FIELDS = (
|
||||||
@@ -150,13 +150,13 @@ def request_link():
|
|||||||
f"Guten Tag{(' ' + str(customer['contact_person'])) if customer['contact_person'] else ''},",
|
f"Guten Tag{(' ' + str(customer['contact_person'])) if customer['contact_person'] else ''},",
|
||||||
"",
|
"",
|
||||||
"mit dem folgenden Link erreichen Sie Ihre Daten im Kundenportal.",
|
"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,
|
access_url,
|
||||||
"",
|
"",
|
||||||
"Wenn Sie diesen Zugang nicht angefragt haben, ignorieren Sie die Nachricht.",
|
"Wenn Sie diesen Zugang nicht angefragt haben, ignorieren Sie die Nachricht.",
|
||||||
"",
|
"",
|
||||||
"Freundliche Gruesse",
|
"Freundliche Grüße",
|
||||||
vendor.get("name") or "Ihr Lizenzgeber",
|
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
|
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
|
valid = row is not None and row["used_at"] is None and expires is not None and _now() < expires
|
||||||
if not valid:
|
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"))
|
return redirect(url_for("portal.request_link"))
|
||||||
|
|
||||||
minutes = int(extension.settings.get(conn, "portal_session_minutes") or 60)
|
minutes = int(extension.settings.get(conn, "portal_session_minutes") or 60)
|
||||||
@@ -247,7 +247,7 @@ def me():
|
|||||||
ip=client_ip(),
|
ip=client_ip(),
|
||||||
severity="notice",
|
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:
|
except issuance.IssuanceError as exc:
|
||||||
flash(str(exc), "danger")
|
flash(str(exc), "danger")
|
||||||
return redirect(url_for("portal.me"))
|
return redirect(url_for("portal.me"))
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ from .common import (
|
|||||||
vendor_info,
|
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")
|
bp = Blueprint("selfservice", __name__, url_prefix="/lizenz")
|
||||||
|
|
||||||
RATE_BUCKET = "selfservice"
|
RATE_BUCKET = "selfservice"
|
||||||
@@ -60,7 +62,7 @@ def _rate_guard() -> None:
|
|||||||
with transaction():
|
with transaction():
|
||||||
ratelimit.record(conn, RATE_BUCKET, client_ip() or "unbekannt")
|
ratelimit.record(conn, RATE_BUCKET, client_ip() or "unbekannt")
|
||||||
if verdict.blocked:
|
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]
|
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))
|
return redirect(url_for("selfservice.overview", ticket_id=ticket_id))
|
||||||
if issuance.ticket_has_open_license(conn, ticket_id):
|
if issuance.ticket_has_open_license(conn, ticket_id):
|
||||||
flash(
|
flash(
|
||||||
"Es existiert bereits eine gueltige Lizenz. Geben Sie diese zuerst frei, wenn Sie "
|
"Es existiert bereits eine gültige Lizenz. Geben Sie diese zuerst frei, wenn Sie "
|
||||||
"auf ein anderes System wechseln moechten.",
|
"auf ein anderes System wechseln möchten.",
|
||||||
"warning",
|
"warning",
|
||||||
)
|
)
|
||||||
return redirect(url_for("selfservice.overview", ticket_id=ticket_id))
|
return redirect(url_for("selfservice.overview", ticket_id=ticket_id))
|
||||||
@@ -236,12 +238,12 @@ def offline(ticket_id: str):
|
|||||||
_log(
|
_log(
|
||||||
"selfservice.offline_rejected",
|
"selfservice.offline_rejected",
|
||||||
ticket,
|
ticket,
|
||||||
"Code gehoert nicht zu diesem Vorgang",
|
"Code gehört nicht zu diesem Vorgang",
|
||||||
severity="warning",
|
severity="warning",
|
||||||
)
|
)
|
||||||
flash(
|
flash(
|
||||||
"Dieser Code gehoert nicht zu Ihrem Vorgang. Bitte pruefen Sie, ob Sie ihn "
|
"Dieser Code gehört nicht zu Ihrem Vorgang. Bitte prüfen Sie, ob Sie ihn "
|
||||||
"vollstaendig kopiert haben.",
|
"vollständig kopiert haben.",
|
||||||
"danger",
|
"danger",
|
||||||
)
|
)
|
||||||
return redirect(url_for("selfservice.offline", ticket_id=ticket_id))
|
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")
|
_log("license.revoked_self_service", ticket, license_id[:12], severity="warning")
|
||||||
reporting.report_soon(core())
|
reporting.report_soon(core())
|
||||||
flash(
|
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.",
|
"ausstellen.",
|
||||||
"success",
|
"success",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ from tesm_core.extension import core, db, transaction
|
|||||||
from ..services import mail
|
from ..services import mail
|
||||||
from .common import log_event, master_endpoint, vendor_info
|
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
|
MAX_LOGO_BYTES = 200 * 1024
|
||||||
LOGO_MIME = {
|
LOGO_MIME = {
|
||||||
@@ -35,7 +35,7 @@ LOGO_MAGIC = {
|
|||||||
|
|
||||||
|
|
||||||
@bp.get("/system")
|
@bp.get("/system")
|
||||||
@permission_required("settings_system.view")
|
@permission_required("settings_system.read")
|
||||||
def system():
|
def system():
|
||||||
extension = core()
|
extension = core()
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -54,6 +54,8 @@ def system():
|
|||||||
"trash_retention_days",
|
"trash_retention_days",
|
||||||
"log_rotate_interval",
|
"log_rotate_interval",
|
||||||
"log_rotate_keep",
|
"log_rotate_keep",
|
||||||
|
"session_idle_minutes",
|
||||||
|
"session_absolute_hours",
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
specs={spec.key: spec for spec in extension.settings.specs()},
|
specs={spec.key: spec for spec in extension.settings.specs()},
|
||||||
@@ -66,8 +68,13 @@ def system():
|
|||||||
else ""
|
else ""
|
||||||
),
|
),
|
||||||
timezones=_timezone_choices(),
|
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_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),
|
sysops_available=bool(sysops and sysops.available),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -82,7 +89,7 @@ def _timezone_choices() -> list[str]:
|
|||||||
|
|
||||||
|
|
||||||
@bp.post("/system")
|
@bp.post("/system")
|
||||||
@permission_required("settings_system.edit")
|
@permission_required("settings_system.write")
|
||||||
def system_save():
|
def system_save():
|
||||||
extension = core()
|
extension = core()
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -97,6 +104,8 @@ def system_save():
|
|||||||
"trash_retention_days",
|
"trash_retention_days",
|
||||||
"log_rotate_interval",
|
"log_rotate_interval",
|
||||||
"log_rotate_keep",
|
"log_rotate_keep",
|
||||||
|
"session_idle_minutes",
|
||||||
|
"session_absolute_hours",
|
||||||
):
|
):
|
||||||
spec = extension.settings.spec(key)
|
spec = extension.settings.spec(key)
|
||||||
if spec.kind == "bool":
|
if spec.kind == "bool":
|
||||||
@@ -116,7 +125,7 @@ def system_save():
|
|||||||
ip=client_ip(),
|
ip=client_ip(),
|
||||||
severity="notice",
|
severity="notice",
|
||||||
)
|
)
|
||||||
flash("Einstellungen gespeichert." if changed else "Keine Aenderungen.",
|
flash("Einstellungen gespeichert." if changed else "Keine Änderungen.",
|
||||||
"success" if changed else "info")
|
"success" if changed else "info")
|
||||||
return redirect(url_for("settings.system"))
|
return redirect(url_for("settings.system"))
|
||||||
|
|
||||||
@@ -128,10 +137,10 @@ def set_hostname():
|
|||||||
sysops = extension.sysops
|
sysops = extension.sysops
|
||||||
hostname = (request.form.get("hostname") or "").strip()
|
hostname = (request.form.get("hostname") or "").strip()
|
||||||
if not hostname or len(hostname) > 63 or not hostname.replace("-", "").isalnum():
|
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"))
|
return redirect(url_for("settings.system"))
|
||||||
if sysops is None or not sysops.available:
|
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"))
|
return redirect(url_for("settings.system"))
|
||||||
result = sysops.set_hostname(hostname)
|
result = sysops.set_hostname(hostname)
|
||||||
if result.ok:
|
if result.ok:
|
||||||
@@ -142,7 +151,7 @@ def set_hostname():
|
|||||||
return redirect(url_for("settings.system"))
|
return redirect(url_for("settings.system"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/system/zeitzone")
|
@bp.post("/system/timezone")
|
||||||
@permission_required("settings_system.execute")
|
@permission_required("settings_system.execute")
|
||||||
def set_timezone():
|
def set_timezone():
|
||||||
import time
|
import time
|
||||||
@@ -153,7 +162,7 @@ def set_timezone():
|
|||||||
flash("Unbekannte Zeitzone.", "danger")
|
flash("Unbekannte Zeitzone.", "danger")
|
||||||
return redirect(url_for("settings.system"))
|
return redirect(url_for("settings.system"))
|
||||||
if sysops is None or not sysops.available:
|
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"))
|
return redirect(url_for("settings.system"))
|
||||||
result = sysops.set_timezone(timezone)
|
result = sysops.set_timezone(timezone)
|
||||||
if result.ok:
|
if result.ok:
|
||||||
@@ -173,8 +182,8 @@ def set_timezone():
|
|||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/anbieter")
|
@bp.get("/vendor")
|
||||||
@permission_required("settings_vendor.view")
|
@permission_required("settings_vendor.read")
|
||||||
def vendor():
|
def vendor():
|
||||||
extension = core()
|
extension = core()
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -195,8 +204,8 @@ def vendor():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/anbieter")
|
@bp.post("/vendor")
|
||||||
@permission_required("settings_vendor.edit")
|
@permission_required("settings_vendor.write")
|
||||||
def vendor_save():
|
def vendor_save():
|
||||||
extension = core()
|
extension = core()
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -222,7 +231,7 @@ def vendor_save():
|
|||||||
if suffix not in LOGO_MIME:
|
if suffix not in LOGO_MIME:
|
||||||
logo_problem = "Erlaubt sind PNG, JPG und SVG."
|
logo_problem = "Erlaubt sind PNG, JPG und SVG."
|
||||||
elif len(data) > MAX_LOGO_BYTES:
|
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):
|
elif not any(head.startswith(magic) for magic in LOGO_MAGIC):
|
||||||
logo_problem = "Die Datei ist kein Bild."
|
logo_problem = "Die Datei ist kein Bild."
|
||||||
else:
|
else:
|
||||||
@@ -240,13 +249,13 @@ def vendor_save():
|
|||||||
log_event(
|
log_event(
|
||||||
"settings.vendor_saved", detail=", ".join(changed), severity="notice", scope="anbieter"
|
"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")
|
"success" if changed else "info")
|
||||||
return redirect(url_for("settings.vendor"))
|
return redirect(url_for("settings.vendor"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/anbieter/logo-entfernen")
|
@bp.post("/vendor/remove-logo")
|
||||||
@permission_required("settings_vendor.edit")
|
@permission_required("settings_vendor.write")
|
||||||
def vendor_logo_remove():
|
def vendor_logo_remove():
|
||||||
with transaction() as conn:
|
with transaction() as conn:
|
||||||
core().settings.set(conn, "vendor_logo_base64", "", actor=current_user.username)
|
core().settings.set(conn, "vendor_logo_base64", "", actor=current_user.username)
|
||||||
@@ -260,7 +269,7 @@ def vendor_logo_remove():
|
|||||||
|
|
||||||
|
|
||||||
@bp.get("/email")
|
@bp.get("/email")
|
||||||
@permission_required("settings_mail.view")
|
@permission_required("settings_mail.read")
|
||||||
def mail_settings():
|
def mail_settings():
|
||||||
extension = core()
|
extension = core()
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -275,7 +284,7 @@ def mail_settings():
|
|||||||
|
|
||||||
|
|
||||||
@bp.post("/email")
|
@bp.post("/email")
|
||||||
@permission_required("settings_mail.edit")
|
@permission_required("settings_mail.write")
|
||||||
def mail_save():
|
def mail_save():
|
||||||
extension = core()
|
extension = core()
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -292,7 +301,7 @@ def mail_save():
|
|||||||
mail.store_secret(conn, request.form.get("client_secret"))
|
mail.store_secret(conn, request.form.get("client_secret"))
|
||||||
changed.append("client_secret")
|
changed.append("client_secret")
|
||||||
log_event("settings.mail_saved", detail=", ".join(changed), severity="notice", scope="mail")
|
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")
|
"success" if changed else "info")
|
||||||
return redirect(url_for("settings.mail_settings"))
|
return redirect(url_for("settings.mail_settings"))
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ bp = Blueprint("tickets", __name__, url_prefix="/tickets")
|
|||||||
|
|
||||||
|
|
||||||
@bp.get("/")
|
@bp.get("/")
|
||||||
@permission_required("tickets.view")
|
@permission_required("tickets.read")
|
||||||
def overview():
|
def overview():
|
||||||
conn = db()
|
conn = db()
|
||||||
tickets = issuance.list_tickets(conn)
|
tickets = issuance.list_tickets(conn)
|
||||||
@@ -45,7 +45,7 @@ def overview():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/neu")
|
@bp.get("/new")
|
||||||
@permission_required("tickets.create")
|
@permission_required("tickets.create")
|
||||||
def new():
|
def new():
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -60,11 +60,11 @@ def new():
|
|||||||
type_labels=type_labels(),
|
type_labels=type_labels(),
|
||||||
default_valid_days=int(extension.settings.get(conn, "default_valid_days") or 365),
|
default_valid_days=int(extension.settings.get(conn, "default_valid_days") or 365),
|
||||||
may_create_license_server=can_issue_license_server_licenses(),
|
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")
|
@permission_required("tickets.create")
|
||||||
def create():
|
def create():
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -106,14 +106,14 @@ def _endpoint_missing() -> str:
|
|||||||
if master_endpoint():
|
if master_endpoint():
|
||||||
return ""
|
return ""
|
||||||
return (
|
return (
|
||||||
"Es ist kein oeffentlicher Endpunkt hinterlegt (Einstellungen -> Anbieter). "
|
"Es ist kein öffentlicher Endpunkt hinterlegt (Einstellungen -> Anbieter). "
|
||||||
"Solange er fehlt, koennen keine Lizenzen ausgestellt werden: der Endpunkt wird "
|
"Solange er fehlt, können keine Lizenzen ausgestellt werden: der Endpunkt wird "
|
||||||
"in die Lizenz signiert und laesst sich spaeter nicht nachtragen."
|
"in die Lizenz signiert und lässt sich später nicht nachtragen."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/<ticket_id>")
|
@bp.get("/<ticket_id>")
|
||||||
@permission_required("tickets.view")
|
@permission_required("tickets.read")
|
||||||
def detail(ticket_id: str):
|
def detail(ticket_id: str):
|
||||||
conn = db()
|
conn = db()
|
||||||
ticket = issuance.get_ticket(conn, ticket_id)
|
ticket = issuance.get_ticket(conn, ticket_id)
|
||||||
@@ -150,7 +150,7 @@ def detail(ticket_id: str):
|
|||||||
|
|
||||||
|
|
||||||
@bp.post("/<ticket_id>")
|
@bp.post("/<ticket_id>")
|
||||||
@permission_required("tickets.edit")
|
@permission_required("tickets.write")
|
||||||
def update(ticket_id: str):
|
def update(ticket_id: str):
|
||||||
conn = db()
|
conn = db()
|
||||||
if issuance.get_ticket(conn, ticket_id) is None:
|
if issuance.get_ticket(conn, ticket_id) is None:
|
||||||
@@ -176,9 +176,9 @@ def update(ticket_id: str):
|
|||||||
)
|
)
|
||||||
reporting.report_soon(core())
|
reporting.report_soon(core())
|
||||||
flash(
|
flash(
|
||||||
"Gespeichert. Die Aenderung wirkt auf die naechste Ausstellung."
|
"Gespeichert. Die Änderung wirkt auf die nächste Ausstellung."
|
||||||
if changes
|
if changes
|
||||||
else "Keine Aenderungen.",
|
else "Keine Änderungen.",
|
||||||
"success" if changes else "info",
|
"success" if changes else "info",
|
||||||
)
|
)
|
||||||
except (issuance.IssuanceError, ValueError) as exc:
|
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))
|
return redirect(url_for("tickets.detail", ticket_id=ticket_id))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/<ticket_id>/sperre")
|
@bp.post("/<ticket_id>/lock")
|
||||||
@permission_required("tickets.edit")
|
@permission_required("tickets.write")
|
||||||
def toggle_block(ticket_id: str):
|
def toggle_block(ticket_id: str):
|
||||||
conn = db()
|
conn = db()
|
||||||
ticket = issuance.get_ticket(conn, ticket_id)
|
ticket = issuance.get_ticket(conn, ticket_id)
|
||||||
@@ -205,7 +205,7 @@ def toggle_block(ticket_id: str):
|
|||||||
)
|
)
|
||||||
reporting.report_soon(core())
|
reporting.report_soon(core())
|
||||||
flash(
|
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"
|
if new_status == "blocked"
|
||||||
else "Ticket entsperrt.",
|
else "Ticket entsperrt.",
|
||||||
"success",
|
"success",
|
||||||
@@ -213,7 +213,7 @@ def toggle_block(ticket_id: str):
|
|||||||
return redirect(url_for("tickets.detail", ticket_id=ticket_id))
|
return redirect(url_for("tickets.detail", ticket_id=ticket_id))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/<ticket_id>/loeschen")
|
@bp.post("/<ticket_id>/delete")
|
||||||
@permission_required("tickets.delete")
|
@permission_required("tickets.delete")
|
||||||
def delete(ticket_id: str):
|
def delete(ticket_id: str):
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -231,7 +231,7 @@ def delete(ticket_id: str):
|
|||||||
scope="ticket",
|
scope="ticket",
|
||||||
)
|
)
|
||||||
reporting.report_soon(core())
|
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"))
|
return redirect(url_for("tickets.overview"))
|
||||||
except issuance.IssuanceError as exc:
|
except issuance.IssuanceError as exc:
|
||||||
flash(str(exc), "danger")
|
flash(str(exc), "danger")
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
"""Kommandozeilenwerkzeuge des Lizenzservers.
|
"""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
|
``bootstrap`` stellt die eigene Lizenz der Wurzelinstanz aus und ist damit der
|
||||||
Master-Lizenz ist die einzige Lizenz, fuer die es **keine hoehere Instanz gibt**,
|
einzige Weg, auf dem eine Lizenzdatei die Rolle ``master`` bekommt (ueber
|
||||||
die sie bestaetigen koennte. Ein Aktivierungs-Handshake gegen sich selbst waere
|
Tickets ist sie auf jedem Server abgewiesen). Die Arbeit dazu liegt im
|
||||||
Theater. Das Skript signiert die Datei mit dem eigenen Schluessel und setzt den
|
Teilpaket ``tesm_license.master``, das nur die Master-Ausgabe mitbringt; fehlt
|
||||||
Aktivierungszustand direkt -- und nur so kann eine Lizenzdatei je die Rolle
|
es, sagt der Befehl das und tut nichts. Siehe dort fuer die Begruendung.
|
||||||
``master`` tragen. Die Ticket-Oberflaeche bietet diese Rolle an keiner Stelle an.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -20,7 +21,6 @@ from pathlib import Path
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import tesm_licensing as lic
|
import tesm_licensing as lic
|
||||||
import tesm_licensing_server as licsrv
|
|
||||||
from tesm_core import audit
|
from tesm_core import audit
|
||||||
from tesm_core.cli_web import add_web_setup, run_web_setup
|
from tesm_core.cli_web import add_web_setup, run_web_setup
|
||||||
from tesm_core.db import Database
|
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.rbac import service as rbac
|
||||||
from tesm_core.security import passwords
|
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
|
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)
|
path = master_key_path(extension.config)
|
||||||
if path.exists() and not args.force:
|
if path.exists() and not args.force:
|
||||||
key = master_key.load(path)
|
key = master_key.load(path)
|
||||||
print(f"Signaturschluessel existiert bereits: {key.key_id} ({path})")
|
print(f"Signaturschlüssel existiert bereits: {key.key_id} ({path})")
|
||||||
print("Ein Austausch macht ALLE bereits ausgestellten Lizenzen unpruefbar.")
|
print("Ein Austausch macht ALLE bereits ausgestellten Lizenzen unprüfbar.")
|
||||||
return 0
|
return 0
|
||||||
if path.exists() and args.force:
|
if path.exists() and args.force:
|
||||||
print(
|
print(
|
||||||
"ABBRUCH: --force auf einen bestehenden Signaturschluessel wird nicht unterstuetzt.\n"
|
"ABBRUCH: --force auf einen bestehenden Signaturschlüssel wird nicht unterstützt.\n"
|
||||||
"Ein neuer Schluessel entwertet jede ausgestellte Lizenz. Bitte die Datei bewusst\n"
|
"Ein neuer Schlüssel entwertet jede ausgestellte Lizenz. Bitte die Datei bewusst\n"
|
||||||
"von Hand beiseite legen, wenn das wirklich gewollt ist.",
|
"von Hand beiseite legen, wenn das wirklich gewollt ist.",
|
||||||
file=sys.stderr,
|
file=sys.stderr,
|
||||||
)
|
)
|
||||||
return 2
|
return 2
|
||||||
key = master_key.ensure(path)
|
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(f"Datei: {path}")
|
||||||
print("Bitte SOFORT sichern -- ohne sie ist keine ausgestellte Lizenz mehr pruefbar.")
|
print("Bitte SOFORT sichern – ohne sie ist keine ausgestellte Lizenz mehr prüfbar.")
|
||||||
print(f"Oeffentlicher Schluessel (zum Pinnen beim Kunden):\n{key.public_key}")
|
print(f"Öffentlicher Schlüssel (zum Pinnen beim Kunden):\n{key.public_key}")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def cmd_bootstrap(args: argparse.Namespace) -> int:
|
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()
|
app, extension = _app_and_core()
|
||||||
key = master_key.ensure(master_key_path(extension.config))
|
document, state = eigene_lizenz_ausstellen(
|
||||||
|
app, extension, days=int(args.days), name=args.name
|
||||||
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, 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"Master-Lizenz ausgestellt: {document['license_id']}")
|
||||||
print(f"Gueltig bis: {document['expires_at']}")
|
print(f"Gültig bis: {document['expires_at']}")
|
||||||
print(f"Datei: {config.license_path}")
|
print(f"Datei: {extension.config.license_path}")
|
||||||
if state is not None:
|
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
|
return 0
|
||||||
|
|
||||||
|
|
||||||
@@ -142,7 +98,7 @@ def cmd_create_admin(args: argparse.Namespace) -> int:
|
|||||||
return 2
|
return 2
|
||||||
password = args.password or getpass.getpass("Passwort: ")
|
password = args.password or getpass.getpass("Passwort: ")
|
||||||
if not args.password and password != getpass.getpass("Passwort wiederholen: "):
|
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
|
return 2
|
||||||
problems = extension.password_policy.check(password, username=username)
|
problems = extension.password_policy.check(password, username=username)
|
||||||
if problems and not args.force:
|
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']}")
|
+ ("unversehrt" if chain["ok"] else f"GEBROCHEN bei {chain['broken_at']}")
|
||||||
)
|
)
|
||||||
print(
|
print(
|
||||||
"Signaturschluessel: "
|
"Signaturschlüssel: "
|
||||||
+ (str(key_health.get("key_id")) if key_health.get("present") else "FEHLT")
|
+ (str(key_health.get("key_id")) if key_health.get("present") else "FEHLT")
|
||||||
)
|
)
|
||||||
for problem in key_health.get("problems", []):
|
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:
|
except legacy_import.LegacyImportError as exc:
|
||||||
if args.adopt_key:
|
if args.adopt_key:
|
||||||
raise
|
raise
|
||||||
print(f"Hinweis: alter Signaturschluessel nicht lesbar ({exc}).")
|
print(f"Hinweis: alter Signaturschlüssel nicht lesbar ({exc}).")
|
||||||
|
|
||||||
if key_path.is_file():
|
if key_path.is_file():
|
||||||
existing = master_key.load(key_path)
|
existing = master_key.load(key_path)
|
||||||
report.key_id = existing.key_id
|
report.key_id = existing.key_id
|
||||||
if legacy_public and existing.public_key == legacy_public:
|
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:
|
elif not legacy_public:
|
||||||
issuer_changes = True
|
issuer_changes = True
|
||||||
print(
|
print(
|
||||||
f"Es wird mit dem vorhandenen Signaturschluessel {existing.key_id} "
|
f"Es wird mit dem vorhandenen Signaturschlüssel {existing.key_id} "
|
||||||
"gearbeitet."
|
"gearbeitet."
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
@@ -315,7 +271,7 @@ def cmd_migrate_legacy(args: argparse.Namespace) -> int:
|
|||||||
# ausgestellte Lizenz waere danach unpruefbar.
|
# ausgestellte Lizenz waere danach unpruefbar.
|
||||||
issuer_changes = True
|
issuer_changes = True
|
||||||
print(
|
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)}. "
|
f"({existing.key_id}); der alte war {lic.key_id(legacy_public)}. "
|
||||||
"Es wird mit dem vorhandenen weitergearbeitet."
|
"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)
|
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_id = legacy_import.adopt_key(legacy_private, legacy_public, key_path)
|
||||||
report.key_adopted = True
|
report.key_adopted = True
|
||||||
print(f"Signaturschluessel uebernommen: {report.key_id}")
|
print(f"Signaturschlüssel übernommen: {report.key_id}")
|
||||||
else:
|
else:
|
||||||
print(
|
print(
|
||||||
"Diese Installation hat noch keinen Signaturschluessel. Mit --adopt-key "
|
"Diese Installation hat noch keinen Signaturschlüssel. Mit --adopt-key "
|
||||||
"wird der alte uebernommen -- dann bleibt die Anbieteridentitaet erhalten "
|
"wird der alte übernommen – dann bleibt die Anbieteridentität erhalten "
|
||||||
"und Kunden muessen keinen Ausstellerwechsel bestaetigen.",
|
"und Kunden müssen keinen Ausstellerwechsel bestätigen.",
|
||||||
file=sys.stderr,
|
file=sys.stderr,
|
||||||
)
|
)
|
||||||
return 2
|
return 2
|
||||||
@@ -356,7 +312,7 @@ def cmd_migrate_legacy(args: argparse.Namespace) -> int:
|
|||||||
# Uebernahme.
|
# Uebernahme.
|
||||||
for einstellung, wert in uebernommen.items():
|
for einstellung, wert in uebernommen.items():
|
||||||
gekuerzt = wert if len(wert) <= 60 else wert[:57] + "..."
|
gekuerzt = wert if len(wert) <= 60 else wert[:57] + "..."
|
||||||
print(f"Uebernommen: {einstellung} = {gekuerzt}")
|
print(f"Übernommen: {einstellung} = {gekuerzt}")
|
||||||
|
|
||||||
vendor = {
|
vendor = {
|
||||||
"name": str(extension.settings.get(conn, "vendor_name") or "Eigenbetrieb"),
|
"name": str(extension.settings.get(conn, "vendor_name") or "Eigenbetrieb"),
|
||||||
@@ -370,7 +326,7 @@ def cmd_migrate_legacy(args: argparse.Namespace) -> int:
|
|||||||
print(
|
print(
|
||||||
"Es ist kein Endpunkt hinterlegt, und die alte Installation nennt "
|
"Es ist kein Endpunkt hinterlegt, und die alte Installation nennt "
|
||||||
"auch keinen. Bitte unter Einstellungen -> Anbieter eintragen: ohne "
|
"auch keinen. Bitte unter Einstellungen -> Anbieter eintragen: ohne "
|
||||||
"ihn koennen uebernommene Lizenzen sich spaeter nicht aktivieren.",
|
"ihn können übernommene Lizenzen sich später nicht aktivieren.",
|
||||||
file=sys.stderr,
|
file=sys.stderr,
|
||||||
)
|
)
|
||||||
return 2
|
return 2
|
||||||
@@ -391,10 +347,10 @@ def cmd_migrate_legacy(args: argparse.Namespace) -> int:
|
|||||||
result.key_id = report.key_id
|
result.key_id = report.key_id
|
||||||
if issuer_changes:
|
if issuer_changes:
|
||||||
result.warnings.append(
|
result.warnings.append(
|
||||||
"Die Lizenzen sind mit einem anderen Schluessel signiert als "
|
"Die Lizenzen sind mit einem anderen Schlüssel signiert als "
|
||||||
"frueher. Kunden, die den alten Aussteller gepinnt haben, muessen "
|
"früher. Kunden, die den alten Aussteller gepinnt haben, müssen "
|
||||||
"beim Einspielen einmal 'Wechsel des Ausstellers ausdruecklich "
|
"beim Einspielen einmal 'Wechsel des Ausstellers ausdrücklich "
|
||||||
"zulassen' bestaetigen."
|
"zulassen' bestätigen."
|
||||||
)
|
)
|
||||||
if args.dry_run:
|
if args.dry_run:
|
||||||
# Nichts festschreiben -- der Lauf war nur zur Ansicht.
|
# Nichts festschreiben -- der Lauf war nur zur Ansicht.
|
||||||
@@ -429,18 +385,23 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
)
|
)
|
||||||
sub = parser.add_subparsers(dest="command", required=True)
|
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.add_argument("--force", action="store_true")
|
||||||
init_key.set_defaults(func=cmd_init_key)
|
init_key.set_defaults(func=cmd_init_key)
|
||||||
|
|
||||||
bootstrap = sub.add_parser(
|
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("--days", type=int, default=3650)
|
||||||
bootstrap.add_argument("--name", default="Lizenzserver (Eigenbetrieb)")
|
bootstrap.add_argument("--name", default="Lizenzserver (Eigenbetrieb)")
|
||||||
bootstrap.set_defaults(func=cmd_bootstrap)
|
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("--username")
|
||||||
admin.add_argument("--password")
|
admin.add_argument("--password")
|
||||||
admin.add_argument("--force", action="store_true")
|
admin.add_argument("--force", action="store_true")
|
||||||
@@ -450,16 +411,16 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
status.add_argument("--json", action="store_true")
|
status.add_argument("--json", action="store_true")
|
||||||
status.set_defaults(func=cmd_status)
|
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)
|
house.set_defaults(func=cmd_housekeeping)
|
||||||
|
|
||||||
migrate = sub.add_parser(
|
migrate = sub.add_parser(
|
||||||
"migrate-legacy",
|
"migrate-legacy",
|
||||||
help="Lizenzserver der Vorgaengerfassung uebernehmen",
|
help="Lizenzserver der Vorgängerfassung übernehmen",
|
||||||
description=(
|
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 "
|
"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."
|
"Lizenzdatei, weil die alte Signatur nicht mehr gilt."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -469,13 +430,13 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
migrate.add_argument(
|
migrate.add_argument(
|
||||||
"--adopt-key",
|
"--adopt-key",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
help="Den alten Signaturschluessel uebernehmen. Nur moeglich, solange diese "
|
help="Den alten Signaturschlüssel übernehmen. Nur möglich, solange diese "
|
||||||
"Installation noch keinen eigenen hat -- ein vorhandener wird nie ueberschrieben.",
|
"Installation noch keinen eigenen hat – ein vorhandener wird nie überschrieben.",
|
||||||
)
|
)
|
||||||
migrate.add_argument(
|
migrate.add_argument(
|
||||||
"--dry-run",
|
"--dry-run",
|
||||||
action="store_true",
|
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)
|
migrate.set_defaults(func=cmd_migrate_legacy)
|
||||||
|
|
||||||
|
|||||||
@@ -28,41 +28,41 @@ def _is_master(_user: Any) -> bool:
|
|||||||
|
|
||||||
NAVIGATION = NavTree(
|
NAVIGATION = NavTree(
|
||||||
[
|
[
|
||||||
NavItem(key="index", label="Uebersicht", icon="grid", endpoint="dashboard.index"),
|
NavItem(key="index", label="Übersicht", icon="grid", endpoint="dashboard.index"),
|
||||||
NavItem(
|
NavItem(
|
||||||
key="customers",
|
key="customers",
|
||||||
label="Kunden",
|
label="Kunden",
|
||||||
icon="building",
|
icon="building",
|
||||||
endpoint="customers.overview",
|
endpoint="customers.overview",
|
||||||
permissions=("customers.view",),
|
permissions=("customers.read",),
|
||||||
),
|
),
|
||||||
NavItem(
|
NavItem(
|
||||||
key="tickets",
|
key="tickets",
|
||||||
label="Tickets",
|
label="Tickets",
|
||||||
icon="ticket",
|
icon="ticket",
|
||||||
endpoint="tickets.overview",
|
endpoint="tickets.overview",
|
||||||
permissions=("tickets.view",),
|
permissions=("tickets.read",),
|
||||||
),
|
),
|
||||||
NavItem(
|
NavItem(
|
||||||
key="licenses",
|
key="licenses",
|
||||||
label="Lizenzen",
|
label="Lizenzen",
|
||||||
icon="shield",
|
icon="shield",
|
||||||
endpoint="licenses.overview",
|
endpoint="licenses.overview",
|
||||||
permissions=("licenses.view",),
|
permissions=("licenses.read",),
|
||||||
),
|
),
|
||||||
NavItem(
|
NavItem(
|
||||||
key="offline_code",
|
key="offline_code",
|
||||||
label="Offline-Aktivierung",
|
label="Offline-Aktivierung",
|
||||||
icon="transfer",
|
icon="transfer",
|
||||||
endpoint="licenses.manual_code",
|
endpoint="licenses.manual_code",
|
||||||
permissions=("licenses.execute",),
|
permissions=("license_activation.execute",),
|
||||||
),
|
),
|
||||||
NavItem(
|
NavItem(
|
||||||
key="reports",
|
key="reports",
|
||||||
label="Sub-Lizenzserver",
|
label="Sub-Lizenzserver",
|
||||||
icon="share",
|
icon="share",
|
||||||
endpoint="licenses.reports",
|
endpoint="licenses.reports",
|
||||||
permissions=("reports.view",),
|
permissions=("reports.read",),
|
||||||
condition=_is_master,
|
condition=_is_master,
|
||||||
),
|
),
|
||||||
NavItem(
|
NavItem(
|
||||||
@@ -75,21 +75,21 @@ NAVIGATION = NavTree(
|
|||||||
label="Laufend",
|
label="Laufend",
|
||||||
icon="terminal",
|
icon="terminal",
|
||||||
endpoint="logs.live",
|
endpoint="logs.live",
|
||||||
permissions=("logs_live.view",),
|
permissions=("logs_live.read",),
|
||||||
),
|
),
|
||||||
NavItem(
|
NavItem(
|
||||||
key="logs_history",
|
key="logs_history",
|
||||||
label="Verlauf",
|
label="Verlauf",
|
||||||
icon="clock",
|
icon="clock",
|
||||||
endpoint="logs.history",
|
endpoint="logs.history",
|
||||||
permissions=("logs_history.view", "logs_history.export"),
|
permissions=("logs_history.read", "logs_history.export"),
|
||||||
),
|
),
|
||||||
NavItem(
|
NavItem(
|
||||||
key="logs_audit",
|
key="logs_audit",
|
||||||
label="Audit",
|
label="Audit",
|
||||||
icon="history",
|
icon="history",
|
||||||
endpoint="logs.audit_view",
|
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",
|
label="Benutzer",
|
||||||
icon="users",
|
icon="users",
|
||||||
endpoint="admin.users",
|
endpoint="admin.users",
|
||||||
permissions=("users.view",),
|
permissions=("users.read",),
|
||||||
),
|
),
|
||||||
NavItem(
|
NavItem(
|
||||||
key="groups",
|
key="groups",
|
||||||
label="Gruppen und Rechte",
|
label="Gruppen und Rechte",
|
||||||
icon="shield-users",
|
icon="shield-users",
|
||||||
endpoint="admin.groups",
|
endpoint="admin.groups",
|
||||||
permissions=("groups.view",),
|
permissions=("groups.read",),
|
||||||
),
|
),
|
||||||
NavItem(
|
NavItem(
|
||||||
key="settings_vendor",
|
key="settings_vendor",
|
||||||
label="Anbieterdaten",
|
label="Anbieterdaten",
|
||||||
icon="building",
|
icon="building",
|
||||||
endpoint="settings.vendor",
|
endpoint="settings.vendor",
|
||||||
permissions=("settings_vendor.view",),
|
permissions=("settings_vendor.read",),
|
||||||
),
|
),
|
||||||
NavItem(
|
NavItem(
|
||||||
key="settings_mail",
|
key="settings_mail",
|
||||||
label="E-Mail-Versand",
|
label="E-Mail-Versand",
|
||||||
icon="mail",
|
icon="mail",
|
||||||
endpoint="settings.mail_settings",
|
endpoint="settings.mail_settings",
|
||||||
permissions=("settings_mail.view",),
|
permissions=("settings_mail.read",),
|
||||||
),
|
),
|
||||||
NavItem(
|
NavItem(
|
||||||
key="settings_system",
|
key="settings_system",
|
||||||
label="System",
|
label="System",
|
||||||
icon="sliders",
|
icon="sliders",
|
||||||
endpoint="settings.system",
|
endpoint="settings.system",
|
||||||
permissions=("settings_system.view",),
|
permissions=("settings_system.read",),
|
||||||
),
|
),
|
||||||
NavItem(
|
NavItem(
|
||||||
key="settings_network",
|
key="settings_network",
|
||||||
label="Netzwerk",
|
label="Netzwerk",
|
||||||
icon="network",
|
icon="network",
|
||||||
endpoint="hostsettings.network",
|
endpoint="hostsettings.network",
|
||||||
permissions=("settings_network.view",),
|
permissions=("settings_network.read",),
|
||||||
),
|
),
|
||||||
NavItem(
|
NavItem(
|
||||||
key="settings_web",
|
key="settings_web",
|
||||||
label="Webserver und TLS",
|
label="Webserver und TLS",
|
||||||
icon="server",
|
icon="server",
|
||||||
endpoint="hostsettings.web",
|
endpoint="hostsettings.web",
|
||||||
permissions=("settings_web.view",),
|
permissions=("settings_web.read",),
|
||||||
),
|
),
|
||||||
NavItem(
|
NavItem(
|
||||||
key="settings_directory",
|
key="settings_directory",
|
||||||
label="Verzeichnisdienst",
|
label="Verzeichnisdienst",
|
||||||
icon="users",
|
icon="users",
|
||||||
endpoint="hostsettings.directory_settings",
|
endpoint="hostsettings.directory_settings",
|
||||||
permissions=("settings_directory.view",),
|
permissions=("settings_directory.read",),
|
||||||
),
|
),
|
||||||
NavItem(
|
NavItem(
|
||||||
key="settings_license",
|
key="settings_license",
|
||||||
label="Eigene Lizenz",
|
label="Eigene Lizenz",
|
||||||
icon="key",
|
icon="key",
|
||||||
endpoint="own_license.overview",
|
endpoint="own_license.overview",
|
||||||
permissions=("settings_license.view",),
|
permissions=("settings_license.read",),
|
||||||
),
|
),
|
||||||
NavItem(
|
NavItem(
|
||||||
key="backup",
|
key="backup",
|
||||||
label="Sicherung",
|
label="Sicherung",
|
||||||
icon="transfer",
|
icon="transfer",
|
||||||
endpoint="backup.overview",
|
endpoint="backup.overview",
|
||||||
permissions=("backup.view", "backup.export", "backup.execute"),
|
permissions=("backup_export.read", "backup_import.read"),
|
||||||
),
|
),
|
||||||
NavItem(
|
NavItem(
|
||||||
key="trash",
|
key="trash",
|
||||||
label="Papierkorb",
|
label="Papierkorb",
|
||||||
icon="trash",
|
icon="trash",
|
||||||
endpoint="trash.overview",
|
endpoint="trash.overview",
|
||||||
permissions=("trash.view",),
|
permissions=("trash.read",),
|
||||||
),
|
),
|
||||||
NavItem(
|
NavItem(
|
||||||
key="diagnostics",
|
key="diagnostics",
|
||||||
label="Diagnose",
|
label="Diagnose",
|
||||||
icon="info",
|
icon="info",
|
||||||
endpoint="diagnostics.overview",
|
endpoint="diagnostics.overview",
|
||||||
permissions=("diagnostics.view",),
|
permissions=("diagnostics.read",),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -28,58 +28,97 @@ A = Action
|
|||||||
CUSTOMERS = Resource(
|
CUSTOMERS = Resource(
|
||||||
key="customers",
|
key="customers",
|
||||||
label="Kunden",
|
label="Kunden",
|
||||||
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE),
|
actions=(A.READ, A.CREATE, A.WRITE, A.DELETE),
|
||||||
hint="Stammdaten der Kundeninstanzen",
|
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(
|
TICKETS = Resource(
|
||||||
key="tickets",
|
key="tickets",
|
||||||
label="Tickets",
|
label="Tickets",
|
||||||
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE),
|
actions=(A.READ, A.CREATE, A.WRITE, A.DELETE),
|
||||||
hint="Dauerhafte Anspruechte auf eine Lizenz",
|
hint="Dauerhafte Ansprüche auf eine Lizenz",
|
||||||
action_hints={
|
action_hints={
|
||||||
A.EDIT: "Parameter aendern und Ticket sperren/entsperren. Wirkt erst auf die "
|
A.WRITE: "Parameter ändern und Ticket sperren/entsperren. Wirkt erst auf die "
|
||||||
"naechste Ausstellung -- bereits ausgestellte Lizenzen bleiben unveraendert.",
|
"nächste Ausstellung – bereits ausgestellte Lizenzen bleiben unverändert.",
|
||||||
A.DELETE: "Nur moeglich, wenn keine offene Lizenz mehr existiert.",
|
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(
|
LICENSES = Resource(
|
||||||
key="licenses",
|
key="licenses",
|
||||||
label="Lizenzen",
|
label="Lizenzen",
|
||||||
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE, A.EXECUTE, A.EXPORT),
|
actions=(A.READ, A.DELETE, A.EXPORT),
|
||||||
hint="Konkrete, signierte Lizenzdateien",
|
hint="Ausgestellte Lizenzen: Laufzeit, Zustand, Datei",
|
||||||
action_hints={
|
action_hints={
|
||||||
A.CREATE: "Aus einem Ticket eine Lizenz ausstellen.",
|
A.DELETE: "Eintrag entfernen (nur ohne offene Aktivierung).",
|
||||||
A.EDIT: "Lizenz widerrufen oder eine neue Revision signieren.",
|
|
||||||
A.EXECUTE: "Offline-Aktivierungscodes verarbeiten.",
|
|
||||||
A.EXPORT: "Lizenzdatei herunterladen oder per E-Mail versenden.",
|
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(
|
PORTAL = Resource(
|
||||||
key="portal",
|
key="portal",
|
||||||
label="Kundenportal",
|
label="Kundenportal",
|
||||||
actions=(A.VIEW, A.EXECUTE),
|
actions=(A.READ, A.EXECUTE),
|
||||||
hint="Zugangslinks fuer Kunden",
|
hint="Zugangslinks für Kunden",
|
||||||
action_hints={A.EXECUTE: "Zugangslink erzeugen und versenden."},
|
action_hints={A.EXECUTE: "Zugangslink erzeugen und versenden."},
|
||||||
)
|
)
|
||||||
|
|
||||||
REPORTS = Resource(
|
REPORTS = Resource(
|
||||||
key="reports",
|
key="reports",
|
||||||
label="Berichte der Sub-Lizenzserver",
|
label="Berichte der Sub-Lizenzserver",
|
||||||
actions=(A.VIEW,),
|
actions=(A.READ,),
|
||||||
hint="Von untergeordneten Lizenzservern gemeldete Kunden, Tickets und Lizenzen",
|
hint="Von untergeordneten Lizenzservern gemeldete Kunden, Tickets und Lizenzen",
|
||||||
)
|
)
|
||||||
|
|
||||||
MAIL = Resource(
|
MAIL = Resource(
|
||||||
key="settings_mail",
|
key="settings_mail",
|
||||||
label="E-Mail-Versand",
|
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)",
|
hint="Microsoft Graph (App-only)",
|
||||||
action_hints={
|
action_hints={
|
||||||
A.EXECUTE: "Verbindungstest ausfuehren.",
|
A.EXECUTE: "Verbindungstest ausführen.",
|
||||||
A.SECRETS: "Client-Secret setzen.",
|
A.SECRETS: "Client-Secret setzen.",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -87,8 +126,8 @@ MAIL = Resource(
|
|||||||
VENDOR = Resource(
|
VENDOR = Resource(
|
||||||
key="settings_vendor",
|
key="settings_vendor",
|
||||||
label="Anbieterdaten",
|
label="Anbieterdaten",
|
||||||
actions=(A.VIEW, A.EDIT),
|
actions=(A.READ, A.WRITE),
|
||||||
hint="Name, Kontakt, Logo und oeffentlicher Endpunkt -- reisen in jeder Lizenz mit",
|
hint="Name, Kontakt, Logo und öffentlicher Endpunkt – reisen in jeder Lizenz mit",
|
||||||
)
|
)
|
||||||
|
|
||||||
LICENSING_AREA = Area(
|
LICENSING_AREA = Area(
|
||||||
@@ -96,7 +135,17 @@ LICENSING_AREA = Area(
|
|||||||
label="Lizenzierung",
|
label="Lizenzierung",
|
||||||
icon="key",
|
icon="key",
|
||||||
hint="Kunden, Tickets, Lizenzen. Ohne diesen Zugang bleibt der gesamte Bereich verborgen.",
|
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(
|
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, ...] = (
|
ROLE_PRESETS: tuple[RolePreset, ...] = (
|
||||||
RolePreset(
|
RolePreset(
|
||||||
key="viewer",
|
key="viewer",
|
||||||
name="Benutzer",
|
name="Benutzer",
|
||||||
description="Standardgruppe: nur das laufende Protokoll lesen.",
|
description="Standardgruppe: nur das laufende Protokoll lesen.",
|
||||||
grants=(("logs_live", (A.VIEW,)),),
|
grants=(("logs_live", (A.READ,)),),
|
||||||
is_default=True,
|
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(
|
RolePreset(
|
||||||
key="administration",
|
key="administration",
|
||||||
name="Administration",
|
name="Administration",
|
||||||
description="Benutzer, Gruppen und Einstellungen -- ohne Admin-Kennzeichen.",
|
description="Benutzer, Gruppen und Einstellungen – ohne Admin-Kennzeichen.",
|
||||||
grants=(
|
grants=(
|
||||||
("users", "*"),
|
("users", "*"),
|
||||||
("groups", "*"),
|
("groups", "*"),
|
||||||
("settings_system", "*"),
|
("settings_system", "*"),
|
||||||
|
("settings_nav", "*"),
|
||||||
("settings_license", "*"),
|
("settings_license", "*"),
|
||||||
("settings_directory", (A.VIEW, A.EDIT, A.EXECUTE)),
|
("settings_directory", (A.READ, A.WRITE, A.EXECUTE)),
|
||||||
("settings_web", "*"),
|
("settings_web", "*"),
|
||||||
("settings_network", "*"),
|
("settings_network", "*"),
|
||||||
("settings_mail", (A.VIEW, A.EDIT, A.EXECUTE)),
|
("settings_mail", (A.READ, A.WRITE, A.EXECUTE)),
|
||||||
("settings_vendor", "*"),
|
("settings_vendor", "*"),
|
||||||
("backup", (A.VIEW, A.EXECUTE, A.EXPORT)),
|
("backup", (A.READ, A.EXECUTE, A.EXPORT)),
|
||||||
("trash", "*"),
|
("trash", "*"),
|
||||||
("diagnostics", (A.VIEW,)),
|
("diagnostics", (A.READ,)),
|
||||||
("logs_audit", (A.VIEW,)),
|
("logs_audit", (A.READ,)),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
FUNKTIONEN_VOLLZUGRIFF,
|
||||||
)
|
)
|
||||||
|
|
||||||
__all__ = ["PERMISSIONS", "ROLE_PRESETS"]
|
__all__ = ["PERMISSIONS", "ROLE_PRESETS"]
|
||||||
@@ -27,6 +27,8 @@ Aenderungen gegenueber dem Vorgaenger:
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from tesm_core.migrations import Migration
|
from tesm_core.migrations import Migration
|
||||||
|
|
||||||
V1 = """
|
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, ...] = (
|
LICENSE_MIGRATIONS: tuple[Migration, ...] = (
|
||||||
Migration(version=1, name="baseline", sql=V1, allow_integrity_error=True),
|
Migration(version=1, name="baseline", sql=V1, allow_integrity_error=True),
|
||||||
Migration(version=2, name="revision-timestamp", sql=V2),
|
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,
|
seen_nonce=seen,
|
||||||
)
|
)
|
||||||
except lic.SignatureInvalid:
|
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:
|
except lic.ReplayDetected as exc:
|
||||||
return None, _error(409, "replay", str(exc))
|
return None, _error(409, "replay", str(exc))
|
||||||
except lic.ProtocolViolation as exc:
|
except lic.ProtocolViolation as exc:
|
||||||
@@ -150,7 +150,7 @@ def process_activate(
|
|||||||
) -> Outcome:
|
) -> Outcome:
|
||||||
row, error = _verify(conn, request, expected_action="activate")
|
row, error = _verify(conn, request, expected_action="activate")
|
||||||
if error is not None or row is None:
|
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"])
|
fingerprint = str(request["fingerprint"])
|
||||||
hostname = str(request.get("hostname") or "")
|
hostname = str(request.get("hostname") or "")
|
||||||
@@ -202,7 +202,7 @@ def process_activate(
|
|||||||
target=str(row["license_id"]),
|
target=str(row["license_id"]),
|
||||||
detail=(
|
detail=(
|
||||||
f"Fingerprint {fingerprint[:16]}..., Host {hostname or 'unbekannt'}"
|
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",
|
severity="notice",
|
||||||
)
|
)
|
||||||
@@ -211,7 +211,7 @@ def process_activate(
|
|||||||
request,
|
request,
|
||||||
master_private_key=master_private_key,
|
master_private_key=master_private_key,
|
||||||
status="ok",
|
status="ok",
|
||||||
detail="Aktivierung bestaetigt.",
|
detail="Aktivierung bestätigt.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -220,7 +220,7 @@ def process_deactivate(
|
|||||||
) -> Outcome:
|
) -> Outcome:
|
||||||
row, error = _verify(conn, request, expected_action="deactivate")
|
row, error = _verify(conn, request, expected_action="deactivate")
|
||||||
if error is not None or row is None:
|
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"])
|
fingerprint = str(request["fingerprint"])
|
||||||
if str(row["status"]) != "active" or str(row["fingerprint"] or "") != fingerprint:
|
if str(row["status"]) != "active" or str(row["fingerprint"] or "") != fingerprint:
|
||||||
@@ -249,7 +249,7 @@ def process_deactivate(
|
|||||||
request,
|
request,
|
||||||
master_private_key=master_private_key,
|
master_private_key=master_private_key,
|
||||||
status="ok",
|
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:
|
) -> Outcome:
|
||||||
row, error = _verify(conn, request, expected_action="heartbeat")
|
row, error = _verify(conn, request, expected_action="heartbeat")
|
||||||
if error is not None or row is None:
|
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"])
|
fingerprint = str(request["fingerprint"])
|
||||||
hostname = str(request.get("hostname") or "")
|
hostname = str(request.get("hostname") or "")
|
||||||
@@ -306,7 +306,7 @@ def process_heartbeat(
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
logger.warning(
|
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["license_id"],
|
||||||
row["role"],
|
row["role"],
|
||||||
)
|
)
|
||||||
@@ -322,7 +322,7 @@ def process_heartbeat(
|
|||||||
request,
|
request,
|
||||||
master_private_key=master_private_key,
|
master_private_key=master_private_key,
|
||||||
status="ok",
|
status="ok",
|
||||||
detail="Verbindung bestaetigt.",
|
detail="Verbindung bestätigt.",
|
||||||
license_update=update,
|
license_update=update,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ def validate_email(value: str, *, required: bool) -> str:
|
|||||||
# sollen weiter bearbeitbar bleiben, statt ausgesperrt zu werden.
|
# sollen weiter bearbeitbar bleiben, statt ausgesperrt zu werden.
|
||||||
return ""
|
return ""
|
||||||
if not EMAIL_RE.match(text):
|
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
|
return text
|
||||||
|
|
||||||
|
|
||||||
@@ -137,7 +137,7 @@ def delete_customer(conn: sqlite3.Connection, customer_id: int) -> None:
|
|||||||
)
|
)
|
||||||
if int(tickets or 0):
|
if int(tickets or 0):
|
||||||
raise IssuanceError(
|
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,))
|
conn.execute("DELETE FROM license_customers WHERE id=?", (customer_id,))
|
||||||
|
|
||||||
@@ -224,7 +224,7 @@ def create_ticket(
|
|||||||
raise IssuanceError("Unbekannter Lizenztyp.")
|
raise IssuanceError("Unbekannter Lizenztyp.")
|
||||||
if role not in lic.LICENSE_ROLES or role == "master":
|
if role not in lic.LICENSE_ROLES or role == "master":
|
||||||
# "master" entsteht ausschliesslich lokal per Bootstrap-Skript.
|
# "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
|
# Trial + unbefristet ist ein sinnloser Zustand: eine permanente Lizenz ohne
|
||||||
# Module. Diese Regel wird an drei Stellen geprueft (hier, beim Bearbeiten
|
# Module. Diese Regel wird an drei Stellen geprueft (hier, beim Bearbeiten
|
||||||
@@ -290,7 +290,7 @@ def update_ticket(
|
|||||||
if license_type != str(ticket["type"]):
|
if license_type != str(ticket["type"]):
|
||||||
changes.append(f"Typ: {ticket['type']} -> {license_type}")
|
changes.append(f"Typ: {ticket['type']} -> {license_type}")
|
||||||
if new_modules != str(ticket["modules"]):
|
if new_modules != str(ticket["modules"]):
|
||||||
changes.append("Module geaendert")
|
changes.append("Module geändert")
|
||||||
if int(valid_days) != int(ticket["valid_days"]):
|
if int(valid_days) != int(ticket["valid_days"]):
|
||||||
changes.append(f"Laufzeit: {ticket['valid_days']} -> {valid_days} Tage")
|
changes.append(f"Laufzeit: {ticket['valid_days']} -> {valid_days} Tage")
|
||||||
if bool(lifetime) != bool(ticket["lifetime"]):
|
if bool(lifetime) != bool(ticket["lifetime"]):
|
||||||
@@ -428,7 +428,7 @@ def issue_from_ticket(
|
|||||||
role = str(ticket["role"])
|
role = str(ticket["role"])
|
||||||
if as_trial and role != "customer":
|
if as_trial and role != "customer":
|
||||||
raise IssuanceError(
|
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:
|
if as_trial:
|
||||||
@@ -452,9 +452,9 @@ def issue_from_ticket(
|
|||||||
# hier, an der einzigen Stelle, durch die jede Ausstellung laeuft.
|
# hier, an der einzigen Stelle, durch die jede Ausstellung laeuft.
|
||||||
if not str(master_endpoint or "").strip():
|
if not str(master_endpoint or "").strip():
|
||||||
raise IssuanceError(
|
raise IssuanceError(
|
||||||
"Es ist kein oeffentlicher Endpunkt hinterlegt (Einstellungen -> Anbieter). "
|
"Es ist kein öffentlicher Endpunkt hinterlegt (Einstellungen -> Anbieter). "
|
||||||
"Ohne ihn koennte sich diese Lizenz nie online aktivieren -- der Endpunkt "
|
"Ohne ihn könnte sich diese Lizenz nie online aktivieren – der Endpunkt "
|
||||||
"wird in die Lizenz signiert und laesst sich spaeter nicht nachtragen."
|
"wird in die Lizenz signiert und lässt sich später nicht nachtragen."
|
||||||
)
|
)
|
||||||
|
|
||||||
customer = get_customer(conn, int(ticket["customer_id"]))
|
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))
|
bundle["client_key"] = keystore.decrypt(encrypted, aad=client_key_aad(license_id))
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
raise IssuanceError(
|
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."
|
"Bitte eine neue Lizenz ausstellen."
|
||||||
) from exc
|
) from exc
|
||||||
return bundle
|
return bundle
|
||||||
@@ -580,7 +580,7 @@ def delete_license(conn: sqlite3.Connection, license_id: str) -> None:
|
|||||||
raise IssuanceError("Diese Lizenz existiert nicht.")
|
raise IssuanceError("Diese Lizenz existiert nicht.")
|
||||||
if str(row["status"]) not in {"revoked", "issued"}:
|
if str(row["status"]) not in {"revoked", "issued"}:
|
||||||
raise IssuanceError(
|
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,))
|
conn.execute("DELETE FROM licenses WHERE license_id=?", (license_id,))
|
||||||
|
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ class Report:
|
|||||||
|
|
||||||
def as_text(self) -> str:
|
def as_text(self) -> str:
|
||||||
lines = [
|
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" ({self.key_id})" if self.key_id else ""),
|
||||||
f"Kunden : {self.customers}",
|
f"Kunden : {self.customers}",
|
||||||
f"Tickets : {self.tickets}",
|
f"Tickets : {self.tickets}",
|
||||||
@@ -69,7 +69,7 @@ class Report:
|
|||||||
]
|
]
|
||||||
if self.reissued:
|
if self.reissued:
|
||||||
lines.append("")
|
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)
|
lines.extend(f" * {entry}" for entry in self.reissued)
|
||||||
if self.skipped:
|
if self.skipped:
|
||||||
lines.append("")
|
lines.append("")
|
||||||
@@ -95,7 +95,7 @@ def open_legacy(root: Path) -> sqlite3.Connection:
|
|||||||
"""Oeffnet die alte Datenbank **nur lesend**."""
|
"""Oeffnet die alte Datenbank **nur lesend**."""
|
||||||
path = Path(root) / "sqlite.db"
|
path = Path(root) / "sqlite.db"
|
||||||
if not path.is_file():
|
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:
|
try:
|
||||||
conn = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
|
conn = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
|
||||||
except sqlite3.Error as exc:
|
except sqlite3.Error as exc:
|
||||||
@@ -107,7 +107,7 @@ def open_legacy(root: Path) -> sqlite3.Connection:
|
|||||||
if missing:
|
if missing:
|
||||||
conn.close()
|
conn.close()
|
||||||
raise LegacyImportError(
|
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
|
return conn
|
||||||
|
|
||||||
@@ -123,7 +123,7 @@ def _standard_b64(wert: str, feld: str) -> str:
|
|||||||
"""
|
"""
|
||||||
roh = wert.strip()
|
roh = wert.strip()
|
||||||
if not roh:
|
if not roh:
|
||||||
raise LegacyImportError(f"Im alten Signaturschluessel fehlt {feld!r}.")
|
raise LegacyImportError(f"Im alten Signaturschlüssel fehlt {feld!r}.")
|
||||||
# Fehlende Polsterung ergaenzen -- der Vorgaenger schrieb sie mit, andere
|
# Fehlende Polsterung ergaenzen -- der Vorgaenger schrieb sie mit, andere
|
||||||
# Werkzeuge lassen sie weg.
|
# Werkzeuge lassen sie weg.
|
||||||
gepolstert = roh + "=" * (-len(roh) % 4)
|
gepolstert = roh + "=" * (-len(roh) % 4)
|
||||||
@@ -138,29 +138,29 @@ def _standard_b64(wert: str, feld: str) -> str:
|
|||||||
continue
|
continue
|
||||||
if len(rohbytes) != 32:
|
if len(rohbytes) != 32:
|
||||||
raise LegacyImportError(
|
raise LegacyImportError(
|
||||||
f"{feld} im alten Signaturschluessel ist {len(rohbytes)} Byte lang, "
|
f"{feld} im alten Signaturschlüssel ist {len(rohbytes)} Byte lang, "
|
||||||
"erwartet werden 32 (Ed25519)."
|
"erwartet werden 32 (Ed25519)."
|
||||||
)
|
)
|
||||||
return base64.b64encode(rohbytes).decode("ascii")
|
return base64.b64encode(rohbytes).decode("ascii")
|
||||||
raise LegacyImportError(f"{feld} im alten Signaturschluessel ist kein Base64.")
|
raise LegacyImportError(f"{feld} im alten Signaturschlüssel ist kein Base64.")
|
||||||
|
|
||||||
|
|
||||||
def read_legacy_key(root: Path) -> tuple[str, str]:
|
def read_legacy_key(root: Path) -> tuple[str, str]:
|
||||||
"""Liest den alten Signaturschluessel. Rueckgabe: ``(privat, oeffentlich)``."""
|
"""Liest den alten Signaturschluessel. Rueckgabe: ``(privat, oeffentlich)``."""
|
||||||
path = Path(root) / "master_signing_key.json"
|
path = Path(root) / "master_signing_key.json"
|
||||||
if not path.is_file():
|
if not path.is_file():
|
||||||
raise LegacyImportError(f"Der alte Signaturschluessel {path} fehlt.")
|
raise LegacyImportError(f"Der alte Signaturschlüssel {path} fehlt.")
|
||||||
try:
|
try:
|
||||||
data = json.loads(path.read_text(encoding="utf-8"))
|
data = json.loads(path.read_text(encoding="utf-8"))
|
||||||
except (OSError, ValueError) as exc:
|
except (OSError, ValueError) as exc:
|
||||||
raise LegacyImportError(f"Der alte Signaturschluessel ist unlesbar: {exc}") from exc
|
raise LegacyImportError(f"Der alte Signaturschlüssel ist unlesbar: {exc}") from exc
|
||||||
|
|
||||||
# Die Vorgaengerfassung nannte die Felder "private"/"public" und kodierte
|
# Die Vorgaengerfassung nannte die Felder "private"/"public" und kodierte
|
||||||
# sie URL-sicher.
|
# sie URL-sicher.
|
||||||
private = str(data.get("private") or data.get("private_key") or "")
|
private = str(data.get("private") or data.get("private_key") or "")
|
||||||
public = str(data.get("public") or data.get("public_key") or "")
|
public = str(data.get("public") or data.get("public_key") or "")
|
||||||
if not private or not public:
|
if not private or not public:
|
||||||
raise LegacyImportError("Der alte Signaturschluessel ist unvollstaendig.")
|
raise LegacyImportError("Der alte Signaturschlüssel ist unvollständig.")
|
||||||
private = _standard_b64(private, "private")
|
private = _standard_b64(private, "private")
|
||||||
public = _standard_b64(public, "public")
|
public = _standard_b64(public, "public")
|
||||||
# Alles, was aus der Protokollschicht kommt, wird hier zu einer Meldung im
|
# Alles, was aus der Protokollschicht kommt, wird hier zu einer Meldung im
|
||||||
@@ -170,11 +170,11 @@ def read_legacy_key(root: Path) -> tuple[str, str]:
|
|||||||
try:
|
try:
|
||||||
passt = licsrv.public_from_private(private) == public
|
passt = licsrv.public_from_private(private) == public
|
||||||
except (ValueError, TypeError) as exc:
|
except (ValueError, TypeError) as exc:
|
||||||
raise LegacyImportError(f"Der alte Signaturschluessel ist unbrauchbar: {exc}") from exc
|
raise LegacyImportError(f"Der alte Signaturschlüssel ist unbrauchbar: {exc}") from exc
|
||||||
if not passt:
|
if not passt:
|
||||||
raise LegacyImportError(
|
raise LegacyImportError(
|
||||||
"Im alten Signaturschluessel passen privater und oeffentlicher Teil nicht "
|
"Im alten Signaturschlüssel passen privater und öffentlicher Teil nicht "
|
||||||
"zueinander. Die Datei ist beschaedigt."
|
"zueinander. Die Datei ist beschädigt."
|
||||||
)
|
)
|
||||||
return private, public
|
return private, public
|
||||||
|
|
||||||
@@ -190,16 +190,16 @@ def adopt_key(private: str, public: str, target: Path) -> str:
|
|||||||
|
|
||||||
if Path(target).is_file():
|
if Path(target).is_file():
|
||||||
raise LegacyImportError(
|
raise LegacyImportError(
|
||||||
f"Unter {target} liegt bereits ein Signaturschluessel. Er wird nicht "
|
f"Unter {target} liegt bereits ein Signaturschlüssel. Er wird nicht "
|
||||||
"ueberschrieben -- das wuerde jede damit ausgestellte Lizenz unbrauchbar "
|
"überschrieben – das würde jede damit ausgestellte Lizenz unbrauchbar "
|
||||||
"machen. Fuer eine Uebernahme bitte eine frische Installation verwenden."
|
"machen. Für eine Übernahme bitte eine frische Installation verwenden."
|
||||||
)
|
)
|
||||||
payload = {
|
payload = {
|
||||||
"private_key": private,
|
"private_key": private,
|
||||||
"public_key": public,
|
"public_key": public,
|
||||||
"key_id": lic.key_id(public),
|
"key_id": lic.key_id(public),
|
||||||
"created_at": _now(),
|
"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")
|
write_private_file(Path(target), json.dumps(payload, indent=2, sort_keys=True) + "\n")
|
||||||
return lic.key_id(public)
|
return lic.key_id(public)
|
||||||
@@ -309,7 +309,7 @@ def import_all(
|
|||||||
)
|
)
|
||||||
if existing is not None:
|
if existing is not None:
|
||||||
customer_map[int(row["id"])] = int(existing["id"])
|
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
|
continue
|
||||||
new_id = issuance.create_customer(
|
new_id = issuance.create_customer(
|
||||||
conn,
|
conn,
|
||||||
@@ -331,7 +331,7 @@ def import_all(
|
|||||||
for row in legacy.execute("SELECT * FROM license_tickets ORDER BY created_at"):
|
for row in legacy.execute("SELECT * FROM license_tickets ORDER BY created_at"):
|
||||||
customer_id = customer_map.get(int(row["customer_id"] or 0))
|
customer_id = customer_map.get(int(row["customer_id"] or 0))
|
||||||
if customer_id is None:
|
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
|
continue
|
||||||
if Database.one(conn, "SELECT id FROM license_tickets WHERE id=?", (row["id"],)):
|
if Database.one(conn, "SELECT id FROM license_tickets WHERE id=?", (row["id"],)):
|
||||||
continue
|
continue
|
||||||
@@ -348,7 +348,7 @@ def import_all(
|
|||||||
int(row["lifetime"] or 0),
|
int(row["lifetime"] or 0),
|
||||||
str(row["status"] or "open"),
|
str(row["status"] or "open"),
|
||||||
str(row["role"] or "customer"),
|
str(row["role"] or "customer"),
|
||||||
"Aus der Vorgaengerfassung uebernommen.",
|
"Aus der Vorgängerfassung übernommen.",
|
||||||
str(row["created_by"] or actor),
|
str(row["created_by"] or actor),
|
||||||
str(row["created_at"] or _now()),
|
str(row["created_at"] or _now()),
|
||||||
_now(),
|
_now(),
|
||||||
@@ -361,7 +361,7 @@ def import_all(
|
|||||||
license_id = str(row["license_id"] or "")
|
license_id = str(row["license_id"] or "")
|
||||||
customer_id = customer_map.get(int(row["customer_id"] or 0))
|
customer_id = customer_map.get(int(row["customer_id"] or 0))
|
||||||
if not license_id or customer_id is None:
|
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
|
continue
|
||||||
if Database.one(conn, "SELECT id FROM licenses WHERE license_id=?", (license_id,)):
|
if Database.one(conn, "SELECT id FROM licenses WHERE license_id=?", (license_id,)):
|
||||||
continue
|
continue
|
||||||
@@ -371,7 +371,7 @@ def import_all(
|
|||||||
modules = _modules(row["modules"])
|
modules = _modules(row["modules"])
|
||||||
|
|
||||||
if not reissue:
|
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
|
report.licenses += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -383,7 +383,7 @@ def import_all(
|
|||||||
if not lifetime and valid_days <= 0:
|
if not lifetime and valid_days <= 0:
|
||||||
report.skipped.append(
|
report.skipped.append(
|
||||||
f"{customer['name']} / {license_id[:12]} -- abgelaufen am "
|
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
|
continue
|
||||||
|
|
||||||
@@ -444,7 +444,7 @@ def import_all(
|
|||||||
if report.reissued:
|
if report.reissued:
|
||||||
report.warnings.append(
|
report.warnings.append(
|
||||||
"Die neu signierten Lizenzen sind noch nicht aktiviert: die Bindung an eine "
|
"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."
|
"Kunde erneut."
|
||||||
)
|
)
|
||||||
return report
|
return report
|
||||||
@@ -92,7 +92,7 @@ def _secret(conn: sqlite3.Connection) -> str:
|
|||||||
return core().keystore.decrypt(str(value), aad=AAD_GRAPH_SECRET)
|
return core().keystore.decrypt(str(value), aad=AAD_GRAPH_SECRET)
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
raise MailError(
|
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
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
@@ -162,7 +162,7 @@ def fetch_token(conn: sqlite3.Connection) -> str:
|
|||||||
def test_connection(conn: sqlite3.Connection) -> dict[str, Any]:
|
def test_connection(conn: sqlite3.Connection) -> dict[str, Any]:
|
||||||
"""Holt nur ein Token -- verschickt bewusst keine echte Nachricht."""
|
"""Holt nur ein Token -- verschickt bewusst keine echte Nachricht."""
|
||||||
if not is_enabled(conn):
|
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:
|
try:
|
||||||
fetch_token(conn)
|
fetch_token(conn)
|
||||||
except MailError as exc:
|
except MailError as exc:
|
||||||
|
|||||||
@@ -48,22 +48,22 @@ class MasterKey:
|
|||||||
def load(path: Path) -> MasterKey:
|
def load(path: Path) -> MasterKey:
|
||||||
if not path.is_file():
|
if not path.is_file():
|
||||||
raise MasterKeyError(
|
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'."
|
"werden. Erzeugen Sie ihn mit 'tesm-license-admin init-key'."
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
data = json.loads(path.read_text(encoding="utf-8"))
|
data = json.loads(path.read_text(encoding="utf-8"))
|
||||||
except (OSError, ValueError) as exc:
|
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 "")
|
private = str(data.get("private_key") or "")
|
||||||
public = str(data.get("public_key") or "")
|
public = str(data.get("public_key") or "")
|
||||||
if not private or not public:
|
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:
|
if licsrv.public_from_private(private) != public:
|
||||||
raise MasterKeyError(
|
raise MasterKeyError(
|
||||||
"Der oeffentliche Teil des Signaturschluessels passt nicht zum privaten. "
|
"Der öffentliche Teil des Signaturschlüssels passt nicht zum privaten. "
|
||||||
"Die Datei ist beschaedigt oder wurde manipuliert."
|
"Die Datei ist beschädigt oder wurde manipuliert."
|
||||||
)
|
)
|
||||||
return MasterKey(
|
return MasterKey(
|
||||||
private_key=private, public_key=public, created_at=str(data.get("created_at") or "")
|
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")
|
write_private_file(path, json.dumps(payload, indent=2, sort_keys=True) + "\n")
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Neuer Signaturschluessel erzeugt (%s). Diese Datei ist unwiederbringlich -- "
|
"Neuer Signaturschlüssel erzeugt (%s). Diese Datei ist unwiederbringlich – "
|
||||||
"bitte sofort gesichert ablegen: %s",
|
"bitte sofort gesichert ablegen: %s",
|
||||||
lic.key_id(public),
|
lic.key_id(public),
|
||||||
path,
|
path,
|
||||||
@@ -98,7 +98,7 @@ def ensure(path: Path) -> MasterKey:
|
|||||||
def health(path: Path) -> dict[str, object]:
|
def health(path: Path) -> dict[str, object]:
|
||||||
"""Zustand fuer die Diagnoseseite."""
|
"""Zustand fuer die Diagnoseseite."""
|
||||||
if not path.is_file():
|
if not path.is_file():
|
||||||
return {"present": False, "problems": ["Signaturschluessel fehlt"]}
|
return {"present": False, "problems": ["Signaturschlüssel fehlt"]}
|
||||||
problems = check_private_file(path)
|
problems = check_private_file(path)
|
||||||
try:
|
try:
|
||||||
key = load(path)
|
key = load(path)
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import sqlite3
|
|||||||
from typing import Any, Callable, Iterable
|
from typing import Any, Callable, Iterable
|
||||||
|
|
||||||
from tesm_core.db import Database
|
from tesm_core.db import Database
|
||||||
|
from tesm_core.rbac.legacy_permissions import umgeschrieben
|
||||||
from tesm_core.transfer import (
|
from tesm_core.transfer import (
|
||||||
MIN_PASSPHRASE_LENGTH,
|
MIN_PASSPHRASE_LENGTH,
|
||||||
PREVIEW_TTL_MINUTES,
|
PREVIEW_TTL_MINUTES,
|
||||||
@@ -41,11 +42,11 @@ APP_TAG = "tesm-license"
|
|||||||
|
|
||||||
CATEGORIES: tuple[Category, ...] = (
|
CATEGORIES: tuple[Category, ...] = (
|
||||||
Category("customers", "Kunden", "Stammdaten der Kundeninstanzen"),
|
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(
|
Category(
|
||||||
"licenses",
|
"licenses",
|
||||||
"Lizenzen",
|
"Lizenzen",
|
||||||
"Ausgestellte Lizenzen mit signiertem Dokument und verschluesseltem Client-Schluessel",
|
"Ausgestellte Lizenzen mit signiertem Dokument und verschlüsseltem Client-Schlüssel",
|
||||||
sensitive=True,
|
sensitive=True,
|
||||||
),
|
),
|
||||||
Category("users", "Benutzer", "Lokale Konten mit Passwort-Hash", admin_only=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
|
zusammengefuehrt; Tickets und Lizenzen ueber ihre ID, weil die zugleich
|
||||||
kryptographisch relevant ist (Self-Service-Link bzw. signierte Lizenz-ID).
|
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}
|
wanted = {key for key in categories if key in CATEGORY_BY_KEY}
|
||||||
report: dict[str, dict[str, int]] = {}
|
report: dict[str, dict[str, int]] = {}
|
||||||
stamp = _stamp()
|
stamp = _stamp()
|
||||||
@@ -332,9 +337,16 @@ def apply_import(
|
|||||||
group_id = int(existing["id"])
|
group_id = int(existing["id"])
|
||||||
bump("groups", "updated")
|
bump("groups", "updated")
|
||||||
conn.execute("DELETE FROM group_permissions WHERE group_id=?", (group_id,))
|
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(
|
conn.executemany(
|
||||||
"INSERT OR IGNORE INTO group_permissions (group_id, permission) VALUES (?,?)",
|
"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:
|
if "users" in wanted:
|
||||||
@@ -393,6 +405,18 @@ def apply_import(
|
|||||||
set_setting(str(key), value)
|
set_setting(str(key), value)
|
||||||
bump("settings", "updated")
|
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
|
return report
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -25,8 +25,8 @@ VENDOR_SETTINGS: tuple[SettingSpec, ...] = (
|
|||||||
"master_endpoint",
|
"master_endpoint",
|
||||||
"",
|
"",
|
||||||
"str",
|
"str",
|
||||||
"Oeffentlicher Endpunkt dieses Lizenzservers",
|
"Öffentlicher Endpunkt dieses Lizenzservers",
|
||||||
hint="Basis-URL, unter der Kundeninstanzen diesen Server erreichen -- z. B. "
|
hint="Basis-URL, unter der Kundeninstanzen diesen Server erreichen – z. B. "
|
||||||
"https://lizenz.firma.de. Wird in jede Lizenz eingebettet und fuer alle "
|
"https://lizenz.firma.de. Wird in jede Lizenz eingebettet und fuer alle "
|
||||||
"kundengerichteten Links verwendet.",
|
"kundengerichteten Links verwendet.",
|
||||||
section="vendor",
|
section="vendor",
|
||||||
@@ -38,8 +38,8 @@ PORTAL_SETTINGS: tuple[SettingSpec, ...] = (
|
|||||||
"portal_link_minutes",
|
"portal_link_minutes",
|
||||||
30,
|
30,
|
||||||
"int",
|
"int",
|
||||||
"Gueltigkeit des Zugangslinks (Minuten)",
|
"Gültigkeit des Zugangslinks (Minuten)",
|
||||||
hint="Der Link ist zusaetzlich nur einmal verwendbar.",
|
hint="Der Link ist zusätzlich nur einmal verwendbar.",
|
||||||
section="portal",
|
section="portal",
|
||||||
minimum=5,
|
minimum=5,
|
||||||
maximum=1440,
|
maximum=1440,
|
||||||
@@ -48,9 +48,9 @@ PORTAL_SETTINGS: tuple[SettingSpec, ...] = (
|
|||||||
"portal_session_minutes",
|
"portal_session_minutes",
|
||||||
60,
|
60,
|
||||||
"int",
|
"int",
|
||||||
"Gueltigkeit der Portalsitzung (Minuten)",
|
"Gültigkeit der Portalsitzung (Minuten)",
|
||||||
hint="Zweites, unabhaengiges Zeitfenster: eines fuer das Anklicken des Links, "
|
hint="Zweites, unabhängiges Zeitfenster: eines für das Anklicken des Links, "
|
||||||
"eines fuer die daraus entstehende Sitzung.",
|
"eines für die daraus entstehende Sitzung.",
|
||||||
section="portal",
|
section="portal",
|
||||||
minimum=5,
|
minimum=5,
|
||||||
maximum=1440,
|
maximum=1440,
|
||||||
@@ -64,7 +64,7 @@ PORTAL_SETTINGS: tuple[SettingSpec, ...] = (
|
|||||||
"bool",
|
"bool",
|
||||||
"Self-Service-Link je Ticket aktiv",
|
"Self-Service-Link je Ticket aktiv",
|
||||||
hint="Erlaubt dem Kunden, seine Lizenz selbst herunterzuladen, zu widerrufen und "
|
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",
|
section="portal",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -83,8 +83,8 @@ DEFAULT_TICKET_SETTINGS: tuple[SettingSpec, ...] = (
|
|||||||
"allow_customer_revoke",
|
"allow_customer_revoke",
|
||||||
True,
|
True,
|
||||||
"bool",
|
"bool",
|
||||||
"Kunden duerfen ihre Lizenz selbst widerrufen",
|
"Kunden dürfen ihre Lizenz selbst widerrufen",
|
||||||
hint="Notwendig fuer einen Systemwechsel ohne Support-Anruf.",
|
hint="Notwendig für einen Systemwechsel ohne Support-Anruf.",
|
||||||
section="licensing",
|
section="licensing",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
{% block topbar_extra %}
|
{% block topbar_extra %}
|
||||||
{% if current_user.has('tickets.create') %}
|
{% 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
|
{{ icon('plus', size=15) }} Ticket anlegen
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -171,22 +171,22 @@
|
|||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
<div class="card__body stack">
|
<div class="card__body stack">
|
||||||
{{ field('name', 'Firmenname', value=customer.name, required=True,
|
{{ 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',
|
{{ 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,
|
{{ 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,
|
{{ 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">
|
<div class="form-grid">
|
||||||
{{ field('street', 'Strasse', value=customer.street,
|
{{ 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,
|
{{ 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,
|
{{ 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,
|
{{ field('city', 'Ort', value=customer.city,
|
||||||
disabled=not current_user.has('customers.edit')) }}
|
disabled=not current_user.has('customers.write')) }}
|
||||||
</div>
|
</div>
|
||||||
{{ textarea_field('notes', 'Notizen', value=customer.notes, rows=3,
|
{{ textarea_field('notes', 'Notizen', value=customer.notes, rows=3,
|
||||||
extra_class='field--full') }}
|
extra_class='field--full') }}
|
||||||
@@ -200,7 +200,7 @@
|
|||||||
{{ icon('copy', size=13) }}</button></dd>
|
{{ icon('copy', size=13) }}</button></dd>
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
{% if current_user.has('customers.edit') %}
|
{% if current_user.has('customers.write') %}
|
||||||
<div class="card__footer modal__footer--split">
|
<div class="card__footer modal__footer--split">
|
||||||
{% if current_user.has('customers.delete') %}
|
{% if current_user.has('customers.delete') %}
|
||||||
<button class="btn btn--sm btn--ghost danger-text" type="submit"
|
<button class="btn btn--sm btn--ghost danger-text" type="submit"
|
||||||
|
|||||||
@@ -101,7 +101,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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">
|
<section class="card">
|
||||||
<div class="card__header">
|
<div class="card__header">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
@@ -170,7 +170,7 @@
|
|||||||
<summary class="card__header" style="cursor:pointer">
|
<summary class="card__header" style="cursor:pointer">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="card__title">{{ icon('file-text', size=17) }} Signiertes Dokument</div>
|
<div class="card__title">{{ icon('file-text', size=17) }} Signiertes Dokument</div>
|
||||||
<div class="card__sub">Genau so, wie es signiert wurde -- jede Änderung bricht die Signatur</div>
|
<div class="card__sub">Genau so, wie es signiert wurde – jede Änderung bricht die Signatur</div>
|
||||||
</div>
|
</div>
|
||||||
{{ icon('chevron-down', size=16) }}
|
{{ icon('chevron-down', size=16) }}
|
||||||
</summary>
|
</summary>
|
||||||
@@ -204,17 +204,17 @@
|
|||||||
</section>
|
</section>
|
||||||
{% endif %}
|
{% 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">
|
<section class="card">
|
||||||
<div class="card__header">
|
<div class="card__header">
|
||||||
<div class="flex-1"><div class="card__title">{{ icon('alert', size=17) }} Lebenszyklus</div></div>
|
<div class="flex-1"><div class="card__title">{{ icon('alert', size=17) }} Lebenszyklus</div></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card__body stack stack--tight">
|
<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"
|
<button class="btn btn--sm btn--danger btn--block" type="button"
|
||||||
data-dialog-open="revoke-dialog">Widerrufen</button>
|
data-dialog-open="revoke-dialog">Widerrufen</button>
|
||||||
<p class="fs-xs faint">
|
<p class="fs-xs faint">
|
||||||
Das Ticket bleibt offen -- eine neue Lizenz kann sofort ausgestellt werden. Genau so
|
Das Ticket bleibt offen – eine neue Lizenz kann sofort ausgestellt werden. Genau so
|
||||||
läuft ein Systemwechsel.
|
läuft ein Systemwechsel.
|
||||||
</p>
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -239,7 +239,7 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block dialogs %}
|
{% 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') %}
|
{% call modal('revoke-dialog', 'Lizenz widerrufen', license.customer_name, 'narrow') %}
|
||||||
<form method="post" action="{{ url_for('licenses.revoke', license_id=license.license_id) }}">
|
<form method="post" action="{{ url_for('licenses.revoke', license_id=license.license_id) }}">
|
||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
{% block topbar_extra %}
|
{% block topbar_extra %}
|
||||||
{{ search_box('licenses-table', 'Kunde, Lizenz-ID, Host ...') }}
|
{{ 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') }}">
|
<a class="btn btn--sm" href="{{ url_for('licenses.manual_code') }}">
|
||||||
{{ icon('transfer', size=15) }} Offline-Code
|
{{ icon('transfer', size=15) }} Offline-Code
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -89,7 +89,7 @@
|
|||||||
</dd>
|
</dd>
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
{% if current_user.has('settings_license.edit') %}
|
{% if current_user.has('settings_license.write') %}
|
||||||
<div class="card__footer">
|
<div class="card__footer">
|
||||||
{% if not state.activated %}
|
{% if not state.activated %}
|
||||||
<form method="post" action="{{ url_for('own_license.activate') }}" data-no-guard>
|
<form method="post" action="{{ url_for('own_license.activate') }}" data-no-guard>
|
||||||
@@ -116,9 +116,16 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
{% if master_edition %}
|
||||||
{{ empty_state('Keine gültige Lizenz',
|
{{ empty_state('Keine gültige Lizenz',
|
||||||
'Spielen Sie eine Lizenzdatei ein oder erzeugen Sie mit "tesm-license-admin bootstrap"
|
'Spielen Sie eine Lizenzdatei ein oder erzeugen Sie mit "tesm-license-admin bootstrap"
|
||||||
die eigene Lizenz der Wurzelinstanz.', 'key') }}
|
die eigene Lizenz der Wurzelinstanz.', 'key') }}
|
||||||
|
{% 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 %}
|
{% endif %}
|
||||||
|
|
||||||
{% if pending %}
|
{% if pending %}
|
||||||
@@ -144,7 +151,7 @@
|
|||||||
</section>
|
</section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if current_user.has('settings_license.edit') %}
|
{% if current_user.has('settings_license.write') %}
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<div class="card__header">
|
<div class="card__header">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<p class="auth-footnote">
|
<p class="auth-footnote">
|
||||||
Aus Datenschutzgründen bekommen Sie immer dieselbe Rückmeldung -- unabhängig davon, ob die
|
Aus Datenschutzgründen bekommen Sie immer dieselbe Rückmeldung – unabhängig davon, ob die
|
||||||
Adresse bei uns bekannt ist.
|
Adresse bei uns bekannt ist.
|
||||||
</p>
|
</p>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
|
|
||||||
{% if response_code %}
|
{% if response_code %}
|
||||||
<div class="field">
|
<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>
|
<pre class="log-view" id="offline-answer" style="max-height:16dvh">{{ response_code }}</pre>
|
||||||
<div class="row row--end mt-2">
|
<div class="row row--end mt-2">
|
||||||
<button class="btn btn--sm" type="button" data-copy="#offline-answer">
|
<button class="btn btn--sm" type="button" data-copy="#offline-answer">
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="fs-sm muted mt-2">
|
<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.
|
„Antwortcode des Lizenzservers“ ein.
|
||||||
{% if summary.hostname %}
|
{% if summary.hostname %}
|
||||||
Die Anfrage kam von <span class="mono">{{ summary.hostname }}</span>.
|
Die Anfrage kam von <span class="mono">{{ summary.hostname }}</span>.
|
||||||
|
|||||||
@@ -33,16 +33,16 @@ bewusst keine echte Nachricht.', 'info') }}
|
|||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
<div class="card__body stack">
|
<div class="card__body stack">
|
||||||
{{ checkbox('graph_enabled', 'E-Mail-Versand aktiv', checked=values.graph_enabled,
|
{{ 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">
|
<div class="form-grid">
|
||||||
{{ field('graph_tenant_id', 'Verzeichnis-ID (Tenant)', value=values.graph_tenant_id,
|
{{ 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,
|
{{ 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',
|
{{ 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',
|
{{ 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>
|
</div>
|
||||||
<fieldset class="fieldset">
|
<fieldset class="fieldset">
|
||||||
<legend>Client-Secret {% if has_secret %}{{ pill('gesetzt', 'online') }}{% else %}{{ pill('fehlt', 'warning') }}{% endif %}</legend>
|
<legend>Client-Secret {% if has_secret %}{{ pill('gesetzt', 'online') }}{% else %}{{ pill('fehlt', 'warning') }}{% endif %}</legend>
|
||||||
@@ -55,7 +55,7 @@ bewusst keine echte Nachricht.', 'info') }}
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</div>
|
</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>
|
<div class="card__footer"><button class="btn btn--primary" type="submit">Speichern</button></div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{% extends "tesm_core/base.html" %}
|
{% 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_title %}Systemeinstellungen{% endblock %}
|
||||||
{% block page_sub %}Portal, Self-Service, Protokolle, Host{% endblock %}
|
{% block page_sub %}Portal, Self-Service, Protokolle, Host{% endblock %}
|
||||||
@@ -60,9 +60,6 @@
|
|||||||
selected=values.log_rotate_interval) }}
|
selected=values.log_rotate_interval) }}
|
||||||
{{ field('log_rotate_keep', 'Aufbewahrte Rotationen', value=values.log_rotate_keep,
|
{{ field('log_rotate_keep', 'Aufbewahrte Rotationen', value=values.log_rotate_keep,
|
||||||
type='number') }}
|
type='number') }}
|
||||||
{{ field('session_idle_minutes', 'Automatische Abmeldung (Minuten)',
|
|
||||||
value=values.session_idle_minutes, type='number',
|
|
||||||
hint_text=specs['session_idle_minutes'].hint) }}
|
|
||||||
{{ field('trash_retention_days', 'Papierkorb-Aufbewahrung (Tage)',
|
{{ field('trash_retention_days', 'Papierkorb-Aufbewahrung (Tage)',
|
||||||
value=values.trash_retention_days, type='number') }}
|
value=values.trash_retention_days, type='number') }}
|
||||||
</div>
|
</div>
|
||||||
@@ -73,6 +70,34 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="stack">
|
<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">
|
<section class="card">
|
||||||
<div class="card__header">
|
<div class="card__header">
|
||||||
<div class="flex-1"><div class="card__title">{{ icon('server', size=17) }} Host</div></div>
|
<div class="flex-1"><div class="card__title">{{ icon('server', size=17) }} Host</div></div>
|
||||||
@@ -104,33 +129,9 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{% if current_user.is_admin %}
|
{% if current_user.is_admin %}
|
||||||
<section class="card">
|
{{ nav_order_card(nav_baum, nav_order, nav_child_order,
|
||||||
<div class="card__header">
|
url_for('admin.nav_order'), csrf_field_name, csrf_token(),
|
||||||
<div class="flex-1">
|
sub='Gilt für alle Benutzer') }}
|
||||||
<div class="card__title">{{ icon('sliders', size=17) }} Navigationsreihenfolge</div>
|
|
||||||
<div class="card__sub">Gilt für 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>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -19,16 +19,16 @@ welche Basis-URL in kundengerichteten Links steht.', 'info') }}
|
|||||||
<div class="card__body stack">
|
<div class="card__body stack">
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
{{ field('vendor_name', 'Anbietername', value=values.vendor_name,
|
{{ 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',
|
{{ 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,
|
{{ 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>
|
</div>
|
||||||
{{ textarea_field('vendor_address', 'Adresse', value=values.vendor_address, rows=3) }}
|
{{ textarea_field('vendor_address', 'Adresse', value=values.vendor_address, rows=3) }}
|
||||||
{{ field('master_endpoint', 'Öffentlicher Endpunkt', value=values.master_endpoint,
|
{{ field('master_endpoint', 'Öffentlicher Endpunkt', value=values.master_endpoint,
|
||||||
placeholder='https://lizenz.firma.de',
|
placeholder='https://lizenz.firma.de',
|
||||||
disabled=not current_user.has('settings_vendor.edit'),
|
disabled=not current_user.has('settings_vendor.write'),
|
||||||
hint_text='Basis-URL, unter der Kundeninstanzen diesen Server erreichen. Wird in
|
hint_text='Basis-URL, unter der Kundeninstanzen diesen Server erreichen. Wird in
|
||||||
jede Lizenz eingebettet und für alle kundengerichteten Links verwendet -- statt
|
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.',
|
des Host-Headers der aktüllen Anfrage, der bei internen Aufrufen falsch wäre.',
|
||||||
@@ -43,14 +43,14 @@ welche Basis-URL in kundengerichteten Links steht.', 'info') }}
|
|||||||
<p class="faint fs-sm">Kein Logo hinterlegt.</p>
|
<p class="faint fs-sm">Kein Logo hinterlegt.</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<input class="input" type="file" name="vendor_logo" accept="image/png,image/jpeg,image/svg+xml"
|
<input class="input" type="file" name="vendor_logo" accept="image/png,image/jpeg,image/svg+xml"
|
||||||
{% if not current_user.has('settings_vendor.edit') %}disabled{% endif %}>
|
{% if not current_user.has('settings_vendor.write') %}disabled{% endif %}>
|
||||||
<span class="field__hint">
|
<span class="field__hint">
|
||||||
Ein zu grosses Logo wird abgelehnt, die übrigen Angaben werden trotzdem gespeichert.
|
Ein zu großes Logo wird abgelehnt, die übrigen Angaben werden trotzdem gespeichert.
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</div>
|
</div>
|
||||||
{% if current_user.has('settings_vendor.edit') %}
|
{% if current_user.has('settings_vendor.write') %}
|
||||||
<div class="card__footer modal__footer--split">
|
<div class="card__footer modal__footer--split">
|
||||||
{% if values.vendor_logo_base64 %}
|
{% if values.vendor_logo_base64 %}
|
||||||
<button class="btn btn--sm btn--ghost" type="submit"
|
<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>
|
<div class="divider"></div>
|
||||||
<p class="fs-sm muted">
|
<p class="fs-sm muted">
|
||||||
Die Angaben werden zum Zeitpunkt der Ausstellung in die Lizenz kopiert und signiert. Eine
|
Die Angaben werden zum Zeitpunkt der Ausstellung in die Lizenz kopiert und signiert. Eine
|
||||||
Änderung hier wirkt auf neue Lizenzen -- bestehende behalten ihren Stand, bis eine neue
|
Änderung hier wirkt auf neue Lizenzen – bestehende behalten ihren Stand, bis eine neue
|
||||||
Revision signiert wird.
|
Revision signiert wird.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
<div class="card__title">{{ icon('shield', size=17) }} Lizenz ausstellen</div>
|
<div class="card__title">{{ icon('shield', size=17) }} Lizenz ausstellen</div>
|
||||||
<div class="card__sub">
|
<div class="card__sub">
|
||||||
{% if has_open_license %}
|
{% 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 %}
|
{% else %}
|
||||||
Erzeugt eine signierte Lizenzdatei mit den Werten dieses Tickets.
|
Erzeugt eine signierte Lizenzdatei mit den Werten dieses Tickets.
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card__body row row--tight">
|
<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) }}"
|
<form method="post" action="{{ url_for('licenses.issue', ticket_id=ticket.id) }}"
|
||||||
data-no-guard>
|
data-no-guard>
|
||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
@@ -128,7 +128,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<p class="fs-xs faint">
|
<p class="fs-xs faint">
|
||||||
Damit kann der Kunde seine Lizenz herunterladen, bei einem Systemwechsel freigeben und
|
Damit kann der Kunde seine Lizenz herunterladen, bei einem Systemwechsel freigeben und
|
||||||
neu ausstellen. Der Link enthält 192 Bit Zufall und ist nicht ratbar -- behandeln Sie
|
neu ausstellen. Der Link enthält 192 Bit Zufall und ist nicht ratbar – behandeln Sie
|
||||||
ihn wie ein Passwort.
|
ihn wie ein Passwort.
|
||||||
</p>
|
</p>
|
||||||
<div class="divider"></div>
|
<div class="divider"></div>
|
||||||
@@ -163,7 +163,7 @@
|
|||||||
<label class="field">
|
<label class="field">
|
||||||
<span class="field__label">Lizenztyp</span>
|
<span class="field__label">Lizenztyp</span>
|
||||||
<select class="select" name="type" id="ticket-type"
|
<select class="select" name="type" id="ticket-type"
|
||||||
{% if not current_user.has('tickets.edit') %}disabled{% endif %}>
|
{% if not current_user.has('tickets.write') %}disabled{% endif %}>
|
||||||
{% for value in types %}
|
{% for value in types %}
|
||||||
<option value="{{ value }}" {% if value == ticket.type %}selected{% endif %}>
|
<option value="{{ value }}" {% if value == ticket.type %}selected{% endif %}>
|
||||||
{{ type_labels.get(value, value) }}
|
{{ type_labels.get(value, value) }}
|
||||||
@@ -178,15 +178,15 @@
|
|||||||
{% for module in modules %}
|
{% for module in modules %}
|
||||||
{{ checkbox('modules', module_labels[module], value=module,
|
{{ checkbox('modules', module_labels[module], value=module,
|
||||||
checked=module in ticket_modules,
|
checked=module in ticket_modules,
|
||||||
disabled=not current_user.has('tickets.edit')) }}
|
disabled=not current_user.has('tickets.write')) }}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{{ field('valid_days', 'Laufzeit (Tage)', value=ticket.valid_days, type='number',
|
{{ 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,
|
{{ 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) }}
|
{{ textarea_field('notes', 'Interne Notiz', value=ticket.notes, rows=2) }}
|
||||||
<div class="divider"></div>
|
<div class="divider"></div>
|
||||||
<dl class="kv fs-xs">
|
<dl class="kv fs-xs">
|
||||||
@@ -195,7 +195,7 @@
|
|||||||
<dt>Angelegt</dt><dd>{{ ticket.created_at | dt }} von {{ ticket.created_by or '-' }}</dd>
|
<dt>Angelegt</dt><dd>{{ ticket.created_at | dt }} von {{ ticket.created_by or '-' }}</dd>
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
{% if current_user.has('tickets.edit') %}
|
{% if current_user.has('tickets.write') %}
|
||||||
<div class="card__footer">
|
<div class="card__footer">
|
||||||
<button class="btn btn--primary" type="submit">Speichern</button>
|
<button class="btn btn--primary" type="submit">Speichern</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -203,13 +203,13 @@
|
|||||||
</form>
|
</form>
|
||||||
</section>
|
</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">
|
<section class="card">
|
||||||
<div class="card__header">
|
<div class="card__header">
|
||||||
<div class="flex-1"><div class="card__title">{{ icon('lock', size=17) }} Sperre und Löschung</div></div>
|
<div class="flex-1"><div class="card__title">{{ icon('lock', size=17) }} Sperre und Löschung</div></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card__body stack stack--tight">
|
<div class="card__body stack stack--tight">
|
||||||
{% if current_user.has('tickets.edit') %}
|
{% if current_user.has('tickets.write') %}
|
||||||
<form method="post" action="{{ url_for('tickets.toggle_block', ticket_id=ticket.id) }}"
|
<form method="post" action="{{ url_for('tickets.toggle_block', ticket_id=ticket.id) }}"
|
||||||
data-no-guard>
|
data-no-guard>
|
||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
<span class="field__hint">
|
<span class="field__hint">
|
||||||
Der Kunde ist nach dem Anlegen unveränderlich -- der Self-Service-Link ist an ihn
|
Der Kunde ist nach dem Anlegen unveränderlich – der Self-Service-Link ist an ihn
|
||||||
gebunden.
|
gebunden.
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -77,7 +77,7 @@
|
|||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="card__title">{{ icon('share', size=17) }} Lizenzserver-Lizenz</div>
|
<div class="card__title">{{ icon('share', size=17) }} Lizenzserver-Lizenz</div>
|
||||||
<div class="card__sub">
|
<div class="card__sub">
|
||||||
Erzeugt eine neue Vertrauensgrenze -- der Empfänger verwaltet danach eigene Kunden
|
Erzeugt eine neue Vertrauensgrenze – der Empfänger verwaltet danach eigene Kunden
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -103,7 +103,7 @@
|
|||||||
<div class="card__body">
|
<div class="card__body">
|
||||||
<ol class="stack stack--tight fs-sm muted" style="list-style:decimal;padding-left:1.2em">
|
<ol class="stack stack--tight fs-sm muted" style="list-style:decimal;padding-left:1.2em">
|
||||||
<li>Ticket anlegen (dieser Schritt).</li>
|
<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>
|
allen Modulen.</li>
|
||||||
<li>Lizenzdatei herunterladen oder per E-Mail versenden.</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
|
<li>Der Kunde spielt sie ein und aktiviert sie; die Aktivierung bindet sie an seine
|
||||||
@@ -112,7 +112,7 @@
|
|||||||
<div class="divider"></div>
|
<div class="divider"></div>
|
||||||
<p class="fs-sm muted">
|
<p class="fs-sm muted">
|
||||||
Der Self-Service-Link des Tickets erlaubt dem Kunden, seine Lizenz selbst
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="card__footer">
|
<div class="card__footer">
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ set -euo pipefail
|
|||||||
|
|
||||||
APP=""
|
APP=""
|
||||||
INSTANCE=""
|
INSTANCE=""
|
||||||
|
#: Verzeichnis der Umgebungsdateien. Ueberschreibbar, damit sich die
|
||||||
|
#: Portsuche in einem Test wirklich ausfuehren laesst.
|
||||||
|
ETC_TESM="${TESM_ETC_DIR:-/etc/tesm}"
|
||||||
PORT=""
|
PORT=""
|
||||||
HTTP_PORT=""
|
HTTP_PORT=""
|
||||||
SKIP_PACKAGES=0
|
SKIP_PACKAGES=0
|
||||||
@@ -46,6 +49,62 @@ warn() { printf '%s !!%s %s\n' "$COLOR_WARN" "$COLOR_OFF" "$*"; }
|
|||||||
fail() { printf '%s !!%s %s\n' "$COLOR_ERR" "$COLOR_OFF" "$*" >&2; exit 1; }
|
fail() { printf '%s !!%s %s\n' "$COLOR_ERR" "$COLOR_OFF" "$*" >&2; exit 1; }
|
||||||
info() { printf ' %s\n' "$*"; }
|
info() { printf ' %s\n' "$*"; }
|
||||||
|
|
||||||
|
#: 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() {
|
usage() {
|
||||||
cat <<'USAGE'
|
cat <<'USAGE'
|
||||||
Verwendung: install.sh --app <tesm|tesm-license> [Optionen]
|
Verwendung: install.sh --app <tesm|tesm-license> [Optionen]
|
||||||
@@ -109,16 +168,35 @@ LOG_DIR="/var/log/$NAME"
|
|||||||
SERVICE_USER="$APP"
|
SERVICE_USER="$APP"
|
||||||
UNIT="$NAME.service"
|
UNIT="$NAME.service"
|
||||||
MONITOR_UNIT="$NAME-monitor.service"
|
MONITOR_UNIT="$NAME-monitor.service"
|
||||||
|
CLEANUP_UNIT="$NAME-cleanup.service"
|
||||||
|
CLEANUP_TIMER="$NAME-cleanup.timer"
|
||||||
NGINX_SITE="/etc/nginx/sites-available/$NAME"
|
NGINX_SITE="/etc/nginx/sites-available/$NAME"
|
||||||
ENV_PREFIX="TESM_"
|
ENV_PREFIX="TESM_"
|
||||||
[[ "$APP" == "tesm-license" ]] && ENV_PREFIX="TESM_LICENSE_"
|
[[ "$APP" == "tesm-license" ]] && ENV_PREFIX="TESM_LICENSE_"
|
||||||
|
|
||||||
|
# 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
|
# Bei einer Aktualisierung gilt zuerst, was schon eingerichtet ist. Sonst
|
||||||
# verschiebt ein Update ohne Portangaben die Anwendung: eine benannte Instanz
|
# verschiebt ein Update ohne Portangaben die Anwendung: eine benannte Instanz
|
||||||
# faellt auf 8080/8443 zurueck, auch wenn sie seit der Installation auf Port 80
|
# 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
|
# lief -- und ein Reverse Proxy davor zeigt dann ins Leere. Dieselbe Regel wie
|
||||||
# beim Secure-Flag weiter unten: eine Aktualisierung nimmt nichts weg, was
|
# beim Secure-Flag weiter unten: eine Aktualisierung nimmt nichts weg, was
|
||||||
# nicht ausdruecklich neu gesetzt wird.
|
# nicht ausdruecklich neu gesetzt wird.
|
||||||
ENV_FILE="/etc/tesm/$NAME.env"
|
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
|
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)"
|
PORT="$(sed -n "s/^${ENV_PREFIX}BIND=127\.0\.0\.1:\([0-9]\{1,5\}\)$/\1/p" "$ENV_FILE" | head -1)"
|
||||||
fi
|
fi
|
||||||
@@ -127,10 +205,30 @@ if [[ -f "$NGINX_SITE" ]]; then
|
|||||||
[[ -z "$HTTPS_PORT" ]] && HTTPS_PORT="$(sed -n 's/^[[:space:]]*listen[[:space:]]\([0-9]\{1,5\}\)[[:space:]]\+ssl.*/\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
|
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
|
if [[ -z "$PORT" ]]; then
|
||||||
PORT=5000
|
BASISPORT=5000
|
||||||
[[ "$APP" == "tesm-license" ]] && PORT=5001
|
[[ "$APP" == "tesm-license" ]] && BASISPORT=5001
|
||||||
[[ -n "$INSTANCE" ]] && PORT=$((PORT + 100))
|
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
|
fi
|
||||||
if [[ -z "$HTTP_PORT" ]]; then
|
if [[ -z "$HTTP_PORT" ]]; then
|
||||||
HTTP_PORT=80
|
HTTP_PORT=80
|
||||||
@@ -212,12 +310,41 @@ fi
|
|||||||
# Der Vorgaenger lief als root. Hier laeuft der Dienst unprivilegiert; Rootrechte
|
# Der Vorgaenger lief als root. Hier laeuft der Dienst unprivilegiert; Rootrechte
|
||||||
# gibt es ausschliesslich ueber den Helfer.
|
# 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 ------------------------------------------------------------------
|
# -- Backup ------------------------------------------------------------------
|
||||||
if [[ $IS_UPDATE -eq 1 ]]; then
|
if [[ $IS_UPDATE -eq 1 ]]; then
|
||||||
step "Sicherung vor der Aktualisierung"
|
step "Sicherung vor der Aktualisierung"
|
||||||
systemctl stop "$UNIT" 2>/dev/null || true
|
systemctl stop "$UNIT" 2>/dev/null || true
|
||||||
[[ "$APP" == "tesm" ]] && systemctl stop "$MONITOR_UNIT" 2>/dev/null || true
|
[[ "$APP" == "tesm" ]] && systemctl stop "$MONITOR_UNIT" 2>/dev/null || true
|
||||||
cp -a "$ROOT" "$BACKUP_DIR"
|
cp -a "$ROOT" "$BACKUP_DIR"
|
||||||
|
SICHERUNG_STEHT=1
|
||||||
info "Sicherung: $BACKUP_DIR"
|
info "Sicherung: $BACKUP_DIR"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -295,6 +422,42 @@ install -o root -g root -m 0440 "$TMP_SUDO" "/etc/sudoers.d/$APP"
|
|||||||
rm -f "$TMP_SUDO"
|
rm -f "$TMP_SUDO"
|
||||||
info "sudoers: $SERVICE_USER darf ausschliesslich tesm-helper als root aufrufen"
|
info "sudoers: $SERVICE_USER darf ausschliesslich tesm-helper als root aufrufen"
|
||||||
|
|
||||||
|
# -- Anmeldebanner (MOTD) ----------------------------------------------------
|
||||||
|
# Das Banner liest die Datenbank dieser Installation und laeuft dabei mit dem
|
||||||
|
# System-Python, ohne das venv und ohne die Anwendung zu importieren. Der
|
||||||
|
# Vorgaenger importierte dafuer die ganze Flask-App -- bei jeder Anmeldung, und
|
||||||
|
# nach einem Wechsel des Quellverzeichnisses zeigte er stillschweigend die
|
||||||
|
# Zahlen einer Datenbank, die niemand mehr fuellte.
|
||||||
|
#
|
||||||
|
# "timeout" verhindert, dass ein haengender Dateizugriff die Anmeldung
|
||||||
|
# blockiert; "|| true" sorgt dafuer, dass ein Fehler hier den Login nie stoert.
|
||||||
|
step "Anmeldebanner"
|
||||||
|
if [[ -d /etc/update-motd.d ]]; then
|
||||||
|
install_if_changed "$ROOT/deploy/tesm-motd" /usr/local/lib/tesm/tesm-motd 0755 >/dev/null \
|
||||||
|
&& info "Bannerskript aktualisiert" || info "Bannerskript unveraendert"
|
||||||
|
TMP_MOTD="$(mktemp)"
|
||||||
|
{
|
||||||
|
echo "#!/bin/sh"
|
||||||
|
echo "# Von TESM erzeugt ($NAME). Aenderungen werden bei der naechsten"
|
||||||
|
echo "# Installation ueberschrieben."
|
||||||
|
echo "timeout 3 /usr/local/lib/tesm/tesm-motd --app $APP --root $ROOT 2>/dev/null || true"
|
||||||
|
} > "$TMP_MOTD"
|
||||||
|
install -o root -g root -m 0755 "$TMP_MOTD" "/etc/update-motd.d/50-$NAME"
|
||||||
|
rm -f "$TMP_MOTD"
|
||||||
|
info "Banner: /etc/update-motd.d/50-$NAME"
|
||||||
|
# Ein Banner der Vorgaengerfassung zeigt Zahlen aus einer Datenbank, die
|
||||||
|
# niemand mehr fuellt. Er wird nur entfernt, wenn er genau darauf zeigt --
|
||||||
|
# nichts anderes wird angefasst.
|
||||||
|
ALT_MOTD="/etc/update-motd.d/50-tesm"
|
||||||
|
if [[ "$ALT_MOTD" != "/etc/update-motd.d/50-$NAME" && -f "$ALT_MOTD" ]] \
|
||||||
|
&& grep -q "motd_banner.py" "$ALT_MOTD" 2>/dev/null; then
|
||||||
|
rm -f "$ALT_MOTD"
|
||||||
|
info "Banner der Vorgaengerfassung entfernt ($ALT_MOTD)"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
info "Kein /etc/update-motd.d -- Banner uebersprungen"
|
||||||
|
fi
|
||||||
|
|
||||||
# -- Rechte ------------------------------------------------------------------
|
# -- Rechte ------------------------------------------------------------------
|
||||||
step "Dateirechte"
|
step "Dateirechte"
|
||||||
chown -R "$SERVICE_USER:$SERVICE_USER" "$ROOT/data" "$LOG_DIR"
|
chown -R "$SERVICE_USER:$SERVICE_USER" "$ROOT/data" "$LOG_DIR"
|
||||||
@@ -311,8 +474,8 @@ step "Umgebung"
|
|||||||
# EnvironmentFile, die Kommandozeilenwerkzeuge ueber ihren Wrapper. Ohne das
|
# EnvironmentFile, die Kommandozeilenwerkzeuge ueber ihren Wrapper. Ohne das
|
||||||
# wuesste "tesm-admin" nicht, wo die Instanz liegt, und wuerde sich eine leere
|
# wuesste "tesm-admin" nicht, wo die Instanz liegt, und wuerde sich eine leere
|
||||||
# Datenbank im Arbeitsverzeichnis anlegen.
|
# Datenbank im Arbeitsverzeichnis anlegen.
|
||||||
install -d -o root -g root -m 0755 /etc/tesm
|
install -d -o root -g root -m 0755 "$ETC_TESM"
|
||||||
ENV_FILE="/etc/tesm/$NAME.env"
|
ENV_FILE="$ETC_TESM/$NAME.env"
|
||||||
TMP_ENV="$(mktemp)"
|
TMP_ENV="$(mktemp)"
|
||||||
{
|
{
|
||||||
echo "# Von install.sh erzeugt fuer $NAME -- nicht von Hand bearbeiten."
|
echo "# Von install.sh erzeugt fuer $NAME -- nicht von Hand bearbeiten."
|
||||||
@@ -378,6 +541,11 @@ render_unit "$ROOT/deploy/systemd/$APP.service" "/etc/systemd/system/$UNIT"
|
|||||||
if [[ "$APP" == "tesm" ]]; then
|
if [[ "$APP" == "tesm" ]]; then
|
||||||
render_unit "$ROOT/deploy/systemd/tesm-monitor.service" "/etc/systemd/system/$MONITOR_UNIT"
|
render_unit "$ROOT/deploy/systemd/tesm-monitor.service" "/etc/systemd/system/$MONITOR_UNIT"
|
||||||
fi
|
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" \
|
[[ $UNITS_CHANGED -eq 1 ]] && systemctl daemon-reload && info "Units neu geladen" \
|
||||||
|| info "Units unveraendert"
|
|| info "Units unveraendert"
|
||||||
|
|
||||||
@@ -450,12 +618,18 @@ systemctl enable --now "$UNIT" >/dev/null
|
|||||||
if [[ "$APP" == "tesm" ]]; then
|
if [[ "$APP" == "tesm" ]]; then
|
||||||
systemctl enable --now "$MONITOR_UNIT" >/dev/null
|
systemctl enable --now "$MONITOR_UNIT" >/dev/null
|
||||||
fi
|
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 ----------------------------------------------
|
# -- Health-Check und Rollback ----------------------------------------------
|
||||||
step "Gesundheitspruefung"
|
step "Gesundheitspruefung"
|
||||||
HEALTHY=0
|
HEALTHY=0
|
||||||
for attempt in $(seq 1 20); do
|
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
|
HEALTHY=1
|
||||||
info "Antwort nach ${attempt}x"
|
info "Antwort nach ${attempt}x"
|
||||||
break
|
break
|
||||||
@@ -466,13 +640,9 @@ done
|
|||||||
if [[ $HEALTHY -eq 0 ]]; then
|
if [[ $HEALTHY -eq 0 ]]; then
|
||||||
warn "Die Anwendung antwortet nicht."
|
warn "Die Anwendung antwortet nicht."
|
||||||
if [[ $IS_UPDATE -eq 1 ]]; then
|
if [[ $IS_UPDATE -eq 1 ]]; then
|
||||||
warn "Rollback wird ausgefuehrt."
|
# Dieselbe Funktion wie die EXIT-Falle -- zwei Wege, die dasselbe tun
|
||||||
systemctl stop "$UNIT" 2>/dev/null || true
|
# sollen, laufen sonst irgendwann auseinander.
|
||||||
[[ "$APP" == "tesm" ]] && systemctl stop "$MONITOR_UNIT" 2>/dev/null || true
|
zurueckrollen_install
|
||||||
mv "$ROOT" "$FAILED_DIR"
|
|
||||||
cp -a "$BACKUP_DIR" "$ROOT"
|
|
||||||
systemctl start "$UNIT" || true
|
|
||||||
[[ "$APP" == "tesm" ]] && systemctl start "$MONITOR_UNIT" || true
|
|
||||||
fail "Aktualisierung fehlgeschlagen. Alter Stand wiederhergestellt.
|
fail "Aktualisierung fehlgeschlagen. Alter Stand wiederhergestellt.
|
||||||
Fehlgeschlagene Version : $FAILED_DIR
|
Fehlgeschlagene Version : $FAILED_DIR
|
||||||
Sicherung : $BACKUP_DIR
|
Sicherung : $BACKUP_DIR
|
||||||
@@ -532,12 +702,41 @@ if [[ $HTTPS -eq 1 ]]; then
|
|||||||
fi
|
fi
|
||||||
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
|
if [[ $IS_UPDATE -eq 1 ]]; then
|
||||||
rm -rf "$BACKUP_DIR"
|
rm -rf "$BACKUP_DIR"
|
||||||
info "Sicherung entfernt (Aktualisierung erfolgreich)"
|
info "Sicherung entfernt (Aktualisierung erfolgreich)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# -- Abschluss ---------------------------------------------------------------
|
# -- Abschluss ---------------------------------------------------------------
|
||||||
|
INSTALL_FERTIG=1
|
||||||
step "Fertig"
|
step "Fertig"
|
||||||
ADMIN_CMD="tesm-admin"
|
ADMIN_CMD="tesm-admin"
|
||||||
[[ "$APP" == "tesm-license" ]] && ADMIN_CMD="tesm-license-admin"
|
[[ "$APP" == "tesm-license" ]] && ADMIN_CMD="tesm-license-admin"
|
||||||
@@ -553,14 +752,24 @@ cat <<SUMMARY
|
|||||||
Naechste Schritte:
|
Naechste Schritte:
|
||||||
SUMMARY
|
SUMMARY
|
||||||
|
|
||||||
if [[ "$APP" == "tesm-license" ]]; then
|
if [[ "$APP" == "tesm-license" && "$EDITION" == "master" ]]; then
|
||||||
cat <<SUMMARY
|
cat <<SUMMARY
|
||||||
1. Signaturschluessel erzeugen und sofort sichern:
|
1. Administrator anlegen:
|
||||||
sudo -u $SERVICE_USER $NAME-admin init-key
|
|
||||||
2. Eigene Master-Lizenz ausstellen:
|
|
||||||
sudo -u $SERVICE_USER $NAME-admin bootstrap
|
|
||||||
3. Administrator anlegen:
|
|
||||||
sudo -u $SERVICE_USER $NAME-admin create-admin
|
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
|
SUMMARY
|
||||||
else
|
else
|
||||||
cat <<SUMMARY
|
cat <<SUMMARY
|
||||||
|
|||||||
@@ -14,13 +14,22 @@ TAG="${1:?Verwendung: release.sh vX.Y.Z [name]}"
|
|||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
REPO_DIR="$(cd "$SCRIPT_DIR/.." && 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
|
if [[ -n "${2:-}" ]]; then
|
||||||
NAME="$2"
|
NAME="$2"
|
||||||
else
|
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)
|
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
|
if [[ ${#APPS[@]} -eq 1 ]]; then NAME="${APPS[0]}"; else NAME="$(basename "$REPO_DIR")"; fi
|
||||||
fi
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
OUT="$NAME-$TAG.tar.gz"
|
OUT="$NAME-$TAG.tar.gz"
|
||||||
git -c core.autocrlf=false -C "$REPO_DIR" archive --format=tar.gz --prefix="$NAME/" -o "$OUT" "$TAG"
|
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."
|
||||||
@@ -5,5 +5,11 @@
|
|||||||
#
|
#
|
||||||
# Bewusst KEIN "ALL=(ALL) NOPASSWD: ALL". Der Vorgaenger loeste dasselbe
|
# Bewusst KEIN "ALL=(ALL) NOPASSWD: ALL". Der Vorgaenger loeste dasselbe
|
||||||
# Problem, indem die gesamte Anwendung als root lief.
|
# 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
|
tesm ALL=(root) NOPASSWD: /usr/local/lib/tesm/tesm-helper
|
||||||
@@ -5,5 +5,11 @@
|
|||||||
#
|
#
|
||||||
# Bewusst KEIN "ALL=(ALL) NOPASSWD: ALL". Der Vorgaenger loeste dasselbe
|
# Bewusst KEIN "ALL=(ALL) NOPASSWD: ALL". Der Vorgaenger loeste dasselbe
|
||||||
# Problem, indem die gesamte Anwendung als root lief.
|
# 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
|
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
|
||||||
@@ -47,7 +47,7 @@ check_in_list() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Name einer Installation auf diesem Host: entweder die Hauptinstallation
|
# Name einer Installation auf diesem Host: entweder die Hauptinstallation
|
||||||
# ("tesm") oder eine benannte Instanz daneben ("tesm-opus"). Mehr als diese
|
# ("tesm") oder eine benannte Instanz daneben ("tesm-test"). Mehr als diese
|
||||||
# beiden Formen gibt es nicht -- so kann keine erfundene Zeichenkette einen
|
# beiden Formen gibt es nicht -- so kann keine erfundene Zeichenkette einen
|
||||||
# Pfad ausserhalb des vorgesehenen Namensraums erzeugen.
|
# Pfad ausserhalb des vorgesehenen Namensraums erzeugen.
|
||||||
check_app_key() {
|
check_app_key() {
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Live-Banner fuer die SSH-Anmeldung (MOTD).
|
||||||
|
|
||||||
|
Eingebunden ueber ``/etc/update-motd.d/50-<name>``, das ``install.sh``
|
||||||
|
erzeugt. Aufruf mit ``--app`` und ``--root``; alles andere steht in der
|
||||||
|
Datenbank der Installation.
|
||||||
|
|
||||||
|
**Nur Standardbibliothek, kein Flask, kein venv.** Der Vorgaenger importierte
|
||||||
|
dafuer die ganze Anwendung -- bei jeder SSH-Anmeldung. Das ist teuer, und es
|
||||||
|
faellt aus, sobald das venv nicht mehr passt: nach dem Umstieg auf diese
|
||||||
|
Fassung zeigte das alte Banner monatelang eingefrorene Zahlen aus einer
|
||||||
|
Datenbank, die niemand mehr fuellte. Hier wird gelesen, was auf der Platte
|
||||||
|
liegt, und zwar **nur lesend** (``mode=ro``).
|
||||||
|
|
||||||
|
Ein Banner darf eine Anmeldung niemals stoeren: jede Auskunft, die nicht zu
|
||||||
|
holen ist, wird zu einem kurzen Hinweis, und am Ende faengt ``main`` alles ab.
|
||||||
|
Der Aufrufer setzt zusaetzlich ein Zeitlimit.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
AMBER = "\033[38;2;226;166;60m"
|
||||||
|
DIM = "\033[2m"
|
||||||
|
BOLD = "\033[1m"
|
||||||
|
RESET = "\033[0m"
|
||||||
|
|
||||||
|
#: Kea schreibt seine Leases als CSV; die letzte Zeile je Adresse gilt.
|
||||||
|
#: Dieselbe Datei liest die Anwendung (``services/kea.py``: ``LEASE_FILE``).
|
||||||
|
LEASE_FILE = "/var/lib/kea/kea-leases4.csv"
|
||||||
|
|
||||||
|
#: Zeile unter der Wortmarke. Wortlaut wie in der jeweiligen Anwendung
|
||||||
|
#: (``AUTH_TAGLINE`` in deren ``__init__.py``) -- ein Hygienetest haelt beides
|
||||||
|
#: zusammen.
|
||||||
|
TAGLINE = {
|
||||||
|
"tesm": "ÜBERWACHEN · BOOTEN · ANBINDEN",
|
||||||
|
"tesm-license": "LIZENZIEREN",
|
||||||
|
}
|
||||||
|
|
||||||
|
TITEL = {"tesm": "TimEShepManager", "tesm-license": "Lizenzserver"}
|
||||||
|
|
||||||
|
#: Wortgleich mit ``services/monitor.py``: ``DASHBOARD_COUNT_SQL``. Die
|
||||||
|
#: Statusuebersicht und dieses Banner sollen nicht zwei Wahrheiten haben; ein
|
||||||
|
#: Hygienetest vergleicht die beiden Zeichenketten.
|
||||||
|
DASHBOARD_COUNT_SQL = """
|
||||||
|
SELECT
|
||||||
|
COUNT(*) AS total,
|
||||||
|
SUM(CASE WHEN d.is_active=0 THEN 1 ELSE 0 END) AS disabled,
|
||||||
|
SUM(CASE WHEN d.is_active=1 AND COALESCE(st.state,'unknown')='online' THEN 1 ELSE 0 END) AS online,
|
||||||
|
SUM(CASE WHEN d.is_active=1 AND COALESCE(st.state,'unknown')='offline' THEN 1 ELSE 0 END) AS offline,
|
||||||
|
SUM(CASE WHEN d.is_active=1 AND COALESCE(st.state,'unknown')='unknown' THEN 1 ELSE 0 END) AS unknown,
|
||||||
|
SUM(CASE WHEN d.is_active=1 AND st.state='restarting' THEN 1 ELSE 0 END) AS restarting
|
||||||
|
FROM devices d LEFT JOIN device_status st ON st.device_id = d.id
|
||||||
|
WHERE d.deleted_at IS NULL
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _zahl(anzahl: int, singular: str, plural: str) -> str:
|
||||||
|
"""Zahl mit passendem Wort. "1 Clients" liest man jeden Tag einmal zu oft."""
|
||||||
|
return f"{BOLD}{anzahl}{RESET} {singular if anzahl == 1 else plural}"
|
||||||
|
|
||||||
|
|
||||||
|
def _oeffnen(root: Path) -> sqlite3.Connection:
|
||||||
|
pfad = root / "data" / "app.db"
|
||||||
|
conn = sqlite3.connect(f"file:{pfad.as_posix()}?mode=ro", uri=True, timeout=1.0)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def _flotte(conn: sqlite3.Connection) -> str:
|
||||||
|
row = conn.execute(DASHBOARD_COUNT_SQL).fetchone()
|
||||||
|
online = int(row["online"] or 0)
|
||||||
|
offline = int(row["offline"] or 0)
|
||||||
|
teile = [
|
||||||
|
_zahl(online, "Client online", "Clients online"),
|
||||||
|
f"{BOLD}{offline}{RESET} nicht erreichbar",
|
||||||
|
]
|
||||||
|
# Nur nennen, wenn es sie gibt: eine Null bei "startet neu" ist keine
|
||||||
|
# Auskunft, sondern Fuellmaterial.
|
||||||
|
restarting = int(row["restarting"] or 0)
|
||||||
|
if restarting:
|
||||||
|
teile.append(_zahl(restarting, "startet neu", "starten neu"))
|
||||||
|
unbekannt = int(row["unknown"] or 0)
|
||||||
|
if unbekannt:
|
||||||
|
teile.append(f"{DIM}{unbekannt} ohne Pruefung{RESET}")
|
||||||
|
return " · ".join(teile)
|
||||||
|
|
||||||
|
|
||||||
|
def _dhcp() -> str:
|
||||||
|
"""Aktive Leases aus der Kea-CSV. Laeuft als root, braucht keinen Helfer."""
|
||||||
|
datei = Path(LEASE_FILE)
|
||||||
|
if not datei.is_file():
|
||||||
|
return "keine Lease-Datei"
|
||||||
|
try:
|
||||||
|
zeilen = datei.read_text(encoding="utf-8", errors="replace").splitlines()
|
||||||
|
except OSError:
|
||||||
|
return "Lease-Datei nicht lesbar"
|
||||||
|
if len(zeilen) < 2:
|
||||||
|
return "0 Leases aktiv"
|
||||||
|
kopf = [name.strip().strip('"') for name in zeilen[0].split(",")]
|
||||||
|
try:
|
||||||
|
i_adresse = kopf.index("address")
|
||||||
|
i_ablauf = kopf.index("expire")
|
||||||
|
i_zustand = kopf.index("state")
|
||||||
|
except ValueError:
|
||||||
|
return "Lease-Datei mit unerwarteten Spalten"
|
||||||
|
|
||||||
|
jetzt = int(time.time())
|
||||||
|
letzte: dict[str, tuple[int, str]] = {}
|
||||||
|
for zeile in zeilen[1:]:
|
||||||
|
felder = [feld.strip().strip('"') for feld in zeile.split(",")]
|
||||||
|
if len(felder) < len(kopf):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
letzte[felder[i_adresse]] = (int(felder[i_ablauf] or 0), felder[i_zustand] or "0")
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
aktiv = sum(1 for ablauf, zustand in letzte.values() if zustand == "0" and ablauf > jetzt)
|
||||||
|
return f"{aktiv} {'Lease' if aktiv == 1 else 'Leases'} aktiv"
|
||||||
|
|
||||||
|
|
||||||
|
def _lizenzen(conn: sqlite3.Connection) -> str:
|
||||||
|
kunden = int(conn.execute("SELECT COUNT(*) FROM license_customers").fetchone()[0] or 0)
|
||||||
|
lizenzen = int(
|
||||||
|
conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM licenses WHERE status IN ('issued','active')"
|
||||||
|
).fetchone()[0]
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
_zahl(kunden, "Kunde", "Kunden")
|
||||||
|
+ " · "
|
||||||
|
+ _zahl(lizenzen, "Lizenz ausgestellt", "Lizenzen ausgestellt")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
parser = argparse.ArgumentParser(prog="tesm-motd", description="Banner fuer die SSH-Anmeldung")
|
||||||
|
parser.add_argument("--app", required=True, choices=sorted(TITEL))
|
||||||
|
parser.add_argument("--root", required=True, help="Verzeichnis der Installation")
|
||||||
|
parser.add_argument("--no-color", action="store_true")
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
if args.no_color:
|
||||||
|
globals().update(AMBER="", DIM="", BOLD="", RESET="")
|
||||||
|
|
||||||
|
root = Path(args.root)
|
||||||
|
# Kein isatty()-Test: dieses Skript schreibt beim Login nicht auf ein
|
||||||
|
# Terminal, sondern ueber run-parts in eine Datei. Der Betrachter ist
|
||||||
|
# trotzdem ein echtes Terminal -- deshalb immer Farbe.
|
||||||
|
print(f"{BOLD}{AMBER}TESM{RESET} · {TITEL[args.app]}")
|
||||||
|
if TAGLINE[args.app]:
|
||||||
|
print(f"{DIM}{TAGLINE[args.app]}{RESET}")
|
||||||
|
print()
|
||||||
|
try:
|
||||||
|
conn = _oeffnen(root)
|
||||||
|
except sqlite3.Error:
|
||||||
|
print(f"{DIM}Datenbank nicht lesbar: {root}{RESET}")
|
||||||
|
print()
|
||||||
|
return 0
|
||||||
|
try:
|
||||||
|
if args.app == "tesm":
|
||||||
|
print(f"{_flotte(conn)} · DHCP: {_dhcp()}")
|
||||||
|
else:
|
||||||
|
print(_lizenzen(conn))
|
||||||
|
except sqlite3.Error as fehler:
|
||||||
|
print(f"{DIM}Zustand nicht verfuegbar ({fehler}){RESET}")
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
print()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
raise SystemExit(main())
|
||||||
|
except SystemExit:
|
||||||
|
raise
|
||||||
|
except Exception: # noqa: BLE001 - ein Banner darf keinen Login stoeren
|
||||||
|
sys.exit(0)
|
||||||
@@ -1,89 +1,199 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Bootstrapper: laedt ein Release herunter und ruft dessen install.sh auf.
|
# Aktualisierung: laedt das aktuelle Release und ruft dessen install.sh auf.
|
||||||
# Enthaelt selbst keinerlei Installationslogik.
|
# Enthaelt selbst keinerlei Installationslogik.
|
||||||
#
|
#
|
||||||
# Zwei Fallen des Vorgaengers sind hier bewusst vermieden:
|
# Eine einzelne Datei genuegt -- sie holt sich alles Weitere:
|
||||||
# * Der Tag "latest" wird ueber die API aufgeloest, nie direkt in die
|
#
|
||||||
# Download-URL geschrieben. Gitea liefert sonst mit HTTP 200 ein
|
# curl -fsSLO https://gitea.int.eertmoed.net/alientim/tesm/raw/branch/main/deploy/update.sh
|
||||||
# automatisch erzeugtes Quell-Snapshot statt des echten Release-Assets.
|
# sudo bash update.sh tesm
|
||||||
# * Nach dem Entpacken wird geprueft, dass ein install.sh existiert -- faengt
|
#
|
||||||
# denselben Fehler ein zweites Mal ab.
|
# Auf einer bestehenden Installation liegt sie schon bereit:
|
||||||
|
#
|
||||||
|
# sudo bash /srv/tesm/deploy/update.sh tesm
|
||||||
|
#
|
||||||
|
# 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
|
set -euo pipefail
|
||||||
|
|
||||||
# Ein Wagenruecklauf im Paket macht jedes Shell-Skript auf dem Ziel
|
|
||||||
# unbrauchbar ("bad interpreter"). Die Pruefung darf dabei nicht selbst
|
|
||||||
# von einem Zusatzpaket abhaengen: mit "file" -- das auf einem schlanken
|
|
||||||
# Server fehlt -- endete sie in "command not found", grep bekam nichts zu
|
|
||||||
# sehen, und das Skript machte weiter, als waere alles in Ordnung.
|
|
||||||
hat_crlf() {
|
|
||||||
local cr
|
|
||||||
cr="$(printf '\r')"
|
|
||||||
# -U (binaer lesen) ist unter Linux ohne Wirkung, auf einer Windows-Buildmaschine
|
|
||||||
# aber entscheidend: dort entfernt grep den Wagenruecklauf beim Lesen und
|
|
||||||
# findet deshalb nie einen.
|
|
||||||
LC_ALL=C grep -qU "$cr" "$1"
|
|
||||||
}
|
|
||||||
|
|
||||||
APP="${1:-}"
|
APP="${1:-}"
|
||||||
TAG="${2:-latest}"
|
TAG="${2:-latest}"
|
||||||
|
|
||||||
# Alles nach dem Tag geht unveraendert an install.sh weiter. Ohne das liesse
|
# Alles nach dem Tag geht unveraendert an install.sh weiter. Ohne das liesse
|
||||||
# sich eine benannte Instanz nicht aktualisieren: install.sh leitet den Namen
|
# sich eine benannte Instanz nicht aktualisieren: install.sh leitet den Namen
|
||||||
# aus "--instance" ab, und ohne den Schalter entstuende neben "tesm-opus" eine
|
# aus "--instance" ab, und ohne den Schalter entstuende neben "tesm-test" eine
|
||||||
# zweite Installation namens "tesm" -- mit eigener Datenbank, eigener
|
# zweite Installation namens "tesm" -- mit eigener Datenbank, eigener
|
||||||
# nginx-Site und demselben Port.
|
# nginx-Site und demselben Port.
|
||||||
shift $(( $# > 2 ? 2 : $# ))
|
shift $(( $# > 2 ? 2 : $# ))
|
||||||
EXTRA=("$@")
|
EXTRA=("$@")
|
||||||
# Bezugsquelle. Die Vorgaben zeigen dorthin, wo die Releases tatsaechlich
|
|
||||||
# liegen -- ein Platzhalter wie "gitea.example.invalid" macht das Skript auf
|
|
||||||
# jedem Host unbrauchbar, und genau das war hier der Fall.
|
|
||||||
#
|
|
||||||
# Das Repository heisst wie die Anwendung; getrennte Repositorien, getrennte
|
|
||||||
# Releases. Der Anhang heisst "<repo>-<tag>.tar.gz", siehe unten.
|
|
||||||
BASE="${TESM_RELEASE_BASE:-https://gitea.int.eertmoed.net}"
|
|
||||||
OWNER="${TESM_RELEASE_OWNER:-alientim}"
|
|
||||||
|
|
||||||
[[ "$APP" == "tesm" || "$APP" == "tesm-license" ]] || {
|
[[ "$APP" == "tesm" || "$APP" == "tesm-license" ]] || {
|
||||||
echo "Verwendung: update.sh <tesm|tesm-license> [tag] [install.sh-Argumente ...]" >&2
|
echo "Verwendung: update.sh <tesm|tesm-license> [tag] [install.sh-Argumente ...]" >&2
|
||||||
echo "Beispiel : update.sh tesm latest --instance opus" >&2
|
echo "Beispiel : update.sh tesm latest --instance test" >&2
|
||||||
|
echo "Ohne Rueckfrage: --yes anhaengen (ohne Terminal zwingend)." >&2
|
||||||
exit 2
|
exit 2
|
||||||
}
|
}
|
||||||
|
|
||||||
REPO="${TESM_RELEASE_REPO:-$APP}"
|
# 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
|
||||||
|
|
||||||
WORK="$(mktemp -d /tmp/tesm-update-XXXXXX)"
|
# --8<-- Bootstrap: Release holen --------------------------------------------
|
||||||
trap 'rm -rf "$WORK"' EXIT # anders als im Vorgaenger wird aufgeraeumt
|
# 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
|
if [[ "$TAG" == "latest" ]]; then
|
||||||
echo "==> Ermittle aktuellen Release-Tag ..."
|
echo "==> Ermittle aktuellen Release-Tag ..."
|
||||||
TAG="$(curl -fsS "$BASE/api/v1/repos/$OWNER/$REPO/releases/latest" \
|
TAG="$(curl -fsS ${kopf[0]+"${kopf[@]}"} "$BASE/api/v1/repos/$OWNER/$repo/releases/latest" \
|
||||||
| python3 -c 'import json,sys; print(json.load(sys.stdin)["tag_name"])')"
|
| python3 -c 'import json,sys; print(json.load(sys.stdin)["tag_name"])')" || {
|
||||||
|
echo "Kein Release gefunden unter $BASE/$OWNER/$repo." >&2
|
||||||
|
[[ -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"
|
echo " $TAG"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
ASSET="$REPO-$TAG.tar.gz"
|
asset="$repo-$TAG.tar.gz"
|
||||||
echo "==> Lade $ASSET ..."
|
echo "==> Lade $asset ..."
|
||||||
curl -fsSL -o "$WORK/$ASSET" "$BASE/$OWNER/$REPO/releases/download/$TAG/$ASSET"
|
curl -fsSL ${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 ..."
|
echo "==> Entpacke ..."
|
||||||
tar -xzf "$WORK/$ASSET" -C "$WORK"
|
tar -xzf "$BOOTSTRAP_WORK/$asset" -C "$BOOTSTRAP_WORK"
|
||||||
# Drei Ebenen, nicht zwei: release.sh packt mit dem Praefix "<name>/", also
|
# Drei Ebenen, nicht zwei: release.sh packt mit dem Praefix "<name>/", also
|
||||||
# liegt das Skript unter <name>/deploy/install.sh. Mit "-maxdepth 2" fand die
|
# liegt das Skript unter <name>/deploy/install.sh. Mit "-maxdepth 2" fand die
|
||||||
# Suche nie etwas -- und die Fehlermeldung schob es auf den Download.
|
# Suche nie etwas -- und die Fehlermeldung schob es auf den Download.
|
||||||
SRC="$(find "$WORK" -maxdepth 3 -name install.sh -path '*/deploy/*' -printf '%h/..\n' | head -1)"
|
SRC="$(find "$BOOTSTRAP_WORK" -maxdepth 3 -name install.sh -path '*/deploy/*' \
|
||||||
[[ -n "$SRC" ]] || { echo "Im Paket fehlt deploy/install.sh -- Download unbrauchbar." >&2; exit 1; }
|
-printf '%h/..\n' | head -1)"
|
||||||
|
[[ -n "$SRC" ]] || {
|
||||||
|
echo "Im Paket fehlt deploy/install.sh -- Download unbrauchbar." >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
SRC="$(cd "$SRC" && pwd)"
|
SRC="$(cd "$SRC" && pwd)"
|
||||||
|
|
||||||
echo "==> Pruefe auf CRLF (Windows-Buildmaschinen) ..."
|
echo "==> Pruefe auf CRLF (Windows-Buildmaschinen) ..."
|
||||||
if hat_crlf "$SRC/deploy/install.sh"; then
|
if hat_crlf "$SRC/deploy/install.sh"; then
|
||||||
echo "Das Paket enthaelt CRLF-Zeilenenden. Beim Paketieren fehlte" >&2
|
echo "Das Paket enthaelt CRLF-Zeilenenden. Beim Paketieren fehlte" >&2
|
||||||
echo "'git -c core.autocrlf=false archive'." >&2
|
echo "'git -c core.autocrlf=false archive'." >&2
|
||||||
exit 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
}
|
||||||
|
# --8<-- Ende Bootstrap ------------------------------------------------------
|
||||||
|
|
||||||
|
release_holen
|
||||||
|
|
||||||
|
# --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
|
||||||
|
|
||||||
|
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
|
read -r -p "Version $TAG fuer $APP${EXTRA[0]+ (${EXTRA[*]})} installieren? [j/N] " answer
|
||||||
[[ "$answer" =~ ^[jJyY]$ ]] || exit 0
|
[[ "$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
|
||||||
|
|
||||||
exec sudo bash "$SRC/deploy/install.sh" --app "$APP" --yes ${EXTRA[0]+"${EXTRA[@]}"}
|
# Kein exec: sonst laeuft die Aufraeumfalle nie, und jedes Update liesse ein
|
||||||
|
# entpacktes Release unter /tmp liegen.
|
||||||
|
bash "$SRC/deploy/install.sh" --app "$APP" --yes ${EXTRA[0]+"${EXTRA[@]}"}
|
||||||
@@ -2,10 +2,17 @@
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Upgrade vom Stand SONNET5 (Version 1.x) auf diese Fassung -- mit Datenuebernahme.
|
# Upgrade vom Stand SONNET5 (Version 1.x) auf diese Fassung -- mit Datenuebernahme.
|
||||||
#
|
#
|
||||||
# Aufruf (aus dem entpackten Release-Verzeichnis):
|
# 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
|
||||||
# sudo bash deploy/upgrade.sh --app tesm-license
|
# sudo bash deploy/upgrade.sh --app tesm --instance test --source /srv/tesm
|
||||||
# sudo bash deploy/upgrade.sh --app tesm --instance opus --source /srv/tesm
|
|
||||||
#
|
#
|
||||||
# Der Ablauf ist die Vorgabe und braucht keine Schalter:
|
# Der Ablauf ist die Vorgabe und braucht keine Schalter:
|
||||||
#
|
#
|
||||||
@@ -44,6 +51,25 @@ DRY_RUN=0
|
|||||||
WITH_DATA=1
|
WITH_DATA=1
|
||||||
STOP_OLD=1
|
STOP_OLD=1
|
||||||
ADOPT_KEY=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=()
|
EXTRA=()
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
@@ -73,6 +99,12 @@ Verwendung: upgrade.sh --app <tesm|tesm-license> [Optionen]
|
|||||||
erzeugen, statt den alten zu uebernehmen. Dann muessen
|
erzeugen, statt den alten zu uebernehmen. Dann muessen
|
||||||
alle Kunden den Ausstellerwechsel bestaetigen -- die
|
alle Kunden den Ausstellerwechsel bestaetigen -- die
|
||||||
Vorgabe ist deshalb die Uebernahme.
|
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
|
--dry-run Nur zeigen, was geschehen wuerde
|
||||||
--yes Keine Rueckfrage
|
--yes Keine Rueckfrage
|
||||||
-h, --help Diese Hilfe
|
-h, --help Diese Hilfe
|
||||||
@@ -85,7 +117,7 @@ Beispiele:
|
|||||||
sudo bash deploy/upgrade.sh --app tesm
|
sudo bash deploy/upgrade.sh --app tesm
|
||||||
|
|
||||||
# Daneben, zum Vergleich, alte Installation bleibt unangetastet:
|
# Daneben, zum Vergleich, alte Installation bleibt unangetastet:
|
||||||
sudo bash deploy/upgrade.sh --app tesm --instance opus --keep-old-running
|
sudo bash deploy/upgrade.sh --app tesm --instance test --keep-old-running
|
||||||
USAGE
|
USAGE
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,6 +131,12 @@ while [[ $# -gt 0 ]]; do
|
|||||||
--keep-old-running) STOP_OLD=0; shift ;;
|
--keep-old-running) STOP_OLD=0; shift ;;
|
||||||
--new-key) ADOPT_KEY=0; shift ;;
|
--new-key) ADOPT_KEY=0; shift ;;
|
||||||
--adopt-key) ADOPT_KEY=1; shift ;; # Vorgabe, hier nur zur Deutlichkeit
|
--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 ;;
|
--dry-run) DRY_RUN=1; shift ;;
|
||||||
--yes|-y) ASSUME_YES=1; shift ;;
|
--yes|-y) ASSUME_YES=1; shift ;;
|
||||||
-h|--help) usage; exit 0 ;;
|
-h|--help) usage; exit 0 ;;
|
||||||
@@ -108,37 +146,364 @@ done
|
|||||||
|
|
||||||
[[ "$APP" == "tesm" || "$APP" == "tesm-license" ]] || { usage; fail "--app fehlt oder ist unbekannt"; }
|
[[ "$APP" == "tesm" || "$APP" == "tesm-license" ]] || { usage; fail "--app fehlt oder ist unbekannt"; }
|
||||||
[[ "$(id -u)" -eq 0 ]] || fail "bitte mit sudo ausfuehren"
|
[[ "$(id -u)" -eq 0 ]] || fail "bitte mit sudo ausfuehren"
|
||||||
[[ -f "$SCRIPT_DIR/install.sh" ]] || fail "install.sh fehlt neben diesem Skript"
|
|
||||||
|
|
||||||
[[ -n "$SOURCE" ]] || SOURCE="/srv/$APP"
|
|
||||||
[[ -d "$SOURCE" ]] || fail "Die alte Installation $SOURCE gibt es nicht (--source setzen)"
|
|
||||||
[[ -f "$SOURCE/sqlite.db" ]] || fail "In $SOURCE liegt keine sqlite.db -- das ist keine Installation der Vorgaengerfassung"
|
|
||||||
|
|
||||||
NAME="$APP"
|
NAME="$APP"
|
||||||
[[ -n "$INSTANCE" ]] && NAME="$APP-$INSTANCE"
|
[[ -n "$INSTANCE" ]] && NAME="$APP-$INSTANCE"
|
||||||
ADMIN="$NAME-admin"
|
ADMIN="$NAME-admin"
|
||||||
NEW_ROOT="/srv/$NAME"
|
NEW_ROOT="/srv/$NAME"
|
||||||
IN_PLACE=0
|
|
||||||
[[ "$(cd "$SOURCE" && pwd)" == "$NEW_ROOT" ]] && IN_PLACE=1
|
|
||||||
|
|
||||||
STAMP="$(date +%Y%m%d-%H%M%S)"
|
STAMP="$(date +%Y%m%d-%H%M%S)"
|
||||||
BACKUP="/srv/$NAME-upgrade-$STAMP"
|
BACKUP="/srv/$NAME-upgrade-$STAMP"
|
||||||
ALT_KOPIE="$BACKUP/alt"
|
ALT_KOPIE="$BACKUP/alt"
|
||||||
|
SYSTEM_KOPIE="$BACKUP/system"
|
||||||
EXPORT_ALT="$BACKUP/export-alt.json"
|
EXPORT_ALT="$BACKUP/export-alt.json"
|
||||||
IMPORT_NEU="$BACKUP/import-neu.json"
|
IMPORT_NEU="$BACKUP/import-neu.json"
|
||||||
|
|
||||||
# Alte Einheiten der Vorgaengerfassung. Der Timer gehoert dazu: nur den Dienst
|
# Alte Einheiten der Vorgaengerfassung. Der Timer gehoert dazu: nur den Dienst
|
||||||
# zu stoppen liesse ihn beim naechsten Ablauf wieder anlaufen.
|
# 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
|
if [[ "$APP" == "tesm" ]]; then
|
||||||
ALTE_UNITS=(tesm.service tesm-check.timer tesm-check.service
|
ALTE_UNITS=(tesm.service tesm-check.timer tesm-check.service
|
||||||
tesm-check-restart.timer tesm-check-restart.service)
|
tesm-check-restart.timer tesm-check-restart.service)
|
||||||
else
|
else
|
||||||
ALTE_UNITS=(tesm-license.service)
|
ALTE_UNITS=(tesm-license.service)
|
||||||
fi
|
fi
|
||||||
|
else
|
||||||
|
ALTE_UNITS=()
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
step "Vorhaben"
|
step "Vorhaben"
|
||||||
info "Anwendung : $APP${INSTANCE:+ (Instanz $INSTANCE)}"
|
info "Anwendung : $APP${INSTANCE:+ (Instanz $INSTANCE)}"
|
||||||
info "Alt : $SOURCE"
|
info "Neue Fassung : $RELEASE_HERKUNFT"
|
||||||
|
info "Alt : $SOURCE${info_quelle:+ (automatisch gefunden)}"
|
||||||
info "Neu : $NEW_ROOT$([[ $IN_PLACE -eq 1 ]] && echo ' << dasselbe Verzeichnis')"
|
info "Neu : $NEW_ROOT$([[ $IN_PLACE -eq 1 ]] && echo ' << dasselbe Verzeichnis')"
|
||||||
info "Sicherung : $ALT_KOPIE"
|
info "Sicherung : $ALT_KOPIE"
|
||||||
info "Datenuebernahme: $([[ $WITH_DATA -eq 1 ]] && echo ja || echo nein)"
|
info "Datenuebernahme: $([[ $WITH_DATA -eq 1 ]] && echo ja || echo nein)"
|
||||||
@@ -160,15 +525,45 @@ fi
|
|||||||
|
|
||||||
# -- 1. Sichern -------------------------------------------------------------- #
|
# -- 1. Sichern -------------------------------------------------------------- #
|
||||||
step "Sichern"
|
step "Sichern"
|
||||||
mkdir -p "$ALT_KOPIE"
|
mkdir -p "$ALT_KOPIE" "$SYSTEM_KOPIE"
|
||||||
chmod 700 "$BACKUP"
|
chmod 700 "$BACKUP"
|
||||||
# -a: Rechte und Zeiten bleiben, damit die Kopie auch als Rueckweg taugt.
|
|
||||||
# Das venv der alten Installation bleibt aussen vor -- es ist nachbaubar,
|
# Das venv kommt nur mit, wenn in dasselbe Verzeichnis installiert wird -- dort
|
||||||
# gross, und seine Shebangs zeigen ohnehin auf den alten Pfad.
|
# 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 -
|
tar -C "$SOURCE" --exclude=venv --exclude=__pycache__ -cf - . | tar -C "$ALT_KOPIE" -xf -
|
||||||
info "$(du -sh "$ALT_KOPIE" | cut -f1) kopiert (ohne venv)"
|
info "$(du -sh "$ALT_KOPIE" | cut -f1) kopiert (ohne venv)"
|
||||||
|
fi
|
||||||
[[ -f "$ALT_KOPIE/sqlite.db" ]] || fail "In der Kopie fehlt die Datenbank -- Abbruch"
|
[[ -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 ---------------------------------------------- #
|
# -- 2. Altes System abschalten ---------------------------------------------- #
|
||||||
if [[ $STOP_OLD -eq 1 ]]; then
|
if [[ $STOP_OLD -eq 1 ]]; then
|
||||||
step "Altes System abschalten"
|
step "Altes System abschalten"
|
||||||
@@ -186,9 +581,12 @@ fi
|
|||||||
step "Neu installieren"
|
step "Neu installieren"
|
||||||
INSTALL_ARGS=(--app "$APP" --yes)
|
INSTALL_ARGS=(--app "$APP" --yes)
|
||||||
[[ -n "$INSTANCE" ]] && INSTALL_ARGS+=(--instance "$INSTANCE")
|
[[ -n "$INSTANCE" ]] && INSTALL_ARGS+=(--instance "$INSTANCE")
|
||||||
bash "$SCRIPT_DIR/install.sh" "${INSTALL_ARGS[@]}" ${EXTRA[0]+"${EXTRA[@]}"}
|
# 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
|
if [[ $WITH_DATA -eq 0 ]]; then
|
||||||
|
UPGRADE_FERTIG=1
|
||||||
step "Fertig -- ohne Datenuebernahme"
|
step "Fertig -- ohne Datenuebernahme"
|
||||||
info "Sicherung der alten Installation: $ALT_KOPIE"
|
info "Sicherung der alten Installation: $ALT_KOPIE"
|
||||||
exit 0
|
exit 0
|
||||||
@@ -240,6 +638,7 @@ else
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# -- Abschluss --------------------------------------------------------------- #
|
# -- Abschluss --------------------------------------------------------------- #
|
||||||
|
UPGRADE_FERTIG=1
|
||||||
step "Fertig"
|
step "Fertig"
|
||||||
info "Sicherung der alten Installation : $ALT_KOPIE"
|
info "Sicherung der alten Installation : $ALT_KOPIE"
|
||||||
if [[ "$APP" == "tesm" ]]; then
|
if [[ "$APP" == "tesm" ]]; then
|
||||||
|
|||||||
@@ -105,19 +105,26 @@ Eine bereits eingerichtete HTTPS-Konfiguration bleibt erhalten, auch ohne
|
|||||||
|
|
||||||
### Ohne Quellbaum: `update.sh`
|
### Ohne Quellbaum: `update.sh`
|
||||||
|
|
||||||
Liegt kein entpacktes Release vor, holt `update.sh` es selbst -- es lädt das
|
`update.sh` genügt als **einzelne Datei**. Auf einem System, auf dem noch nichts
|
||||||
Release-Asset, entpackt es und ruft dessen `install.sh` auf:
|
von diesem Projekt liegt:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo bash /opt/tesm/deploy/update.sh tesm
|
curl -fsSLO https://gitea.int.eertmoed.net/alientim/tesm/raw/branch/main/deploy/update.sh
|
||||||
sudo bash /opt/tesm-license/deploy/update.sh tesm-license
|
sudo bash update.sh tesm
|
||||||
|
```
|
||||||
|
|
||||||
|
Auf einer bestehenden Installation liegt sie schon bereit:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo bash /srv/tesm/deploy/update.sh tesm
|
||||||
|
sudo bash /srv/tesm-license/deploy/update.sh tesm-license
|
||||||
```
|
```
|
||||||
|
|
||||||
Alles nach dem Tag geht unverändert an `install.sh` weiter -- das ist der Weg,
|
Alles nach dem Tag geht unverändert an `install.sh` weiter -- das ist der Weg,
|
||||||
eine **benannte Instanz** zu aktualisieren:
|
eine **benannte Instanz** zu aktualisieren:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo bash /opt/tesm-opus/deploy/update.sh tesm latest --instance opus
|
sudo bash /srv/tesm/deploy/update.sh tesm
|
||||||
```
|
```
|
||||||
|
|
||||||
Ohne `--instance` entstünde daneben eine zweite Installation namens `tesm`, mit
|
Ohne `--instance` entstünde daneben eine zweite Installation namens `tesm`, mit
|
||||||
@@ -147,11 +154,51 @@ Ein eigenes Skript, weil dabei mehr passiert als ein Dateiabgleich: die alte
|
|||||||
Fassung hat ein anderes Schema, andere Rechteschlüssel und ein anderes
|
Fassung hat ein anderes Schema, andere Rechteschlüssel und ein anderes
|
||||||
Exportformat.
|
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
|
```bash
|
||||||
sudo bash deploy/upgrade.sh --app tesm
|
sudo bash deploy/upgrade.sh --app tesm
|
||||||
sudo bash deploy/upgrade.sh --app tesm-license
|
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:
|
Der Ablauf ist die Vorgabe und braucht keine Schalter:
|
||||||
|
|
||||||
| | |
|
| | |
|
||||||
@@ -177,7 +224,7 @@ Export des Vorgängers und taugt deshalb auch als Rückweg.
|
|||||||
und die alte bleibt vollständig liegen:
|
und die alte bleibt vollständig liegen:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo bash deploy/upgrade.sh --app tesm --instance opus --keep-old-running --port 5100 --http-port 8080
|
sudo bash deploy/upgrade.sh --app tesm --instance test --keep-old-running --port 5100 --http-port 8080
|
||||||
```
|
```
|
||||||
|
|
||||||
**Was nicht mitkommt** -- und warum:
|
**Was nicht mitkommt** -- und warum:
|
||||||
@@ -251,6 +298,83 @@ würde). Der ACME-Pfad bleibt auch bei aktiver Umleitung erreichbar.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 4a. Anmeldebanner (MOTD)
|
||||||
|
|
||||||
|
`install.sh` legt `/etc/update-motd.d/50-<name>` an. Bei jeder SSH-Anmeldung
|
||||||
|
steht dann der Zustand der Installation im Login-Banner:
|
||||||
|
|
||||||
|
```
|
||||||
|
TESM · TimEShepManager
|
||||||
|
UEBERWACHEN · BOOTEN · ANBINDEN (DHCP) · BETRIEBSSYSTEM GEBEN (PXE)
|
||||||
|
|
||||||
|
3 Clients online · 0 nicht erreichbar · DHCP: 2 Leases aktiv
|
||||||
|
```
|
||||||
|
|
||||||
|
Das Skript liegt unter `/usr/local/lib/tesm/tesm-motd` und benutzt **nur die
|
||||||
|
Standardbibliothek**: kein venv, kein Import der Anwendung, die Datenbank wird
|
||||||
|
nur lesend geöffnet. Ein Lauf dauert Millisekunden. Der Vorgänger importierte
|
||||||
|
dafür die ganze Flask-Anwendung -- bei jeder Anmeldung, und nach einem Wechsel
|
||||||
|
des Quellverzeichnisses zeigte er stillschweigend die Zahlen einer Datenbank,
|
||||||
|
die niemand mehr füllte.
|
||||||
|
|
||||||
|
Von Hand prüfen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo /usr/local/lib/tesm/tesm-motd --app tesm --root /srv/tesm-test
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Aufruf im Banner ist mit `timeout 3` und `|| true` abgesichert: ein
|
||||||
|
hängender oder fehlschlagender Bannerlauf darf eine Anmeldung nie blockieren.
|
||||||
|
Ein Banner der Vorgängerfassung (`50-tesm`, das `motd_banner.py` aufruft) wird
|
||||||
|
entfernt -- nur dieses, nichts anderes in dem Verzeichnis.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4b. Installation umbenennen
|
||||||
|
|
||||||
|
Aus einer Instanz die Hauptinstallation machen (oder umgekehrt):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo bash deploy/rename.sh --app tesm --from tesm-test --to tesm
|
||||||
|
sudo bash deploy/rename.sh --app tesm-license --from tesm-license-test --to tesm-license
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Name steckt an mehr Stellen als im Verzeichnisnamen, und drei davon sind
|
||||||
|
leicht zu übersehen:
|
||||||
|
|
||||||
|
* **`SITE_KEY`** in der Umgebungsdatei -- daraus entsteht der Name des
|
||||||
|
Sitzungscookies. Bleibt er alt, heisst das Cookie weiter wie die alte
|
||||||
|
Instanz.
|
||||||
|
* **`web_cert_path`, `web_key_path`, `web_acme_webroot`** in der Datenbank. Sie
|
||||||
|
enthalten den Namen; ein Zertifikat unter dem alten Pfad findet nginx nach
|
||||||
|
dem Umzug nicht mehr.
|
||||||
|
* **`server_name`** der nginx-Site. `install.sh` schreibt die Datei aus seiner
|
||||||
|
Vorlage neu, sobald sich die `alias`-Pfade ändern -- und die ändern sich hier
|
||||||
|
zwangsläufig. Ohne den Namen aus der Datenbank stünden danach zwei Sites mit
|
||||||
|
`server_name _` auf Port 80, und die erste gewinnt.
|
||||||
|
|
||||||
|
Ausserdem wird das **venv verworfen und neu gebaut**: seine Konsolenskripte
|
||||||
|
tragen den absoluten Pfad im Shebang, und nach dem Umzug zeigt
|
||||||
|
`venv/bin/gunicorn` auf einen Interpreter, den es nicht mehr gibt. systemd
|
||||||
|
meldet dann `No such file or directory` -- für eine Datei, die sichtbar da ist.
|
||||||
|
|
||||||
|
Belegt der Zielname noch eine Installation der Fassung 1.x, wandert **alles**,
|
||||||
|
was ihn belegt, in ein Archiv `/srv/<ziel>-sonnet5`: das Verzeichnis, die
|
||||||
|
Protokolle unter `/var/log/<ziel>`, der ACME-Pfad, die logrotate-Datei, und die
|
||||||
|
systemd-Units samt nginx-Site unter `systemd-alt/`. Gelöscht wird nichts.
|
||||||
|
|
||||||
|
Ports und HTTPS bleiben, wie sie waren; ein vorhandenes Zertifikat zieht mit
|
||||||
|
und wird weiterverwendet. **Alle angemeldeten Benutzer werden abgemeldet** --
|
||||||
|
das Sitzungscookie heisst danach anders.
|
||||||
|
|
||||||
|
Danach ist auch das Aktualisieren einfacher, weil der Instanzname wegfällt:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo bash /srv/tesm/deploy/update.sh tesm
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 5. Dienste
|
## 5. Dienste
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -322,7 +446,7 @@ Unverzichtbar:
|
|||||||
**Die Anwendung antwortet nicht.**
|
**Die Anwendung antwortet nicht.**
|
||||||
```bash
|
```bash
|
||||||
journalctl -u tesm -n 200 --no-pager
|
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.
|
Antwortet gunicorn, aber der Browser nicht, liegt es an nginx.
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ Was geschützt wird, womit, und wo bewusst Grenzen gezogen sind.
|
|||||||
| Speicherung | Argon2id (argon2-cffi) |
|
| Speicherung | Argon2id (argon2-cffi) |
|
||||||
| Altbestand | bcrypt wird noch geprüft und bei der nächsten erfolgreichen Anmeldung stillschweigend auf Argon2id umgestellt |
|
| Altbestand | bcrypt wird noch geprüft und bei der nächsten erfolgreichen Anmeldung stillschweigend auf Argon2id umgestellt |
|
||||||
| Zweiter Faktor | TOTP, optional erzwingbar für Administratoren |
|
| 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 Konto | nach `LOGIN_MAX_ATTEMPTS` Versuchen |
|
||||||
| Sperre je Adresse | zusätzlich `LOGIN_MAX_ATTEMPTS_PER_IP` -- sonst lässt sich die Kontosperre umgehen, indem man viele Konten je einmal probiert |
|
| Sperre je Adresse | zusätzlich `LOGIN_MAX_ATTEMPTS_PER_IP` -- sonst lässt sich die Kontosperre umgehen, indem man viele Konten je einmal probiert |
|
||||||
| Verzeichnisdienst | LDAP nur über TLS, Zertifikat wird geprüft. Ohne gültiges Zertifikat keine Anmeldung -- kein Schalter, der das abschaltet |
|
| Verzeichnisdienst | LDAP nur über TLS, Zertifikat wird geprüft. Ohne gültiges Zertifikat keine Anmeldung -- kein Schalter, der das abschaltet |
|
||||||
@@ -171,6 +172,10 @@ braucht der Dienst kein `--workers 1` mehr.
|
|||||||
* **Kein CSRF-Freibrief für cookie-autorisierte Routen.**
|
* **Kein CSRF-Freibrief für cookie-autorisierte Routen.**
|
||||||
* **Kein `unsafe-inline` für Skripte**, auch nicht "vorübergehend".
|
* **Kein `unsafe-inline` für Skripte**, auch nicht "vorübergehend".
|
||||||
* **Keine Rechteausweitung**, auch nicht für Administratoren untereinander.
|
* **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
|
* **Kein Hostname im Lizenz-Fingerabdruck** -- Umbenennen darf die Bindung
|
||||||
nicht brechen.
|
nicht brechen.
|
||||||
* **Keine von Hand gepflegte nginx-Site.** Sie wird immer neu erzeugt.
|
* **Keine von Hand gepflegte nginx-Site.** Sie wird immer neu erzeugt.
|
||||||
|
|||||||
@@ -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]
|
[project]
|
||||||
name = "tesm-core"
|
name = "tesm-core"
|
||||||
version = "2.0.3"
|
version = "2.8.3"
|
||||||
description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver"
|
description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -8,7 +8,20 @@ Design-System.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
__version__ = "2.0.2"
|
#: Version aus den Metadaten des installierten Pakets -- die einzige Quelle
|
||||||
|
#: ist ``pyproject.toml``. Vorher stand hier eine feste Zahl, und die
|
||||||
|
#: Fusszeile zeigte vier Releases lang 2.0.2.
|
||||||
|
#:
|
||||||
|
#: Im Quellbaum ohne ``pip install`` gibt es keine Metadaten; dann steht dort
|
||||||
|
#: "unbekannt" statt einer erfundenen Nummer. In einem editierbaren Install
|
||||||
|
#: zeigt sie den Stand des letzten ``pip install -e`` -- also die Version, die
|
||||||
|
#: tatsaechlich installiert ist.
|
||||||
|
try: # pragma: no cover - haengt an der Installationsart
|
||||||
|
from importlib.metadata import PackageNotFoundError, version as _paketversion
|
||||||
|
|
||||||
|
__version__ = _paketversion("tesm-core")
|
||||||
|
except (ImportError, PackageNotFoundError): # pragma: no cover
|
||||||
|
__version__ = "unbekannt"
|
||||||
|
|
||||||
from .app_factory import create_base_app
|
from .app_factory import create_base_app
|
||||||
from .config import CoreConfig, SecurityConfig, load_core_config
|
from .config import CoreConfig, SecurityConfig, load_core_config
|
||||||
|
|||||||
@@ -13,10 +13,12 @@ import secrets
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any, Sequence
|
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 werkzeug.exceptions import HTTPException
|
||||||
|
|
||||||
from . import audit, logging_setup, ui
|
from . import audit, legacy_urls, logging_setup, ui
|
||||||
from .config import CoreConfig
|
from .config import CoreConfig
|
||||||
from .core_schema import CORE_MIGRATIONS
|
from .core_schema import CORE_MIGRATIONS
|
||||||
from .db import Database
|
from .db import Database
|
||||||
@@ -31,6 +33,7 @@ from .security import csrf
|
|||||||
from .security import sessions as session_store
|
from .security import sessions as session_store
|
||||||
from .security.sessions import SqliteSessionInterface
|
from .security.sessions import SqliteSessionInterface
|
||||||
from .settings_store import SettingSpec, SettingsStore
|
from .settings_store import SettingSpec, SettingsStore
|
||||||
|
from . import uploads
|
||||||
from .sysops import SysOps
|
from .sysops import SysOps
|
||||||
from .auth import directory
|
from .auth import directory
|
||||||
from .auth import routes as auth_routes
|
from .auth import routes as auth_routes
|
||||||
@@ -56,18 +59,29 @@ CORE_SETTINGS: tuple[SettingSpec, ...] = (
|
|||||||
30,
|
30,
|
||||||
"int",
|
"int",
|
||||||
"Aufbewahrung im Papierkorb (Tage)",
|
"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",
|
section="system",
|
||||||
minimum=1,
|
minimum=1,
|
||||||
maximum=3650,
|
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(
|
SettingSpec(
|
||||||
"session_idle_minutes",
|
"session_idle_minutes",
|
||||||
30,
|
30,
|
||||||
"int",
|
"int",
|
||||||
"Automatische Abmeldung nach Leerlauf (Minuten)",
|
"Automatische Abmeldung nach Leerlauf (Minuten)",
|
||||||
hint="Jede Anfrage setzt den Zaehler zurueck -- gezaehlt wird nur Untaetigkeit. "
|
hint="Jede Anfrage setzt den Zähler zurück – gezählt wird nur Untätigkeit. "
|
||||||
"Gilt auch fuer die eingebundenen Dateifreigaben: mit der Sitzung endet die "
|
"Gilt auch für die eingebundenen Dateifreigaben: mit der Sitzung endet die "
|
||||||
"Einbindung.",
|
"Einbindung.",
|
||||||
section="system",
|
section="system",
|
||||||
minimum=5,
|
minimum=5,
|
||||||
@@ -88,6 +102,14 @@ CORE_SETTINGS: tuple[SettingSpec, ...] = (
|
|||||||
),
|
),
|
||||||
# -- Webserver/TLS (Host-Ebene, in beiden Anwendungen gleich) ----------- #
|
# -- Webserver/TLS (Host-Ebene, in beiden Anwendungen gleich) ----------- #
|
||||||
# Lizenzserver: eigenes Zertifikat statt abgeschalteter Pruefung.
|
# 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(
|
SettingSpec(
|
||||||
"license_server_ca",
|
"license_server_ca",
|
||||||
"",
|
"",
|
||||||
@@ -100,8 +122,8 @@ CORE_SETTINGS: tuple[SettingSpec, ...] = (
|
|||||||
SettingSpec("web_hsts_enabled", False, "bool", "HSTS senden", section="web"),
|
SettingSpec("web_hsts_enabled", False, "bool", "HSTS senden", section="web"),
|
||||||
SettingSpec("web_redirect_http", True, "bool", "HTTP auf HTTPS umleiten", 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_cert_path", "", "str", "Zertifikat (Pfad)", section="web"),
|
||||||
SettingSpec("web_key_path", "", "str", "Privater Schluessel (Pfad)", section="web"),
|
SettingSpec("web_key_path", "", "str", "Privater Schlüssel (Pfad)", section="web"),
|
||||||
SettingSpec("web_acme_email", "", "str", "E-Mail fuer Let's Encrypt", 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"),
|
SettingSpec("web_acme_webroot", "", "str", "ACME-Webroot", section="web"),
|
||||||
# Abweichende Ports braucht nur eine Testinstanz neben der Produktion --
|
# Abweichende Ports braucht nur eine Testinstanz neben der Produktion --
|
||||||
# im Regelbetrieb bleibt es bei 80 und 443.
|
# im Regelbetrieb bleibt es bei 80 und 443.
|
||||||
@@ -112,7 +134,7 @@ CORE_SETTINGS: tuple[SettingSpec, ...] = (
|
|||||||
"web_https_port", 443, "int", "HTTPS-Port", section="web", minimum=1, maximum=65535
|
"web_https_port", 443, "int", "HTTPS-Port", section="web", minimum=1, maximum=65535
|
||||||
),
|
),
|
||||||
SettingSpec(
|
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,
|
minimum=1, maximum=8192,
|
||||||
),
|
),
|
||||||
*directory.SETTINGS,
|
*directory.SETTINGS,
|
||||||
@@ -130,11 +152,15 @@ def create_base_app(
|
|||||||
import_name: str = "tesm_core",
|
import_name: str = "tesm_core",
|
||||||
app_short: str = "TESM",
|
app_short: str = "TESM",
|
||||||
app_edition: str = "",
|
app_edition: str = "",
|
||||||
#: Zeile unter der Wortmarke auf den Anmeldeseiten. Leer = kein
|
#: Zeile unter der Wortmarke auf den Anmeldeseiten. Leer = nur die
|
||||||
#: Seitenhintergrund. Der Lizenzserver setzt sie bewusst nicht: die
|
#: Wortmarke, ohne Zeile darunter; die Marke selbst zeigen beide
|
||||||
#: Funktionsliste von TESM waere dort schlicht falsch.
|
#: Anwendungen.
|
||||||
auth_tagline: str = "",
|
auth_tagline: str = "",
|
||||||
app_version: str = "2.0.3",
|
#: 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, ...] = (),
|
built_in_trusted_keys: tuple[str, ...] = (),
|
||||||
enable_license: bool = True,
|
enable_license: bool = True,
|
||||||
template_folder: str | None = None,
|
template_folder: str | None = None,
|
||||||
@@ -146,6 +172,10 @@ def create_base_app(
|
|||||||
static_folder=static_folder or "static",
|
static_folder=static_folder or "static",
|
||||||
static_url_path="/assets",
|
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
|
# Templates und Design-System des Kerns liegen im Paket ``tesm_core`` und
|
||||||
# werden ueber einen eigenen Blueprint bereitgestellt. Dadurch kann jede
|
# werden ueber einen eigenen Blueprint bereitgestellt. Dadurch kann jede
|
||||||
@@ -163,6 +193,10 @@ def create_base_app(
|
|||||||
config.data_dir.mkdir(parents=True, exist_ok=True)
|
config.data_dir.mkdir(parents=True, exist_ok=True)
|
||||||
config.log_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(
|
app.config.update(
|
||||||
SECRET_KEY=load_or_create_secret_key(config.secret_key_file),
|
SECRET_KEY=load_or_create_secret_key(config.secret_key_file),
|
||||||
# site_key statt app_key: Cookies unterscheiden keine Ports. Zwei
|
# site_key statt app_key: Cookies unterscheiden keine Ports. Zwei
|
||||||
@@ -172,6 +206,9 @@ def create_base_app(
|
|||||||
SESSION_COOKIE_HTTPONLY=True,
|
SESSION_COOKIE_HTTPONLY=True,
|
||||||
SESSION_COOKIE_SECURE=config.security.cookie_secure,
|
SESSION_COOKIE_SECURE=config.security.cookie_secure,
|
||||||
SESSION_COOKIE_SAMESITE=config.security.cookie_samesite,
|
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,
|
MAX_CONTENT_LENGTH=config.security.max_content_length,
|
||||||
JSON_SORT_KEYS=False,
|
JSON_SORT_KEYS=False,
|
||||||
TRAP_HTTP_EXCEPTIONS=False,
|
TRAP_HTTP_EXCEPTIONS=False,
|
||||||
@@ -222,12 +259,21 @@ def create_base_app(
|
|||||||
wert = 0
|
wert = 0
|
||||||
return wert if wert > 0 else config.security.session_idle_minutes
|
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(
|
app.session_interface = SqliteSessionInterface(
|
||||||
database,
|
database,
|
||||||
cookie_name=app.config["SESSION_COOKIE_NAME"],
|
cookie_name=app.config["SESSION_COOKIE_NAME"],
|
||||||
idle_minutes=config.security.session_idle_minutes,
|
idle_minutes=config.security.session_idle_minutes,
|
||||||
absolute_hours=config.security.session_absolute_hours,
|
absolute_hours=config.security.session_absolute_hours,
|
||||||
idle_lookup=_leerlauf,
|
idle_lookup=_leerlauf,
|
||||||
|
absolute_lookup=_gesamtdauer,
|
||||||
)
|
)
|
||||||
|
|
||||||
# -- Schema und Startzustand ------------------------------------------ #
|
# -- Schema und Startzustand ------------------------------------------ #
|
||||||
@@ -259,6 +305,17 @@ def create_base_app(
|
|||||||
|
|
||||||
@app.after_request
|
@app.after_request
|
||||||
def _finish_request(response): # type: ignore[no-untyped-def]
|
def _finish_request(response): # type: ignore[no-untyped-def]
|
||||||
|
# Ein Aktualisierungsdurchlauf, der mit Sitzungscookie kommt und
|
||||||
|
# unangemeldet endet, hat gerade seine Sitzung verloren. Bei einer
|
||||||
|
# geschuetzten Seite sagt das schon der Status (401); bei einer, die
|
||||||
|
# auch oeffentlich antwortet -- die Statusuebersicht etwa -- kommt eine
|
||||||
|
# 200 mit den oeffentlichen Kacheln zurueck, und der offene Tab merkt
|
||||||
|
# nichts: er zeigt weiter eine Oberflaeche, als sei alles in Ordnung.
|
||||||
|
# Deshalb ein Kopf, den der Browser unabhaengig vom Status auswerten
|
||||||
|
# kann.
|
||||||
|
if session_store.is_automatic(request) and not current_user.is_authenticated:
|
||||||
|
if request.cookies.get(app.config["SESSION_COOKIE_NAME"], ""):
|
||||||
|
response.headers["X-Session-Expired"] = "1"
|
||||||
hsts = ""
|
hsts = ""
|
||||||
if config.security.hsts_enabled:
|
if config.security.hsts_enabled:
|
||||||
hsts = f"max-age={config.security.hsts_max_age}; includeSubDomains"
|
hsts = f"max-age={config.security.hsts_max_age}; includeSubDomains"
|
||||||
@@ -317,6 +374,26 @@ def create_base_app(
|
|||||||
# -- Fehlerseiten ------------------------------------------------------ #
|
# -- Fehlerseiten ------------------------------------------------------ #
|
||||||
@app.errorhandler(HTTPException)
|
@app.errorhandler(HTTPException)
|
||||||
def _http_error(error: HTTPException): # type: ignore[no-untyped-def]
|
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():
|
if _wants_json():
|
||||||
return {
|
return {
|
||||||
"error": error.name,
|
"error": error.name,
|
||||||
@@ -370,7 +447,7 @@ def create_base_app(
|
|||||||
with app.app_context():
|
with app.app_context():
|
||||||
trash_views.register_core_providers()
|
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
|
return app
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ import sqlite3
|
|||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Iterable
|
from typing import Any, Collection, Iterable, Mapping, Sequence
|
||||||
|
|
||||||
from .db import Database
|
from .db import Database
|
||||||
|
|
||||||
@@ -116,6 +116,49 @@ def log(
|
|||||||
return int(cursor.lastrowid or 0)
|
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]:
|
def verify_chain(conn: sqlite3.Connection, *, limit: int | None = None) -> dict[str, Any]:
|
||||||
"""Prueft die Hash-Kette. Rueckgabe: ``{"ok", "checked", "broken_at", "reason"}``."""
|
"""Prueft die Hash-Kette. Rueckgabe: ``{"ok", "checked", "broken_at", "reason"}``."""
|
||||||
sql = "SELECT * FROM audit_log ORDER BY id"
|
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,
|
"ok": False,
|
||||||
"checked": checked,
|
"checked": checked,
|
||||||
"broken_at": data["id"],
|
"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"]:
|
if compute_hash(str(data["prev_hash"]), data) != data["hash"]:
|
||||||
return {
|
return {
|
||||||
"ok": False,
|
"ok": False,
|
||||||
"checked": checked,
|
"checked": checked,
|
||||||
"broken_at": data["id"],
|
"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"])
|
expected_prev = str(data["hash"])
|
||||||
checked += 1
|
checked += 1
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import re
|
|||||||
import socket
|
import socket
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import ssl
|
import ssl
|
||||||
|
import time
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
@@ -59,20 +60,20 @@ SETTINGS: tuple[SettingSpec, ...] = (
|
|||||||
section="ldap",
|
section="ldap",
|
||||||
choices=(("ldaps", "LDAPS (Port 636)"), ("starttls", "StartTLS (Port 389)"), ("none", "Unverschluesselt")),
|
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_ca_file", "", "str", "CA-Zertifikat (Pfad)", section="ldap"),
|
||||||
SettingSpec(
|
SettingSpec(
|
||||||
"ldap_allow_insecure",
|
"ldap_allow_insecure",
|
||||||
False,
|
False,
|
||||||
"bool",
|
"bool",
|
||||||
"Unverschluesselte Verbindung ausdruecklich erlauben",
|
"Unverschlüsselte Verbindung ausdrücklich erlauben",
|
||||||
hint="Nur fuer Testumgebungen. Das Passwort geht dabei im Klartext ueber das Netz.",
|
hint="Nur für Testumgebungen. Das Passwort geht dabei im Klartext über das Netz.",
|
||||||
section="ldap",
|
section="ldap",
|
||||||
),
|
),
|
||||||
SettingSpec("ldap_base_dn", "", "str", "Basis-DN (leer = automatisch)", section="ldap"),
|
SettingSpec("ldap_base_dn", "", "str", "Basis-DN (leer = automatisch)", section="ldap"),
|
||||||
SettingSpec("ldap_user_attribute", "sAMAccountName", "str", "Anmeldeattribut", section="ldap"),
|
SettingSpec("ldap_user_attribute", "sAMAccountName", "str", "Anmeldeattribut", section="ldap"),
|
||||||
SettingSpec("ldap_email_attribute", "userPrincipalName", "str", "E-Mail-Attribut", 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),
|
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:
|
try:
|
||||||
secret = core().keystore.decrypt(encrypted, aad=AAD_LDAP_BIND)
|
secret = core().keystore.decrypt(encrypted, aad=AAD_LDAP_BIND)
|
||||||
except Exception: # noqa: BLE001 - Schluessel gewechselt/Datei kaputt
|
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 ""), ""
|
||||||
return str(row.get("username") or ""), secret
|
return str(row.get("username") or ""), secret
|
||||||
|
|
||||||
@@ -162,19 +163,82 @@ def _tls_object(config: dict[str, Any]):
|
|||||||
|
|
||||||
|
|
||||||
def _server(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
|
from ldap3 import Server
|
||||||
|
|
||||||
security = str(config["ldap_security"])
|
security = str(config["ldap_security"])
|
||||||
|
braucht_basis = not str(config.get("ldap_base_dn") or "").strip()
|
||||||
return Server(
|
return Server(
|
||||||
str(config["ldap_host"]),
|
str(config["ldap_host"]),
|
||||||
port=int(config["ldap_port"]),
|
port=int(config["ldap_port"]),
|
||||||
use_ssl=security == "ldaps",
|
use_ssl=security == "ldaps",
|
||||||
tls=_tls_object(config) if security in {"ldaps", "starttls"} else None,
|
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"]),
|
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):
|
def _connect(config: dict[str, Any], user: str, password: str):
|
||||||
"""Baut eine Verbindung auf und bindet. Wirft bei Netz-/TLS-Problemen."""
|
"""Baut eine Verbindung auf und bindet. Wirft bei Netz-/TLS-Problemen."""
|
||||||
from ldap3 import Connection
|
from ldap3 import Connection
|
||||||
@@ -182,7 +246,7 @@ def _connect(config: dict[str, Any], user: str, password: str):
|
|||||||
security = str(config["ldap_security"])
|
security = str(config["ldap_security"])
|
||||||
if security == "none" and not config["ldap_allow_insecure"]:
|
if security == "none" and not config["ldap_allow_insecure"]:
|
||||||
raise PermissionError(
|
raise PermissionError(
|
||||||
"Unverschluesselte Verzeichnisverbindungen sind deaktiviert. "
|
"Unverschlüsselte Verzeichnisverbindungen sind deaktiviert. "
|
||||||
"Nutzen Sie LDAPS oder StartTLS."
|
"Nutzen Sie LDAPS oder StartTLS."
|
||||||
)
|
)
|
||||||
connection = Connection(
|
connection = Connection(
|
||||||
@@ -192,6 +256,12 @@ def _connect(config: dict[str, Any], user: str, password: str):
|
|||||||
auto_bind=False,
|
auto_bind=False,
|
||||||
raise_exceptions=False,
|
raise_exceptions=False,
|
||||||
receive_timeout=int(config["ldap_timeout"]),
|
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 security == "starttls":
|
||||||
if not connection.open():
|
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)
|
bind_user, bind_password = bind_credentials(conn)
|
||||||
warnings: list[str] = []
|
warnings: list[str] = []
|
||||||
if str(config["ldap_security"]) == "none":
|
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"]:
|
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:
|
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:
|
except (PermissionError, ConnectionError) as exc:
|
||||||
logger.error("Verzeichnisdienst: %s", exc)
|
logger.error("Verzeichnisdienst: %s", exc)
|
||||||
return DirectoryResult("unavailable", message=str(exc), warnings=tuple(warnings))
|
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),
|
warnings=tuple(warnings),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
marke = gemessen("Dienstbindung", begonnen)
|
||||||
try:
|
try:
|
||||||
base = _base_dn(service_conn, config)
|
base = _base_dn(service_conn, config)
|
||||||
|
marke = gemessen("Basis-DN", marke)
|
||||||
if not base:
|
if not base:
|
||||||
return DirectoryResult(
|
return DirectoryResult(
|
||||||
"unavailable", message="Basis-DN konnte nicht ermittelt werden.", warnings=tuple(warnings)
|
"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)),
|
attributes=list(dict.fromkeys(wanted)),
|
||||||
size_limit=2,
|
size_limit=2,
|
||||||
)
|
)
|
||||||
|
marke = gemessen("Benutzersuche", marke)
|
||||||
entries = list(service_conn.entries)
|
entries = list(service_conn.entries)
|
||||||
if len(entries) != 1:
|
if len(entries) != 1:
|
||||||
|
logger.info("Verzeichnisanmeldung %s: %s", username, ", ".join(zeiten))
|
||||||
return DirectoryResult("invalid", warnings=tuple(warnings))
|
return DirectoryResult("invalid", warnings=tuple(warnings))
|
||||||
|
|
||||||
entry = entries[0]
|
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
|
# Zweite, unabhaengige Verbindung mit den Benutzerdaten -- die eigentliche
|
||||||
# Passwortpruefung. Niemals ueber die Dienstkonto-Verbindung rebinden.
|
# 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:
|
try:
|
||||||
if not user_bound:
|
if not user_bound:
|
||||||
|
logger.info("Verzeichnisanmeldung %s: %s", username, ", ".join(zeiten))
|
||||||
return DirectoryResult("invalid", warnings=tuple(warnings))
|
return DirectoryResult("invalid", warnings=tuple(warnings))
|
||||||
finally:
|
finally:
|
||||||
try:
|
try:
|
||||||
@@ -313,6 +407,7 @@ def authenticate(conn: sqlite3.Connection, username: str, password: str) -> Dire
|
|||||||
group_dns: tuple[str, ...] = ()
|
group_dns: tuple[str, ...] = ()
|
||||||
if config["ldap_group_sync"]:
|
if config["ldap_group_sync"]:
|
||||||
group_dns = _resolve_groups(service_conn, base, dn)
|
group_dns = _resolve_groups(service_conn, base, dn)
|
||||||
|
marke = gemessen("Gruppen", marke)
|
||||||
|
|
||||||
profile = DirectoryProfile(
|
profile = DirectoryProfile(
|
||||||
username=_attr(entry, attribute) or username,
|
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),
|
email=_attr(entry, email_attribute),
|
||||||
group_dns=group_dns,
|
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))
|
return DirectoryResult("ok", profile=profile, warnings=tuple(warnings))
|
||||||
finally:
|
finally:
|
||||||
try:
|
try:
|
||||||
@@ -359,7 +463,7 @@ def _resolve_groups(connection: Any, base: str, user_dn: str) -> tuple[str, ...]
|
|||||||
)
|
)
|
||||||
return tuple(str(entry.entry_dn) for entry in connection.entries)
|
return tuple(str(entry.entry_dn) for entry in connection.entries)
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
logger.warning("Gruppenaufloesung fehlgeschlagen: %s", exc)
|
logger.warning("Gruppenauflösung fehlgeschlagen: %s", exc)
|
||||||
return ()
|
return ()
|
||||||
|
|
||||||
|
|
||||||
@@ -423,18 +527,24 @@ def _apply_group_mappings(
|
|||||||
zugewiesene Gruppen bleiben erhalten, wenn sie kein Verzeichnis-Mapping
|
zugewiesene Gruppen bleiben erhalten, wenn sie kein Verzeichnis-Mapping
|
||||||
haben -- sonst waere jede lokale Zuweisung nach dem naechsten Login weg.
|
haben -- sonst waere jede lokale Zuweisung nach dem naechsten Login weg.
|
||||||
"""
|
"""
|
||||||
if not group_dns:
|
|
||||||
return
|
|
||||||
mappings = Database.all(
|
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:
|
if not mappings:
|
||||||
return
|
return
|
||||||
mapped_group_ids = {int(row["app_group_id"]) for row in mappings}
|
|
||||||
lowered = {dn.lower() for dn in group_dns}
|
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 = {
|
wanted = {
|
||||||
int(row["app_group_id"])
|
int(row["app_group_id"])
|
||||||
for row in mappings
|
for row in gruppenzuordnungen
|
||||||
if str(row["ad_group_dn"]).lower() in lowered
|
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]]:
|
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,))
|
return Database.all(conn, "SELECT group_id AS id FROM user_groups WHERE user_id=?", (user_id,))
|
||||||
|
|
||||||
|
|||||||
@@ -77,25 +77,40 @@ class User:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def effective_permissions(self) -> frozenset[str]:
|
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:
|
if self._effective is None:
|
||||||
tree = self._tree
|
tree = self._tree
|
||||||
self._effective = (
|
if tree is None:
|
||||||
frozenset(tree.effective(self.granted)) if tree is not None else frozenset()
|
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
|
return self._effective
|
||||||
|
|
||||||
def has(self, *permissions: str) -> bool:
|
def has(self, *permissions: str) -> bool:
|
||||||
"""True, wenn **alle** angegebenen Rechte greifen. Admins duerfen immer alles."""
|
"""True, wenn **alle** angegebenen Rechte greifen.
|
||||||
if self.is_admin:
|
|
||||||
return True
|
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:
|
if not permissions:
|
||||||
return True
|
return True
|
||||||
return all(permission in self.effective_permissions for permission in permissions)
|
return all(permission in self.effective_permissions for permission in permissions)
|
||||||
|
|
||||||
def has_any(self, permissions: Iterable[str]) -> bool:
|
def has_any(self, permissions: Iterable[str]) -> bool:
|
||||||
if self.is_admin:
|
|
||||||
return True
|
|
||||||
effective = self.effective_permissions
|
effective = self.effective_permissions
|
||||||
return any(permission in effective for permission in permissions)
|
return any(permission in effective for permission in permissions)
|
||||||
|
|
||||||
@@ -200,7 +215,12 @@ def _deny(message: str) -> Any:
|
|||||||
abort(401, description="Sitzung abgelaufen.")
|
abort(401, description="Sitzung abgelaufen.")
|
||||||
if request.accept_mimetypes.accept_json and not request.accept_mimetypes.accept_html:
|
if request.accept_mimetypes.accept_json and not request.accept_mimetypes.accept_html:
|
||||||
abort(401, description="Anmeldung erforderlich.")
|
abort(401, description="Anmeldung erforderlich.")
|
||||||
flash("Bitte melden Sie sich an.", "info")
|
# "Bitte melden Sie sich an." stand auf einer Seite, die aus einem
|
||||||
|
# Anmeldefeld, einem Passwortfeld und einem Knopf "Anmelden" besteht --
|
||||||
|
# eine Meldung, die nichts hinzufuegt. Wer dagegen gerade noch
|
||||||
|
# angemeldet war, soll erfahren, warum er es nicht mehr ist.
|
||||||
|
if getattr(g, "session_expired", False):
|
||||||
|
flash("Ihre Sitzung ist abgelaufen. Bitte melden Sie sich erneut an.", "info")
|
||||||
return redirect(url_for("auth.login"))
|
return redirect(url_for("auth.login"))
|
||||||
abort(403, description=message)
|
abort(403, description=message)
|
||||||
|
|
||||||
@@ -215,6 +235,37 @@ def login_required(view: Callable[..., Any]) -> Callable[..., Any]:
|
|||||||
return wrapper
|
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 permission_required(*permissions: str, mode: str = "all") -> Callable[..., Any]:
|
def permission_required(*permissions: str, mode: str = "all") -> Callable[..., Any]:
|
||||||
"""Erzwingt Rechte serverseitig.
|
"""Erzwingt Rechte serverseitig.
|
||||||
|
|
||||||
@@ -232,7 +283,15 @@ def permission_required(*permissions: str, mode: str = "all") -> Callable[..., A
|
|||||||
else current_user.has(*permissions)
|
else current_user.has(*permissions)
|
||||||
)
|
)
|
||||||
if not ok:
|
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 = [p for p in permissions if _verlangt_wiederanmeldung(p)]
|
||||||
|
if heikel and not _wiederanmeldung_frisch():
|
||||||
|
return _bitte_bestaetigen(heikel[0])
|
||||||
return view(*args, **kwargs)
|
return view(*args, **kwargs)
|
||||||
|
|
||||||
wrapper._required_permissions = permissions # type: ignore[attr-defined]
|
wrapper._required_permissions = permissions # type: ignore[attr-defined]
|
||||||
@@ -274,7 +333,7 @@ def reauth_required(view: Callable[..., Any]) -> Callable[..., Any]:
|
|||||||
return _deny("Anmeldung erforderlich.")
|
return _deny("Anmeldung erforderlich.")
|
||||||
if not reauth_is_fresh():
|
if not reauth_is_fresh():
|
||||||
session["_after_reauth"] = request.full_path if request.query_string else request.path
|
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 redirect(url_for("auth.reauth"))
|
||||||
return view(*args, **kwargs)
|
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():
|
def second_factor():
|
||||||
conn = db()
|
conn = db()
|
||||||
user_row = service.pending_totp_user(conn)
|
user_row = service.pending_totp_user(conn)
|
||||||
@@ -116,7 +116,7 @@ def logout_get():
|
|||||||
return redirect(url_for("auth.login"))
|
return redirect(url_for("auth.login"))
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/bestaetigen", methods=["GET", "POST"])
|
@bp.route("/confirm", methods=["GET", "POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def reauth():
|
def reauth():
|
||||||
target = _safe_next(session.get("_after_reauth"))
|
target = _safe_next(session.get("_after_reauth"))
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ def authenticate(
|
|||||||
if ip_verdict.blocked:
|
if ip_verdict.blocked:
|
||||||
return AuthOutcome(
|
return AuthOutcome(
|
||||||
"rate_limited",
|
"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,
|
retry_after=ip_verdict.retry_after_seconds,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -165,7 +165,7 @@ def authenticate(
|
|||||||
if user_verdict.blocked:
|
if user_verdict.blocked:
|
||||||
return AuthOutcome(
|
return AuthOutcome(
|
||||||
"rate_limited",
|
"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,
|
retry_after=user_verdict.retry_after_seconds,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -248,7 +248,7 @@ def _verify_credentials(
|
|||||||
return AuthOutcome(
|
return AuthOutcome(
|
||||||
"directory_unavailable",
|
"directory_unavailable",
|
||||||
"Der Verzeichnisdienst ist derzeit nicht erreichbar. "
|
"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:
|
if result.status != "ok" or result.profile is None:
|
||||||
return AuthOutcome("invalid", GENERIC_FAILURE)
|
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)
|
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(
|
def complete_login(
|
||||||
conn: sqlite3.Connection,
|
conn: sqlite3.Connection,
|
||||||
user_row: dict[str, Any],
|
user_row: dict[str, Any],
|
||||||
@@ -275,20 +293,25 @@ def complete_login(
|
|||||||
"""
|
"""
|
||||||
from ..security import csrf
|
from ..security import csrf
|
||||||
|
|
||||||
|
# Vor dem UPDATE lesen -- danach steht ueberall ein Zeitstempel.
|
||||||
|
erstanmeldung = _ist_erstanmeldung(user_row)
|
||||||
|
|
||||||
with core().database.transaction(conn):
|
with core().database.transaction(conn):
|
||||||
session_store.rotate(conn, session, reason="login") # type: ignore[arg-type]
|
session_store.rotate(conn, session, reason="login") # type: ignore[arg-type]
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE users SET last_login_at=? WHERE id=?", (_iso(_now()), user_row["id"])
|
"UPDATE users SET last_login_at=? WHERE id=?", (_iso(_now()), user_row["id"])
|
||||||
)
|
)
|
||||||
|
if erstanmeldung:
|
||||||
audit.log(
|
audit.log(
|
||||||
conn,
|
conn,
|
||||||
actor=str(user_row["username"]),
|
actor=str(user_row["username"]),
|
||||||
actor_id=int(user_row["id"]),
|
actor_id=int(user_row["id"]),
|
||||||
action="auth.login",
|
action="auth.first_login",
|
||||||
target=str(user_row["username"]),
|
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,
|
ip=ip,
|
||||||
request_id=getattr(g, "request_id", ""),
|
request_id=getattr(g, "request_id", ""),
|
||||||
|
severity="notice",
|
||||||
)
|
)
|
||||||
|
|
||||||
session.clear()
|
session.clear()
|
||||||
@@ -336,15 +359,11 @@ def logout(conn: sqlite3.Connection) -> None:
|
|||||||
current_app.logger.exception(
|
current_app.logger.exception(
|
||||||
"Abmeldehaken %r fehlgeschlagen", getattr(hook, "__name__", hook)
|
"Abmeldehaken %r fehlgeschlagen", getattr(hook, "__name__", hook)
|
||||||
)
|
)
|
||||||
with core().database.transaction(conn):
|
# Kein Protokolleintrag fuer das Abmelden: eine Abmeldung aendert
|
||||||
audit.log(
|
# nichts. Sie stand als "Info" in jeder zweiten Zeile und verdraengte
|
||||||
conn,
|
# die Eintraege, um die es geht. Wer wissen will, wer wann da war,
|
||||||
actor=user.username,
|
# liest die Sitzungsuebersicht; fehlgeschlagene Anmeldungen und
|
||||||
actor_id=user.id,
|
# Sperren stehen weiterhin jedes Mal im Protokoll.
|
||||||
action="auth.logout",
|
|
||||||
target=user.username,
|
|
||||||
ip=client_ip(),
|
|
||||||
)
|
|
||||||
session.destroy() if hasattr(session, "destroy") else session.clear() # type: ignore[attr-defined]
|
session.destroy() if hasattr(session, "destroy") else session.clear() # type: ignore[attr-defined]
|
||||||
g.user = ANONYMOUS
|
g.user = ANONYMOUS
|
||||||
|
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ def run_web_setup(app: Any, extension: Any, args: argparse.Namespace) -> int:
|
|||||||
port = getattr(args, flag, None)
|
port = getattr(args, flag, None)
|
||||||
if port:
|
if port:
|
||||||
if not 1 <= int(port) <= 65535:
|
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
|
return 2
|
||||||
values[key] = int(port)
|
values[key] = int(port)
|
||||||
|
|
||||||
@@ -93,11 +93,11 @@ def run_web_setup(app: Any, extension: Any, args: argparse.Namespace) -> int:
|
|||||||
|
|
||||||
# -- Zertifikat --------------------------------------------------- #
|
# -- Zertifikat --------------------------------------------------- #
|
||||||
config = _config_from_settings(extension, conn)
|
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 args.acme:
|
||||||
if sysops is None or not sysops.available:
|
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
|
return 2
|
||||||
cert = webserver.request_certificate(
|
cert = webserver.request_certificate(
|
||||||
sysops,
|
sysops,
|
||||||
@@ -118,7 +118,7 @@ def run_web_setup(app: Any, extension: Any, args: argparse.Namespace) -> int:
|
|||||||
|
|
||||||
if args.self_signed and not args.acme:
|
if args.self_signed and not args.acme:
|
||||||
if sysops is None or not sysops.available:
|
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
|
return 2
|
||||||
suggested = webserver.local_names()
|
suggested = webserver.local_names()
|
||||||
cert = webserver.create_self_signed(
|
cert = webserver.create_self_signed(
|
||||||
@@ -144,7 +144,7 @@ def run_web_setup(app: Any, extension: Any, args: argparse.Namespace) -> int:
|
|||||||
if args.https:
|
if args.https:
|
||||||
if not (config.cert_path and config.key_path):
|
if not (config.cert_path and config.key_path):
|
||||||
print(
|
print(
|
||||||
"HTTPS verlangt Zertifikat und Schluessel -- bitte --self-signed "
|
"HTTPS verlangt Zertifikat und Schlüssel – bitte --self-signed "
|
||||||
"oder --acme angeben.",
|
"oder --acme angeben.",
|
||||||
file=sys.stderr,
|
file=sys.stderr,
|
||||||
)
|
)
|
||||||
@@ -172,7 +172,7 @@ def run_web_setup(app: Any, extension: Any, args: argparse.Namespace) -> int:
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
if sysops is None or not sysops.available:
|
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
|
return 2
|
||||||
result = webserver.apply_config(sysops, rendered)
|
result = webserver.apply_config(sysops, rendered)
|
||||||
print(result["message"])
|
print(result["message"])
|
||||||
@@ -194,7 +194,7 @@ def add_web_setup(sub: Any, runner: Any) -> None:
|
|||||||
help="nginx und TLS einrichten (Port 80/443)",
|
help="nginx und TLS einrichten (Port 80/443)",
|
||||||
description=(
|
description=(
|
||||||
"Setzt die Webserver-Einstellungen und erzeugt daraus die nginx-Site. "
|
"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."
|
"bearbeitete Site-Datei gibt es in diesem Projekt bewusst nicht."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -207,20 +207,20 @@ def add_web_setup(sub: Any, runner: Any) -> None:
|
|||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--acme",
|
"--acme",
|
||||||
action="store_true",
|
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("--domain", help="Öffentlicher Name dieser Installation")
|
||||||
parser.add_argument("--acme-email", help="Kontaktadresse fuer Let's Encrypt")
|
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("--hsts", action="store_true", help="HSTS-Header setzen")
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--no-redirect",
|
"--no-redirect",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
help="Port 80 nicht auf HTTPS umleiten (Sonderfall)",
|
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("--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("--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(
|
parser.add_argument(
|
||||||
"--print-config", action="store_true", help="Nur die erzeugte Datei ausgeben"
|
"--print-config", action="store_true", help="Nur die erzeugte Datei ausgeben"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ class CoreConfig:
|
|||||||
"""Technischer Kurzname, z. B. ``tesm`` -- Namensraum fuer Pfade und Units."""
|
"""Technischer Kurzname, z. B. ``tesm`` -- Namensraum fuer Pfade und Units."""
|
||||||
|
|
||||||
site_key: str
|
site_key: str
|
||||||
"""Name *dieser Installation* auf dem Host, z. B. ``tesm-opus``.
|
"""Name *dieser Installation* auf dem Host, z. B. ``tesm-test``.
|
||||||
|
|
||||||
Weicht vom ``app_key`` ab, sobald neben der Hauptinstallation eine zweite
|
Weicht vom ``app_key`` ab, sobald neben der Hauptinstallation eine zweite
|
||||||
Instanz laeuft. Alles, was ausserhalb von ``/srv/<name>`` liegt und damit
|
Instanz laeuft. Alles, was ausserhalb von ``/srv/<name>`` liegt und damit
|
||||||
@@ -175,11 +175,11 @@ def load_core_config(
|
|||||||
|
|
||||||
bind = (_env(env_prefix, "BIND") or default_bind).strip()
|
bind = (_env(env_prefix, "BIND") or default_bind).strip()
|
||||||
if not re.fullmatch(r"[0-9A-Za-z_.\[\]:-]{1,80}:\d{1,5}", bind):
|
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()
|
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):
|
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(
|
return CoreConfig(
|
||||||
app_key=app_key,
|
app_key=app_key,
|
||||||
|
|||||||
@@ -141,11 +141,16 @@ CREATE TABLE IF NOT EXISTS service_accounts (
|
|||||||
updated_at TEXT NOT NULL
|
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 (
|
CREATE TABLE IF NOT EXISTS ldap_group_mappings (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
ad_group_dn TEXT NOT NULL UNIQUE,
|
ad_group_dn TEXT NOT NULL UNIQUE,
|
||||||
ad_group_name TEXT NOT NULL,
|
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
|
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);
|
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, ...] = (
|
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=2, name="trusted-issuer-keys", sql=V2_TRUSTED_KEYS),
|
||||||
Migration(version=3, name="jobs", sql=V3_JOBS),
|
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."""
|
"""Oeffnet eine neue Verbindung. Nicht aufrufen, solange eine Transaktion laeuft."""
|
||||||
if self._depth() > 0:
|
if self._depth() > 0:
|
||||||
raise NestedConnectionError(
|
raise NestedConnectionError(
|
||||||
"Es laeuft bereits eine Schreibtransaktion auf dieser Datenbank. "
|
"Es läuft bereits eine Schreibtransaktion auf dieser Datenbank. "
|
||||||
"Reiche die bestehende Verbindung durch, statt eine neue zu oeffnen "
|
"Reiche die bestehende Verbindung durch, statt eine neue zu öffnen "
|
||||||
"(sonst blockiert SQLite sich selbst)."
|
"(sonst blockiert SQLite sich selbst)."
|
||||||
)
|
)
|
||||||
return self._raw_connect()
|
return self._raw_connect()
|
||||||
@@ -119,6 +119,29 @@ class Database:
|
|||||||
finally:
|
finally:
|
||||||
conn.close()
|
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
|
# Transaktionen
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ def core(app: Flask | None = None) -> TesmCore:
|
|||||||
try:
|
try:
|
||||||
return target.extensions["tesm"] # type: ignore[return-value]
|
return target.extensions["tesm"] # type: ignore[return-value]
|
||||||
except KeyError as exc: # pragma: no cover - Programmierfehler
|
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:
|
def db() -> sqlite3.Connection:
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ def check_private_file(path: Path) -> list[str]:
|
|||||||
except OSError:
|
except OSError:
|
||||||
return problems
|
return problems
|
||||||
if mode & (stat.S_IRGRP | stat.S_IROTH | stat.S_IWGRP | stat.S_IWOTH):
|
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
|
return problems
|
||||||
|
|
||||||
|
|
||||||
@@ -110,11 +110,11 @@ class Keystore:
|
|||||||
created_at=str(entry.get("created_at", "")),
|
created_at=str(entry.get("created_at", "")),
|
||||||
)
|
)
|
||||||
if len(key.material) != _KEY_BYTES:
|
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._keys[key.key_id] = key
|
||||||
self._active = str(raw.get("active") or "")
|
self._active = str(raw.get("active") or "")
|
||||||
if self._active not in self._keys:
|
if self._active not in self._keys:
|
||||||
raise ValueError("Aktiver Schluessel fehlt im Schluesselbund")
|
raise ValueError("Aktiver Schlüssel fehlt im Schlüsselbund")
|
||||||
return
|
return
|
||||||
self._active = "1"
|
self._active = "1"
|
||||||
self._keys = {
|
self._keys = {
|
||||||
@@ -160,7 +160,7 @@ class Keystore:
|
|||||||
def drop_key(self, key_id: str) -> None:
|
def drop_key(self, key_id: str) -> None:
|
||||||
"""Entfernt einen alten Schluessel -- erst nach vollstaendiger Neuverschluesselung."""
|
"""Entfernt einen alten Schluessel -- erst nach vollstaendiger Neuverschluesselung."""
|
||||||
if key_id == self._active:
|
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._keys.pop(key_id, None)
|
||||||
self._persist()
|
self._persist()
|
||||||
|
|
||||||
@@ -183,14 +183,14 @@ class Keystore:
|
|||||||
raise DecryptionError(f"Unbekannte Chiffrat-Version: {prefix!r}")
|
raise DecryptionError(f"Unbekannte Chiffrat-Version: {prefix!r}")
|
||||||
key = self._keys.get(key_id)
|
key = self._keys.get(key_id)
|
||||||
if key is None:
|
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:
|
try:
|
||||||
plain = AESGCM(key.material).decrypt(
|
plain = AESGCM(key.material).decrypt(
|
||||||
_b64d(nonce_b64), _b64d(ct_b64), aad.encode("utf-8")
|
_b64d(nonce_b64), _b64d(ct_b64), aad.encode("utf-8")
|
||||||
)
|
)
|
||||||
except (InvalidTag, ValueError) as exc:
|
except (InvalidTag, ValueError) as exc:
|
||||||
raise DecryptionError(
|
raise DecryptionError(
|
||||||
"Chiffrat konnte nicht entschluesselt werden (falscher Schluessel, "
|
"Chiffrat konnte nicht entschlüsselt werden (falscher Schlüssel, "
|
||||||
"manipulierte Daten oder falscher Verwendungszweck)"
|
"manipulierte Daten oder falscher Verwendungszweck)"
|
||||||
) from exc
|
) from exc
|
||||||
return plain.decode("utf-8")
|
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 dataclasses import dataclass, field
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Callable
|
from typing import Any, Callable, Iterable
|
||||||
|
|
||||||
import tesm_licensing as lic
|
import tesm_licensing as lic
|
||||||
|
|
||||||
@@ -54,6 +54,21 @@ SETTING_HEARTBEAT_ERROR = "license_heartbeat_error"
|
|||||||
EXPIRY_HINT_DAYS = 90
|
EXPIRY_HINT_DAYS = 90
|
||||||
SETTING_EXPORT_GRACE = "license_export_grace"
|
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_INACTIVE = "inactive"
|
||||||
STATUS_PENDING = "pending"
|
STATUS_PENDING = "pending"
|
||||||
STATUS_ACTIVE = "active"
|
STATUS_ACTIVE = "active"
|
||||||
@@ -68,10 +83,10 @@ API_PATHS = {
|
|||||||
#: die einzige Wahrheit -- Templates lesen sie, Routen erzwingen sie.
|
#: die einzige Wahrheit -- Templates lesen sie, Routen erzwingen sie.
|
||||||
LICENSED_FEATURES: dict[str, str] = {
|
LICENSED_FEATURES: dict[str, str] = {
|
||||||
"directory": "Verzeichnisanmeldung (LDAP/AD) konfigurieren",
|
"directory": "Verzeichnisanmeldung (LDAP/AD) konfigurieren",
|
||||||
"nginx": "Webserver-Einstellungen aendern",
|
"nginx": "Webserver-Einstellungen ändern",
|
||||||
"export": "Daten exportieren",
|
"export": "Daten exportieren",
|
||||||
"trash_restore": "Papierkorb wiederherstellen und endgueltig loeschen",
|
"trash_restore": "Papierkorb wiederherstellen und endgültig löschen",
|
||||||
"monitor_interval": "Pruefintervall der Ueberwachung aendern",
|
"monitor_interval": "Prüfintervall der Überwachung ändern",
|
||||||
"auto_poe_restart": "Automatischer PoE-Neustart bei Ausfall",
|
"auto_poe_restart": "Automatischer PoE-Neustart bei Ausfall",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,7 +100,7 @@ def describe_transport_error(exc: BaseException, endpoint: str) -> str:
|
|||||||
"""
|
"""
|
||||||
text = str(exc)
|
text = str(exc)
|
||||||
if "CERTIFICATE_VERIFY_FAILED" in text or "certificate verify failed" in text:
|
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:
|
if "self-signed" in text or "self signed" in text:
|
||||||
detail = "Der Lizenzserver benutzt ein selbstsigniertes Zertifikat"
|
detail = "Der Lizenzserver benutzt ein selbstsigniertes Zertifikat"
|
||||||
return (
|
return (
|
||||||
@@ -94,7 +109,7 @@ def describe_transport_error(exc: BaseException, endpoint: str) -> str:
|
|||||||
"einer anerkannten Stelle."
|
"einer anerkannten Stelle."
|
||||||
)
|
)
|
||||||
if "Name or service not known" in text or "nodename nor servname" in text:
|
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:
|
if "Connection refused" in text:
|
||||||
return f"{endpoint} nimmt keine Verbindung an."
|
return f"{endpoint} nimmt keine Verbindung an."
|
||||||
if "timed out" in text or "timeout" in text.lower():
|
if "timed out" in text or "timeout" in text.lower():
|
||||||
@@ -121,6 +136,8 @@ class LicenseState:
|
|||||||
last_heartbeat_at: str = ""
|
last_heartbeat_at: str = ""
|
||||||
heartbeat_error: str = ""
|
heartbeat_error: str = ""
|
||||||
pending_action: str = ""
|
pending_action: str = ""
|
||||||
|
#: Vor Ort abgeschaltet -- unabhaengig davon, was die Lizenz erlaubt.
|
||||||
|
modules_off: frozenset[str] = frozenset()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def role(self) -> str:
|
def role(self) -> str:
|
||||||
@@ -128,8 +145,14 @@ class LicenseState:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def modules(self) -> frozenset[str]:
|
def modules(self) -> frozenset[str]:
|
||||||
|
"""Was die Lizenz freischaltet -- ohne Ruecksicht auf den Schalter."""
|
||||||
return self.status.modules if self.status else frozenset()
|
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
|
@property
|
||||||
def customer_name(self) -> str:
|
def customer_name(self) -> str:
|
||||||
return str((self.status.customer if self.status else {}).get("name") or "")
|
return str((self.status.customer if self.status else {}).get("name") or "")
|
||||||
@@ -179,6 +202,7 @@ class LicenseManager:
|
|||||||
"""Liest Lizenzdatei und Status neu ein."""
|
"""Liest Lizenzdatei und Status neu ein."""
|
||||||
with self._lock:
|
with self._lock:
|
||||||
state = LicenseState()
|
state = LicenseState()
|
||||||
|
state.modules_off = self.abgeschaltete_module(conn)
|
||||||
state.last_heartbeat_at = str(self.settings.get(conn, SETTING_LAST_HEARTBEAT) or "")
|
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 "")
|
state.heartbeat_error = str(self.settings.get(conn, SETTING_HEARTBEAT_ERROR) or "")
|
||||||
pending = self._pending(conn)
|
pending = self._pending(conn)
|
||||||
@@ -239,9 +263,51 @@ class LicenseManager:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def module_active(self, name: str) -> bool:
|
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
|
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:
|
def feature_allowed(self, feature: str) -> bool:
|
||||||
"""Lizenzpflichtige Einzelfunktion.
|
"""Lizenzpflichtige Einzelfunktion.
|
||||||
|
|
||||||
@@ -257,8 +323,8 @@ class LicenseManager:
|
|||||||
def require(self, feature: str) -> None:
|
def require(self, feature: str) -> None:
|
||||||
if not self.feature_allowed(feature):
|
if not self.feature_allowed(feature):
|
||||||
raise LicenseError(
|
raise LicenseError(
|
||||||
f"{LICENSED_FEATURES.get(feature, feature)} ist ohne gueltige, "
|
f"{LICENSED_FEATURES.get(feature, feature)} ist ohne gültige, "
|
||||||
"aktivierte Lizenz nicht moeglich."
|
"aktivierte Lizenz nicht möglich."
|
||||||
)
|
)
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
@@ -284,7 +350,7 @@ class LicenseManager:
|
|||||||
try:
|
try:
|
||||||
payload = json.loads(text)
|
payload = json.loads(text)
|
||||||
except ValueError as exc:
|
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:
|
try:
|
||||||
document, client_key = lic.split_bundle(payload)
|
document, client_key = lic.split_bundle(payload)
|
||||||
@@ -294,20 +360,20 @@ class LicenseManager:
|
|||||||
try:
|
try:
|
||||||
lic.verify_license(document) # innere Konsistenz
|
lic.verify_license(document) # innere Konsistenz
|
||||||
except lic.LicensingError as exc:
|
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 "")
|
master_pubkey = str(document.get("master_pubkey") or "")
|
||||||
known = self.trusted_keys(conn)
|
known = self.trusted_keys(conn)
|
||||||
if known and master_pubkey not in set(known) and not allow_new_issuer:
|
if known and master_pubkey not in set(known) and not allow_new_issuer:
|
||||||
raise LicenseError(
|
raise LicenseError(
|
||||||
"Diese Lizenz wurde von einem anderen Lizenzserver ausgestellt "
|
"Diese Lizenz wurde von einem anderen Lizenzserver ausgestellt "
|
||||||
f"(Schluessel {lic.key_id(master_pubkey)}). Ein Administrator muss den "
|
f"(Schlüssel {lic.key_id(master_pubkey)}). Ein Administrator muss den "
|
||||||
"Wechsel des Ausstellers ausdruecklich bestaetigen."
|
"Wechsel des Ausstellers ausdrücklich bestätigen."
|
||||||
)
|
)
|
||||||
|
|
||||||
if not client_key and not self.key_path.is_file():
|
if not client_key and not self.key_path.is_file():
|
||||||
raise LicenseError(
|
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."
|
"Lizenz-Bundle des Lizenzservers verwenden."
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -332,7 +398,7 @@ class LicenseManager:
|
|||||||
actor=actor,
|
actor=actor,
|
||||||
action="license.issuer_pinned",
|
action="license.issuer_pinned",
|
||||||
target=lic.key_id(master_pubkey),
|
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",
|
severity="notice",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -397,11 +463,11 @@ class LicenseManager:
|
|||||||
self, action: str, *, hostname: str = "", report: dict[str, Any] | None = None
|
self, action: str, *, hostname: str = "", report: dict[str, Any] | None = None
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
if not self._state.valid:
|
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()
|
key = self.client_key()
|
||||||
if not key:
|
if not key:
|
||||||
raise LicenseError(
|
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(
|
return lic.build_client_request(
|
||||||
action, self._state.document, key, hostname=hostname, report=report
|
action, self._state.document, key, hostname=hostname, report=report
|
||||||
@@ -442,8 +508,8 @@ class LicenseManager:
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
reason = (
|
reason = (
|
||||||
"In dieser Lizenz ist kein Lizenzserver hinterlegt -- sie kann sich nur "
|
"In dieser Lizenz ist kein Lizenzserver hinterlegt – sie kann sich nur "
|
||||||
"ueber den Offline-Weg aktivieren."
|
"über den Offline-Weg aktivieren."
|
||||||
)
|
)
|
||||||
|
|
||||||
# Offline-Weg: Anfrage merken -- **mit** dem Code. Zwischen Anfrage und
|
# Offline-Weg: Anfrage merken -- **mit** dem Code. Zwischen Anfrage und
|
||||||
@@ -475,7 +541,7 @@ class LicenseManager:
|
|||||||
"code": code,
|
"code": code,
|
||||||
"message": (
|
"message": (
|
||||||
(reason + " " if reason else "")
|
(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."
|
"Ihren Ansprechpartner und tragen Sie die Antwort hier ein."
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
@@ -527,7 +593,7 @@ class LicenseManager:
|
|||||||
current_seq=current_seq,
|
current_seq=current_seq,
|
||||||
)
|
)
|
||||||
except lic.LicensingError as exc:
|
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 "")
|
status = str(response.get("status") or "")
|
||||||
detail = str(response.get("detail") or "")
|
detail = str(response.get("detail") or "")
|
||||||
@@ -597,7 +663,7 @@ class LicenseManager:
|
|||||||
actor=actor,
|
actor=actor,
|
||||||
)
|
)
|
||||||
self.settings.set(conn, SETTING_HEARTBEAT_ERROR, "", 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)
|
self.settings.set(conn, SETTING_PENDING, "", actor=actor)
|
||||||
# Ein erfolgreicher Heartbeat laeuft alle paar Minuten und ist kein
|
# Ein erfolgreicher Heartbeat laeuft alle paar Minuten und ist kein
|
||||||
@@ -648,7 +714,7 @@ class LicenseManager:
|
|||||||
url: str, payload: dict[str, Any], *, timeout: int, ca_pem: str = ""
|
url: str, payload: dict[str, Any], *, timeout: int, ca_pem: str = ""
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
if not url.startswith(("https://", "http://")):
|
if not url.startswith(("https://", "http://")):
|
||||||
raise ValueError("Ungueltiger Endpunkt")
|
raise ValueError("Ungültiger Endpunkt")
|
||||||
data = json.dumps(payload).encode("utf-8")
|
data = json.dumps(payload).encode("utf-8")
|
||||||
request = urllib.request.Request( # noqa: S310 - Schema oben geprueft
|
request = urllib.request.Request( # noqa: S310 - Schema oben geprueft
|
||||||
url,
|
url,
|
||||||
@@ -748,7 +814,7 @@ class LicenseManager:
|
|||||||
}
|
}
|
||||||
if not state.valid:
|
if not state.valid:
|
||||||
return {
|
return {
|
||||||
"label": "Lizenz ungueltig",
|
"label": "Lizenz ungültig",
|
||||||
"css_class": "status-pill--danger",
|
"css_class": "status-pill--danger",
|
||||||
"dot": "1",
|
"dot": "1",
|
||||||
"dot_class": "status-pill__dot--pulse",
|
"dot_class": "status-pill__dot--pulse",
|
||||||
@@ -773,29 +839,29 @@ class LicenseManager:
|
|||||||
"dot": "1",
|
"dot": "1",
|
||||||
"dot_class": "status-pill__dot--pulse",
|
"dot_class": "status-pill__dot--pulse",
|
||||||
"title": (
|
"title": (
|
||||||
"Die Kulanzfrist laeuft noch, Funktionen bleiben vorerst verfuegbar."
|
"Die Kulanzfrist läuft noch, Funktionen bleiben vorerst verfügbar."
|
||||||
if status.grace_active
|
if status.grace_active
|
||||||
else "Lizenzpflichtige Funktionen sind abgeschaltet."
|
else "Lizenzpflichtige Funktionen sind abgeschaltet."
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
if status.expiring_soon:
|
if status.expiring_soon:
|
||||||
return {
|
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",
|
"css_class": "status-pill--warning",
|
||||||
"dot": "1",
|
"dot": "1",
|
||||||
"dot_class": "status-pill__dot--pulse",
|
"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
|
# Zwischen der Warnschwelle und 90 Tagen: gruen, rein als Hinweis. Wer
|
||||||
# eine Verlaengerung im Voraus plant, will das Datum sehen, ohne dass es
|
# eine Verlaengerung im Voraus plant, will das Datum sehen, ohne dass es
|
||||||
# nach einem Problem aussieht.
|
# nach einem Problem aussieht.
|
||||||
if status.days_left is not None and 0 < status.days_left <= EXPIRY_HINT_DAYS:
|
if status.days_left is not None and 0 < status.days_left <= EXPIRY_HINT_DAYS:
|
||||||
return {
|
return {
|
||||||
"label": f"Lizenz laeuft in {status.days_left} Tagen ab",
|
"label": f"Lizenz läuft in {status.days_left} Tagen ab",
|
||||||
"css_class": "",
|
"css_class": "",
|
||||||
"dot": "1",
|
"dot": "1",
|
||||||
"dot_class": "",
|
"dot_class": "",
|
||||||
"title": "Nur ein Hinweis -- es ist nichts eingeschraenkt.",
|
"title": "Nur ein Hinweis – es ist nichts eingeschränkt.",
|
||||||
}
|
}
|
||||||
if state.last_heartbeat_at:
|
if state.last_heartbeat_at:
|
||||||
try:
|
try:
|
||||||
@@ -809,6 +875,6 @@ class LicenseManager:
|
|||||||
"css_class": "status-pill--warning",
|
"css_class": "status-pill--warning",
|
||||||
"dot": "1",
|
"dot": "1",
|
||||||
"dot_class": "",
|
"dot_class": "",
|
||||||
"title": "Rein informativ -- es wird nichts abgeschaltet.",
|
"title": "Rein informativ – es wird nichts abgeschaltet.",
|
||||||
}
|
}
|
||||||
return None
|
return None
|
||||||
@@ -94,7 +94,7 @@ def init_app(app: Flask, *, log_dir: Path, debug: bool, json_logs: bool = False)
|
|||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
except OSError as exc: # pragma: no cover - z. B. fehlende Rechte
|
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.
|
# Zugriffsprotokoll separat: eine Zeile pro Anfrage, ohne Stacktraces.
|
||||||
access = logging.getLogger(ACCESS_LOGGER)
|
access = logging.getLogger(ACCESS_LOGGER)
|
||||||
|
|||||||
@@ -50,6 +50,12 @@ class Migration:
|
|||||||
sql: str = ""
|
sql: str = ""
|
||||||
fn: MigrationFn | None = None
|
fn: MigrationFn | None = None
|
||||||
allow_integrity_error: bool = False
|
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=())
|
tags: tuple[str, ...] = field(default=())
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -175,10 +181,26 @@ class MigrationRunner:
|
|||||||
record = known.get(migration.version)
|
record = known.get(migration.version)
|
||||||
if record is not None:
|
if record is not None:
|
||||||
if record["checksum"] != migration.checksum:
|
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(
|
logger.warning(
|
||||||
"Migration %s/%s (%s) wurde nachtraeglich geaendert "
|
"Migration %s/%s (%s) wurde nachträglich geändert "
|
||||||
"(Pruefsumme %s != %s). Bereits angewendete Migrationen werden "
|
"(Prüfsumme %s != %s). Bereits angewendete Migrationen werden "
|
||||||
"nicht erneut ausgefuehrt -- neue Aenderungen brauchen eine neue "
|
"nicht erneut ausgeführt – neue Änderungen brauchen eine neue "
|
||||||
"Versionsnummer.",
|
"Versionsnummer.",
|
||||||
component,
|
component,
|
||||||
migration.version,
|
migration.version,
|
||||||
@@ -210,7 +232,7 @@ class MigrationRunner:
|
|||||||
raise
|
raise
|
||||||
logger.error(
|
logger.error(
|
||||||
"Migration %s/%s (%s) konnte wegen bestehender Altdaten nicht "
|
"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.",
|
"manuell bereinigt werden.",
|
||||||
component,
|
component,
|
||||||
migration.version,
|
migration.version,
|
||||||
|
|||||||
@@ -16,10 +16,10 @@ A = Action
|
|||||||
USERS = Resource(
|
USERS = Resource(
|
||||||
key="users",
|
key="users",
|
||||||
label="Benutzer",
|
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",
|
hint="Konten anlegen, sperren, Gruppen zuordnen",
|
||||||
action_hints={
|
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).",
|
A.DELETE: "Konto in den Papierkorb legen (Verzeichniskonten sofort entfernen).",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -27,35 +27,46 @@ USERS = Resource(
|
|||||||
GROUPS = Resource(
|
GROUPS = Resource(
|
||||||
key="groups",
|
key="groups",
|
||||||
label="Gruppen und Rechte",
|
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",
|
hint="Rechtematrix pflegen",
|
||||||
action_hints={
|
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(
|
SETTINGS_SYSTEM = Resource(
|
||||||
key="settings_system",
|
key="settings_system",
|
||||||
label="Systemeinstellungen",
|
label="Systemeinstellungen",
|
||||||
actions=(A.VIEW, A.EDIT, A.EXECUTE),
|
actions=(A.READ, A.WRITE, A.EXECUTE),
|
||||||
hint="Hostname, Zeitzone, Protokollrotation, Navigation",
|
hint="Hostname, Zeitzone, Protokollrotation, Sitzungsdauer",
|
||||||
action_hints={A.EXECUTE: "Systemaktionen ausloesen (Dienste, Netzwerk uebernehmen)."},
|
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(
|
SETTINGS_LICENSE = Resource(
|
||||||
key="settings_license",
|
key="settings_license",
|
||||||
label="Lizenz",
|
label="Lizenz",
|
||||||
actions=(A.VIEW, A.EDIT),
|
actions=(A.READ, A.WRITE),
|
||||||
hint="Lizenzdatei einspielen, aktivieren, deaktivieren",
|
hint="Lizenzdatei einspielen, aktivieren, deaktivieren",
|
||||||
)
|
)
|
||||||
|
|
||||||
SETTINGS_DIRECTORY = Resource(
|
SETTINGS_DIRECTORY = Resource(
|
||||||
key="settings_directory",
|
key="settings_directory",
|
||||||
label="Verzeichnisdienst (LDAP/AD)",
|
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",
|
hint="Anbindung und Gruppen-Zuordnung",
|
||||||
action_hints={
|
action_hints={
|
||||||
A.EXECUTE: "Verbindungstest ausfuehren.",
|
A.EXECUTE: "Verbindungstest ausführen.",
|
||||||
A.SECRETS: "Bind-Passwort des Dienstkontos setzen.",
|
A.SECRETS: "Bind-Passwort des Dienstkontos setzen.",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -63,67 +74,78 @@ SETTINGS_DIRECTORY = Resource(
|
|||||||
SETTINGS_WEB = Resource(
|
SETTINGS_WEB = Resource(
|
||||||
key="settings_web",
|
key="settings_web",
|
||||||
label="Webserver und TLS",
|
label="Webserver und TLS",
|
||||||
actions=(A.VIEW, A.EDIT, A.EXECUTE),
|
actions=(A.READ, A.WRITE, A.EXECUTE),
|
||||||
hint="Domain, Zertifikat, HSTS",
|
hint="Domain, Zertifikat, HSTS",
|
||||||
action_hints={A.EXECUTE: "Konfiguration uebernehmen, Zertifikat anfordern."},
|
action_hints={A.EXECUTE: "Konfiguration übernehmen, Zertifikat anfordern."},
|
||||||
)
|
)
|
||||||
|
|
||||||
SETTINGS_NETWORK = Resource(
|
SETTINGS_NETWORK = Resource(
|
||||||
key="settings_network",
|
key="settings_network",
|
||||||
label="Netzwerkeinstellungen",
|
label="Netzwerkeinstellungen",
|
||||||
actions=(A.VIEW, A.EDIT),
|
actions=(A.READ, A.WRITE),
|
||||||
hint="IP-Adresse, Gateway, DNS des Hosts",
|
hint="IP-Adresse, Gateway, DNS des Hosts",
|
||||||
)
|
)
|
||||||
|
|
||||||
BACKUP = Resource(
|
#: Sichern und Einspielen sind zwei Zeilen, weil es zwei Vorgaenge mit sehr
|
||||||
key="backup",
|
#: verschiedenen Folgen sind: eine Sicherung zu erzeugen aendert nichts, eine
|
||||||
label="Sicherung und Uebertragung",
|
#: einzuspielen aendert alles. Sie liefen unter einem Recht, und wer eine
|
||||||
actions=(A.VIEW, A.EXECUTE, A.EXPORT, A.SECRETS),
|
#: Sicherung ziehen durfte, durfte damit auch eine fremde einspielen.
|
||||||
hint="Verschluesselter Export und Import",
|
BACKUP_EXPORT = Resource(
|
||||||
|
key="backup_export",
|
||||||
|
label="Sicherung erstellen",
|
||||||
|
actions=(A.READ, A.EXPORT, A.SECRETS),
|
||||||
|
hint="Verschlüsselter Export des Datenstands",
|
||||||
action_hints={
|
action_hints={
|
||||||
A.EXECUTE: "Eine Sicherung einspielen.",
|
|
||||||
A.EXPORT: "Sicherung erzeugen und herunterladen.",
|
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(
|
TRASH = Resource(
|
||||||
key="trash",
|
key="trash",
|
||||||
label="Papierkorb",
|
label="Papierkorb",
|
||||||
actions=(A.VIEW, A.EDIT, A.DELETE),
|
actions=(A.READ, A.WRITE, A.DELETE),
|
||||||
hint="Geloeschtes wiederherstellen oder endgueltig entfernen",
|
hint="Gelöschtes wiederherstellen oder endgültig entfernen",
|
||||||
action_hints={
|
action_hints={
|
||||||
A.EDIT: "Eintraege wiederherstellen.",
|
A.WRITE: "Einträge wiederherstellen.",
|
||||||
A.DELETE: "Eintraege endgueltig entfernen.",
|
A.DELETE: "Einträge endgültig entfernen.",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
DIAGNOSTICS = Resource(
|
DIAGNOSTICS = Resource(
|
||||||
key="diagnostics",
|
key="diagnostics",
|
||||||
label="Diagnose",
|
label="Diagnose",
|
||||||
actions=(A.VIEW,),
|
actions=(A.READ,),
|
||||||
hint="Schemastand, Protokollintegritaet, Schluesselrechte",
|
hint="Schemastand, Protokollintegrität, Schlüsselrechte",
|
||||||
)
|
)
|
||||||
|
|
||||||
LOGS_LIVE = Resource(
|
LOGS_LIVE = Resource(
|
||||||
key="logs_live",
|
key="logs_live",
|
||||||
label="Laufendes Protokoll",
|
label="Laufendes Protokoll",
|
||||||
actions=(A.VIEW,),
|
actions=(A.READ,),
|
||||||
)
|
)
|
||||||
|
|
||||||
LOGS_HISTORY = Resource(
|
LOGS_HISTORY = Resource(
|
||||||
key="logs_history",
|
key="logs_history",
|
||||||
label="Protokollverlauf",
|
label="Protokollverlauf",
|
||||||
actions=(A.VIEW, A.DELETE, A.EXPORT),
|
actions=(A.READ, A.DELETE, A.EXPORT),
|
||||||
hint="Rotierte und archivierte Protokolle",
|
hint="Rotierte und archivierte Protokolle",
|
||||||
action_hints={A.DELETE: "Archivierte Tage vom Server entfernen."},
|
action_hints={A.DELETE: "Archivierte Tage vom Server entfernen."},
|
||||||
)
|
)
|
||||||
|
|
||||||
LOGS_AUDIT = Resource(
|
LOGS_AUDIT = Resource(
|
||||||
key="logs_audit",
|
key="logs_audit",
|
||||||
label="Aenderungsprotokoll",
|
label="Änderungsprotokoll",
|
||||||
actions=(A.VIEW, A.EXPORT),
|
actions=(A.READ, A.EXPORT),
|
||||||
hint="Wer hat wann was geaendert",
|
hint="Wer hat wann was geändert",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -139,11 +161,13 @@ def administration_area(*extra: Resource) -> Area:
|
|||||||
USERS,
|
USERS,
|
||||||
GROUPS,
|
GROUPS,
|
||||||
SETTINGS_SYSTEM,
|
SETTINGS_SYSTEM,
|
||||||
|
SETTINGS_NAV,
|
||||||
SETTINGS_LICENSE,
|
SETTINGS_LICENSE,
|
||||||
SETTINGS_DIRECTORY,
|
SETTINGS_DIRECTORY,
|
||||||
SETTINGS_WEB,
|
SETTINGS_WEB,
|
||||||
SETTINGS_NETWORK,
|
SETTINGS_NETWORK,
|
||||||
BACKUP,
|
BACKUP_EXPORT,
|
||||||
|
BACKUP_IMPORT,
|
||||||
TRASH,
|
TRASH,
|
||||||
DIAGNOSTICS,
|
DIAGNOSTICS,
|
||||||
*extra,
|
*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.
|
"""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
|
das Loeschen **mit abdeckte** und ``D``/``X`` je nach Zeile etwas voellig anderes
|
||||||
bedeuteten (Dashboard-Neustart bzw. Export). Das war kompakt, aber:
|
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.
|
rendert nur die Spalten, die im jeweiligen Bereich wirklich vorkommen.
|
||||||
|
|
||||||
Beibehalten wird der bewaehrte **Kill-Switch**: jeder Bereich hat ein
|
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
|
Bereichs -- unabhaengig davon, was einzeln angehakt ist. Das erlaubt es, einer
|
||||||
Gruppe einen ganzen Bereich mit einem Klick zu entziehen, ohne die fein
|
Gruppe einen ganzen Bereich mit einem Klick zu entziehen, ohne die fein
|
||||||
eingestellten Einzelrechte zu verlieren.
|
eingestellten Einzelrechte zu verlieren.
|
||||||
@@ -29,38 +37,68 @@ from typing import Iterable, Mapping, Sequence
|
|||||||
|
|
||||||
|
|
||||||
class Action(str, Enum):
|
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"
|
CREATE = "create"
|
||||||
EDIT = "edit"
|
WRITE = "write"
|
||||||
DELETE = "delete"
|
DELETE = "delete"
|
||||||
EXECUTE = "execute"
|
EXECUTE = "execute"
|
||||||
EXPORT = "export"
|
EXPORT = "export"
|
||||||
SECRETS = "secrets"
|
SECRETS = "secrets"
|
||||||
|
|
||||||
|
|
||||||
#: Kuerzel und Erklaerung je Aktion -- Kopfzeile der Rechtematrix.
|
#: Kuerzel, englischer Name, deutsche Beschriftung, Erklaerung.
|
||||||
ACTION_META: dict[Action, tuple[str, str, str]] = {
|
#:
|
||||||
Action.VIEW: ("R", "Ansehen", "Datensaetze und Seiten dieses Bereichs lesen."),
|
#: Beides hat seinen Ort: die Matrix fuehrt die Spalte unter ihrem englischen
|
||||||
Action.CREATE: ("C", "Anlegen", "Neue Datensaetze erstellen."),
|
#: Namen (das ist das Vokabular, und es steht so auch im Schluessel), die
|
||||||
Action.EDIT: ("U", "Bearbeiten", "Bestehende Datensaetze aendern -- ohne Loeschen."),
|
#: Kleinschrift darunter und jeder Satz in der Oberflaeche bleiben deutsch.
|
||||||
Action.DELETE: ("D", "Loeschen", "Datensaetze loeschen bzw. endgueltig entfernen."),
|
ACTION_META: dict[Action, tuple[str, str, str, str]] = {
|
||||||
Action.EXECUTE: ("X", "Ausfuehren", "Aktionen ausloesen (Neustart, Dienste, Jobs, Import)."),
|
Action.READ: ("R", "Read", "ansehen", "Datensätze und Seiten dieses Bereichs ansehen."),
|
||||||
Action.EXPORT: ("E", "Exportieren", "Daten aus dem System heraustragen (Datei-Download)."),
|
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: (
|
Action.SECRETS: (
|
||||||
"S",
|
"S",
|
||||||
"Geheimnisse",
|
"Secrets",
|
||||||
"Passwoerter und Schluessel im Klartext sehen oder mitnehmen.",
|
"Klartext",
|
||||||
|
"Passwörter und Schlüssel im Klartext sehen oder mitnehmen.",
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
#: Aktionen, die zusaetzlich eine frische Passworteingabe verlangen (sudo-Modus).
|
#: Aktionen, die zusaetzlich eine frische Passworteingabe verlangen (sudo-Modus).
|
||||||
SENSITIVE_ACTIONS: frozenset[Action] = frozenset({Action.SECRETS, Action.EXPORT})
|
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, ...]] = {
|
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 = ""
|
hint: str = ""
|
||||||
#: Abweichende Erklaerung einzelner Aktionen (z. B. was "Ausfuehren" hier meint).
|
#: Abweichende Erklaerung einzelner Aktionen (z. B. was "Ausfuehren" hier meint).
|
||||||
action_hints: Mapping[Action, str] = field(default_factory=dict)
|
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:
|
def permission(self, action: Action) -> str:
|
||||||
return f"{self.key}.{action.value}"
|
return f"{self.key}.{action.value}"
|
||||||
@@ -96,8 +160,9 @@ class Area:
|
|||||||
hint: str = ""
|
hint: str = ""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def view_key(self) -> str:
|
def read_key(self) -> str:
|
||||||
return f"{self.key}.view"
|
"""Der Kill-Switch des Bereichs. Ohne ihn greift keine Zeile darunter."""
|
||||||
|
return f"{self.key}.read"
|
||||||
|
|
||||||
def columns(self) -> tuple[Action, ...]:
|
def columns(self) -> tuple[Action, ...]:
|
||||||
"""Nur die Aktionen, die in diesem Bereich tatsaechlich vorkommen."""
|
"""Nur die Aktionen, die in diesem Bereich tatsaechlich vorkommen."""
|
||||||
@@ -123,45 +188,92 @@ class PermissionTree:
|
|||||||
self._parent: dict[str, str] = {}
|
self._parent: dict[str, str] = {}
|
||||||
self._all: list[str] = []
|
self._all: list[str] = []
|
||||||
self._resources: dict[str, tuple[Area, Resource]] = {}
|
self._resources: dict[str, tuple[Area, Resource]] = {}
|
||||||
|
self._verwaltung: frozenset[str] | None = None
|
||||||
|
|
||||||
seen_keys: set[str] = set()
|
seen_keys: set[str] = set()
|
||||||
for area in self.areas:
|
for area in self.areas:
|
||||||
if area.key in seen_keys:
|
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)
|
seen_keys.add(area.key)
|
||||||
self._by_key[area.key] = area
|
self._by_key[area.key] = area
|
||||||
self._all.append(area.view_key)
|
self._all.append(area.read_key)
|
||||||
for resource in area.resources:
|
for resource in area.resources:
|
||||||
if resource.key in seen_keys:
|
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)
|
seen_keys.add(resource.key)
|
||||||
if Action.VIEW not in resource.actions:
|
if Action.READ not in resource.actions:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Ressource {resource.key} muss die Aktion 'view' anbieten -- "
|
f"Ressource {resource.key} muss die Aktion 'read' anbieten – "
|
||||||
"sie ist Voraussetzung fuer jede andere Aktion."
|
"sie ist Voraussetzung für jede andere Aktion."
|
||||||
)
|
)
|
||||||
self._resources[resource.key] = (area, resource)
|
self._resources[resource.key] = (area, resource)
|
||||||
for permission in resource.permissions():
|
for permission in resource.permissions():
|
||||||
self._all.append(permission)
|
self._all.append(permission)
|
||||||
self._parent[permission] = area.view_key
|
self._parent[permission] = area.read_key
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
def all_keys(self) -> tuple[str, ...]:
|
def all_keys(self) -> tuple[str, ...]:
|
||||||
return tuple(self._all)
|
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:
|
def area(self, key: str) -> Area | None:
|
||||||
return self._by_key.get(key)
|
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)
|
return self._parent.get(permission)
|
||||||
|
|
||||||
def resource(self, key: str) -> tuple[Area, Resource] | None:
|
def resource(self, key: str) -> tuple[Area, Resource] | None:
|
||||||
return self._resources.get(key)
|
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:
|
def label_for(self, permission: str) -> str:
|
||||||
"""Menschenlesbare Beschreibung eines Rechteschluessels -- fuer Audit-Logs."""
|
"""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"
|
return f"Bereich {self._by_key[permission[:-5]].label}: Zugang"
|
||||||
resource_key, _, action_value = permission.rpartition(".")
|
resource_key, _, action_value = permission.rpartition(".")
|
||||||
entry = self._resources.get(resource_key)
|
entry = self._resources.get(resource_key)
|
||||||
@@ -172,7 +284,10 @@ class PermissionTree:
|
|||||||
action = Action(action_value)
|
action = Action(action_value)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return permission
|
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]:
|
def sanitize(self, permissions: Iterable[str]) -> list[str]:
|
||||||
"""Wirft unbekannte Rechte weg und ergaenzt implizierte.
|
"""Wirft unbekannte Rechte weg und ergaenzt implizierte.
|
||||||
@@ -180,7 +295,7 @@ class PermissionTree:
|
|||||||
Regeln:
|
Regeln:
|
||||||
* Ein Recht, das nicht im Baum steht, wird verworfen (kein stilles
|
* Ein Recht, das nicht im Baum steht, wird verworfen (kein stilles
|
||||||
Weiterschleppen von Altrechten nach einem Refactoring).
|
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
|
* Existiert irgendein Recht eines Bereichs, wird der Kill-Switch des
|
||||||
Bereichs mitgesetzt -- sonst waere die Auswahl wirkungslos und der
|
Bereichs mitgesetzt -- sonst waere die Auswahl wirkungslos und der
|
||||||
Bearbeiter wuerde sich wundern.
|
Bearbeiter wuerde sich wundern.
|
||||||
@@ -200,13 +315,13 @@ class PermissionTree:
|
|||||||
result.add(candidate)
|
result.add(candidate)
|
||||||
for area in self.areas:
|
for area in self.areas:
|
||||||
if any(p in result for resource in area.resources for p in resource.permissions()):
|
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)
|
return sorted(result)
|
||||||
|
|
||||||
def effective(self, permissions: Iterable[str]) -> set[str]:
|
def effective(self, permissions: Iterable[str]) -> set[str]:
|
||||||
"""Rechte, die nach Anwendung der Kill-Switches tatsaechlich greifen."""
|
"""Rechte, die nach Anwendung der Kill-Switches tatsaechlich greifen."""
|
||||||
granted = set(permissions)
|
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 {
|
return {
|
||||||
key
|
key
|
||||||
for key in granted
|
for key in granted
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ from typing import Any, Iterable, Sequence
|
|||||||
|
|
||||||
from ..db import Database
|
from ..db import Database
|
||||||
from ..security import passwords
|
from ..security import passwords
|
||||||
from .model import Action, PermissionTree, RolePreset
|
from .model import ACTION_META, Action, PermissionTree, RolePreset
|
||||||
|
|
||||||
|
|
||||||
class PermissionDenied(Exception):
|
class PermissionDenied(Exception):
|
||||||
@@ -124,7 +124,20 @@ def group_permissions(conn: sqlite3.Connection, group_id: int) -> list[str]:
|
|||||||
def guard_no_amplification(
|
def guard_no_amplification(
|
||||||
actor: Any, tree: PermissionTree, added: Iterable[str], *, what: str = "Recht"
|
actor: Any, tree: PermissionTree, added: Iterable[str], *, what: str = "Recht"
|
||||||
) -> None:
|
) -> 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):
|
if getattr(actor, "is_admin", False):
|
||||||
return
|
return
|
||||||
missing = sorted(p for p in added if not actor.has(p))
|
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])
|
labels = ", ".join(tree.label_for(p) for p in missing[:6])
|
||||||
more = "" if len(missing) <= 6 else f" (und {len(missing) - 6} weitere)"
|
more = "" if len(missing) <= 6 else f" (und {len(missing) - 6} weitere)"
|
||||||
raise PermissionDenied(
|
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"]:
|
if existing["deleted_at"]:
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
f"Eine Gruppe namens {clean_name!r} liegt im Papierkorb. "
|
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.")
|
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 name is not None and name.strip() != group.name:
|
||||||
if group.is_system:
|
if group.is_system:
|
||||||
raise PermissionDenied("Systemgruppen koennen nicht umbenannt werden.")
|
raise PermissionDenied("Systemgruppen können nicht umbenannt werden.")
|
||||||
clean = name.strip()
|
clean = name.strip()
|
||||||
if not clean:
|
if not clean:
|
||||||
raise ValidationError("Der Gruppenname darf nicht leer sein.")
|
raise ValidationError("Der Gruppenname darf nicht leer sein.")
|
||||||
@@ -224,13 +237,13 @@ def update_group(
|
|||||||
"UPDATE groups SET description=?, updated_at=? WHERE id=?",
|
"UPDATE groups SET description=?, updated_at=? WHERE id=?",
|
||||||
(description.strip(), _now(), group_id),
|
(description.strip(), _now(), group_id),
|
||||||
)
|
)
|
||||||
changes.append("Beschreibung geaendert")
|
changes.append("Beschreibung geändert")
|
||||||
|
|
||||||
if permissions is not None:
|
if permissions is not None:
|
||||||
if group.is_system and not unlock_system:
|
if group.is_system and not unlock_system:
|
||||||
raise PermissionDenied(
|
raise PermissionDenied(
|
||||||
"Die Rechte dieser Systemgruppe sind gesperrt. "
|
"Die Rechte dieser Systemgruppe sind gesperrt. "
|
||||||
"Sie muessen sie zuerst ausdruecklich freischalten."
|
"Sie müssen sie zuerst ausdrücklich freischalten."
|
||||||
)
|
)
|
||||||
wanted = tree.sanitize(permissions)
|
wanted = tree.sanitize(permissions)
|
||||||
added = sorted(set(wanted) - set(group.permissions))
|
added = sorted(set(wanted) - set(group.permissions))
|
||||||
@@ -240,7 +253,7 @@ def update_group(
|
|||||||
_write_permissions(conn, group_id, wanted)
|
_write_permissions(conn, group_id, wanted)
|
||||||
conn.execute("UPDATE groups SET updated_at=? WHERE id=?", (_now(), group_id))
|
conn.execute("UPDATE groups SET updated_at=? WHERE id=?", (_now(), group_id))
|
||||||
if added:
|
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:
|
if removed:
|
||||||
changes.append("Rechte entfernt: " + ", ".join(tree.label_for(p) for p in 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:
|
if group is None:
|
||||||
raise ValidationError("Diese Gruppe existiert nicht.")
|
raise ValidationError("Diese Gruppe existiert nicht.")
|
||||||
if group.is_system:
|
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))
|
conn.execute("UPDATE groups SET deleted_at=? WHERE id=?", (_now(), group_id))
|
||||||
|
|
||||||
|
|
||||||
@@ -315,6 +328,113 @@ def ensure_presets(
|
|||||||
return created
|
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:
|
def default_group_id(conn: sqlite3.Connection) -> int | None:
|
||||||
value = Database.value(
|
value = Database.value(
|
||||||
conn, "SELECT id FROM groups WHERE is_default=1 AND deleted_at IS NULL ORDER BY id LIMIT 1"
|
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."""
|
"""Ein Administratorkonto darf nur ein echter Administrator anfassen."""
|
||||||
if bool(target.get("is_admin")) and not getattr(actor, "is_admin", False):
|
if bool(target.get("is_admin")) and not getattr(actor, "is_admin", False):
|
||||||
raise PermissionDenied(
|
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
|
return
|
||||||
if count_active_admins(conn, exclude_id=user_id) == 0:
|
if count_active_admins(conn, exclude_id=user_id) == 0:
|
||||||
raise PermissionDenied(
|
raise PermissionDenied(
|
||||||
f"{action} nicht moeglich: es muss mindestens ein entsperrtes "
|
f"{action} nicht möglich: es muss mindestens ein entsperrtes "
|
||||||
"Administratorkonto uebrig bleiben."
|
"Administratorkonto übrig bleiben."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -482,12 +602,12 @@ def create_user(
|
|||||||
if existing["deleted_at"]:
|
if existing["deleted_at"]:
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
f"Der Benutzername {clean!r} liegt im Papierkorb. Stellen Sie das Konto "
|
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.")
|
raise ValidationError(f"Der Benutzername {clean!r} ist bereits vergeben.")
|
||||||
|
|
||||||
if is_admin and not getattr(actor, "is_admin", False):
|
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":
|
if auth_source == "local":
|
||||||
problems = policy.check(password, username=clean)
|
problems = policy.check(password, username=clean)
|
||||||
@@ -552,24 +672,30 @@ def update_user(
|
|||||||
# ueberschrieben -- die Bearbeitung waere eine Illusion.
|
# ueberschrieben -- die Bearbeitung waere eine Illusion.
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
"Name und E-Mail eines Verzeichniskontos werden bei jeder Anmeldung "
|
"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] = {}
|
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 (
|
for column, value in (
|
||||||
("first_name", first_name),
|
("first_name", first_name),
|
||||||
("last_name", last_name),
|
("last_name", last_name),
|
||||||
("email", email),
|
("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()
|
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 is_admin is not None and bool(is_admin) != bool(target["is_admin"]):
|
||||||
if not getattr(actor, "is_admin", False):
|
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)):
|
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:
|
if not is_admin:
|
||||||
guard_last_admin(conn, user_id, action="Herabstufen")
|
guard_last_admin(conn, user_id, action="Herabstufen")
|
||||||
fields["is_admin"] = int(bool(is_admin))
|
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 is_locked is not None and bool(is_locked) != bool(target["is_locked"]):
|
||||||
if int(target["id"]) == int(getattr(actor, "id", 0)):
|
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:
|
if is_locked:
|
||||||
guard_last_admin(conn, user_id, action="Sperren")
|
guard_last_admin(conn, user_id, action="Sperren")
|
||||||
fields["is_locked"] = int(bool(is_locked))
|
fields["is_locked"] = int(bool(is_locked))
|
||||||
@@ -595,7 +721,7 @@ def update_user(
|
|||||||
after = {int(gid) for gid in group_ids}
|
after = {int(gid) for gid in group_ids}
|
||||||
if before != after:
|
if before != after:
|
||||||
set_user_groups(conn, user_id, sorted(after), actor=actor, tree=tree)
|
set_user_groups(conn, user_id, sorted(after), actor=actor, tree=tree)
|
||||||
changes.append("Gruppenzuordnung geaendert")
|
changes.append("Gruppenzuordnung geändert")
|
||||||
|
|
||||||
return changes
|
return changes
|
||||||
|
|
||||||
@@ -613,7 +739,7 @@ def set_password(
|
|||||||
raise ValidationError("Dieses Konto existiert nicht.")
|
raise ValidationError("Dieses Konto existiert nicht.")
|
||||||
if str(row.get("auth_source")) == "ldap":
|
if str(row.get("auth_source")) == "ldap":
|
||||||
raise ValidationError(
|
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"]))
|
problems = policy.check(new_password, username=str(row["username"]))
|
||||||
if problems:
|
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).")
|
raise ValidationError("Dieses Konto existiert nicht (mehr).")
|
||||||
guard_target_account(actor, target)
|
guard_target_account(actor, target)
|
||||||
if int(target["id"]) == int(getattr(actor, "id", 0)):
|
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")
|
guard_last_admin(conn, user_id, action="Loeschen")
|
||||||
|
|
||||||
if str(target.get("auth_source")) == "ldap":
|
if str(target.get("auth_source")) == "ldap":
|
||||||
@@ -677,7 +803,7 @@ def matrix_state(
|
|||||||
granted = set(permissions)
|
granted = set(permissions)
|
||||||
state: dict[str, dict[str, bool]] = {}
|
state: dict[str, dict[str, bool]] = {}
|
||||||
for area in tree.areas:
|
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:
|
for resource in area.resources:
|
||||||
state[resource.key] = {
|
state[resource.key] = {
|
||||||
action.value: resource.permission(action) in granted for action in resource.actions
|
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)
|
granted = set(permissions)
|
||||||
lines: list[str] = []
|
lines: list[str] = []
|
||||||
for area in tree.areas:
|
for area in tree.areas:
|
||||||
if area.view_key not in granted:
|
if area.read_key not in granted:
|
||||||
continue
|
continue
|
||||||
parts: list[str] = []
|
parts: list[str] = []
|
||||||
for resource in area.resources:
|
for resource in area.resources:
|
||||||
@@ -706,12 +832,9 @@ def summarize_permissions(tree: PermissionTree, permissions: Iterable[str]) -> l
|
|||||||
return lines
|
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_LETTERS: dict[Action, str] = {
|
||||||
Action.VIEW: "R",
|
action: meta[0] for action, meta in ACTION_META.items()
|
||||||
Action.CREATE: "C",
|
|
||||||
Action.EDIT: "U",
|
|
||||||
Action.DELETE: "D",
|
|
||||||
Action.EXECUTE: "X",
|
|
||||||
Action.EXPORT: "E",
|
|
||||||
Action.SECRETS: "S",
|
|
||||||
}
|
}
|
||||||
@@ -112,7 +112,7 @@ def protect() -> None:
|
|||||||
if not expected or not provided:
|
if not expected or not provided:
|
||||||
abort(403, description="Das Sicherheitstoken des Formulars fehlt. Bitte Seite neu laden.")
|
abort(403, description="Das Sicherheitstoken des Formulars fehlt. Bitte Seite neu laden.")
|
||||||
if not hmac.compare_digest(str(expected), str(provided)):
|
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):
|
def exempt(view):
|
||||||
|
|||||||
@@ -65,10 +65,10 @@ class PasswordPolicy:
|
|||||||
if classes < self.require_classes:
|
if classes < self.require_classes:
|
||||||
problems.append(
|
problems.append(
|
||||||
f"Mindestens {self.require_classes} verschiedene Zeichenarten "
|
f"Mindestens {self.require_classes} verschiedene Zeichenarten "
|
||||||
"(Klein-/Grossbuchstaben, Ziffern, Sonderzeichen)."
|
"(Klein-/Großbuchstaben, Ziffern, Sonderzeichen)."
|
||||||
)
|
)
|
||||||
if normalized.lower() in _FORBIDDEN:
|
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():
|
if username and len(username) >= 3 and username.lower() in normalized.lower():
|
||||||
problems.append("Das Passwort darf den Benutzernamen nicht enthalten.")
|
problems.append("Das Passwort darf den Benutzernamen nicht enthalten.")
|
||||||
if len(set(normalized)) <= 3 and normalized:
|
if len(set(normalized)) <= 3 and normalized:
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ import sqlite3
|
|||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from flask import Flask, Request, Response
|
from flask import Flask, Request, Response, g
|
||||||
|
from flask import request as aktuelle_anfrage
|
||||||
from flask.sessions import SessionInterface, SessionMixin
|
from flask.sessions import SessionInterface, SessionMixin
|
||||||
from werkzeug.datastructures import CallbackDict
|
from werkzeug.datastructures import CallbackDict
|
||||||
|
|
||||||
@@ -49,9 +50,63 @@ SESSION_ID_BYTES = 32
|
|||||||
AUTO_REFRESH_HEADER = "X-Auto-Refresh"
|
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:
|
def is_automatic(request: Request) -> bool:
|
||||||
"""Kam die Anfrage von einem Zeitgeber statt von einem Menschen?"""
|
"""Kam die Anfrage von einem Zeitgeber statt von einem Menschen?
|
||||||
return request.headers.get(AUTO_REFRESH_HEADER, "") == "1"
|
|
||||||
|
Zwei Kennzeichen, und eines genuegt: der Kopf, den das Skript setzt,
|
||||||
|
und die Route selbst. Das zweite traegt auch dann, wenn ein lange
|
||||||
|
offener Tab noch ein aelteres Skript faehrt -- und das ist der Tab,
|
||||||
|
um den es geht.
|
||||||
|
"""
|
||||||
|
if request.headers.get(AUTO_REFRESH_HEADER, "") == "1":
|
||||||
|
return True
|
||||||
|
return _ist_fragment_route()
|
||||||
|
|
||||||
|
|
||||||
def _now() -> datetime:
|
def _now() -> datetime:
|
||||||
@@ -108,6 +163,7 @@ class SqliteSessionInterface(SessionInterface):
|
|||||||
idle_minutes: int,
|
idle_minutes: int,
|
||||||
absolute_hours: int,
|
absolute_hours: int,
|
||||||
idle_lookup: Any = None,
|
idle_lookup: Any = None,
|
||||||
|
absolute_lookup: Any = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.database = database
|
self.database = database
|
||||||
self.cookie_name = cookie_name
|
self.cookie_name = cookie_name
|
||||||
@@ -117,6 +173,16 @@ class SqliteSessionInterface(SessionInterface):
|
|||||||
#: Zeit zur Laufzeit einstellbar, ohne den Dienst neu zu starten -- und
|
#: Zeit zur Laufzeit einstellbar, ohne den Dienst neu zu starten -- und
|
||||||
#: ohne eine zweite Datenbankverbindung, denn sie bekommt die offene.
|
#: ohne eine zweite Datenbankverbindung, denn sie bekommt die offene.
|
||||||
self._idle_lookup = idle_lookup
|
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:
|
def _idle(self, conn: Any) -> int:
|
||||||
if self._idle_lookup is None:
|
if self._idle_lookup is None:
|
||||||
@@ -154,6 +220,11 @@ class SqliteSessionInterface(SessionInterface):
|
|||||||
"WHERE id=?",
|
"WHERE id=?",
|
||||||
(_iso(now), sid),
|
(_iso(now), sid),
|
||||||
)
|
)
|
||||||
|
# Fuer die Anmeldeseite vermerken, **warum** sie erscheint. Ohne
|
||||||
|
# das steht dort entweder nichts -- dann wundert sich, wer
|
||||||
|
# gerade noch angemeldet war -- oder eine Meldung, die jeder
|
||||||
|
# Besucher zu sehen bekommt und die deshalb nichts sagt.
|
||||||
|
g.session_expired = True
|
||||||
return self.session_class(new=True)
|
return self.session_class(new=True)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -220,6 +291,9 @@ class SqliteSessionInterface(SessionInterface):
|
|||||||
payload = json.dumps(dict(session), separators=(",", ":"), sort_keys=True)
|
payload = json.dumps(dict(session), separators=(",", ":"), sort_keys=True)
|
||||||
with self.database.session() as conn, self.database.transaction(conn):
|
with self.database.session() as conn, self.database.transaction(conn):
|
||||||
if session.new or not session.sid:
|
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)
|
session.sid = secrets.token_urlsafe(SESSION_ID_BYTES)
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO user_sessions "
|
"INSERT INTO user_sessions "
|
||||||
@@ -232,13 +306,38 @@ class SqliteSessionInterface(SessionInterface):
|
|||||||
_iso(now),
|
_iso(now),
|
||||||
_iso(now),
|
_iso(now),
|
||||||
_iso(now + timedelta(minutes=self._idle(conn))),
|
_iso(now + timedelta(minutes=self._idle(conn))),
|
||||||
_iso(now + timedelta(hours=self.absolute_hours)),
|
_iso(now + timedelta(hours=self._absolute(conn))),
|
||||||
session.get("_ip", ""),
|
session.get("_ip", ""),
|
||||||
session.get("_ua", ""),
|
session.get("_ua", ""),
|
||||||
payload,
|
payload,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
session.new = False
|
session.new = False
|
||||||
|
# ``save_session`` bekommt die Anfrage nicht als Argument -- anders als
|
||||||
|
# ``open_session``, wo ein Parameter denselben Namen traegt. Hier ist es
|
||||||
|
# der Anfragekontext von Flask; die Antwort wird darin erzeugt.
|
||||||
|
elif is_automatic(aktuelle_anfrage):
|
||||||
|
# Die zweite Schreibstelle, und die eigentliche Ursache dafuer,
|
||||||
|
# dass die Abmeldung nie ausgeloest hat: hier wurde das
|
||||||
|
# Leerlauffenster bei **jeder** Anfrage verlaengert, die die
|
||||||
|
# Sitzung anfasst. Die Pruefung in open_session lief damit ins
|
||||||
|
# Leere -- ein Aktualisierungsdurchlauf einer Kachel fasst die
|
||||||
|
# Sitzung an (Flash-Speicher, CSRF), und schon stand das Fenster
|
||||||
|
# wieder 30 Minuten offen. Auf dem Lizenzserver fiel es nicht
|
||||||
|
# auf: dort pollt keine Kachel.
|
||||||
|
#
|
||||||
|
# Geaenderte Sitzungsdaten muessen trotzdem erhalten bleiben --
|
||||||
|
# sie zu verwerfen waere eine zweite Fehlerquelle. Nur die
|
||||||
|
# Fristen bleiben stehen.
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE user_sessions SET user_id=?, data=?, reauth_at=? WHERE id=?",
|
||||||
|
(
|
||||||
|
session.get("user_id"),
|
||||||
|
payload,
|
||||||
|
session.get("_reauth_at"),
|
||||||
|
session.sid,
|
||||||
|
),
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE user_sessions SET user_id=?, data=?, last_seen_at=?, "
|
"UPDATE user_sessions SET user_id=?, data=?, last_seen_at=?, "
|
||||||
@@ -247,7 +346,11 @@ class SqliteSessionInterface(SessionInterface):
|
|||||||
session.get("user_id"),
|
session.get("user_id"),
|
||||||
payload,
|
payload,
|
||||||
_iso(now),
|
_iso(now),
|
||||||
_iso(now + timedelta(minutes=self.idle_minutes)),
|
# ``_idle(conn)`` und nicht ``idle_minutes``: die Zeit ist
|
||||||
|
# zur Laufzeit einstellbar, und diese Stelle hat die
|
||||||
|
# Einstellung ignoriert. Bei der Vorgabe von 30 Minuten
|
||||||
|
# fiel das nicht auf -- bei einer geaenderten schon.
|
||||||
|
_iso(now + timedelta(minutes=self._idle(conn))),
|
||||||
session.get("_reauth_at"),
|
session.get("_reauth_at"),
|
||||||
session.sid,
|
session.sid,
|
||||||
),
|
),
|
||||||
@@ -282,7 +385,12 @@ def rotate(conn: sqlite3.Connection, session: ServerSession, *, reason: str = "p
|
|||||||
"UPDATE user_sessions SET revoked_at=?, revoked_reason=? WHERE id=?",
|
"UPDATE user_sessions SET revoked_at=?, revoked_reason=? WHERE id=?",
|
||||||
(_iso(_now()), reason, old_sid),
|
(_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.new = True
|
||||||
session.modified = True
|
session.modified = True
|
||||||
return old_sid
|
return old_sid
|
||||||
|
|||||||
@@ -58,6 +58,30 @@ class SettingSpec:
|
|||||||
return str(raw)
|
return str(raw)
|
||||||
|
|
||||||
def serialize(self, value: Any) -> str:
|
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":
|
if self.kind == "bool":
|
||||||
return "1" if value else "0"
|
return "1" if value else "0"
|
||||||
if self.kind == "json":
|
if self.kind == "json":
|
||||||
@@ -67,6 +91,45 @@ class SettingSpec:
|
|||||||
return "" if value is None else str(value)
|
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:
|
class SettingsStore:
|
||||||
"""Zugriff auf ``settings``. Alle Methoden nehmen die bestehende Verbindung."""
|
"""Zugriff auf ``settings``. Alle Methoden nehmen die bestehende Verbindung."""
|
||||||
|
|
||||||
@@ -140,16 +203,24 @@ class SettingsStore:
|
|||||||
|
|
||||||
def set_many(
|
def set_many(
|
||||||
self, conn: sqlite3.Connection, values: dict[str, Any], *, actor: str = ""
|
self, conn: sqlite3.Connection, values: dict[str, Any], *, actor: str = ""
|
||||||
) -> list[str]:
|
) -> list["Aenderung"]:
|
||||||
"""Setzt mehrere Werte, gibt die tatsaechlich geaenderten Schluessel zurueck."""
|
"""Setzt mehrere Werte und gibt die Aenderungen zurueck.
|
||||||
changed: list[str] = []
|
|
||||||
|
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))
|
current = self.get_many(conn, list(values))
|
||||||
for key, value in values.items():
|
for key, value in values.items():
|
||||||
spec = self.spec(key)
|
spec = self.spec(key)
|
||||||
if spec.serialize(current.get(key)) == spec.serialize(value):
|
if spec.serialize(current.get(key)) == spec.serialize(value):
|
||||||
continue
|
continue
|
||||||
self.set(conn, key, value, actor=actor)
|
self.set(conn, key, value, actor=actor)
|
||||||
changed.append(key)
|
changed.append(Aenderung(key, current.get(key), value, geheim=spec.kind == "secret"))
|
||||||
return changed
|
return changed
|
||||||
|
|
||||||
def delete(self, conn: sqlite3.Connection, key: str) -> None:
|
def delete(self, conn: sqlite3.Connection, key: str) -> None:
|
||||||
|
|||||||
@@ -412,7 +412,10 @@
|
|||||||
und beide sind rot. */
|
und beide sind rot. */
|
||||||
.pill--critical {
|
.pill--critical {
|
||||||
background: var(--danger);
|
background: var(--danger);
|
||||||
color: var(--surface-0);
|
/* Weiss wie bei .btn--danger. Vorher stand hier ein Token, das es nicht
|
||||||
|
gibt -- die Deklaration war damit unwirksam, und die Schrift blieb in
|
||||||
|
der Textfarbe: dunkelgrau auf Rot. */
|
||||||
|
color: #fff;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
.pill--critical::before { background: currentColor; }
|
.pill--critical::before { background: currentColor; }
|
||||||
@@ -682,7 +685,10 @@
|
|||||||
.modal__sub { font-size: var(--fs-xs); color: var(--text-faint); margin-top: 2px; }
|
.modal__sub { font-size: var(--fs-xs); color: var(--text-faint); margin-top: 2px; }
|
||||||
/* Eine Datei-Vorschau soll die ganze Flaeche des Dialogs nutzen; der
|
/* Eine Datei-Vorschau soll die ganze Flaeche des Dialogs nutzen; der
|
||||||
uebliche Innenabstand des Dialogkoerpers wuerde sie einrahmen. */
|
uebliche Innenabstand des Dialogkoerpers wuerde sie einrahmen. */
|
||||||
.modal__body--flush { padding: 0; max-height: none; }
|
/* 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 {
|
.modal__caption {
|
||||||
padding: 0 var(--sp-5) var(--sp-2);
|
padding: 0 var(--sp-5) var(--sp-2);
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
@@ -695,7 +701,177 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: min(70dvh, 760px);
|
height: min(70dvh, 760px);
|
||||||
border: 0;
|
border: 0;
|
||||||
background: var(--surface-0);
|
/* Die Vorschau zeigt fremde Dokumente; ein neutraler Grund gehoert
|
||||||
|
darunter. --surface-0 gibt es nicht, der Rahmen blieb durchsichtig. */
|
||||||
|
background: var(--surface-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -- 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
|
/* Der Dateiname ist ein Knopf (er oeffnet die Vorschau), soll aber wie ein
|
||||||
Link aussehen -- ein Knopf mit Rahmen mitten in einer Tabellenzeile waere
|
Link aussehen -- ein Knopf mit Rahmen mitten in einer Tabellenzeile waere
|
||||||
@@ -834,7 +1010,25 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
.matrix thead th:first-child { text-align: left; }
|
.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 {
|
.matrix tr.matrix__area > td {
|
||||||
background: var(--surface-2);
|
background: var(--surface-2);
|
||||||
font-weight: 640;
|
font-weight: 640;
|
||||||
@@ -849,7 +1043,10 @@
|
|||||||
width: 17px;
|
width: 17px;
|
||||||
height: 17px;
|
height: 17px;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
background: var(--surface-0);
|
/* Dunkler als die Karte -- genau das war die Absicht: "dunkler heisst,
|
||||||
|
es gibt das Recht nicht". Mit dem nicht existierenden --surface-0 war
|
||||||
|
das Feld durchsichtig und damit so hell wie die Karte. */
|
||||||
|
background: var(--canvas);
|
||||||
border: 1px solid var(--border-subtle);
|
border: 1px solid var(--border-subtle);
|
||||||
box-shadow: inset 0 1px 2px rgb(0 0 0 / 28%);
|
box-shadow: inset 0 1px 2px rgb(0 0 0 / 28%);
|
||||||
vertical-align: -3px;
|
vertical-align: -3px;
|
||||||
@@ -857,6 +1054,46 @@
|
|||||||
}
|
}
|
||||||
.matrix__resource-label { display: flex; flex-direction: column; gap: 1px; }
|
.matrix__resource-label { display: flex; flex-direction: column; gap: 1px; }
|
||||||
.matrix__resource-label small { color: var(--text-faint); font-size: var(--fs-xs); }
|
.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 {
|
.matrix__legend {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -943,9 +1180,15 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
padding: var(--sp-6);
|
padding: var(--sp-6);
|
||||||
|
/* Zwei warme Kreise, Geometrie und Staerke aus der Fassung 1.x
|
||||||
|
uebernommen: oben links deutlich, unten rechts angedeutet. Die vorige
|
||||||
|
Fassung setzte den zweiten Kreis auf --info und 10 % -- ein kaltes
|
||||||
|
Blau, das man auf dunklem Grund nicht sah und das nicht zur Marke
|
||||||
|
gehoert. Ueber color-mix statt festem rgba, damit es im hellen Thema
|
||||||
|
nicht schmutzig wird. */
|
||||||
background:
|
background:
|
||||||
radial-gradient(1100px 520px at 12% -10%, color-mix(in srgb, var(--accent) 12%, transparent), transparent 70%),
|
radial-gradient(circle at 15% 15%, color-mix(in srgb, var(--accent) 14%, transparent), transparent 45%),
|
||||||
radial-gradient(900px 480px at 100% 110%, color-mix(in srgb, var(--info) 10%, transparent), transparent 70%),
|
radial-gradient(circle at 85% 85%, color-mix(in srgb, var(--accent) 8%, transparent), transparent 40%),
|
||||||
var(--canvas);
|
var(--canvas);
|
||||||
}
|
}
|
||||||
/* Die Wortmarke liegt hinter allem (z-index 0), die Anmeldebox davor.
|
/* Die Wortmarke liegt hinter allem (z-index 0), die Anmeldebox davor.
|
||||||
@@ -1026,24 +1269,35 @@
|
|||||||
color: var(--text-faint);
|
color: var(--text-faint);
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-page { position: relative; overflow: hidden; align-content: center; }
|
/* Marke und Karte stehen uebereinander im normalen Fluss. Vorher lag die
|
||||||
|
Marke absolut positioniert hinter der Karte -- auf einem flachen Fenster
|
||||||
|
schnitt die Karte mitten durch den Schriftzug und die Funktionszeile. Ein
|
||||||
|
Stapel kann nicht kollidieren, und die Seite darf notfalls scrollen. */
|
||||||
|
.auth-stack {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-8);
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
.auth-backdrop {
|
.auth-backdrop {
|
||||||
position: absolute;
|
|
||||||
top: 4vh;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
width: min(72vw, 880px);
|
width: min(72vw, 880px);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 0;
|
/* Volle Deckung: solange die Marke hinter der Karte lag, war sie ein
|
||||||
opacity: 0.5;
|
Wasserzeichen und musste zurueckhaltend sein. Jetzt steht sie ueber der
|
||||||
|
Karte und ist das Erste, was man sieht -- halbtransparent sah sie dort
|
||||||
|
nur blass aus. Die Farben kommen ohnehin aus zurueckhaltenden Token. */
|
||||||
|
opacity: 1;
|
||||||
}
|
}
|
||||||
.auth-backdrop svg { width: 100%; height: auto; display: block; }
|
.auth-backdrop svg { width: 100%; height: auto; display: block; }
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.auth-backdrop { width: min(90vw, 460px); opacity: 0.32; }
|
.auth-backdrop { width: min(90vw, 460px); }
|
||||||
}
|
}
|
||||||
/* Wer wenig Bewegung/Reize moechte, bekommt das Motiv dezenter. */
|
/* Wer wenig Bewegung/Reize moechte, bekommt das Motiv dezenter. */
|
||||||
|
/* Wer weniger Reize moechte, bekommt die Marke ruhiger -- aber nicht
|
||||||
|
unsichtbar: sie ist die Ueberschrift der Seite. */
|
||||||
@media (prefers-contrast: more) {
|
@media (prefers-contrast: more) {
|
||||||
.auth-backdrop { opacity: 0.18; }
|
.auth-backdrop { opacity: 0.75; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-card {
|
.auth-card {
|
||||||
@@ -1138,3 +1392,340 @@
|
|||||||
.hide-sm { display: none !important; }
|
.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;
|
||||||
|
}
|
||||||
@@ -66,6 +66,15 @@ export async function api(url, options = {}) {
|
|||||||
...(options.headers || {}),
|
...(options.headers || {}),
|
||||||
};
|
};
|
||||||
const response = await fetch(url, opts);
|
const response = await fetch(url, opts);
|
||||||
|
// Eine Seite, die auch oeffentlich antwortet -- die Statusuebersicht etwa --
|
||||||
|
// liefert nach dem Ablauf der Sitzung eine 200 mit den oeffentlichen
|
||||||
|
// Kacheln. Am Status ist der Verlust also nicht zu erkennen; an diesem Kopf
|
||||||
|
// schon. Behandelt wird er wie eine 401.
|
||||||
|
if (response.headers.get("X-Session-Expired") === "1") {
|
||||||
|
const abgelaufen = new Error("Die Sitzung ist abgelaufen.");
|
||||||
|
abgelaufen.status = 401;
|
||||||
|
throw abgelaufen;
|
||||||
|
}
|
||||||
const type = response.headers.get("content-type") || "";
|
const type = response.headers.get("content-type") || "";
|
||||||
const payload = type.includes("application/json") ? await response.json() : await response.text();
|
const payload = type.includes("application/json") ? await response.json() : await response.text();
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -100,7 +109,7 @@ export function showToast(message, type = "info", { timeout = 6000 } = {}) {
|
|||||||
const close = document.createElement("button");
|
const close = document.createElement("button");
|
||||||
close.type = "button";
|
close.type = "button";
|
||||||
close.className = "toast__close";
|
close.className = "toast__close";
|
||||||
close.setAttribute("aria-label", "Meldung schliessen");
|
close.setAttribute("aria-label", "Meldung schließen");
|
||||||
close.textContent = "×";
|
close.textContent = "×";
|
||||||
toast.append(body, close);
|
toast.append(body, close);
|
||||||
toastStack.appendChild(toast);
|
toastStack.appendChild(toast);
|
||||||
@@ -284,7 +293,7 @@ function initConfirmations() {
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
pending = trigger;
|
pending = trigger;
|
||||||
titleNode.textContent = trigger.dataset.confirmTitle || "Bitte bestaetigen";
|
titleNode.textContent = trigger.dataset.confirmTitle || "Bitte bestätigen";
|
||||||
textNode.textContent = trigger.dataset.confirm;
|
textNode.textContent = trigger.dataset.confirm;
|
||||||
okButton.textContent = trigger.dataset.confirmOk || "Fortfahren";
|
okButton.textContent = trigger.dataset.confirmOk || "Fortfahren";
|
||||||
okButton.className = `btn btn--${trigger.dataset.confirmStyle || "danger"}`;
|
okButton.className = `btn btn--${trigger.dataset.confirmStyle || "danger"}`;
|
||||||
@@ -431,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) => {
|
$$("[data-area-switch]", table).forEach((master) => {
|
||||||
const areaKey = master.dataset.areaSwitch;
|
const areaKey = master.dataset.areaSwitch;
|
||||||
syncArea(areaKey);
|
syncArea(areaKey);
|
||||||
@@ -442,8 +501,12 @@ function initPermissionMatrix() {
|
|||||||
if (!(box instanceof HTMLInputElement) || !box.dataset.action) return;
|
if (!(box instanceof HTMLInputElement) || !box.dataset.action) return;
|
||||||
const row = box.closest("tr");
|
const row = box.closest("tr");
|
||||||
if (!row) return;
|
if (!row) return;
|
||||||
const viewBox = $('input[data-action="view"]', row);
|
// "read", nicht "view": die Aktion heisst seit 2.8.0 wie das, was sie
|
||||||
if (box.dataset.action === "view") {
|
// 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.
|
// "Ansehen" abwaehlen entfernt alles andere dieser Zeile.
|
||||||
if (!box.checked) {
|
if (!box.checked) {
|
||||||
$$('input[type="checkbox"][name]', row).forEach((other) => {
|
$$('input[type="checkbox"][name]', row).forEach((other) => {
|
||||||
@@ -460,6 +523,30 @@ function initPermissionMatrix() {
|
|||||||
master.dispatchEvent(new Event("change"));
|
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();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -488,14 +575,14 @@ function initAutoRefresh() {
|
|||||||
nameField.value = entry.name || "";
|
nameField.value = entry.name || "";
|
||||||
dnField.value = entry.dn || "";
|
dnField.value = entry.dn || "";
|
||||||
results.hidden = true;
|
results.hidden = true;
|
||||||
show(`Uebernommen: ${entry.name || entry.dn}`);
|
show(`Übernommen: ${entry.name || entry.dn}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const search = async () => {
|
const search = async () => {
|
||||||
const term = query.value.trim();
|
const term = query.value.trim();
|
||||||
if (term.length < 2) { show("Bitte mindestens zwei Zeichen eingeben."); return; }
|
if (term.length < 2) { show("Bitte mindestens zwei Zeichen eingeben."); return; }
|
||||||
button.disabled = true;
|
button.disabled = true;
|
||||||
show("Suche laeuft ...");
|
show("Suche läuft ...");
|
||||||
results.innerHTML = "";
|
results.innerHTML = "";
|
||||||
try {
|
try {
|
||||||
const url = `${picker.dataset.searchUrl}?q=${encodeURIComponent(term)}`;
|
const url = `${picker.dataset.searchUrl}?q=${encodeURIComponent(term)}`;
|
||||||
@@ -523,10 +610,10 @@ function initAutoRefresh() {
|
|||||||
results.appendChild(row);
|
results.appendChild(row);
|
||||||
});
|
});
|
||||||
results.hidden = false;
|
results.hidden = false;
|
||||||
show(`${entries.length} Treffer -- zum Uebernehmen anklicken.`);
|
show(`${entries.length} Treffer – zum Übernehmen anklicken.`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
results.hidden = true;
|
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 {
|
} finally {
|
||||||
button.disabled = false;
|
button.disabled = false;
|
||||||
}
|
}
|
||||||
@@ -711,7 +798,7 @@ function initClipboard() {
|
|||||||
timeout: 2500,
|
timeout: 2500,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
showToast("Kopieren nicht moeglich -- bitte manuell markieren.", "warning");
|
showToast("Kopieren nicht möglich – bitte manuell markieren.", "warning");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -847,6 +934,81 @@ 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 ---------------------------------------------------------------- */
|
/* -- Start ---------------------------------------------------------------- */
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -884,8 +1046,23 @@ function boot() {
|
|||||||
initPalette();
|
initPalette();
|
||||||
initSubmitGuards();
|
initSubmitGuards();
|
||||||
initTileFilters();
|
initTileFilters();
|
||||||
|
initReorder();
|
||||||
initLogTails();
|
initLogTails();
|
||||||
unsavedGuard.init();
|
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") {
|
if (document.readyState === "loading") {
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
pdf.js 4.10.38 -- https://github.com/mozilla/pdf.js
|
||||||
|
Aus dem npm-Paket pdfjs-dist; nur build/pdf.min.mjs, build/pdf.worker.min.mjs,
|
||||||
|
web/pdf_viewer.mjs, web/pdf_viewer.css, web/images und standard_fonts.
|
||||||
|
Apache-2.0, siehe LICENSE.
|
||||||
|
|
||||||
|
Die drei Skriptdateien sind von .mjs auf .js umbenannt: die mime.types von
|
||||||
|
nginx kennen .mjs nicht, und mit application/octet-stream lehnt jeder Browser
|
||||||
|
ein Modul ab. Der Inhalt ist unveraendert.
|
||||||
|
|
||||||
|
Warum eingebettet und nicht von einem CDN: TESM laeuft in abgeschotteten
|
||||||
|
Netzen. Was die Oberflaeche braucht, muss im Paket liegen.
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
|
||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
4.10.38
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="12" height="13" viewBox="0 0 12 13" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M5.375 7.625V11.875C5.375 12.0408 5.44085 12.1997 5.55806 12.3169C5.67527 12.4342 5.83424 12.5 6 12.5C6.16576 12.5 6.32473 12.4342 6.44194 12.3169C6.55915 12.1997 6.625 12.0408 6.625 11.875V7.625L7.125 7.125H11.375C11.5408 7.125 11.6997 7.05915 11.8169 6.94194C11.9342 6.82473 12 6.66576 12 6.5C12 6.33424 11.9342 6.17527 11.8169 6.05806C11.6997 5.94085 11.5408 5.875 11.375 5.875H7.125L6.625 5.375V1.125C6.625 0.95924 6.55915 0.800269 6.44194 0.683058C6.32473 0.565848 6.16576 0.5 6 0.5C5.83424 0.5 5.67527 0.565848 5.55806 0.683058C5.44085 0.800269 5.375 0.95924 5.375 1.125V5.375L4.875 5.875H0.625C0.45924 5.875 0.300269 5.94085 0.183058 6.05806C0.065848 6.17527 0 6.33424 0 6.5C0 6.66576 0.065848 6.82473 0.183058 6.94194C0.300269 7.05915 0.45924 7.125 0.625 7.125H4.762L5.375 7.625Z" fill="black"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 920 B |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="17" height="16" viewBox="0 0 17 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M3.49073 1.3015L3.30873 2.1505C3.29349 2.22246 3.25769 2.28844 3.20568 2.34045C3.15368 2.39246 3.08769 2.42826 3.01573 2.4435L2.16673 2.6255C1.76473 2.7125 1.76473 3.2865 2.16673 3.3725L3.01573 3.5555C3.08769 3.57074 3.15368 3.60654 3.20568 3.65855C3.25769 3.71056 3.29349 3.77654 3.30873 3.8485L3.49073 4.6975C3.57773 5.0995 4.15173 5.0995 4.23773 4.6975L4.42073 3.8485C4.43598 3.77654 4.47177 3.71056 4.52378 3.65855C4.57579 3.60654 4.64178 3.57074 4.71373 3.5555L5.56173 3.3725C5.96373 3.2855 5.96373 2.7115 5.56173 2.6255L4.71273 2.4435C4.64083 2.42814 4.57491 2.3923 4.52292 2.34031C4.47093 2.28832 4.43509 2.2224 4.41973 2.1505L4.23773 1.3015C4.15073 0.8995 3.57673 0.8995 3.49073 1.3015ZM10.8647 13.9995C10.4853 14.0056 10.1158 13.8782 9.82067 13.6397C9.52553 13.4013 9.32347 13.0667 9.24973 12.6945L8.89273 11.0275C8.83676 10.7687 8.70738 10.5316 8.52009 10.3445C8.3328 10.1574 8.09554 10.0282 7.83673 9.9725L6.16973 9.6155C5.38873 9.4465 4.86473 8.7975 4.86473 7.9995C4.86473 7.2015 5.38873 6.5525 6.16973 6.3845L7.83673 6.0275C8.09551 5.97135 8.33267 5.84193 8.51992 5.65468C8.70716 5.46744 8.83658 5.23028 8.89273 4.9715L9.25073 3.3045C9.41773 2.5235 10.0667 1.9995 10.8647 1.9995C11.6627 1.9995 12.3117 2.5235 12.4797 3.3045L12.8367 4.9715C12.9507 5.4995 13.3647 5.9135 13.8927 6.0265L15.5597 6.3835C16.3407 6.5525 16.8647 7.2015 16.8647 7.9995C16.8647 8.7975 16.3407 9.4465 15.5597 9.6145L13.8927 9.9715C13.6337 10.0275 13.3963 10.157 13.209 10.3445C13.0217 10.5319 12.8925 10.7694 12.8367 11.0285L12.4787 12.6945C12.4054 13.0667 12.2036 13.4014 11.9086 13.6399C11.6135 13.8784 11.2441 14.0057 10.8647 13.9995ZM10.8647 3.2495C10.7667 3.2495 10.5337 3.2795 10.4727 3.5655L10.1147 5.2335C10.0081 5.72777 9.76116 6.18082 9.40361 6.53837C9.04606 6.89593 8.59301 7.14283 8.09873 7.2495L6.43173 7.6065C6.14573 7.6685 6.11473 7.9015 6.11473 7.9995C6.11473 8.0975 6.14573 8.3305 6.43173 8.3925L8.09873 8.7495C8.59301 8.85617 9.04606 9.10307 9.40361 9.46062C9.76116 9.81817 10.0081 10.2712 10.1147 10.7655L10.4727 12.4335C10.5337 12.7195 10.7667 12.7495 10.8647 12.7495C10.9627 12.7495 11.1957 12.7195 11.2567 12.4335L11.6147 10.7665C11.7212 10.272 11.9681 9.81878 12.3256 9.46103C12.6832 9.10329 13.1363 8.85624 13.6307 8.7495L15.2977 8.3925C15.5837 8.3305 15.6147 8.0975 15.6147 7.9995C15.6147 7.9015 15.5837 7.6685 15.2977 7.6065L13.6307 7.2495C13.1365 7.14283 12.6834 6.89593 12.3259 6.53837C11.9683 6.18082 11.7214 5.72777 11.6147 5.2335L11.2567 3.5655C11.1957 3.2795 10.9627 3.2495 10.8647 3.2495ZM3.30873 12.1505L3.49073 11.3015C3.57673 10.8995 4.15073 10.8995 4.23773 11.3015L4.41973 12.1505C4.43509 12.2224 4.47093 12.2883 4.52292 12.3403C4.57491 12.3923 4.64083 12.4281 4.71273 12.4435L5.56173 12.6255C5.96373 12.7115 5.96373 13.2855 5.56173 13.3725L4.71273 13.5545C4.64083 13.5699 4.57491 13.6057 4.52292 13.6577C4.47093 13.7097 4.43509 13.7756 4.41973 13.8475L4.23773 14.6965C4.15173 15.0985 3.57773 15.0985 3.49073 14.6965L3.30873 13.8475C3.29337 13.7756 3.25754 13.7097 3.20555 13.6577C3.15356 13.6057 3.08764 13.5699 3.01573 13.5545L2.16673 13.3725C1.76473 13.2865 1.76473 12.7125 2.16673 12.6255L3.01573 12.4435C3.08769 12.4283 3.15368 12.3925 3.20568 12.3405C3.25769 12.2884 3.29349 12.2225 3.30873 12.1505Z" fill="black"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.3 KiB |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="12" height="13" viewBox="0 0 12 13" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M6 0.5C5.21207 0.5 4.43185 0.655195 3.7039 0.956723C2.97595 1.25825 2.31451 1.70021 1.75736 2.25736C1.20021 2.81451 0.758251 3.47595 0.456723 4.2039C0.155195 4.93185 0 5.71207 0 6.5C0 7.28793 0.155195 8.06815 0.456723 8.7961C0.758251 9.52405 1.20021 10.1855 1.75736 10.7426C2.31451 11.2998 2.97595 11.7417 3.7039 12.0433C4.43185 12.3448 5.21207 12.5 6 12.5C7.5913 12.5 9.11742 11.8679 10.2426 10.7426C11.3679 9.61742 12 8.0913 12 6.5C12 4.9087 11.3679 3.38258 10.2426 2.25736C9.11742 1.13214 7.5913 0.5 6 0.5ZM5.06 8.9L2.9464 6.7856C2.85273 6.69171 2.80018 6.56446 2.80033 6.43183C2.80048 6.29921 2.85331 6.17207 2.9472 6.0784C3.04109 5.98473 3.16834 5.93218 3.30097 5.93233C3.43359 5.93248 3.56073 5.98531 3.6544 6.0792L5.3112 7.7368L8.3464 4.7008C8.44109 4.6109 8.56715 4.56153 8.69771 4.56322C8.82827 4.56492 8.95301 4.61754 9.04534 4.70986C9.13766 4.80219 9.19028 4.92693 9.19198 5.05749C9.19367 5.18805 9.1443 5.31411 9.0544 5.4088L5.5624 8.9H5.06Z" fill="#FBFBFE"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,30 @@
|
|||||||
|
<!-- This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
- License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">
|
||||||
|
<style>
|
||||||
|
@media not (prefers-reduced-motion) {
|
||||||
|
@keyframes loadingRotate {
|
||||||
|
from { rotate: 0; } to { rotate: 360deg }
|
||||||
|
}
|
||||||
|
#circle-arrows {
|
||||||
|
animation: loadingRotate 1.8s linear infinite;
|
||||||
|
transform-origin: 50% 50%;
|
||||||
|
}
|
||||||
|
#hourglass {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion) {
|
||||||
|
#circle-arrows {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<path id="circle-arrows" d="M9 5.528c0 .42.508.63.804.333l2.528-2.528a.47.47 0 0 0 0-.666L9.805.14A.471.471 0 0 0 9 .472v1.866A5.756 5.756 0 0 0 2.25 8c0 .942.232 1.83.635 2.615l1.143-1.143A4.208 4.208 0 0 1 3.75 8 4.254 4.254 0 0 1 8 3.75c.345 0 .68.042 1 .122v1.656zM7 10.472v1.656c.32.08.655.122 1 .122A4.254 4.254 0 0 0 12.25 8c0-.52-.107-1.013-.279-1.474l1.143-1.143c.404.786.636 1.674.636 2.617A5.756 5.756 0 0 1 7 13.662v1.866a.47.47 0 0 1-.804.333l-2.528-2.528a.47.47 0 0 1 0-.666l2.528-2.528a.47.47 0 0 1 .804.333z"/>
|
||||||
|
<g id="hourglass">
|
||||||
|
<path d="M13,1 C13.5522847,1 14,1.44771525 14,2 C14,2.55228475 13.5522847,3 13,3 L12.9854217,2.99990801 C12.9950817,3.16495885 13,3.33173274 13,3.5 C13,5.24679885 10.9877318,6.01090495 10.9877318,8.0017538 C10.9877318,9.99260264 13,10.7536922 13,12.5 C13,12.6686079 12.9950617,12.8357163 12.985363,13.0010943 L13,13 C13.5522847,13 14,13.4477153 14,14 C14,14.5522847 13.5522847,15 13,15 L3,15 C2.44771525,15 2,14.5522847 2,14 C2,13.4477153 2.44771525,13 3,13 L3.01463704,13.0010943 C3.00493827,12.8357163 3,12.6686079 3,12.5 C3,10.7536922 4.9877318,9.99260264 5,8.0017538 C5.0122682,6.01090495 3,5.24679885 3,3.5 C3,3.33173274 3.00491834,3.16495885 3.01457832,2.99990801 L3,3 C2.44771525,3 2,2.55228475 2,2 C2,1.44771525 2.44771525,1 3,1 L13,1 Z M10.987,3 L5.012,3 L5.00308914,3.24815712 C5.00103707,3.33163368 5,3.4155948 5,3.5 C5,5.36125069 6.99153646,6.01774089 6.99153646,8.0017538 C6.99153646,9.98576671 5,10.6393737 5,12.5 L5.00307746,12.7513676 L5.01222201,12.9998392 L5.60191711,12.9988344 L6.0425138,12.2959826 C7.02362731,10.7653275 7.67612271,10 8,10 C8.37014547,10 9.16950644,10.9996115 10.3980829,12.9988344 L10.987778,12.9998392 C10.9958674,12.8352104 11,12.66849 11,12.5 C11,10.6393737 8.98689779,10.0147381 8.98689779,8.0017538 C8.98689779,5.98876953 11,5.36125069 11,3.5 L10.9969109,3.24815712 L10.987,3 Z"/>
|
||||||
|
<path d="M6,4 L10,4 C8.95166016,6 8.28499349,7 8,7 C7.71500651,7 7.04833984,6 6,4 Z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.6 KiB |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="17" height="16" viewBox="0 0 17 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M8.78182 2.63903C8.58882 2.28803 8.25782 2.25003 8.12482 2.25003C7.99019 2.24847 7.85771 2.28393 7.74185 2.35253C7.62599 2.42113 7.5312 2.52023 7.46782 2.63903L1.97082 12.639C1.90673 12.7528 1.87406 12.8816 1.87617 13.0122C1.87828 13.1427 1.91509 13.2704 1.98282 13.382C2.04798 13.4951 2.14207 13.5888 2.25543 13.6535C2.36879 13.7182 2.49732 13.7515 2.62782 13.75H13.6218C13.7523 13.7515 13.8809 13.7182 13.9942 13.6535C14.1076 13.5888 14.2017 13.4951 14.2668 13.382C14.3346 13.2704 14.3714 13.1427 14.3735 13.0122C14.3756 12.8816 14.3429 12.7528 14.2788 12.639L8.78182 2.63903ZM6.37282 2.03703C6.75182 1.34603 7.43882 1.00003 8.12482 1.00003C8.48341 0.997985 8.83583 1.09326 9.14454 1.2757C9.45325 1.45814 9.70668 1.72092 9.87782 2.03603L15.3748 12.036C16.1078 13.369 15.1438 15 13.6228 15H2.62782C1.10682 15 0.141823 13.37 0.875823 12.037L6.37282 2.03703ZM8.74982 9.06203C8.74982 9.22779 8.68397 9.38676 8.56676 9.50397C8.44955 9.62118 8.29058 9.68703 8.12482 9.68703C7.95906 9.68703 7.80009 9.62118 7.68288 9.50397C7.56566 9.38676 7.49982 9.22779 7.49982 9.06203V5.62503C7.49982 5.45927 7.56566 5.3003 7.68288 5.18309C7.80009 5.06588 7.95906 5.00003 8.12482 5.00003C8.29058 5.00003 8.44955 5.06588 8.56676 5.18309C8.68397 5.3003 8.74982 5.45927 8.74982 5.62503V9.06203ZM7.74982 12L7.49982 11.75V11L7.74982 10.75H8.49982L8.74982 11V11.75L8.49982 12H7.74982Z" fill="black"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="40"
|
||||||
|
height="40"
|
||||||
|
viewBox="0 0 40 40">
|
||||||
|
<path
|
||||||
|
d="M 1.5006714,23.536225 6.8925879,18.994244 14.585721,26.037937 34.019683,4.5410479 38.499329,9.2235032 14.585721,35.458952 z"
|
||||||
|
id="path4"
|
||||||
|
style="fill:#ffff00;fill-opacity:1;stroke:#000000;stroke-width:1.25402856;stroke-opacity:1" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 415 B |
@@ -0,0 +1,16 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
height="40"
|
||||||
|
width="40"
|
||||||
|
viewBox="0 0 40 40">
|
||||||
|
<rect
|
||||||
|
style="fill:#ffff00;fill-opacity:1;fill-rule:evenodd;stroke:#000000;stroke-width:1;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none"
|
||||||
|
width="33.76017"
|
||||||
|
height="33.76017"
|
||||||
|
x="3.119915"
|
||||||
|
y="3.119915" />
|
||||||
|
<path
|
||||||
|
d="m 20.677967,8.54499 c -7.342801,0 -13.295293,4.954293 -13.295293,11.065751 0,2.088793 0.3647173,3.484376 1.575539,5.150563 L 6.0267418,31.45501 13.560595,29.011117 c 2.221262,1.387962 4.125932,1.665377 7.117372,1.665377 7.3428,0 13.295291,-4.954295 13.295291,-11.065753 0,-6.111458 -5.952491,-11.065751 -13.295291,-11.065751 z"
|
||||||
|
style="fill:#ffffff;fill-opacity:1;stroke:#000000;stroke-width:0.93031836;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 883 B |
@@ -0,0 +1,26 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="40"
|
||||||
|
height="40"
|
||||||
|
viewBox="0 0 40 40">
|
||||||
|
<g
|
||||||
|
transform="translate(0,-60)"
|
||||||
|
id="layer1">
|
||||||
|
<rect
|
||||||
|
width="36.460953"
|
||||||
|
height="34.805603"
|
||||||
|
x="1.7695236"
|
||||||
|
y="62.597198"
|
||||||
|
style="fill:#ffff00;fill-opacity:1;fill-rule:evenodd;stroke:#000000;stroke-width:1.30826771;stroke-opacity:1" />
|
||||||
|
<g
|
||||||
|
transform="matrix(0.88763677,0,0,0.88763677,2.2472646,8.9890584)">
|
||||||
|
<path
|
||||||
|
d="M 20,64.526342 C 11.454135,64.526342 4.5263421,71.454135 4.5263421,80 4.5263421,88.545865 11.454135,95.473658 20,95.473658 28.545865,95.473658 35.473658,88.545865 35.473658,80 35.473658,71.454135 28.545865,64.526342 20,64.526342 z m -0.408738,9.488564 c 3.527079,0 6.393832,2.84061 6.393832,6.335441 0,3.494831 -2.866753,6.335441 -6.393832,6.335441 -3.527079,0 -6.393832,-2.84061 -6.393832,-6.335441 0,-3.494831 2.866753,-6.335441 6.393832,-6.335441 z"
|
||||||
|
style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:#000000;stroke-width:1.02768445;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none" />
|
||||||
|
<path
|
||||||
|
d="m 7.2335209,71.819938 4.9702591,4.161823 c -1.679956,2.581606 -1.443939,6.069592 0.159325,8.677725 l -5.1263071,3.424463 c 0.67516,1.231452 3.0166401,3.547686 4.2331971,4.194757 l 3.907728,-4.567277 c 2.541952,1.45975 5.730694,1.392161 8.438683,-0.12614 l 3.469517,6.108336 c 1.129779,-0.44367 4.742234,-3.449633 5.416358,-5.003859 l -5.46204,-4.415541 c 1.44319,-2.424098 1.651175,-5.267515 0.557303,-7.748623 l 5.903195,-3.833951 C 33.14257,71.704996 30.616217,69.018606 29.02952,67.99296 l -4.118813,4.981678 C 22.411934,71.205099 18.900853,70.937534 16.041319,72.32916 l -3.595408,-5.322091 c -1.345962,0.579488 -4.1293881,2.921233 -5.2123901,4.812869 z m 8.1010311,3.426672 c 2.75284,-2.446266 6.769149,-2.144694 9.048998,0.420874 2.279848,2.56557 2.113919,6.596919 -0.638924,9.043185 -2.752841,2.446267 -6.775754,2.13726 -9.055604,-0.428308 -2.279851,-2.565568 -2.107313,-6.589485 0.64553,-9.035751 z"
|
||||||
|
style="fill:#000000;fill-opacity:1;stroke:none" />
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="64"
|
||||||
|
height="64"
|
||||||
|
viewBox="0 0 64 64">
|
||||||
|
<path
|
||||||
|
d="M 32.003143,1.4044602 57.432701,62.632577 6.5672991,62.627924 z"
|
||||||
|
style="fill:#ffff00;fill-opacity:0.94117647;fill-rule:nonzero;stroke:#000000;stroke-width:1.00493038;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 408 B |
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="64"
|
||||||
|
height="64"
|
||||||
|
viewBox="0 0 64 64">
|
||||||
|
<path
|
||||||
|
d="M 25.470843,9.4933766 C 25.30219,12.141818 30.139101,14.445969 34.704831,13.529144 40.62635,12.541995 41.398833,7.3856498 35.97505,5.777863 31.400921,4.1549155 25.157674,6.5445892 25.470843,9.4933766 z M 4.5246282,17.652051 C 4.068249,11.832873 9.2742983,5.9270407 18.437379,3.0977088 29.751911,-0.87185184 45.495663,1.4008022 53.603953,7.1104009 c 9.275765,6.1889221 7.158128,16.2079421 -3.171076,21.5939521 -1.784316,1.635815 -6.380222,1.21421 -7.068351,3.186186 -1.04003,0.972427 -1.288046,2.050158 -1.232864,3.168203 1.015111,2.000108 -3.831548,1.633216 -3.270553,3.759574 0.589477,5.264544 -0.179276,10.53738 -0.362842,15.806257 -0.492006,2.184998 1.163456,4.574232 -0.734888,6.610642 -2.482919,2.325184 -7.30604,2.189143 -9.193497,-0.274767 -2.733688,-1.740626 -8.254447,-3.615254 -6.104247,-6.339626 3.468112,-1.708686 -2.116197,-3.449897 0.431242,-5.080274 5.058402,-1.39256 -2.393215,-2.304318 -0.146889,-4.334645 3.069198,-0.977415 2.056986,-2.518352 -0.219121,-3.540397 1.876567,-1.807151 1.484149,-4.868919 -2.565455,-5.942205 0.150866,-1.805474 2.905737,-4.136876 -1.679967,-5.20493 C 10.260902,27.882167 4.6872697,22.95045 4.5245945,17.652051 z"
|
||||||
|
id="path604"
|
||||||
|
style="fill:#ffff00;fill-opacity:1;stroke:#000000;stroke-width:1.72665179;stroke-opacity:1" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="64"
|
||||||
|
height="64"
|
||||||
|
viewBox="0 0 64 64">
|
||||||
|
<path
|
||||||
|
d="M 32.003143,10.913072 57.432701,53.086929 6.567299,53.083723 z"
|
||||||
|
id="path2985"
|
||||||
|
style="fill:#ffff00;fill-opacity:0.94117647;fill-rule:nonzero;stroke:#000000;stroke-width:0.83403099;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 426 B |
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="40"
|
||||||
|
height="40"
|
||||||
|
viewBox="0 0 40 40">
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 158 B |
@@ -0,0 +1,42 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="40"
|
||||||
|
height="40"
|
||||||
|
viewBox="0 0 40 40">
|
||||||
|
<rect
|
||||||
|
width="36.075428"
|
||||||
|
height="31.096582"
|
||||||
|
x="1.962286"
|
||||||
|
y="4.4517088"
|
||||||
|
id="rect4"
|
||||||
|
style="fill:#ffff00;fill-opacity:1;fill-rule:evenodd;stroke:#000000;stroke-width:1.23004246;stroke-opacity:1" />
|
||||||
|
<rect
|
||||||
|
width="27.96859"
|
||||||
|
height="1.5012145"
|
||||||
|
x="6.0157046"
|
||||||
|
y="10.285"
|
||||||
|
id="rect6"
|
||||||
|
style="fill:#000000;fill-opacity:1;stroke:none" />
|
||||||
|
<rect
|
||||||
|
width="27.96859"
|
||||||
|
height="0.85783684"
|
||||||
|
x="6.0157056"
|
||||||
|
y="23.21689"
|
||||||
|
id="rect8"
|
||||||
|
style="fill:#000000;fill-opacity:1;stroke:none" />
|
||||||
|
<rect
|
||||||
|
width="27.96859"
|
||||||
|
height="0.85783684"
|
||||||
|
x="5.8130345"
|
||||||
|
y="28.964394"
|
||||||
|
id="rect10"
|
||||||
|
style="fill:#000000;fill-opacity:1;stroke:none" />
|
||||||
|
<rect
|
||||||
|
width="27.96859"
|
||||||
|
height="0.85783684"
|
||||||
|
x="6.0157046"
|
||||||
|
y="17.426493"
|
||||||
|
id="rect12"
|
||||||
|
style="fill:#000000;fill-opacity:1;stroke:none" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,6 @@
|
|||||||
|
<!-- This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
- License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" height="40" width="40">
|
||||||
|
<path d="M9 3.5a1.5 1.5 0 0 0-3-.001v7.95C6 12.83 7.12 14 8.5 14s2.5-1.17 2.5-2.55V5.5a.5.5 0 0 1 1 0v6.03C11.955 13.427 10.405 15 8.5 15S5.044 13.426 5 11.53V3.5a2.5 2.5 0 0 1 5 0v7.003a1.5 1.5 0 0 1-3-.003v-5a.5.5 0 0 1 1 0v5a.5.5 0 0 0 1 0Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 552 B |
@@ -0,0 +1,16 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="40"
|
||||||
|
height="40"
|
||||||
|
viewBox="0 0 40 40">
|
||||||
|
<rect
|
||||||
|
width="33.76017"
|
||||||
|
height="33.76017"
|
||||||
|
x="3.119915"
|
||||||
|
y="3.119915"
|
||||||
|
style="fill:#ffff00;fill-opacity:1;fill-rule:evenodd;stroke:#000000;stroke-width:1;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none" />
|
||||||
|
<path
|
||||||
|
d="m 17.692678,34.50206 0,-16.182224 c -1.930515,-0.103225 -3.455824,-0.730383 -4.57593,-1.881473 -1.12011,-1.151067 -1.680164,-2.619596 -1.680164,-4.405591 0,-1.992435 0.621995,-3.5796849 1.865988,-4.7617553 1.243989,-1.1820288 3.06352,-1.7730536 5.458598,-1.7730764 l 9.802246,0 0,2.6789711 -2.229895,0 0,26.3251486 -2.632515,0 0,-26.3251486 -3.45324,0 0,26.3251486 z"
|
||||||
|
style="font-size:29.42051125px;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;text-align:start;line-height:125%;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#ffffff;fill-opacity:1;stroke:#000000;stroke-width:1.07795751;stroke-opacity:1;font-family:Arial;-inkscape-font-specification:Arial" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,7 @@
|
|||||||
|
<!-- This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
- License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" height="40" width="40">
|
||||||
|
<path d="M8.156 12.5a.99.99 0 0 0 .707-.294l.523-2.574L10.5 8.499l1.058-1.04 2.65-.601a.996.996 0 0 0 0-1.414l-3.657-3.658a.996.996 0 0 0-1.414 0l-.523 2.576L7.5 5.499 6.442 6.535l-2.65.6a.996.996 0 0 0 0 1.413l3.657 3.658a.999.999 0 0 0 .707.295z"/>
|
||||||
|
<path d="M9.842.996c-.386 0-.77.146-1.06.44a.5.5 0 0 0-.136.251l-.492 2.43-1.008 1.03-.953.933-2.511.566a.5.5 0 0 0-.243.133 1.505 1.505 0 0 0-.002 2.123l1.477 1.477-2.768 2.767a.5.5 0 0 0 0 .707.5.5 0 0 0 .708 0l2.767-2.767 1.475 1.474a1.494 1.494 0 0 0 2.123-.002.5.5 0 0 0 .135-.254l.492-2.427 1.008-1.024.953-.937 2.511-.57a.5.5 0 0 0 .243-.132c.586-.58.583-1.543.002-2.125l-3.659-3.656A1.501 1.501 0 0 0 9.842.996Zm.05 1.025a.394.394 0 0 1 .305.12l3.658 3.657c.18.18.141.432.002.627l-2.41.545a.5.5 0 0 0-.24.131L10.15 8.142a.5.5 0 0 0-.007.006L9.029 9.283a.5.5 0 0 0-.133.25l-.48 2.36c-.082.053-.165.109-.26.109a.492.492 0 0 1-.353-.149L4.145 8.195c-.18-.18-.141-.432-.002-.627l2.41-.545a.5.5 0 0 0 .238-.13L7.85 5.857a.5.5 0 0 0 .007-.008l1.114-1.138a.5.5 0 0 0 .133-.25l.472-2.323a.619.619 0 0 1 .317-.117Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,6 @@
|
|||||||
|
<svg width="18" height="19" viewBox="0 0 18 19" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M12.2 3.09C12.28 3.01 12.43 3 12.43 3C12.48 3 12.58 3.02 12.66 3.1L14.45 4.89C14.58 5.02 14.58 5.22 14.45 5.35L11.7713 8.02872L9.51628 5.77372L12.2 3.09ZM13.2658 5.12L11.7713 6.6145L10.9305 5.77372L12.425 4.27921L13.2658 5.12Z" fill="#FBFBFE"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.98 9.32L8.23 11.57L10.7106 9.08938L8.45562 6.83438L5.98 9.31V9.32ZM8.23 10.1558L9.29641 9.08938L8.45562 8.24859L7.38921 9.315L8.23 10.1558Z" fill="#FBFBFE"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.1526 13.1816L16.2125 7.1217C16.7576 6.58919 17.05 5.8707 17.05 5.12C17.05 4.36931 16.7576 3.65084 16.2126 3.11834L14.4317 1.33747C13.8992 0.79242 13.1807 0.5 12.43 0.5C11.6643 0.5 10.9529 0.812929 10.4329 1.33289L3.68289 8.08289C3.04127 8.72452 3.00459 9.75075 3.57288 10.4363L1.29187 12.7239C1.09186 12.9245 0.990263 13.1957 1.0007 13.4685L1 14.5C0.447715 14.5 0 14.9477 0 15.5V17.5C0 18.0523 0.447715 18.5 1 18.5H16C16.5523 18.5 17 18.0523 17 17.5V15.5C17 14.9477 16.5523 14.5 16 14.5H10.2325C9.83594 14.5 9.39953 13.9347 10.1526 13.1816ZM4.39 9.85L4.9807 10.4407L2.39762 13.0312H6.63877L7.10501 12.565L7.57125 13.0312H8.88875L15.51 6.41C15.86 6.07 16.05 5.61 16.05 5.12C16.05 4.63 15.86 4.17 15.51 3.83L13.72 2.04C13.38 1.69 12.92 1.5 12.43 1.5C11.94 1.5 11.48 1.7 11.14 2.04L4.39 8.79C4.1 9.08 4.1 9.56 4.39 9.85ZM16 17.5V15.5H1V17.5H16Z" fill="#FBFBFE"/>
|
||||||
|
<path d="M15.1616 6.05136L15.1616 6.05132L15.1564 6.05645L8.40645 12.8064C8.35915 12.8537 8.29589 12.88 8.23 12.88C8.16411 12.88 8.10085 12.8537 8.05355 12.8064L7.45857 12.2115L7.10501 11.8579L6.75146 12.2115L6.03289 12.93H3.20465L5.33477 10.7937L5.6873 10.4402L5.33426 10.0871L4.74355 9.49645C4.64882 9.40171 4.64882 9.23829 4.74355 9.14355L11.4936 2.39355C11.7436 2.14354 12.0779 2 12.43 2C12.7883 2 13.1179 2.13776 13.3614 2.38839L13.3613 2.38843L13.3664 2.39355L15.1564 4.18355L15.1564 4.18359L15.1616 4.18864C15.4122 4.43211 15.55 4.76166 15.55 5.12C15.55 5.47834 15.4122 5.80789 15.1616 6.05136ZM7.87645 11.9236L8.23 12.2771L8.58355 11.9236L11.0642 9.44293L11.4177 9.08938L11.0642 8.73582L8.80918 6.48082L8.45562 6.12727L8.10207 6.48082L5.62645 8.95645L5.48 9.10289V9.31V9.32V9.52711L5.62645 9.67355L7.87645 11.9236ZM11.4177 8.38227L11.7713 8.73582L12.1248 8.38227L14.8036 5.70355C15.1288 5.37829 15.1288 4.86171 14.8036 4.53645L13.0136 2.74645C12.8186 2.55146 12.5792 2.5 12.43 2.5H12.4134L12.3967 2.50111L12.43 3C12.3967 2.50111 12.3966 2.50112 12.3965 2.50112L12.3963 2.50114L12.3957 2.50117L12.3947 2.50125L12.3924 2.50142L12.387 2.50184L12.3732 2.50311C12.3628 2.50416 12.3498 2.50567 12.3346 2.50784C12.3049 2.51208 12.2642 2.51925 12.2178 2.53146C12.1396 2.55202 11.9797 2.60317 11.8464 2.73645L9.16273 5.42016L8.80918 5.77372L9.16273 6.12727L11.4177 8.38227ZM1.5 16H15.5V17H1.5V16Z" stroke="#15141A"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.9 KiB |