Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3295583591 | ||
|
|
47aeaba9c8 | ||
|
|
d31173fa5e | ||
|
|
05885bfff1 | ||
|
|
168d10bf87 | ||
|
|
2dea198bcc | ||
|
|
50da075c15 | ||
|
|
23980182b0 | ||
|
|
85bec34d7d | ||
|
|
ab5fda658e | ||
|
|
4e488625fe | ||
|
|
9799843cd2 | ||
|
|
8b39bfaf10 | ||
|
|
8e66d9e7bc | ||
|
|
4bc8637a74 | ||
|
|
6ac2ed0a62 | ||
|
|
f7805a2180 | ||
|
|
c130c85b17 | ||
|
|
ed2ed00a4e | ||
|
|
286a3464f1 | ||
|
|
619673ac76 | ||
|
|
20abadb0e7 |
@@ -0,0 +1,9 @@
|
||||
# Auf dem Zielsystem laufen Shell- und Python-Dateien -- CRLF wuerde sie
|
||||
# unbrauchbar machen ("bad interpreter"). Darum hart auf LF festgelegt.
|
||||
* text=auto eol=lf
|
||||
*.sh text eol=lf
|
||||
*.py text eol=lf
|
||||
*.html text eol=lf
|
||||
*.css text eol=lf
|
||||
*.js text eol=lf
|
||||
deploy/tesm-helper text eol=lf
|
||||
+27
-14
@@ -1,17 +1,30 @@
|
||||
# Virtuelle Umgebung (Linux — das Projekt ist ausschließlich für Linux ausgelegt)
|
||||
/srv/tesm-license/venv/
|
||||
# Instanzdaten und Geheimnisse gehoeren nie ins Repo.
|
||||
instance/
|
||||
*.db
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
data.keys
|
||||
secret.key
|
||||
license.json
|
||||
license_key.json
|
||||
master_signing_key.json
|
||||
known_hosts
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.py[cod]
|
||||
.venv/
|
||||
*.egg-info/
|
||||
build/
|
||||
dist/
|
||||
|
||||
# Logfiles
|
||||
*.log
|
||||
# Werkzeuge
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
.mypy_cache/
|
||||
|
||||
# Secrets — niemals committen
|
||||
/srv/tesm-license/sqlite.db
|
||||
/srv/tesm-license/fernet.key
|
||||
/srv/tesm-license/secret.key
|
||||
/srv/tesm-license/license.json
|
||||
/srv/tesm-license/master_signing_key.json
|
||||
|
||||
# Laufzeit-Uploads — werden bei Bedarf automatisch angelegt
|
||||
/srv/tesm-license/static/uploads/
|
||||
# Release-Pakete entstehen aus einem Tag und haengen am Release -- sie gehoeren
|
||||
# nicht ins Repository. Einmal ist genau das passiert: der Tarball lag im
|
||||
# Arbeitsbaum, wanderte in den Commit, und das naechste Paket enthielt das
|
||||
# vorige. Doppelte Groesse, verschachtelter Inhalt.
|
||||
*.tar.gz
|
||||
|
||||
@@ -1,74 +1,137 @@
|
||||
# TESM-Lizenzserver
|
||||
|
||||
Master-Lizenzserver für [TESM (TimEShepManager)](https://gitea.int.eertmoed.net/alientim/tesm) —
|
||||
verwaltet Kunden und deren Lizenzen, statt Netzwerkgeräte. Ursprünglich als
|
||||
Teil des TESM-Hauptrepos gebaut (gleiche Codebasis: Login/Benutzer/Gruppen,
|
||||
LDAP/AD, NGINX- und Systemeinstellungen, Live-Log/Verlauf/Auditlog,
|
||||
Im-/Export-Grundgerüst, das komplette Rechtesystem) und in dieses eigene
|
||||
Repo ausgelagert.
|
||||
Ausgabe und Verwaltung der Lizenzen für TESM: Kunde -> Ticket -> Lizenz,
|
||||
Aktivierung online und offline, Kundenportal, Selbstbedienung, Versand über
|
||||
MS Graph und eine Hierarchie aus Unter-Lizenzservern.
|
||||
|
||||
**Das Projekt ist ausschließlich für Linux ausgelegt** (Zielsystem: eine
|
||||
Linux-VM, identisches Deployment-Muster wie TESM selbst).
|
||||
**Dieses Repository enthält genau das, was zum Installieren und Betreiben des
|
||||
Lizenzservers gebraucht wird** -- nichts darüber hinaus:
|
||||
|
||||
## Kernfunktionen
|
||||
```
|
||||
apps/tesm-license die Anwendung
|
||||
packages/tesm-core gemeinsamer Kern (Anmeldung, Rechte, Sicherung, nginx, ...)
|
||||
packages/tesm-licensing Lizenzprotokoll, gemeinsame Hälfte
|
||||
packages/tesm-licensing-server Lizenzprotokoll, Serverhälfte: ausstellen, erneuern, antworten
|
||||
deploy/ install.sh, update.sh, release.sh, systemd, privilegierter Helfer
|
||||
docs/ Betrieb und Sicherheit
|
||||
```
|
||||
|
||||
- **Kunden-/Lizenzübersicht** als Dashboard (Typ, Module, Ablaufdatum,
|
||||
Aktivierungsstatus, letzter Heartbeat)
|
||||
- **Lizenz-Ausstellung**: Trial/Standard/Custom/Enterprise, frei wählbare
|
||||
Gültigkeitsdauer, bei Custom einzeln wählbare Module
|
||||
- **Ed25519-signierte Lizenzdateien** (siehe `srv/tesm-license/licensing.py`)
|
||||
— dieselbe Datei existiert byte-identisch im TESM-Repo, da Client
|
||||
(TESM) und Master (dieses Repo) exakt dasselbe Signier-/
|
||||
Verifikationsprotokoll sprechen müssen
|
||||
- **Online-Aktivierung/-Deaktivierung/-Heartbeat** über eine schlanke
|
||||
JSON-API (`/api/activate`, `/api/deactivate`, `/api/heartbeat`,
|
||||
unauthentifiziert per Design — die Signatur der Anfrage selbst ist der
|
||||
Berechtigungsnachweis)
|
||||
- **Offline-Fallback**: dieselbe Aktivierungs-/Deaktivierungs-/Heartbeat-
|
||||
Logik auch als manuell kopierbarer Code für Kunden ohne Netzwerkzugriff
|
||||
auf den Lizenzserver
|
||||
- **E-Mail-Versand** ausgestellter Lizenzen per Microsoft Graph
|
||||
(Client-Credentials-Flow, keine zusätzliche Abhängigkeit) inkl.
|
||||
Einrichtungsanleitung und Verbindungstest in der GUI
|
||||
- Eigene Bootstrap-Lizenz des Masters selbst (`create_master_license.py`,
|
||||
rein lokal, kein externer Super-Master nötig)
|
||||
Die verwaltete Anwendung liegt hier **nicht** mit drin.
|
||||
|
||||
| Wo sonst noch | |
|
||||
|---|---|
|
||||
| [alientim/tesm](https://gitea.int.eertmoed.net/alientim/tesm) | TESM selbst |
|
||||
| [alientim/TESM-DEV](https://gitea.int.eertmoed.net/alientim/TESM-DEV) | Entwicklung: beide Anwendungen, Tests, Architekturdoku |
|
||||
|
||||
## Zweige
|
||||
|
||||
| Zweig | Inhalt |
|
||||
|---|---|
|
||||
| `main` | dieser Neubau (Version 2.0.0) |
|
||||
| `SONNET5` | der Stand davor, unverändert aufbewahrt |
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
sudo ./install.sh
|
||||
sudo bash deploy/install.sh --app tesm-license --https --domain lizenz.firma.de
|
||||
sudo -u tesm-license tesm-license-admin init-key
|
||||
sudo -u tesm-license tesm-license-admin bootstrap
|
||||
sudo -u tesm-license tesm-license-admin create-admin
|
||||
```
|
||||
|
||||
Richtet System-Pakete, die Flask-App (systemd: `tesm-license.service`)
|
||||
sowie nginx als Reverse-Proxy unter `/srv/tesm-license` ein. Erkennt
|
||||
selbstständig, ob dort bereits eine Installation existiert, und
|
||||
aktualisiert sie entsprechend (In-Place bei unverändertem
|
||||
Datenbank-Schema, sonst mit automatischem Backup + Health-Check +
|
||||
Rückroll bei Fehlschlag — siehe Kommentarkopf in `install.sh`).
|
||||
**`init-key` erzeugt den Signaturschlüssel dieses Servers. Sichern Sie ihn
|
||||
sofort.** Ohne ihn lässt sich keine ausgestellte Lizenz mehr erneuern, und
|
||||
Kunden müssten einen Ausstellerwechsel bestätigen.
|
||||
|
||||
Nach der Erstinstallation:
|
||||
Zielzustand: **Port 443 mit TLS, Port 80 leitet dorthin um** -- ausser dem
|
||||
ACME-Pfad, der erreichbar bleiben muss, sonst kann Let's Encrypt nie erneuern.
|
||||
Sitzt ein eigener Reverse Proxy davor, der TLS beendet, läuft der Dienst ohne
|
||||
`--https` schlicht auf Port 80.
|
||||
|
||||
Der Lizenzserver darf neben TESM auf demselben Host laufen; nginx unterscheidet
|
||||
die beiden am Servernamen, und jede Installation hat ihren eigenen `SITE_KEY`
|
||||
für nginx-Site, logrotate, Zertifikate und Sitzungscookie.
|
||||
|
||||
### Aktualisieren
|
||||
|
||||
```bash
|
||||
sudo /srv/tesm-license/venv/bin/python3 /srv/tesm-license/create_admin.py
|
||||
sudo /srv/tesm-license/venv/bin/python3 /srv/tesm-license/create_master_license.py
|
||||
sudo bash deploy/update.sh --app tesm-license
|
||||
```
|
||||
|
||||
## Update (von einem bereits installierten System aus)
|
||||
Vor jedem Update entsteht ein vollständiges Backup, danach läuft ein
|
||||
Health-Check mit automatischem Rollback.
|
||||
|
||||
---
|
||||
|
||||
## Betrieb von der Kommandozeile
|
||||
|
||||
```bash
|
||||
sudo ./update.sh
|
||||
# oder für eine bestimmte Version statt "latest":
|
||||
TESM_RELEASE_TAG=v1.0.0 sudo -E ./update.sh
|
||||
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 bootstrap # Grunddaten
|
||||
sudo -u tesm-license tesm-license-admin create-admin
|
||||
sudo -u tesm-license tesm-license-admin migrate-legacy # Altbestand übernehmen
|
||||
```
|
||||
|
||||
Lädt das aktuelle Gitea-Release herunter und übergibt an `install.sh`.
|
||||
`migrate-legacy` liest die SQLite-Datei eines alten Lizenzservers **nur
|
||||
lesend**, übernimmt dessen Signaturschlüssel und stellt die Lizenzen mit ihrer
|
||||
restlichen Laufzeit neu aus. Wird der Schlüssel nicht übernommen, müssen
|
||||
Kunden den Ausstellerwechsel einmal bestätigen.
|
||||
|
||||
## Zusammenspiel mit TESM
|
||||
---
|
||||
|
||||
Eine TESM-Instanz aktiviert sich gegen genau einen Lizenzserver
|
||||
(`master_endpoint`, in der Lizenzdatei hinterlegt). Ein Protokoll, zwei
|
||||
Transportwege: online automatisch per HTTPS/JSON, offline als manuell
|
||||
auszutauschender Code — beide Seiten nutzen dieselben Funktionen aus
|
||||
`licensing.py`. Details zum kryptografischen Format und Ablauf siehe die
|
||||
Docstrings in `srv/tesm-license/licensing.py` bzw. dem identischen Modul
|
||||
im [TESM-Repo](https://gitea.int.eertmoed.net/alientim/tesm).
|
||||
## Release bauen
|
||||
|
||||
```bash
|
||||
git tag -a v2.0.0 -m "TESM-Lizenzserver 2.0.0" && git push origin v2.0.0
|
||||
bash deploy/release.sh v2.0.0
|
||||
```
|
||||
|
||||
Heraus kommt `tesm-license-v2.0.0.tar.gz` samt SHA-256. Der Name kommt aus dem
|
||||
Repository -- ein Lizenzserver-Release, das `tesm-...` heisst, verwechselt man
|
||||
genau einmal.
|
||||
|
||||
---
|
||||
|
||||
## Lizenzprotokoll in Kurzform
|
||||
|
||||
* Ed25519, getrennte Signaturkontexte je Nachrichtenart
|
||||
(`tesm/license-file/v2\0`) -- eine Signatur aus einem Kontext ist in einem
|
||||
anderen nicht verwendbar.
|
||||
* Kanonisches JSON, damit dieselbe Aussage genau eine Signatur hat.
|
||||
* Nonce gegen Wiedereinspielung, `seq` gegen das Zurückrollen auf eine ältere
|
||||
Lizenz.
|
||||
* Aussteller wird beim ersten Kontakt gemerkt (TOFU); ein Wechsel muss
|
||||
ausdrücklich bestätigt werden.
|
||||
* Der Fingerabdruck der Maschine enthält **keinen** Hostnamen -- Umbenennen
|
||||
brach im Vorgänger die Bindung.
|
||||
* Offline-Aktivierung über einen kurzen Code in beide Richtungen; die Lizenz
|
||||
bleibt auf dem Server als "Aktivierung offen" markiert, bis sie zurückkommt.
|
||||
* Keine Lizenz ohne `master_endpoint`: eine Ausstellung ohne Endpunkt wird
|
||||
abgelehnt statt eine Lizenz zu erzeugen, die sich nie aktivieren kann.
|
||||
|
||||
---
|
||||
|
||||
## Dokumentation
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| [docs/BETRIEB.md](docs/BETRIEB.md) | Installation, Update, HTTPS, Sicherung, Fehlersuche |
|
||||
| [docs/SICHERHEIT.md](docs/SICHERHEIT.md) | Anmeldung, Sitzungen, CSRF, Geheimnisse, Rechtetrennung |
|
||||
|
||||
Aufbau und Entwurfsentscheidungen stehen in `docs/ARCHITEKTUR.md` im
|
||||
Entwicklungsrepository, ebenso die Tests.
|
||||
|
||||
---
|
||||
|
||||
## Was diese Fassung anders macht
|
||||
|
||||
* **Keine doppelte `licensing.py`.** Ein Paket, das beide Anwendungen
|
||||
installieren -- statt zweier Dateien, die byte-identisch bleiben sollen.
|
||||
* **Kein root für die Anwendung.** Ein einziger allowlisted Helfer mit
|
||||
Argumentprüfung.
|
||||
* **CSRF-Schutz überhaupt.** Der Vorgänger hatte keinen.
|
||||
* **Rechte werden serverseitig geprüft**, nicht nur im Template ausgeblendet.
|
||||
* **Verschachtelte Datenbankverbindungen sind ein Fehler**, kein Deadlock.
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "tesm-license"
|
||||
version = "2.0.7"
|
||||
description = "TESM-Lizenzserver -- Kunden, Tickets, Lizenzen, Aktivierung"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"tesm-core>=2.0.0",
|
||||
"tesm-licensing>=2.0.0",
|
||||
"PyYAML>=6.0",
|
||||
"ldap3>=2.9",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
server = ["gunicorn>=23.0"]
|
||||
|
||||
[project.scripts]
|
||||
tesm-license-admin = "tesm_license.cli:main"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
tesm_license = ["templates/**/*.html", "static/**/*"]
|
||||
@@ -0,0 +1,212 @@
|
||||
"""TESM-Lizenzserver -- Kunden, Tickets, Lizenzen, Aktivierung.
|
||||
|
||||
Kein Fork von TESM: beide Anwendungen bauen auf demselben Paket ``tesm_core``
|
||||
auf. Der Vorgaenger kopierte die gesamte Codebasis und entfernte danach, was er
|
||||
nicht brauchte -- mit der Folge, dass jede Korrektur zweimal gemacht werden
|
||||
musste (und ``licensing.py`` byte-identisch gehalten werden *musste*).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from flask import Flask
|
||||
from tesm_core.app_factory import create_base_app
|
||||
from tesm_core.config import load_core_config
|
||||
from tesm_core.extension import core
|
||||
from tesm_core.views import trash as trash_views
|
||||
|
||||
from .navigation import NAVIGATION
|
||||
from .permissions import PERMISSIONS, ROLE_PRESETS
|
||||
from .schema import LICENSE_MIGRATIONS
|
||||
from .settings_spec import LICENSE_SERVER_SETTINGS
|
||||
|
||||
#: Version aus den Metadaten des installierten Pakets -- die einzige Quelle
|
||||
#: ist ``pyproject.toml``. Vorher stand hier eine feste Zahl, und die
|
||||
#: Fusszeile zeigte vier Releases lang 2.0.2.
|
||||
#:
|
||||
#: Im Quellbaum ohne ``pip install`` gibt es keine Metadaten; dann steht dort
|
||||
#: "unbekannt" statt einer erfundenen Nummer. In einem editierbaren Install
|
||||
#: zeigt sie den Stand des letzten ``pip install -e`` -- also die Version, die
|
||||
#: tatsaechlich installiert ist.
|
||||
try: # pragma: no cover - haengt an der Installationsart
|
||||
from importlib.metadata import PackageNotFoundError, version as _paketversion
|
||||
|
||||
__version__ = _paketversion("tesm-license")
|
||||
except (ImportError, PackageNotFoundError): # pragma: no cover
|
||||
__version__ = "unbekannt"
|
||||
|
||||
APP_KEY = "tesm-license"
|
||||
APP_NAME = "TESM-Lizenzserver"
|
||||
APP_SHORT = "TL"
|
||||
APP_EDITION = "Lizenzverwaltung"
|
||||
|
||||
#: Zeile unter der Wortmarke auf den Anmeldeseiten, in derselben Schrift wie
|
||||
#: bei TESM. Ein Wort statt einer Liste: der Lizenzserver tut genau eine Sache.
|
||||
AUTH_TAGLINE = "LIZENZIEREN"
|
||||
ENV_PREFIX = "TESM_LICENSE_"
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def master_key_path(config: Any) -> Path:
|
||||
return Path(config.data_dir) / "master_signing_key.json"
|
||||
|
||||
|
||||
def create_app(**overrides: Any) -> Flask:
|
||||
config = load_core_config(
|
||||
app_key=APP_KEY,
|
||||
app_name=APP_NAME,
|
||||
env_prefix=ENV_PREFIX,
|
||||
default_bind="127.0.0.1:5001",
|
||||
)
|
||||
|
||||
app = create_base_app(
|
||||
config=config,
|
||||
permissions=PERMISSIONS,
|
||||
nav=NAVIGATION,
|
||||
role_presets=ROLE_PRESETS,
|
||||
app_migrations={"tesm_license": LICENSE_MIGRATIONS},
|
||||
settings_specs=LICENSE_SERVER_SETTINGS,
|
||||
import_name="tesm_license",
|
||||
app_short=APP_SHORT,
|
||||
app_edition=APP_EDITION,
|
||||
auth_tagline=AUTH_TAGLINE,
|
||||
app_version=__version__,
|
||||
enable_license=True,
|
||||
)
|
||||
|
||||
_register_blueprints(app)
|
||||
_register_registry(app)
|
||||
_startup_tasks(app)
|
||||
|
||||
if overrides:
|
||||
app.config.update(overrides)
|
||||
return app
|
||||
|
||||
|
||||
def _register_blueprints(app: Flask) -> None:
|
||||
from .blueprints import (
|
||||
api,
|
||||
backup,
|
||||
customers,
|
||||
dashboard,
|
||||
licenses,
|
||||
own_license,
|
||||
portal,
|
||||
selfservice,
|
||||
settings,
|
||||
tickets,
|
||||
)
|
||||
|
||||
app.register_blueprint(dashboard.bp)
|
||||
app.register_blueprint(customers.bp)
|
||||
app.register_blueprint(tickets.bp)
|
||||
app.register_blueprint(licenses.bp)
|
||||
app.register_blueprint(api.bp)
|
||||
app.register_blueprint(portal.bp)
|
||||
app.register_blueprint(selfservice.bp)
|
||||
app.register_blueprint(own_license.bp)
|
||||
app.register_blueprint(settings.bp)
|
||||
app.register_blueprint(backup.bp)
|
||||
|
||||
# Die Uebersicht ist die Startseite. Anders als bei TESM verlangt sie eine
|
||||
# Anmeldung: das hier ist eine interne Verwaltungsanwendung, kein
|
||||
# Informationsboard fuer das Netz.
|
||||
app.add_url_rule("/", "index", dashboard.index_view)
|
||||
|
||||
|
||||
def _register_registry(app: Flask) -> None:
|
||||
from tesm_core.system import hostnet # noqa: F401 - haelt das Modul geladen
|
||||
|
||||
from .services import issuance, mail, reporting
|
||||
from .services.monitorlog import LiveLog
|
||||
|
||||
extension = core(app)
|
||||
# Beide Anwendungen koennen auf demselben Host laufen -- eigener Port je Dienst.
|
||||
extension.registry["upstream"] = extension.config.bind
|
||||
extension.registry["static_root"] = str(extension.config.base_dir / "static")
|
||||
extension.registry["websocket_path"] = ""
|
||||
extension.registry["master_key_path"] = master_key_path(extension.config)
|
||||
extension.registry["live_log"] = LiveLog(extension.config.log_dir / "live.log")
|
||||
extension.registry["status_pill_builder"] = _status_pills
|
||||
extension.registry["reencrypt_secrets"] = _reencrypt_secrets
|
||||
|
||||
# Als Sub-Lizenzserver meldet diese Instanz ihren Bestand an ihren Master.
|
||||
if extension.license is not None:
|
||||
extension.license.set_report_builder(reporting.build_own_report)
|
||||
|
||||
|
||||
def _reencrypt_secrets(conn: Any, keystore: Any) -> int:
|
||||
"""Verschluesselt gespeicherte Geheimnisse unter dem neuen Datenschluessel neu."""
|
||||
from tesm_core.auth.directory import AAD_LDAP_BIND, SERVICE_PURPOSE as LDAP_PURPOSE
|
||||
from tesm_core.db import Database
|
||||
from tesm_core.keystore import DecryptionError
|
||||
|
||||
from .services.mail import AAD_GRAPH_SECRET, SERVICE_PURPOSE as MAIL_PURPOSE
|
||||
|
||||
migrated = 0
|
||||
for purpose, aad in ((LDAP_PURPOSE, AAD_LDAP_BIND), (MAIL_PURPOSE, AAD_GRAPH_SECRET)):
|
||||
token = Database.value(
|
||||
conn, "SELECT password_encrypted FROM service_accounts WHERE purpose=?", (purpose,)
|
||||
)
|
||||
if not token or not keystore.needs_reencrypt(str(token)):
|
||||
continue
|
||||
try:
|
||||
plain = keystore.decrypt(str(token), aad=aad)
|
||||
except DecryptionError:
|
||||
continue
|
||||
conn.execute(
|
||||
"UPDATE service_accounts SET password_encrypted=? WHERE purpose=?",
|
||||
(keystore.encrypt(plain, aad=aad), purpose),
|
||||
)
|
||||
migrated += 1
|
||||
return migrated
|
||||
|
||||
|
||||
def _status_pills(user: Any) -> list[dict[str, str]]:
|
||||
if not user.is_authenticated or not user.has_any(("licenses.view",)):
|
||||
return []
|
||||
from tesm_core.extension import db
|
||||
|
||||
from .services import issuance
|
||||
|
||||
summary = issuance.dashboard_summary(db())
|
||||
critical = len(summary["buckets"]["red"])
|
||||
if not critical:
|
||||
return []
|
||||
return [
|
||||
{
|
||||
"label": f"{critical} Lizenz(en) kritisch",
|
||||
"css_class": "status-pill--danger",
|
||||
"dot": "1",
|
||||
"dot_class": "status-pill__dot--pulse",
|
||||
"title": "Weniger als 30 Tage Restlaufzeit oder bereits abgelaufen.",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def _startup_tasks(app: Flask) -> None:
|
||||
extension = core(app)
|
||||
if not extension.config.web_process:
|
||||
logger.info("Startaufgaben uebersprungen -- kein Webprozess.")
|
||||
return
|
||||
|
||||
from .services import activation
|
||||
|
||||
with extension.database.session() as conn, extension.database.transaction(conn):
|
||||
removed = activation.prune_nonces(conn)
|
||||
if removed:
|
||||
logger.info("%s abgelaufene Protokoll-Nonces entfernt", removed)
|
||||
|
||||
# Auch der Lizenzserver hat eine eigene Lizenz (Rolle master oder
|
||||
# license_server) und meldet sich bei einem Master, falls es einen gibt.
|
||||
if extension.license is not None:
|
||||
import socket
|
||||
|
||||
extension.license.start_heartbeat(hostname=socket.gethostname())
|
||||
|
||||
|
||||
app = None # wird von wsgi.py gesetzt
|
||||
@@ -0,0 +1 @@
|
||||
"""Blueprints des Lizenzservers."""
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Maschinen-API: Aktivierung, Deaktivierung, Heartbeat.
|
||||
|
||||
Diese Endpunkte werden nicht ueber Cookies autorisiert, sondern ueber die
|
||||
Ed25519-Signatur der Anfrage -- darum sind sie vom CSRF-Schutz ausgenommen. Das
|
||||
ist die einzige Ausnahme in der ganzen Anwendung, und sie ist es nur, weil hier
|
||||
gar kein Cookie im Spiel ist.
|
||||
|
||||
Zusaetzlich greift eine Ratenbegrenzung pro Absenderadresse: ohne sie waere der
|
||||
Endpunkt ein kostenloser Weg, Argon2- und Ed25519-Rechenzeit zu verbrennen.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
from tesm_core.extension import core, db, transaction
|
||||
from tesm_core.security import csrf, ratelimit
|
||||
|
||||
from ..services import activation
|
||||
from .common import signing_key
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
bp = Blueprint("api", __name__, url_prefix="/api/v1/licenses")
|
||||
|
||||
RATE_BUCKET = "api:license"
|
||||
RATE_LIMIT = 60
|
||||
RATE_WINDOW_MINUTES = 5
|
||||
MAX_BODY_BYTES = 512 * 1024
|
||||
|
||||
|
||||
def _client_ip() -> str:
|
||||
from tesm_core.auth.service import client_ip
|
||||
|
||||
return client_ip() or "unbekannt"
|
||||
|
||||
|
||||
def _rate_limited() -> bool:
|
||||
conn = db()
|
||||
verdict = ratelimit.check(
|
||||
conn,
|
||||
RATE_BUCKET,
|
||||
_client_ip(),
|
||||
limit=RATE_LIMIT,
|
||||
window_minutes=RATE_WINDOW_MINUTES,
|
||||
)
|
||||
with transaction():
|
||||
ratelimit.record(conn, RATE_BUCKET, _client_ip())
|
||||
return verdict.blocked
|
||||
|
||||
|
||||
def _handle(action: str):
|
||||
if _rate_limited():
|
||||
return (
|
||||
jsonify({"error": "rate_limited", "message": "Zu viele Anfragen."}),
|
||||
429,
|
||||
{"Retry-After": str(RATE_WINDOW_MINUTES * 60)},
|
||||
)
|
||||
|
||||
if request.content_length and request.content_length > MAX_BODY_BYTES:
|
||||
return jsonify({"error": "too_large", "message": "Anfrage zu gross."}), 413
|
||||
|
||||
payload = request.get_json(silent=True)
|
||||
if not isinstance(payload, dict):
|
||||
return jsonify({"error": "bad_request", "message": "JSON-Objekt erwartet."}), 400
|
||||
|
||||
processor = activation.PROCESSORS[action]
|
||||
key = signing_key()
|
||||
conn = db()
|
||||
with transaction():
|
||||
outcome = processor(conn, payload, master_private_key=key.private_key)
|
||||
|
||||
live = core().registry.get("live_log")
|
||||
if live is not None:
|
||||
target = str(payload.get("license_id") or "unbekannt")[:12]
|
||||
if outcome.ok:
|
||||
live.info("api", f"{action} von {target} -- {outcome.response.get('status')}")
|
||||
else:
|
||||
live.warning(
|
||||
"api", f"{action} von {target} abgewiesen: {outcome.response.get('error')}"
|
||||
)
|
||||
return jsonify(outcome.response), outcome.status_code
|
||||
|
||||
|
||||
@bp.post("/activate")
|
||||
@csrf.exempt
|
||||
def activate():
|
||||
return _handle("activate")
|
||||
|
||||
|
||||
@bp.post("/deactivate")
|
||||
@csrf.exempt
|
||||
def deactivate():
|
||||
return _handle("deactivate")
|
||||
|
||||
|
||||
@bp.post("/heartbeat")
|
||||
@csrf.exempt
|
||||
def heartbeat():
|
||||
return _handle("heartbeat")
|
||||
|
||||
|
||||
@bp.get("/hinweis")
|
||||
def info():
|
||||
"""Kurze, oeffentliche Selbstbeschreibung -- ohne verwertbare Details."""
|
||||
import tesm_licensing as lic
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"service": "tesm-license",
|
||||
"protocol": lic.FORMAT_VERSION,
|
||||
"actions": list(lic.ACTIONS),
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,201 @@
|
||||
"""Sicherung und Uebertragung des Lizenzservers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from flask import Blueprint, Response, flash, redirect, render_template, request, session, url_for
|
||||
from tesm_core import audit
|
||||
from tesm_core.auth.models import current_user, permission_required, reauth_required
|
||||
from tesm_core.auth.service import client_ip
|
||||
from tesm_core.extension import core, db, transaction
|
||||
|
||||
from ..services import transfer
|
||||
from .common import log_event
|
||||
|
||||
bp = Blueprint("backup", __name__, url_prefix="/sicherung")
|
||||
|
||||
MAX_IMPORT_BYTES = 64 * 1024 * 1024
|
||||
PREVIEW_SESSION_KEY = "_import_token"
|
||||
|
||||
|
||||
def _allowed_categories() -> list[transfer.Category]:
|
||||
allowed: list[transfer.Category] = []
|
||||
for category in transfer.CATEGORIES:
|
||||
if category.admin_only and not current_user.is_admin:
|
||||
continue
|
||||
if category.sensitive and not current_user.has("backup.secrets"):
|
||||
continue
|
||||
allowed.append(category)
|
||||
return allowed
|
||||
|
||||
|
||||
@bp.get("/")
|
||||
@permission_required("backup.view", "backup.export", "backup.execute", mode="any")
|
||||
def overview():
|
||||
conn = db()
|
||||
preview = None
|
||||
token = session.get(PREVIEW_SESSION_KEY, "")
|
||||
if token:
|
||||
try:
|
||||
payload = transfer.load_preview(conn, token, actor=current_user.username)
|
||||
preview = {"token": token, "summary": transfer.summarize(payload)}
|
||||
except transfer.TransferError:
|
||||
session.pop(PREVIEW_SESSION_KEY, None)
|
||||
|
||||
return render_template(
|
||||
"license/backup.html",
|
||||
categories=_allowed_categories(),
|
||||
all_categories=transfer.CATEGORIES,
|
||||
preview=preview,
|
||||
min_passphrase=transfer.MIN_PASSPHRASE_LENGTH,
|
||||
preview_ttl=transfer.PREVIEW_TTL_MINUTES,
|
||||
can_secrets=current_user.has("backup.secrets"),
|
||||
signing_key_path=str(core().registry["master_key_path"]),
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/export")
|
||||
@permission_required("backup.export")
|
||||
@reauth_required
|
||||
def export():
|
||||
conn = db()
|
||||
allowed = {category.key for category in _allowed_categories()}
|
||||
chosen = [key for key in request.form.getlist("categories") if key in allowed]
|
||||
if not chosen:
|
||||
flash("Es wurde keine Kategorie ausgewaehlt.", "warning")
|
||||
return redirect(url_for("backup.overview"))
|
||||
|
||||
passphrase = request.form.get("passphrase") or ""
|
||||
if passphrase != (request.form.get("passphrase_repeat") or ""):
|
||||
flash("Die beiden Passphrasen stimmen nicht ueberein.", "danger")
|
||||
return redirect(url_for("backup.overview"))
|
||||
|
||||
try:
|
||||
envelope = transfer.seal(transfer.build_export(conn, categories=chosen), passphrase)
|
||||
except transfer.TransferError as exc:
|
||||
flash(str(exc), "danger")
|
||||
return redirect(url_for("backup.overview"))
|
||||
|
||||
log_event(
|
||||
"backup.exported",
|
||||
detail=f"Kategorien: {', '.join(chosen)}",
|
||||
severity="warning",
|
||||
scope="sicherung",
|
||||
)
|
||||
stamp = datetime.now().strftime("%Y%m%d-%H%M")
|
||||
return Response(
|
||||
envelope.encode("utf-8"),
|
||||
mimetype="application/json",
|
||||
headers={
|
||||
"Content-Disposition": f'attachment; filename="tesm-lizenzserver-{stamp}.json"',
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/import/vorschau")
|
||||
@permission_required("backup.execute")
|
||||
@reauth_required
|
||||
def import_preview():
|
||||
conn = db()
|
||||
upload = request.files.get("backup_file")
|
||||
if upload is None or not upload.filename:
|
||||
flash("Keine Datei ausgewaehlt.", "danger")
|
||||
return redirect(url_for("backup.overview"))
|
||||
raw = upload.stream.read(MAX_IMPORT_BYTES + 1)
|
||||
if len(raw) > MAX_IMPORT_BYTES:
|
||||
flash("Die Datei ist zu gross.", "danger")
|
||||
return redirect(url_for("backup.overview"))
|
||||
|
||||
try:
|
||||
payload, header = transfer.unseal(raw, request.form.get("passphrase") or "")
|
||||
except transfer.TransferError as exc:
|
||||
flash(str(exc), "danger")
|
||||
return redirect(url_for("backup.overview"))
|
||||
|
||||
with transaction():
|
||||
token = transfer.store_preview(conn, payload, actor=current_user.username)
|
||||
session[PREVIEW_SESSION_KEY] = token
|
||||
log_event(
|
||||
"backup.import_previewed",
|
||||
detail=f"Erstellt {header.get('created_at')}",
|
||||
severity="notice",
|
||||
scope="sicherung",
|
||||
)
|
||||
flash(
|
||||
f"Datei geoeffnet. Sie haben {transfer.PREVIEW_TTL_MINUTES} Minuten Zeit, die "
|
||||
"Uebernahme zu bestaetigen.",
|
||||
"success",
|
||||
)
|
||||
return redirect(url_for("backup.overview"))
|
||||
|
||||
|
||||
@bp.post("/import/uebernehmen")
|
||||
@permission_required("backup.execute")
|
||||
@reauth_required
|
||||
def import_apply():
|
||||
extension = core()
|
||||
conn = db()
|
||||
token = session.get(PREVIEW_SESSION_KEY, "")
|
||||
if not token:
|
||||
flash("Es liegt keine geoeffnete Sicherung vor.", "danger")
|
||||
return redirect(url_for("backup.overview"))
|
||||
|
||||
allowed = {category.key for category in _allowed_categories()}
|
||||
chosen = [key for key in request.form.getlist("categories") if key in allowed]
|
||||
if not chosen:
|
||||
flash("Es wurde keine Kategorie ausgewaehlt.", "warning")
|
||||
return redirect(url_for("backup.overview"))
|
||||
|
||||
try:
|
||||
payload = transfer.load_preview(conn, token, actor=current_user.username)
|
||||
except transfer.TransferError as exc:
|
||||
session.pop(PREVIEW_SESSION_KEY, None)
|
||||
flash(str(exc), "danger")
|
||||
return redirect(url_for("backup.overview"))
|
||||
|
||||
with transaction():
|
||||
report = transfer.apply_import(
|
||||
conn,
|
||||
payload,
|
||||
categories=chosen,
|
||||
set_setting=lambda key, value: extension.settings.set(
|
||||
conn, key, value, actor=current_user.username
|
||||
),
|
||||
)
|
||||
transfer.drop_preview(conn, token)
|
||||
audit.log(
|
||||
conn,
|
||||
actor=current_user.username,
|
||||
actor_id=current_user.id,
|
||||
action="backup.imported",
|
||||
detail="; ".join(
|
||||
f"{key}: {counts['created']} neu, {counts['updated']} aktualisiert, "
|
||||
f"{counts['skipped']} uebersprungen"
|
||||
for key, counts in report.items()
|
||||
),
|
||||
ip=client_ip(),
|
||||
severity="warning",
|
||||
)
|
||||
session.pop(PREVIEW_SESSION_KEY, None)
|
||||
lines = [
|
||||
f"{transfer.CATEGORY_BY_KEY[key].label}: {counts['created']} neu, "
|
||||
f"{counts['updated']} aktualisiert"
|
||||
+ (f", {counts['skipped']} uebersprungen" if counts["skipped"] else "")
|
||||
for key, counts in report.items()
|
||||
if key in transfer.CATEGORY_BY_KEY
|
||||
]
|
||||
flash("Uebernahme abgeschlossen. " + " | ".join(lines), "success")
|
||||
return redirect(url_for("backup.overview"))
|
||||
|
||||
|
||||
@bp.post("/import/verwerfen")
|
||||
@permission_required("backup.execute")
|
||||
def import_discard():
|
||||
token = session.pop(PREVIEW_SESSION_KEY, "")
|
||||
if token:
|
||||
with transaction() as conn:
|
||||
transfer.drop_preview(conn, token)
|
||||
flash("Zwischenstand verworfen.", "info")
|
||||
return redirect(url_for("backup.overview"))
|
||||
@@ -0,0 +1,135 @@
|
||||
"""Gemeinsame Helfer der Lizenzserver-Blueprints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from flask import url_for
|
||||
from tesm_core import audit
|
||||
from tesm_core.auth.models import current_user
|
||||
from tesm_core.auth.service import client_ip
|
||||
from tesm_core.extension import core, db, transaction
|
||||
|
||||
import tesm_licensing as lic
|
||||
|
||||
from ..services import master_key
|
||||
|
||||
|
||||
def log_event(
|
||||
action: str,
|
||||
*,
|
||||
target: str = "",
|
||||
detail: str = "",
|
||||
severity: str = "info",
|
||||
scope: str = "lizenz",
|
||||
) -> None:
|
||||
"""Schreibt ins Aenderungsprotokoll **und** ins laufende Protokoll."""
|
||||
extension = core()
|
||||
with transaction() as conn:
|
||||
audit.log(
|
||||
conn,
|
||||
actor=current_user.username or "system",
|
||||
actor_id=getattr(current_user, "id", None) or None,
|
||||
action=action,
|
||||
target=target,
|
||||
detail=detail,
|
||||
ip=client_ip(),
|
||||
severity=severity,
|
||||
)
|
||||
live = extension.registry.get("live_log")
|
||||
if live is not None:
|
||||
text = f"{action} {target}".strip() + (f" -- {detail}" if detail else "")
|
||||
if severity in {"warning", "critical"}:
|
||||
live.warning(scope, text)
|
||||
else:
|
||||
live.info(scope, text)
|
||||
|
||||
|
||||
def signing_key() -> master_key.MasterKey:
|
||||
"""Signaturschluessel dieser Installation (erzeugt ihn beim ersten Aufruf)."""
|
||||
return master_key.ensure(core().registry["master_key_path"])
|
||||
|
||||
|
||||
def vendor_info() -> dict[str, Any]:
|
||||
"""Anbieterdaten, wie sie in jede Lizenz eingebettet werden."""
|
||||
values = core().settings.get_many(
|
||||
db(),
|
||||
[
|
||||
"vendor_name",
|
||||
"vendor_email",
|
||||
"vendor_phone",
|
||||
"vendor_address",
|
||||
"vendor_logo_base64",
|
||||
],
|
||||
)
|
||||
return {
|
||||
"name": str(values["vendor_name"] or ""),
|
||||
"email": str(values["vendor_email"] or ""),
|
||||
"phone": str(values["vendor_phone"] or ""),
|
||||
"address": str(values["vendor_address"] or ""),
|
||||
"logo_base64": str(values["vendor_logo_base64"] or ""),
|
||||
}
|
||||
|
||||
|
||||
def master_endpoint() -> str:
|
||||
return str(core().settings.get(db(), "master_endpoint") or "").rstrip("/")
|
||||
|
||||
|
||||
def customer_facing_url(endpoint: str, **values: Any) -> str:
|
||||
"""Baut Links, die ein Kunde tatsaechlich oeffnen soll.
|
||||
|
||||
``url_for(..., _external=True)`` nimmt den Host-Header der aktuellen
|
||||
Anfrage. Bei einem intern angemeldeten Administrator ist das oft eine interne
|
||||
IP -- fuer den Browser oder das Mailprogramm eines Kunden unbrauchbar. Darum
|
||||
kommt die Basis aus der Einstellung ``master_endpoint``; nur wenn die leer
|
||||
ist (frische Installation), faellt es auf das Host-Header-Verhalten zurueck.
|
||||
"""
|
||||
base = master_endpoint()
|
||||
if not base:
|
||||
return url_for(endpoint, _external=True, **values)
|
||||
return urljoin(base + "/", url_for(endpoint, **values).lstrip("/"))
|
||||
|
||||
|
||||
def can_issue_license_server_licenses() -> bool:
|
||||
"""Darf diese Instanz Lizenzen fuer weitere Lizenzserver ausstellen?
|
||||
|
||||
Das haengt an der Rolle der **eigenen** Lizenzdatei, nicht an einem
|
||||
Gruppenrecht. Ein Gruppenrecht koennte sich ein lokaler Administrator eines
|
||||
Sub-Lizenzservers selbst einraeumen; eine von woanders signierte Lizenzdatei
|
||||
nicht. Zusaetzlich verlangt es echte Administratorrechte -- das Ausstellen
|
||||
einer Lizenzserver-Lizenz erzeugt eine neue Vertrauensgrenze und ist damit
|
||||
keine delegierbare Alltagsaufgabe.
|
||||
"""
|
||||
manager = core().license
|
||||
if manager is None:
|
||||
return bool(getattr(current_user, "is_admin", False))
|
||||
return bool(
|
||||
getattr(current_user, "is_admin", False)
|
||||
and manager.active()
|
||||
and manager.state.role == "master"
|
||||
)
|
||||
|
||||
|
||||
def module_labels() -> dict[str, str]:
|
||||
return dict(lic.MODULE_LABELS)
|
||||
|
||||
|
||||
def type_labels() -> dict[str, str]:
|
||||
return dict(lic.LICENSE_TYPE_LABELS)
|
||||
|
||||
|
||||
def role_labels() -> dict[str, str]:
|
||||
return dict(lic.ROLE_LABELS)
|
||||
|
||||
|
||||
def license_filename(license_id: str, customer_name: str) -> str:
|
||||
safe = "".join(
|
||||
char if char.isalnum() or char in "-_" else "-" for char in (customer_name or "kunde")
|
||||
).strip("-")
|
||||
return f"tesm-lizenz-{safe.lower() or 'kunde'}-{license_id[:8]}.json"
|
||||
|
||||
|
||||
def bundle_bytes(bundle: dict[str, Any]) -> bytes:
|
||||
return (json.dumps(bundle, indent=2, ensure_ascii=False, sort_keys=True) + "\n").encode("utf-8")
|
||||
@@ -0,0 +1,126 @@
|
||||
"""Kunden: Liste, Detailueberblick, Pflege."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from flask import Blueprint, abort, flash, redirect, render_template, request, url_for
|
||||
from tesm_core.auth.models import current_user, permission_required
|
||||
from tesm_core.extension import db, transaction
|
||||
|
||||
from ..services import issuance, reporting
|
||||
from .common import customer_facing_url, log_event
|
||||
|
||||
bp = Blueprint("customers", __name__, url_prefix="/kunden")
|
||||
|
||||
FIELDS = (
|
||||
"name",
|
||||
"contact_email",
|
||||
"contact_phone",
|
||||
"contact_person",
|
||||
"street",
|
||||
"house_number",
|
||||
"postal_code",
|
||||
"city",
|
||||
"notes",
|
||||
)
|
||||
|
||||
|
||||
def _form() -> dict[str, str]:
|
||||
return {key: request.form.get(key, "") for key in FIELDS}
|
||||
|
||||
|
||||
@bp.get("/")
|
||||
@permission_required("customers.view")
|
||||
def overview():
|
||||
return render_template("license/customers.html", customers=issuance.list_customers(db()))
|
||||
|
||||
|
||||
@bp.get("/<int:customer_id>")
|
||||
@permission_required("customers.view")
|
||||
def detail(customer_id: int):
|
||||
conn = db()
|
||||
customer = issuance.get_customer(conn, customer_id)
|
||||
if customer is None:
|
||||
abort(404)
|
||||
tickets = issuance.list_tickets(conn, customer_id=customer_id)
|
||||
licenses = [
|
||||
{**row, **issuance.display_state(row)}
|
||||
for row in issuance.list_licenses(conn, customer_id=customer_id)
|
||||
]
|
||||
reported = (
|
||||
reporting.reported_for_customer(conn, customer_id)
|
||||
if current_user.has("reports.view")
|
||||
else {"customers": [], "source_license_id": ""}
|
||||
)
|
||||
return render_template(
|
||||
"license/customer_detail.html",
|
||||
customer=customer,
|
||||
tickets=tickets,
|
||||
licenses=licenses,
|
||||
reported=reported,
|
||||
portal_url=customer_facing_url("portal.request_link"),
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/neu")
|
||||
@permission_required("customers.create")
|
||||
def create():
|
||||
try:
|
||||
with transaction() as conn:
|
||||
customer_id = issuance.create_customer(
|
||||
conn, actor=current_user.username, **_form()
|
||||
)
|
||||
log_event(
|
||||
"customer.created",
|
||||
target=request.form.get("name", ""),
|
||||
severity="notice",
|
||||
scope="kunde",
|
||||
)
|
||||
flash("Kunde angelegt.", "success")
|
||||
return redirect(url_for("customers.detail", customer_id=customer_id))
|
||||
except issuance.IssuanceError as exc:
|
||||
flash(str(exc), "danger")
|
||||
return redirect(url_for("customers.overview"))
|
||||
|
||||
|
||||
@bp.post("/<int:customer_id>")
|
||||
@permission_required("customers.edit")
|
||||
def update(customer_id: int):
|
||||
try:
|
||||
with transaction() as conn:
|
||||
changes = issuance.update_customer(conn, customer_id, **_form())
|
||||
if changes:
|
||||
log_event(
|
||||
"customer.updated",
|
||||
target=request.form.get("name", str(customer_id)),
|
||||
detail=", ".join(changes),
|
||||
severity="notice",
|
||||
scope="kunde",
|
||||
)
|
||||
flash("Aenderungen gespeichert." if changes else "Keine Aenderungen.",
|
||||
"success" if changes else "info")
|
||||
except issuance.IssuanceError as exc:
|
||||
flash(str(exc), "danger")
|
||||
return redirect(url_for("customers.detail", customer_id=customer_id))
|
||||
|
||||
|
||||
@bp.post("/<int:customer_id>/loeschen")
|
||||
@permission_required("customers.delete")
|
||||
def delete(customer_id: int):
|
||||
conn = db()
|
||||
customer = issuance.get_customer(conn, customer_id)
|
||||
if customer is None:
|
||||
abort(404)
|
||||
try:
|
||||
with transaction():
|
||||
issuance.delete_customer(conn, customer_id)
|
||||
log_event(
|
||||
"customer.deleted",
|
||||
target=str(customer["name"]),
|
||||
severity="warning",
|
||||
scope="kunde",
|
||||
)
|
||||
flash("Kunde geloescht.", "success")
|
||||
return redirect(url_for("customers.overview"))
|
||||
except issuance.IssuanceError as exc:
|
||||
flash(str(exc), "danger")
|
||||
return redirect(url_for("customers.detail", customer_id=customer_id))
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Uebersicht -- die Startseite des Lizenzservers.
|
||||
|
||||
Anders als TESMs Geraete-Dashboard verlangt sie eine Anmeldung: das hier ist
|
||||
eine interne Verwaltungsanwendung. Die Anmeldeseite ist der einzige sinnvolle
|
||||
erste Einstiegspunkt.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from flask import Blueprint, render_template
|
||||
from tesm_core.auth.models import current_user, login_required
|
||||
from tesm_core.extension import core, db
|
||||
|
||||
from ..services import issuance, reporting
|
||||
|
||||
bp = Blueprint("dashboard", __name__)
|
||||
|
||||
|
||||
@bp.get("/uebersicht")
|
||||
@login_required
|
||||
def index():
|
||||
conn = db()
|
||||
extension = core()
|
||||
if not current_user.has("licenses.view"):
|
||||
# Leere Huelle statt 403: die Startseite soll fuer jeden erreichbar
|
||||
# bleiben, auch fuer ein Konto ohne Lizenzrechte.
|
||||
return render_template("license/dashboard.html", summary=None, sources=[])
|
||||
|
||||
summary = issuance.dashboard_summary(conn)
|
||||
return render_template(
|
||||
"license/dashboard.html",
|
||||
summary=summary,
|
||||
sources=reporting.reporting_sources(conn) if current_user.has("reports.view") else [],
|
||||
own_role=(extension.license.state.role if extension.license else "master"),
|
||||
type_labels=dict(),
|
||||
)
|
||||
|
||||
|
||||
def index_view():
|
||||
return index()
|
||||
@@ -0,0 +1,453 @@
|
||||
"""Lizenzen: ausstellen, herunterladen, versenden, widerrufen, Offline-Codes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from flask import (
|
||||
Blueprint,
|
||||
Response,
|
||||
abort,
|
||||
flash,
|
||||
redirect,
|
||||
render_template,
|
||||
request,
|
||||
url_for,
|
||||
)
|
||||
from tesm_core.auth.models import (
|
||||
admin_required,
|
||||
current_user,
|
||||
permission_required,
|
||||
reauth_required,
|
||||
)
|
||||
from tesm_core.extension import core, db, transaction
|
||||
|
||||
import tesm_licensing as lic
|
||||
|
||||
from ..services import activation, issuance, mail, reporting
|
||||
from .common import (
|
||||
bundle_bytes,
|
||||
customer_facing_url,
|
||||
license_filename,
|
||||
log_event,
|
||||
master_endpoint,
|
||||
module_labels,
|
||||
role_labels,
|
||||
signing_key,
|
||||
type_labels,
|
||||
vendor_info,
|
||||
)
|
||||
|
||||
bp = Blueprint("licenses", __name__, url_prefix="/lizenzen")
|
||||
|
||||
|
||||
@bp.get("/")
|
||||
@permission_required("licenses.view")
|
||||
def overview():
|
||||
conn = db()
|
||||
rows = [
|
||||
{**row, **issuance.display_state(row)}
|
||||
for row in issuance.list_licenses(
|
||||
conn,
|
||||
customer_id=request.args.get("kunde", type=int),
|
||||
ticket_id=request.args.get("ticket"),
|
||||
status=request.args.get("status"),
|
||||
)
|
||||
]
|
||||
return render_template(
|
||||
"license/licenses.html",
|
||||
licenses=rows,
|
||||
type_labels=type_labels(),
|
||||
role_labels=role_labels(),
|
||||
states=issuance.LICENSE_STATES,
|
||||
active_filter=request.args.get("status", ""),
|
||||
)
|
||||
|
||||
|
||||
@bp.get("/<license_id>")
|
||||
@permission_required("licenses.view")
|
||||
def detail(license_id: str):
|
||||
conn = db()
|
||||
row = issuance.get_license(conn, license_id)
|
||||
if row is None:
|
||||
abort(404)
|
||||
from tesm_core.db import Database
|
||||
|
||||
document = json.loads(str(row["license_document"]))
|
||||
return render_template(
|
||||
"license/license_detail.html",
|
||||
license=row,
|
||||
state=issuance.display_state(row),
|
||||
document=document,
|
||||
document_json=json.dumps(document, indent=2, ensure_ascii=False, sort_keys=True),
|
||||
deliveries=Database.all(
|
||||
conn,
|
||||
"SELECT * FROM license_deliveries WHERE license_id=? ORDER BY delivered_at DESC",
|
||||
(license_id,),
|
||||
),
|
||||
modules=json.loads(str(row["modules"] or "[]")),
|
||||
module_labels=module_labels(),
|
||||
type_labels=type_labels(),
|
||||
role_labels=role_labels(),
|
||||
mail_ready=mail.is_enabled(conn),
|
||||
all_modules=lic.ALL_MODULES,
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Ausstellung
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@bp.post("/aus-ticket/<ticket_id>")
|
||||
@permission_required("licenses.create")
|
||||
def issue(ticket_id: str):
|
||||
conn = db()
|
||||
as_trial = request.form.get("as_trial") == "1"
|
||||
key = signing_key()
|
||||
try:
|
||||
with transaction():
|
||||
result = issuance.issue_from_ticket(
|
||||
conn,
|
||||
ticket_id,
|
||||
master_private_key=key.private_key,
|
||||
master_public_key=key.public_key,
|
||||
master_endpoint=master_endpoint(),
|
||||
vendor=vendor_info(),
|
||||
actor=current_user.username,
|
||||
keystore=core().keystore,
|
||||
as_trial=as_trial,
|
||||
)
|
||||
except issuance.IssuanceError as exc:
|
||||
flash(str(exc), "danger")
|
||||
return redirect(url_for("tickets.detail", ticket_id=ticket_id))
|
||||
|
||||
if result.already_existed:
|
||||
flash(
|
||||
"Zu diesem Ticket war bereits eine Lizenz offen -- sie wird angezeigt statt eine "
|
||||
"zweite auszustellen.",
|
||||
"info",
|
||||
)
|
||||
else:
|
||||
log_event(
|
||||
"license.issued",
|
||||
target=result.license_id[:12],
|
||||
detail=("Trial-Ausstellung" if as_trial else "regulaer"),
|
||||
severity="notice",
|
||||
)
|
||||
reporting.report_soon(core())
|
||||
flash("Lizenz ausgestellt.", "success")
|
||||
return redirect(url_for("licenses.detail", license_id=result.license_id))
|
||||
|
||||
|
||||
@bp.get("/<license_id>/datei")
|
||||
@permission_required("licenses.export")
|
||||
@reauth_required
|
||||
def download(license_id: str):
|
||||
"""Laedt das vollstaendige Auslieferungs-Bundle herunter.
|
||||
|
||||
Es enthaelt den privaten Client-Schluessel der Lizenz -- damit ist es ein
|
||||
Geheimnis. Deshalb: eigenes Recht, frische Passwortbestaetigung, Eintrag im
|
||||
Zustellprotokoll und im Aenderungsprotokoll.
|
||||
"""
|
||||
conn = db()
|
||||
row = issuance.get_license(conn, license_id)
|
||||
if row is None:
|
||||
abort(404)
|
||||
try:
|
||||
bundle = issuance.delivery_bundle(conn, license_id, keystore=core().keystore)
|
||||
except issuance.IssuanceError as exc:
|
||||
flash(str(exc), "danger")
|
||||
return redirect(url_for("licenses.detail", license_id=license_id))
|
||||
with transaction():
|
||||
issuance.record_delivery(
|
||||
conn,
|
||||
license_id=license_id,
|
||||
channel="download",
|
||||
recipient=current_user.username,
|
||||
actor=current_user.username,
|
||||
detail="Vollstaendiges Bundle" if "client_key" in bundle else "Nur Lizenzdokument",
|
||||
)
|
||||
log_event("license.downloaded", target=license_id[:12], severity="notice")
|
||||
return Response(
|
||||
bundle_bytes(bundle),
|
||||
mimetype="application/json",
|
||||
headers={
|
||||
"Content-Disposition": (
|
||||
f'attachment; filename="{license_filename(license_id, str(row["customer_name"]))}"'
|
||||
),
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/<license_id>/neu-signieren")
|
||||
@permission_required("licenses.edit")
|
||||
@reauth_required
|
||||
def resign(license_id: str):
|
||||
"""Signiert eine neue Revision -- z. B. nach einer Modulerweiterung.
|
||||
|
||||
Der Client bekommt sie beim naechsten Heartbeat automatisch, ohne erneute
|
||||
Aktivierung.
|
||||
"""
|
||||
conn = db()
|
||||
row = issuance.get_license(conn, license_id)
|
||||
if row is None:
|
||||
abort(404)
|
||||
modules = sorted(set(request.form.getlist("modules")) & set(lic.ALL_MODULES))
|
||||
changes: dict[str, object] = {}
|
||||
if str(row["role"]) == "customer":
|
||||
changes["modules"] = modules
|
||||
if request.form.get("extend_days"):
|
||||
try:
|
||||
days = int(request.form["extend_days"])
|
||||
except ValueError:
|
||||
flash("Die Verlaengerung muss eine Zahl sein.", "danger")
|
||||
return redirect(url_for("licenses.detail", license_id=license_id))
|
||||
if days > 0:
|
||||
document = json.loads(str(row["license_document"]))
|
||||
base = lic.parse_iso(str(document["expires_at"]))
|
||||
from datetime import timedelta
|
||||
|
||||
changes["expires_at"] = lic.iso(base + timedelta(days=days))
|
||||
|
||||
if not changes:
|
||||
flash("Es wurde keine Aenderung angegeben.", "info")
|
||||
return redirect(url_for("licenses.detail", license_id=license_id))
|
||||
|
||||
key = signing_key()
|
||||
try:
|
||||
with transaction():
|
||||
updated = issuance.resign_license(
|
||||
conn, license_id, master_private_key=key.private_key, changes=changes
|
||||
)
|
||||
except (issuance.IssuanceError, ValueError) as exc:
|
||||
flash(str(exc), "danger")
|
||||
return redirect(url_for("licenses.detail", license_id=license_id))
|
||||
|
||||
log_event(
|
||||
"license.resigned",
|
||||
target=license_id[:12],
|
||||
detail=f"Revision {updated['seq']}: " + ", ".join(sorted(changes)),
|
||||
severity="notice",
|
||||
)
|
||||
reporting.report_soon(core())
|
||||
flash(
|
||||
f"Neue Revision {updated['seq']} signiert. Der Client erhaelt sie beim naechsten "
|
||||
"Heartbeat.",
|
||||
"success",
|
||||
)
|
||||
return redirect(url_for("licenses.detail", license_id=license_id))
|
||||
|
||||
|
||||
@bp.post("/<license_id>/widerrufen")
|
||||
@permission_required("licenses.edit")
|
||||
def revoke(license_id: str):
|
||||
conn = db()
|
||||
try:
|
||||
with transaction():
|
||||
issuance.revoke_license(conn, license_id, reason=request.form.get("reason", ""))
|
||||
except issuance.IssuanceError as exc:
|
||||
flash(str(exc), "danger")
|
||||
return redirect(url_for("licenses.detail", license_id=license_id))
|
||||
log_event(
|
||||
"license.revoked",
|
||||
target=license_id[:12],
|
||||
detail=request.form.get("reason", ""),
|
||||
severity="warning",
|
||||
)
|
||||
reporting.report_soon(core())
|
||||
flash(
|
||||
"Lizenz widerrufen. Das Ticket bleibt offen -- eine neue Lizenz kann sofort "
|
||||
"ausgestellt werden.",
|
||||
"success",
|
||||
)
|
||||
return redirect(url_for("licenses.detail", license_id=license_id))
|
||||
|
||||
|
||||
@bp.post("/<license_id>/loeschen")
|
||||
@permission_required("licenses.delete")
|
||||
def delete(license_id: str):
|
||||
conn = db()
|
||||
try:
|
||||
with transaction():
|
||||
issuance.delete_license(conn, license_id)
|
||||
except issuance.IssuanceError as exc:
|
||||
flash(str(exc), "danger")
|
||||
return redirect(url_for("licenses.detail", license_id=license_id))
|
||||
log_event("license.deleted", target=license_id[:12], severity="warning")
|
||||
reporting.report_soon(core())
|
||||
flash("Lizenz geloescht.", "success")
|
||||
return redirect(url_for("licenses.overview"))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Versand
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@bp.post("/<license_id>/versenden")
|
||||
@permission_required("licenses.export")
|
||||
def send_mail(license_id: str):
|
||||
conn = db()
|
||||
row = issuance.get_license(conn, license_id)
|
||||
if row is None:
|
||||
abort(404)
|
||||
recipient = (request.form.get("recipient") or str(row["contact_email"] or "")).strip()
|
||||
if not recipient:
|
||||
flash("Es ist keine Empfaengeradresse hinterlegt.", "danger")
|
||||
return redirect(url_for("licenses.detail", license_id=license_id))
|
||||
|
||||
try:
|
||||
bundle = issuance.delivery_bundle(conn, license_id, keystore=core().keystore)
|
||||
except issuance.IssuanceError as exc:
|
||||
flash(str(exc), "danger")
|
||||
return redirect(url_for("licenses.detail", license_id=license_id))
|
||||
vendor = vendor_info()
|
||||
is_customer = str(row["role"]) == "customer"
|
||||
subject = (
|
||||
"Ihre TESM-Lizenz" if is_customer else "Ihre TESM-Lizenzserver-Lizenz"
|
||||
)
|
||||
lines = [
|
||||
f"Guten Tag{(' ' + str(row['contact_person'])) if row['contact_person'] else ''},",
|
||||
"",
|
||||
"im Anhang finden Sie Ihre Lizenzdatei.",
|
||||
"",
|
||||
f"Lizenz-ID: {license_id}",
|
||||
f"Typ: {type_labels().get(str(row['type']), str(row['type']))}",
|
||||
f"Gueltig bis: {row['expires_at']}"
|
||||
+ (" (unbefristet)" if row["lifetime"] else ""),
|
||||
]
|
||||
if is_customer and row["ticket_id"]:
|
||||
# Self-Service- und Portallinks nur fuer Kundenlizenzen: fuer andere
|
||||
# Rollen sind diese Wege ohnehin gesperrt.
|
||||
lines += [
|
||||
"",
|
||||
"Ihre Lizenz verwalten (herunterladen, bei einem Systemwechsel freigeben):",
|
||||
customer_facing_url("selfservice.overview", ticket_id=str(row["ticket_id"])),
|
||||
"",
|
||||
"Ihre Stammdaten pflegen:",
|
||||
customer_facing_url("portal.request_link"),
|
||||
]
|
||||
lines += ["", "Freundliche Gruesse", vendor.get("name") or "Ihr Lizenzgeber"]
|
||||
if vendor.get("phone"):
|
||||
lines.append(f"Telefon: {vendor['phone']}")
|
||||
if vendor.get("email"):
|
||||
lines.append(f"E-Mail: {vendor['email']}")
|
||||
|
||||
try:
|
||||
mail.send(
|
||||
conn,
|
||||
to=recipient,
|
||||
subject=subject,
|
||||
body="\n".join(lines),
|
||||
attachment=mail.Attachment(
|
||||
name=license_filename(license_id, str(row["customer_name"])),
|
||||
content=bundle_bytes(bundle),
|
||||
),
|
||||
)
|
||||
except mail.MailError as exc:
|
||||
flash(f"Versand fehlgeschlagen: {exc}", "danger")
|
||||
return redirect(url_for("licenses.detail", license_id=license_id))
|
||||
|
||||
with transaction():
|
||||
issuance.record_delivery(
|
||||
conn,
|
||||
license_id=license_id,
|
||||
channel="email",
|
||||
recipient=recipient,
|
||||
actor=current_user.username,
|
||||
)
|
||||
log_event("license.sent", target=license_id[:12], detail=recipient, severity="notice")
|
||||
flash(f"Lizenz an {recipient} versendet.", "success")
|
||||
return redirect(url_for("licenses.detail", license_id=license_id))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Offline-Codes
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@bp.route("/offline-code", methods=["GET", "POST"])
|
||||
@permission_required("licenses.execute")
|
||||
def manual_code():
|
||||
"""Verarbeitet einen Offline-Code des Kunden.
|
||||
|
||||
Bewusst dieselben Verarbeitungsfunktionen wie die JSON-API: identische
|
||||
Signatur-, Replay- und Fingerprint-Pruefung, nur ein anderer Transportweg.
|
||||
"""
|
||||
response_code = ""
|
||||
request_summary: dict[str, object] = {}
|
||||
message = ""
|
||||
ok = False
|
||||
|
||||
if request.method == "POST":
|
||||
conn = db()
|
||||
raw = request.form.get("code", "")
|
||||
try:
|
||||
payload = lic.decode_code(raw)
|
||||
except ValueError as exc:
|
||||
flash(f"Der Code ist unlesbar: {exc}", "danger")
|
||||
return render_template("license/manual_code.html", response_code="", summary={})
|
||||
|
||||
request_summary = {
|
||||
"license_id": payload.get("license_id"),
|
||||
"action": payload.get("action"),
|
||||
"fingerprint": str(payload.get("fingerprint") or "")[:24] + "...",
|
||||
"hostname": payload.get("hostname"),
|
||||
"timestamp": payload.get("timestamp"),
|
||||
}
|
||||
key = signing_key()
|
||||
with transaction():
|
||||
outcome = activation.process(conn, payload, master_private_key=key.private_key)
|
||||
if outcome.ok:
|
||||
response_code = lic.encode_code(outcome.response)
|
||||
ok = True
|
||||
message = "Antwortcode erzeugt. Bitte an den Kunden zuruecksenden."
|
||||
log_event(
|
||||
f"license.offline_{payload.get('action')}",
|
||||
target=str(payload.get("license_id") or "")[:12],
|
||||
detail="Offline-Code verarbeitet",
|
||||
severity="notice",
|
||||
)
|
||||
else:
|
||||
message = str(outcome.response.get("message") or "Die Anfrage wurde abgewiesen.")
|
||||
flash(message, "danger")
|
||||
|
||||
return render_template(
|
||||
"license/manual_code.html",
|
||||
response_code=response_code,
|
||||
summary=request_summary,
|
||||
message=message,
|
||||
ok=ok,
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Sub-Lizenzserver
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@bp.get("/sub-lizenzserver")
|
||||
@permission_required("reports.view")
|
||||
def reports():
|
||||
conn = db()
|
||||
sources = reporting.reporting_sources(conn)
|
||||
return render_template(
|
||||
"license/reports.html",
|
||||
sources=sources,
|
||||
heartbeat_hours=lic.HEARTBEAT_INTERVAL_SECONDS // 3600,
|
||||
)
|
||||
|
||||
|
||||
@bp.get("/sub-lizenzserver/<license_id>")
|
||||
@permission_required("reports.view")
|
||||
def report_detail(license_id: str):
|
||||
conn = db()
|
||||
row = issuance.get_license(conn, license_id)
|
||||
if row is None or str(row["role"]) != "license_server":
|
||||
abort(404)
|
||||
reported = reporting.reported_for_customer(conn, int(row["customer_id"]))
|
||||
return render_template(
|
||||
"license/report_detail.html", license=row, reported=reported
|
||||
)
|
||||
@@ -0,0 +1,183 @@
|
||||
"""Die eigene Lizenz dieses Lizenzservers.
|
||||
|
||||
Auch ein Lizenzserver braucht eine Lizenz -- sie entscheidet ueber seine Rolle:
|
||||
|
||||
* ``master``: Wurzelinstanz. Nur sie darf Lizenzen fuer weitere Lizenzserver
|
||||
ausstellen. Diese Rolle entsteht ausschliesslich lokal ueber
|
||||
``tesm-license-admin bootstrap`` -- es gibt keine hoehere Instanz, die sie
|
||||
bestaetigen koennte, und die Ticket-Oberflaeche bietet sie an keiner Stelle an.
|
||||
* ``license_server``: von einem Master ausgestellter Sub-Lizenzserver. Er darf
|
||||
alles, was ein Lizenzserver koennen soll -- **ausser** selbst weitere
|
||||
Lizenzserver-Lizenzen auszustellen. Diese Grenze haengt an der signierten
|
||||
Rolle der Lizenzdatei, nicht an einem Gruppenrecht.
|
||||
|
||||
Die Seite bleibt immer erreichbar, auch ohne gueltige Lizenz -- sonst liesse
|
||||
sich keine neue einspielen.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import socket
|
||||
|
||||
from flask import Blueprint, flash, redirect, render_template, request, url_for
|
||||
from tesm_core.auth.models import (
|
||||
admin_required,
|
||||
current_user,
|
||||
permission_required,
|
||||
reauth_required,
|
||||
)
|
||||
from tesm_core.db import Database
|
||||
from tesm_core.extension import core, db, transaction
|
||||
from tesm_core.licensing_client import LICENSED_FEATURES, LicenseError
|
||||
|
||||
import tesm_licensing as lic
|
||||
|
||||
from ..services import master_key
|
||||
from .common import can_issue_license_server_licenses, log_event, signing_key
|
||||
|
||||
bp = Blueprint("own_license", __name__, url_prefix="/eigene-lizenz")
|
||||
|
||||
MAX_UPLOAD_BYTES = 256 * 1024
|
||||
|
||||
|
||||
@bp.get("/")
|
||||
@permission_required("settings_license.view")
|
||||
def overview():
|
||||
extension = core()
|
||||
conn = db()
|
||||
manager = extension.license
|
||||
state = manager.reload(conn) if manager else None
|
||||
pending = manager._pending(conn) if manager else {} # noqa: SLF001 - eigenes Paket
|
||||
return render_template(
|
||||
"license/own_license.html",
|
||||
state=state,
|
||||
pending=pending,
|
||||
features=LICENSED_FEATURES,
|
||||
role_labels=dict(lic.ROLE_LABELS),
|
||||
type_labels=dict(lic.LICENSE_TYPE_LABELS),
|
||||
fingerprint=lic.system_fingerprint(),
|
||||
hostname=socket.gethostname(),
|
||||
trusted_keys=Database.all(
|
||||
conn, "SELECT * FROM trusted_issuer_keys ORDER BY added_at DESC"
|
||||
),
|
||||
signing_key_health=master_key.health(extension.registry["master_key_path"]),
|
||||
may_issue_license_server=can_issue_license_server_licenses(),
|
||||
grace_days=lic.GRACE_PERIOD_DAYS,
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/einspielen")
|
||||
@permission_required("settings_license.edit")
|
||||
def upload():
|
||||
extension = core()
|
||||
manager = extension.license
|
||||
if manager is None:
|
||||
flash("Diese Installation nutzt keine Lizenzierung.", "info")
|
||||
return redirect(url_for("own_license.overview"))
|
||||
|
||||
upload_file = request.files.get("license_file")
|
||||
if upload_file is None or not upload_file.filename:
|
||||
flash("Keine Datei ausgewaehlt.", "danger")
|
||||
return redirect(url_for("own_license.overview"))
|
||||
raw = upload_file.stream.read(MAX_UPLOAD_BYTES + 1)
|
||||
if len(raw) > MAX_UPLOAD_BYTES:
|
||||
flash("Die Datei ist zu gross fuer eine Lizenzdatei.", "danger")
|
||||
return redirect(url_for("own_license.overview"))
|
||||
|
||||
try:
|
||||
with transaction() as conn:
|
||||
state = manager.import_bundle(
|
||||
conn,
|
||||
raw,
|
||||
actor=current_user.username,
|
||||
allow_new_issuer=request.form.get("allow_new_issuer") == "1"
|
||||
and current_user.is_admin,
|
||||
)
|
||||
except LicenseError as exc:
|
||||
flash(str(exc), "danger")
|
||||
return redirect(url_for("own_license.overview"))
|
||||
|
||||
log_event(
|
||||
"own_license.imported",
|
||||
target=(state.status.license_id[:12] if state.status else ""),
|
||||
detail=f"Rolle {state.role}",
|
||||
severity="warning",
|
||||
scope="eigene",
|
||||
)
|
||||
flash("Lizenz eingespielt. Bitte jetzt aktivieren.", "success")
|
||||
return redirect(url_for("own_license.overview"))
|
||||
|
||||
|
||||
def _handshake(action: str):
|
||||
manager = core().license
|
||||
if manager is None:
|
||||
return redirect(url_for("own_license.overview"))
|
||||
try:
|
||||
with transaction() as conn:
|
||||
result = manager.handshake(
|
||||
conn, action, actor=current_user.username, hostname=socket.gethostname()
|
||||
)
|
||||
except LicenseError as exc:
|
||||
flash(str(exc), "danger")
|
||||
return redirect(url_for("own_license.overview"))
|
||||
flash(
|
||||
result["message"],
|
||||
"success" if result["ok"] else ("warning" if result["transport"] == "offline" else "danger"),
|
||||
)
|
||||
return redirect(url_for("own_license.overview"))
|
||||
|
||||
|
||||
@bp.post("/aktivieren")
|
||||
@permission_required("settings_license.edit")
|
||||
def activate():
|
||||
return _handshake("activate")
|
||||
|
||||
|
||||
@bp.post("/deaktivieren")
|
||||
@permission_required("settings_license.edit")
|
||||
@reauth_required
|
||||
def deactivate():
|
||||
return _handshake("deactivate")
|
||||
|
||||
|
||||
@bp.post("/heartbeat")
|
||||
@permission_required("settings_license.edit")
|
||||
def heartbeat():
|
||||
return _handshake("heartbeat")
|
||||
|
||||
|
||||
@bp.post("/offline-antwort")
|
||||
@permission_required("settings_license.edit")
|
||||
def offline_response():
|
||||
manager = core().license
|
||||
if manager is None:
|
||||
return redirect(url_for("own_license.overview"))
|
||||
try:
|
||||
with transaction() as conn:
|
||||
result = manager.submit_offline_response(
|
||||
conn, request.form.get("code", ""), actor=current_user.username
|
||||
)
|
||||
except LicenseError as exc:
|
||||
flash(str(exc), "danger")
|
||||
return redirect(url_for("own_license.overview"))
|
||||
flash(result["message"], "success" if result["ok"] else "danger")
|
||||
return redirect(url_for("own_license.overview"))
|
||||
|
||||
|
||||
@bp.get("/signaturschluessel")
|
||||
@permission_required("settings_license.view")
|
||||
def signing_key_info():
|
||||
"""Zeigt den oeffentlichen Signaturschluessel -- zum Pinnen beim Kunden.
|
||||
|
||||
Der oeffentliche Teil ist kein Geheimnis; er reist ohnehin in jeder Lizenz
|
||||
mit. Ihn hier anzuzeigen erlaubt es, ihn unabhaengig zu vergleichen, bevor
|
||||
ein Kunde ihn als vertrauenswuerdig hinterlegt.
|
||||
"""
|
||||
key = signing_key()
|
||||
return render_template(
|
||||
"license/signing_key.html",
|
||||
key_id=key.key_id,
|
||||
public_key=key.public_key,
|
||||
created_at=key.created_at,
|
||||
health=master_key.health(core().registry["master_key_path"]),
|
||||
)
|
||||
@@ -0,0 +1,278 @@
|
||||
"""Kundenportal -- zeitlich begrenzter Zugang ohne Konto.
|
||||
|
||||
Zweistufig, bewusst:
|
||||
|
||||
1. ``/portal`` nimmt eine E-Mail-Adresse an und verschickt einen **einmal**
|
||||
verwendbaren Zugangslink mit kurzer Gueltigkeit.
|
||||
2. Der Link erzeugt eine Sitzung mit eigener, unabhaengiger Gueltigkeit.
|
||||
|
||||
Der Vorgaenger hatte zuerst einen permanenten, nie ablaufenden Link pro Kunde
|
||||
(``/portal/<token>``). Das wurde als Schwachstelle erkannt und ersetzt -- hier
|
||||
existiert die permanente Variante gar nicht.
|
||||
|
||||
Anti-Enumeration: die Antwort ist immer dieselbe, unabhaengig davon, ob die
|
||||
Adresse bekannt ist. Ein Fehler beim Mailversand wird dem Anfragenden gegenueber
|
||||
verschwiegen, aber serverseitig protokolliert -- sonst waere eine kaputte
|
||||
Mailkonfiguration unsichtbar.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
from flask import Blueprint, flash, redirect, render_template, request, session, url_for
|
||||
from tesm_core import audit
|
||||
from tesm_core.auth.service import client_ip
|
||||
from tesm_core.db import Database
|
||||
from tesm_core.extension import core, db, transaction
|
||||
from tesm_core.security import ratelimit
|
||||
|
||||
from ..services import issuance, mail
|
||||
from .common import customer_facing_url, vendor_info
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
bp = Blueprint("portal", __name__, url_prefix="/portal")
|
||||
|
||||
SESSION_CUSTOMER = "portal_customer_id"
|
||||
SESSION_EXPIRES = "portal_expires_at"
|
||||
RATE_BUCKET = "portal:request"
|
||||
RATE_LIMIT = 5
|
||||
RATE_WINDOW_MINUTES = 15
|
||||
|
||||
GENERIC_ANSWER = (
|
||||
"Falls diese E-Mail-Adresse bei uns hinterlegt ist, wurde soeben eine Nachricht mit "
|
||||
"einem {minutes} Minuten gueltigen Zugangslink verschickt."
|
||||
)
|
||||
|
||||
EDITABLE_FIELDS = (
|
||||
"contact_person",
|
||||
"contact_phone",
|
||||
"contact_email",
|
||||
"street",
|
||||
"house_number",
|
||||
"postal_code",
|
||||
"city",
|
||||
)
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _iso(value: datetime) -> str:
|
||||
return value.isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def _parse(value: Any) -> datetime | None:
|
||||
"""Liest einen Zeitstempel aus der Datenbank.
|
||||
|
||||
Bewusst geparst statt als Zeichenkette verglichen: ein Wert, der ueber
|
||||
``datetime('now')`` von SQLite selbst geschrieben wurde, hat ein Leerzeichen
|
||||
statt ``T`` und keine Zeitzone -- ein lexikografischer Vergleich mit unserem
|
||||
ISO-Format waere dann falsch, und zwar zugunsten von "abgelaufen".
|
||||
"""
|
||||
if not value:
|
||||
return None
|
||||
text = str(value).strip().replace(" ", "T")
|
||||
if text.endswith("Z"):
|
||||
text = text[:-1] + "+00:00"
|
||||
try:
|
||||
parsed = datetime.fromisoformat(text)
|
||||
except ValueError:
|
||||
return None
|
||||
return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def _enabled() -> bool:
|
||||
return bool(core().settings.get(db(), "portal_enabled"))
|
||||
|
||||
|
||||
@bp.route("/", methods=["GET", "POST"])
|
||||
def request_link():
|
||||
extension = core()
|
||||
conn = db()
|
||||
if not _enabled():
|
||||
return render_template("license/portal_disabled.html"), 404
|
||||
|
||||
minutes = int(extension.settings.get(conn, "portal_link_minutes") or 30)
|
||||
if request.method == "POST":
|
||||
email = (request.form.get("email") or "").strip()
|
||||
verdict = ratelimit.check(
|
||||
conn,
|
||||
RATE_BUCKET,
|
||||
client_ip() or "unbekannt",
|
||||
limit=RATE_LIMIT,
|
||||
window_minutes=RATE_WINDOW_MINUTES,
|
||||
)
|
||||
with transaction():
|
||||
ratelimit.record(conn, RATE_BUCKET, client_ip() or "unbekannt")
|
||||
if verdict.blocked:
|
||||
# Auch hier keine abweichende Aussage -- nur eine Bremse.
|
||||
flash(GENERIC_ANSWER.format(minutes=minutes), "info")
|
||||
return redirect(url_for("portal.request_link"))
|
||||
|
||||
customer = issuance.find_customer_by_email(conn, email)
|
||||
if customer is not None:
|
||||
token = secrets.token_urlsafe(32)
|
||||
with transaction():
|
||||
conn.execute(
|
||||
"INSERT INTO portal_access_tokens (token, customer_id, created_at, "
|
||||
"expires_at, created_ip) VALUES (?,?,?,?,?)",
|
||||
(
|
||||
token,
|
||||
int(customer["id"]),
|
||||
_iso(_now()),
|
||||
_iso(_now() + timedelta(minutes=minutes)),
|
||||
client_ip() or "",
|
||||
),
|
||||
)
|
||||
audit.log(
|
||||
conn,
|
||||
actor="portal",
|
||||
action="portal.link_requested",
|
||||
target=str(customer["name"]),
|
||||
ip=client_ip(),
|
||||
severity="notice",
|
||||
)
|
||||
access_url = customer_facing_url("portal.access", token=token)
|
||||
vendor = vendor_info()
|
||||
try:
|
||||
mail.send(
|
||||
conn,
|
||||
to=str(customer["contact_email"]),
|
||||
subject="Ihr Zugang zum Kundenportal",
|
||||
body="\n".join(
|
||||
[
|
||||
f"Guten Tag{(' ' + str(customer['contact_person'])) if customer['contact_person'] else ''},",
|
||||
"",
|
||||
"mit dem folgenden Link erreichen Sie Ihre Daten im Kundenportal.",
|
||||
f"Der Link ist {minutes} Minuten gueltig und laesst sich nur einmal verwenden:",
|
||||
"",
|
||||
access_url,
|
||||
"",
|
||||
"Wenn Sie diesen Zugang nicht angefragt haben, ignorieren Sie die Nachricht.",
|
||||
"",
|
||||
"Freundliche Gruesse",
|
||||
vendor.get("name") or "Ihr Lizenzgeber",
|
||||
]
|
||||
),
|
||||
)
|
||||
except mail.MailError as exc:
|
||||
# Bewusst nicht nach aussen tragen (Anti-Enumeration), aber
|
||||
# serverseitig sichtbar machen.
|
||||
logger.error("Portal-Mail an %s fehlgeschlagen: %s", email, exc)
|
||||
|
||||
flash(GENERIC_ANSWER.format(minutes=minutes), "info")
|
||||
return redirect(url_for("portal.request_link"))
|
||||
|
||||
return render_template("license/portal_request.html", minutes=minutes)
|
||||
|
||||
|
||||
@bp.get("/zugang/<token>")
|
||||
def access(token: str):
|
||||
extension = core()
|
||||
conn = db()
|
||||
if not _enabled():
|
||||
return render_template("license/portal_disabled.html"), 404
|
||||
|
||||
row = Database.one(
|
||||
conn, "SELECT * FROM portal_access_tokens WHERE token=?", (token,)
|
||||
)
|
||||
expires = _parse(row["expires_at"]) if row is not None else None
|
||||
valid = row is not None and row["used_at"] is None and expires is not None and _now() < expires
|
||||
if not valid:
|
||||
flash("Dieser Link ist ungueltig oder abgelaufen.", "danger")
|
||||
return redirect(url_for("portal.request_link"))
|
||||
|
||||
minutes = int(extension.settings.get(conn, "portal_session_minutes") or 60)
|
||||
with transaction():
|
||||
# Einmalverwendung erzwingen: auch innerhalb der Gueltigkeit scheitert
|
||||
# ein zweiter Aufruf.
|
||||
conn.execute(
|
||||
"UPDATE portal_access_tokens SET used_at=? WHERE token=? AND used_at IS NULL",
|
||||
(_iso(_now()), token),
|
||||
)
|
||||
audit.log(
|
||||
conn,
|
||||
actor="portal",
|
||||
action="portal.session_started",
|
||||
target=str(row["customer_id"]),
|
||||
ip=client_ip(),
|
||||
severity="notice",
|
||||
)
|
||||
session[SESSION_CUSTOMER] = int(row["customer_id"])
|
||||
session[SESSION_EXPIRES] = _iso(_now() + timedelta(minutes=minutes))
|
||||
return redirect(url_for("portal.me"))
|
||||
|
||||
|
||||
def _current_customer() -> dict[str, Any] | None:
|
||||
customer_id = session.get(SESSION_CUSTOMER)
|
||||
expires = _parse(session.get(SESSION_EXPIRES))
|
||||
if not customer_id or expires is None or _now() >= expires:
|
||||
session.pop(SESSION_CUSTOMER, None)
|
||||
session.pop(SESSION_EXPIRES, None)
|
||||
return None
|
||||
return issuance.get_customer(db(), int(customer_id))
|
||||
|
||||
|
||||
@bp.route("/meine-daten", methods=["GET", "POST"])
|
||||
def me():
|
||||
conn = db()
|
||||
if not _enabled():
|
||||
return render_template("license/portal_disabled.html"), 404
|
||||
|
||||
customer = _current_customer()
|
||||
if customer is None:
|
||||
flash("Ihre Sitzung ist abgelaufen. Bitte fordern Sie einen neuen Zugangslink an.", "info")
|
||||
return redirect(url_for("portal.request_link"))
|
||||
|
||||
if request.method == "POST":
|
||||
fields = {key: request.form.get(key, "") for key in EDITABLE_FIELDS}
|
||||
try:
|
||||
with transaction():
|
||||
changes = issuance.update_customer(
|
||||
conn, int(customer["id"]), name=str(customer["name"]), **fields
|
||||
)
|
||||
if changes:
|
||||
audit.log(
|
||||
conn,
|
||||
actor="portal",
|
||||
action="portal.customer_updated",
|
||||
target=str(customer["name"]),
|
||||
detail=", ".join(changes),
|
||||
ip=client_ip(),
|
||||
severity="notice",
|
||||
)
|
||||
flash("Ihre Angaben wurden gespeichert." if changes else "Keine Aenderungen.", "success")
|
||||
except issuance.IssuanceError as exc:
|
||||
flash(str(exc), "danger")
|
||||
return redirect(url_for("portal.me"))
|
||||
|
||||
tickets = issuance.list_tickets(conn, customer_id=int(customer["id"]))
|
||||
licenses = {
|
||||
str(row["ticket_id"]): {**row, **issuance.display_state(row)}
|
||||
for row in issuance.list_licenses(conn, customer_id=int(customer["id"]))
|
||||
if row["status"] in {"issued", "active"}
|
||||
}
|
||||
self_service_enabled = bool(core().settings.get(conn, "self_service_enabled"))
|
||||
return render_template(
|
||||
"license/portal_me.html",
|
||||
customer=customer,
|
||||
tickets=tickets,
|
||||
licenses=licenses,
|
||||
expires_at=session.get(SESSION_EXPIRES),
|
||||
self_service_enabled=self_service_enabled,
|
||||
vendor=vendor_info(),
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/abmelden")
|
||||
def logout():
|
||||
session.pop(SESSION_CUSTOMER, None)
|
||||
session.pop(SESSION_EXPIRES, None)
|
||||
flash("Sie wurden abgemeldet.", "info")
|
||||
return redirect(url_for("portal.request_link"))
|
||||
@@ -0,0 +1,304 @@
|
||||
"""Self-Service je Ticket -- ohne Anmeldung, allein ueber den Ticket-Link.
|
||||
|
||||
Die Berechtigung ist der Besitz der Ticket-ID: 192 Bit Zufall
|
||||
(``secrets.token_urlsafe(24)``), zugleich Primaerschluessel. Damit kann ein Kunde
|
||||
seine Lizenz herunterladen, bei einem Systemwechsel freigeben und neu ausstellen
|
||||
lassen -- ohne Support-Anruf.
|
||||
|
||||
Fuer Tickets mit einer anderen Rolle als ``customer`` antworten **alle** Routen
|
||||
mit 404, nicht mit "keine Berechtigung": aus der Fehlermeldung soll sich nicht
|
||||
ablesen lassen, dass eine geratene ID zu einem Lizenzserver-Ticket gehoert.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from flask import Blueprint, Response, abort, flash, redirect, render_template, request, url_for
|
||||
from tesm_core import audit
|
||||
from tesm_core.auth.service import client_ip
|
||||
from tesm_core.extension import core, db, transaction
|
||||
from tesm_core.security import ratelimit
|
||||
|
||||
import tesm_licensing as lic
|
||||
|
||||
from ..services import activation, issuance, reporting
|
||||
from .common import (
|
||||
bundle_bytes,
|
||||
customer_facing_url,
|
||||
license_filename,
|
||||
master_endpoint,
|
||||
signing_key,
|
||||
type_labels,
|
||||
vendor_info,
|
||||
)
|
||||
|
||||
bp = Blueprint("selfservice", __name__, url_prefix="/lizenz")
|
||||
|
||||
RATE_BUCKET = "selfservice"
|
||||
RATE_LIMIT = 20
|
||||
RATE_WINDOW_MINUTES = 10
|
||||
|
||||
|
||||
def _ticket_or_404(ticket_id: str) -> dict:
|
||||
conn = db()
|
||||
if not core().settings.get(conn, "self_service_enabled"):
|
||||
abort(404)
|
||||
ticket = issuance.get_ticket(conn, ticket_id)
|
||||
if ticket is None or str(ticket["role"]) != "customer":
|
||||
abort(404)
|
||||
return ticket
|
||||
|
||||
|
||||
def _rate_guard() -> None:
|
||||
conn = db()
|
||||
verdict = ratelimit.check(
|
||||
conn,
|
||||
RATE_BUCKET,
|
||||
client_ip() or "unbekannt",
|
||||
limit=RATE_LIMIT,
|
||||
window_minutes=RATE_WINDOW_MINUTES,
|
||||
)
|
||||
with transaction():
|
||||
ratelimit.record(conn, RATE_BUCKET, client_ip() or "unbekannt")
|
||||
if verdict.blocked:
|
||||
abort(429, description="Zu viele Anfragen. Bitte spaeter erneut versuchen.")
|
||||
|
||||
|
||||
def _needs_offline(conn, ticket_id: str): # type: ignore[no-untyped-def]
|
||||
"""Die Lizenz, fuer die der Offline-Weg gerade einen Zweck hat -- sonst ``None``.
|
||||
|
||||
Zwei Faelle, und nur diese:
|
||||
|
||||
* **Noch nicht aktiviert.** Der Kunde muss aktivieren.
|
||||
* **Neue Revision ausstehend.** Der Anbieter hat nachsigniert (andere
|
||||
Module, laengere Laufzeit); eine Installation ohne Verbindung holt sie
|
||||
sonst nie ab.
|
||||
|
||||
Ist die Lizenz aktiv und auf dem aktuellen Stand, gibt es nichts zu tun --
|
||||
fuer einen Systemwechsel gibt es die Freigabe.
|
||||
"""
|
||||
for row in issuance.list_licenses(conn, ticket_id=ticket_id):
|
||||
status = str(row["status"])
|
||||
if status == "issued":
|
||||
return row
|
||||
if status == "active" and issuance.revision_pending(row):
|
||||
return row
|
||||
return None
|
||||
|
||||
|
||||
def _log(action: str, ticket: dict, detail: str = "", severity: str = "notice") -> None:
|
||||
extension = core()
|
||||
with transaction() as conn:
|
||||
audit.log(
|
||||
conn,
|
||||
actor="self-service",
|
||||
action=action,
|
||||
target=str(ticket["customer_name"]),
|
||||
detail=detail,
|
||||
ip=client_ip(),
|
||||
severity=severity,
|
||||
)
|
||||
live = extension.registry.get("live_log")
|
||||
if live is not None:
|
||||
live.info("selfservice", f"{action} {ticket['customer_name']} {detail}".strip())
|
||||
|
||||
|
||||
@bp.get("/<ticket_id>")
|
||||
def overview(ticket_id: str):
|
||||
_rate_guard()
|
||||
ticket = _ticket_or_404(ticket_id)
|
||||
conn = db()
|
||||
licenses = [
|
||||
{**row, **issuance.display_state(row)}
|
||||
for row in issuance.list_licenses(conn, ticket_id=ticket_id)
|
||||
]
|
||||
current = next((row for row in licenses if row["status"] in {"issued", "active"}), None)
|
||||
return render_template(
|
||||
"license/selfservice.html",
|
||||
offline_needed=_needs_offline(db(), ticket_id),
|
||||
ticket=ticket,
|
||||
current=current,
|
||||
history=[row for row in licenses if row is not current],
|
||||
type_labels=type_labels(),
|
||||
vendor=vendor_info(),
|
||||
portal_url=url_for("portal.request_link"),
|
||||
allow_revoke=bool(core().settings.get(conn, "allow_customer_revoke")),
|
||||
blocked=str(ticket["status"]) == "blocked",
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/<ticket_id>/ausstellen")
|
||||
def issue(ticket_id: str):
|
||||
_rate_guard()
|
||||
ticket = _ticket_or_404(ticket_id)
|
||||
conn = db()
|
||||
if str(ticket["status"]) == "blocked":
|
||||
flash(
|
||||
"Dieses Ticket ist gesperrt. Bitte wenden Sie sich an Ihren Ansprechpartner.",
|
||||
"danger",
|
||||
)
|
||||
return redirect(url_for("selfservice.overview", ticket_id=ticket_id))
|
||||
if issuance.ticket_has_open_license(conn, ticket_id):
|
||||
flash(
|
||||
"Es existiert bereits eine gueltige Lizenz. Geben Sie diese zuerst frei, wenn Sie "
|
||||
"auf ein anderes System wechseln moechten.",
|
||||
"warning",
|
||||
)
|
||||
return redirect(url_for("selfservice.overview", ticket_id=ticket_id))
|
||||
|
||||
key = signing_key()
|
||||
try:
|
||||
with transaction():
|
||||
result = issuance.issue_from_ticket(
|
||||
conn,
|
||||
ticket_id,
|
||||
master_private_key=key.private_key,
|
||||
master_public_key=key.public_key,
|
||||
master_endpoint=master_endpoint(),
|
||||
vendor=vendor_info(),
|
||||
actor="self-service",
|
||||
keystore=core().keystore,
|
||||
)
|
||||
except issuance.IssuanceError as exc:
|
||||
flash(str(exc), "danger")
|
||||
return redirect(url_for("selfservice.overview", ticket_id=ticket_id))
|
||||
|
||||
_log("license.issued_self_service", ticket, result.license_id[:12])
|
||||
reporting.report_soon(core())
|
||||
flash(
|
||||
"Ihre Lizenz wurde erstellt. Laden Sie die Datei herunter und spielen Sie sie in Ihrer "
|
||||
"TESM-Installation ein.",
|
||||
"success",
|
||||
)
|
||||
return redirect(url_for("selfservice.overview", ticket_id=ticket_id))
|
||||
|
||||
|
||||
@bp.get("/<ticket_id>/datei/<license_id>")
|
||||
def download(ticket_id: str, license_id: str):
|
||||
_rate_guard()
|
||||
ticket = _ticket_or_404(ticket_id)
|
||||
conn = db()
|
||||
row = issuance.get_license(conn, license_id)
|
||||
if row is None or str(row["ticket_id"]) != ticket_id:
|
||||
abort(404)
|
||||
if str(row["status"]) == "revoked":
|
||||
abort(410, description="Diese Lizenz wurde widerrufen.")
|
||||
try:
|
||||
bundle = issuance.delivery_bundle(conn, license_id, keystore=core().keystore)
|
||||
except issuance.IssuanceError as exc:
|
||||
abort(409, description=str(exc))
|
||||
|
||||
with transaction():
|
||||
issuance.record_delivery(
|
||||
conn,
|
||||
license_id=license_id,
|
||||
channel="self-service",
|
||||
recipient=str(ticket["contact_email"] or ""),
|
||||
actor="self-service",
|
||||
)
|
||||
_log("license.downloaded_self_service", ticket, license_id[:12], severity="info")
|
||||
return Response(
|
||||
bundle_bytes(bundle),
|
||||
mimetype="application/json",
|
||||
headers={
|
||||
"Content-Disposition": (
|
||||
f'attachment; filename="{license_filename(license_id, str(ticket["customer_name"]))}"'
|
||||
),
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/<ticket_id>/offline", methods=["GET", "POST"])
|
||||
def offline(ticket_id: str):
|
||||
"""Loest einen Aktivierungscode ein, wenn TESM den Server nicht erreicht."""
|
||||
ticket = _ticket_or_404(ticket_id)
|
||||
conn = db()
|
||||
pending = _needs_offline(conn, ticket_id)
|
||||
if pending is None:
|
||||
# Ohne noch nicht aktivierte Lizenz gibt es hier nichts zu tun.
|
||||
return redirect(url_for("selfservice.overview", ticket_id=ticket_id))
|
||||
|
||||
response_code = ""
|
||||
summary: dict[str, object] = {}
|
||||
|
||||
if request.method == "POST":
|
||||
_rate_guard()
|
||||
raw = request.form.get("code", "")
|
||||
try:
|
||||
payload = lic.decode_code(raw)
|
||||
except ValueError as exc:
|
||||
flash(f"Der Code ist unlesbar: {exc}", "danger")
|
||||
return redirect(url_for("selfservice.offline", ticket_id=ticket_id))
|
||||
|
||||
# Nur Lizenzen dieses Vorgangs -- ein fremder Code gehoert nicht hierher.
|
||||
row = issuance.get_license(conn, str(payload.get("license_id") or ""))
|
||||
if row is None or str(row["ticket_id"]) != ticket_id:
|
||||
_log(
|
||||
"selfservice.offline_rejected",
|
||||
ticket,
|
||||
"Code gehoert nicht zu diesem Vorgang",
|
||||
severity="warning",
|
||||
)
|
||||
flash(
|
||||
"Dieser Code gehoert nicht zu Ihrem Vorgang. Bitte pruefen Sie, ob Sie ihn "
|
||||
"vollstaendig kopiert haben.",
|
||||
"danger",
|
||||
)
|
||||
return redirect(url_for("selfservice.offline", ticket_id=ticket_id))
|
||||
|
||||
key = signing_key()
|
||||
with transaction():
|
||||
outcome = activation.process(conn, payload, master_private_key=key.private_key)
|
||||
summary = {"action": payload.get("action"), "hostname": payload.get("hostname")}
|
||||
if outcome.ok:
|
||||
response_code = lic.encode_code(outcome.response)
|
||||
_log(
|
||||
f"selfservice.offline_{payload.get('action')}",
|
||||
ticket,
|
||||
f"Lizenz {str(payload.get('license_id'))[:12]}",
|
||||
)
|
||||
else:
|
||||
message = str(outcome.response.get("message") or "Die Anfrage wurde abgewiesen.")
|
||||
_log("selfservice.offline_rejected", ticket, message, severity="warning")
|
||||
flash(message, "danger")
|
||||
|
||||
return render_template(
|
||||
"license/selfservice_offline.html",
|
||||
ticket=ticket,
|
||||
license=pending,
|
||||
response_code=response_code,
|
||||
summary=summary,
|
||||
vendor=vendor_info(),
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/<ticket_id>/freigeben/<license_id>")
|
||||
def revoke(ticket_id: str, license_id: str):
|
||||
"""Gibt die aktuelle Lizenz frei -- fuer einen Systemwechsel.
|
||||
|
||||
Fachlich ein Widerruf: die Datei gilt nicht mehr, das Ticket bleibt offen,
|
||||
eine neue Lizenz kann sofort ausgestellt werden.
|
||||
"""
|
||||
_rate_guard()
|
||||
ticket = _ticket_or_404(ticket_id)
|
||||
conn = db()
|
||||
if not core().settings.get(conn, "allow_customer_revoke"):
|
||||
abort(404)
|
||||
row = issuance.get_license(conn, license_id)
|
||||
if row is None or str(row["ticket_id"]) != ticket_id:
|
||||
abort(404)
|
||||
try:
|
||||
with transaction():
|
||||
issuance.revoke_license(conn, license_id, reason="Freigabe durch den Kunden")
|
||||
except issuance.IssuanceError as exc:
|
||||
flash(str(exc), "danger")
|
||||
return redirect(url_for("selfservice.overview", ticket_id=ticket_id))
|
||||
|
||||
_log("license.revoked_self_service", ticket, license_id[:12], severity="warning")
|
||||
reporting.report_soon(core())
|
||||
flash(
|
||||
"Die Lizenz wurde freigegeben. Sie koennen jetzt eine neue Lizenz fuer Ihr neues System "
|
||||
"ausstellen.",
|
||||
"success",
|
||||
)
|
||||
return redirect(url_for("selfservice.overview", ticket_id=ticket_id))
|
||||
@@ -0,0 +1,305 @@
|
||||
"""Einstellungen des Lizenzservers: System, Anbieterdaten, E-Mail-Versand.
|
||||
|
||||
Netzwerk, Webserver/TLS und Verzeichnisdienst kommen aus dem Kern.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from typing import Any
|
||||
|
||||
from flask import Blueprint, flash, redirect, render_template, request, url_for
|
||||
from tesm_core import audit
|
||||
from tesm_core.auth.models import current_user, permission_required
|
||||
from tesm_core.auth.service import client_ip
|
||||
from tesm_core.extension import core, db, transaction
|
||||
|
||||
from ..services import mail
|
||||
from .common import log_event, master_endpoint, vendor_info
|
||||
|
||||
bp = Blueprint("settings", __name__, url_prefix="/einstellungen")
|
||||
|
||||
MAX_LOGO_BYTES = 200 * 1024
|
||||
LOGO_MIME = {
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".svg": "image/svg+xml",
|
||||
}
|
||||
LOGO_MAGIC = {
|
||||
b"\x89PNG\r\n\x1a\n": "image/png",
|
||||
b"\xff\xd8\xff": "image/jpeg",
|
||||
b"<svg": "image/svg+xml",
|
||||
b"<?xml": "image/svg+xml",
|
||||
}
|
||||
|
||||
|
||||
@bp.get("/system")
|
||||
@permission_required("settings_system.view")
|
||||
def system():
|
||||
extension = core()
|
||||
conn = db()
|
||||
sysops = extension.sysops
|
||||
return render_template(
|
||||
"license/settings_system.html",
|
||||
values=extension.settings.get_many(
|
||||
conn,
|
||||
[
|
||||
"portal_enabled",
|
||||
"portal_link_minutes",
|
||||
"portal_session_minutes",
|
||||
"self_service_enabled",
|
||||
"allow_customer_revoke",
|
||||
"default_valid_days",
|
||||
"trash_retention_days",
|
||||
"log_rotate_interval",
|
||||
"log_rotate_keep",
|
||||
],
|
||||
),
|
||||
specs={spec.key: spec for spec in extension.settings.specs()},
|
||||
hostname=(sysops.read_command(["hostname"]).stdout.strip() if sysops else ""),
|
||||
timezone=(
|
||||
sysops.read_command(
|
||||
["timedatectl", "show", "--property=Timezone", "--value"]
|
||||
).stdout.strip()
|
||||
if sysops
|
||||
else ""
|
||||
),
|
||||
timezones=_timezone_choices(),
|
||||
nav_items=extension.nav.items,
|
||||
nav_order=extension.settings.get(conn, "nav_order") or extension.nav.default_order(),
|
||||
sysops_available=bool(sysops and sysops.available),
|
||||
)
|
||||
|
||||
|
||||
def _timezone_choices() -> list[str]:
|
||||
try:
|
||||
from zoneinfo import available_timezones
|
||||
|
||||
return sorted(available_timezones())
|
||||
except Exception: # noqa: BLE001
|
||||
return ["Europe/Berlin", "UTC"]
|
||||
|
||||
|
||||
@bp.post("/system")
|
||||
@permission_required("settings_system.edit")
|
||||
def system_save():
|
||||
extension = core()
|
||||
conn = db()
|
||||
values: dict[str, Any] = {}
|
||||
for key in (
|
||||
"portal_enabled",
|
||||
"portal_link_minutes",
|
||||
"portal_session_minutes",
|
||||
"self_service_enabled",
|
||||
"allow_customer_revoke",
|
||||
"default_valid_days",
|
||||
"trash_retention_days",
|
||||
"log_rotate_interval",
|
||||
"log_rotate_keep",
|
||||
):
|
||||
spec = extension.settings.spec(key)
|
||||
if spec.kind == "bool":
|
||||
values[key] = request.form.get(key) == "1"
|
||||
elif key in request.form:
|
||||
values[key] = request.form.get(key)
|
||||
|
||||
with transaction():
|
||||
changed = extension.settings.set_many(conn, values, actor=current_user.username)
|
||||
if changed:
|
||||
audit.log(
|
||||
conn,
|
||||
actor=current_user.username,
|
||||
actor_id=current_user.id,
|
||||
action="settings.system_saved",
|
||||
detail=", ".join(changed),
|
||||
ip=client_ip(),
|
||||
severity="notice",
|
||||
)
|
||||
flash("Einstellungen gespeichert." if changed else "Keine Aenderungen.",
|
||||
"success" if changed else "info")
|
||||
return redirect(url_for("settings.system"))
|
||||
|
||||
|
||||
@bp.post("/system/hostname")
|
||||
@permission_required("settings_system.execute")
|
||||
def set_hostname():
|
||||
extension = core()
|
||||
sysops = extension.sysops
|
||||
hostname = (request.form.get("hostname") or "").strip()
|
||||
if not hostname or len(hostname) > 63 or not hostname.replace("-", "").isalnum():
|
||||
flash("Der Hostname ist ungueltig.", "danger")
|
||||
return redirect(url_for("settings.system"))
|
||||
if sysops is None or not sysops.available:
|
||||
flash("Systemaktionen sind in dieser Umgebung nicht verfuegbar.", "warning")
|
||||
return redirect(url_for("settings.system"))
|
||||
result = sysops.set_hostname(hostname)
|
||||
if result.ok:
|
||||
log_event("settings.hostname_changed", target=hostname, severity="notice", scope="system")
|
||||
flash(f"Hostname auf {hostname} gesetzt.", "success")
|
||||
else:
|
||||
flash(f"Hostname konnte nicht gesetzt werden: {result.message}", "danger")
|
||||
return redirect(url_for("settings.system"))
|
||||
|
||||
|
||||
@bp.post("/system/zeitzone")
|
||||
@permission_required("settings_system.execute")
|
||||
def set_timezone():
|
||||
import time
|
||||
|
||||
sysops = core().sysops
|
||||
timezone = (request.form.get("timezone") or "").strip()
|
||||
if timezone not in _timezone_choices():
|
||||
flash("Unbekannte Zeitzone.", "danger")
|
||||
return redirect(url_for("settings.system"))
|
||||
if sysops is None or not sysops.available:
|
||||
flash("Systemaktionen sind in dieser Umgebung nicht verfuegbar.", "warning")
|
||||
return redirect(url_for("settings.system"))
|
||||
result = sysops.set_timezone(timezone)
|
||||
if result.ok:
|
||||
try:
|
||||
time.tzset() # type: ignore[attr-defined]
|
||||
except AttributeError: # pragma: no cover - Windows
|
||||
pass
|
||||
log_event("settings.timezone_changed", target=timezone, severity="notice", scope="system")
|
||||
flash(f"Zeitzone auf {timezone} gesetzt.", "success")
|
||||
else:
|
||||
flash(f"Zeitzone konnte nicht gesetzt werden: {result.message}", "danger")
|
||||
return redirect(url_for("settings.system"))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Anbieterdaten
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@bp.get("/anbieter")
|
||||
@permission_required("settings_vendor.view")
|
||||
def vendor():
|
||||
extension = core()
|
||||
conn = db()
|
||||
return render_template(
|
||||
"license/settings_vendor.html",
|
||||
values=extension.settings.get_many(
|
||||
conn,
|
||||
[
|
||||
"vendor_name",
|
||||
"vendor_email",
|
||||
"vendor_phone",
|
||||
"vendor_address",
|
||||
"vendor_logo_base64",
|
||||
"master_endpoint",
|
||||
],
|
||||
),
|
||||
max_logo_kb=MAX_LOGO_BYTES // 1024,
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/anbieter")
|
||||
@permission_required("settings_vendor.edit")
|
||||
def vendor_save():
|
||||
extension = core()
|
||||
conn = db()
|
||||
values = {
|
||||
key: (request.form.get(key) or "").strip()
|
||||
for key in ("vendor_name", "vendor_email", "vendor_phone", "vendor_address")
|
||||
}
|
||||
endpoint = (request.form.get("master_endpoint") or "").strip().rstrip("/")
|
||||
if endpoint and not endpoint.startswith(("http://", "https://")):
|
||||
flash("Der Endpunkt muss mit http:// oder https:// beginnen.", "danger")
|
||||
return redirect(url_for("settings.vendor"))
|
||||
values["master_endpoint"] = endpoint
|
||||
|
||||
logo_problem = ""
|
||||
upload = request.files.get("vendor_logo")
|
||||
if upload is not None and upload.filename:
|
||||
from pathlib import Path
|
||||
|
||||
suffix = Path(upload.filename).suffix.lower()
|
||||
head = upload.stream.read(16)
|
||||
upload.stream.seek(0)
|
||||
data = upload.stream.read(MAX_LOGO_BYTES + 1)
|
||||
if suffix not in LOGO_MIME:
|
||||
logo_problem = "Erlaubt sind PNG, JPG und SVG."
|
||||
elif len(data) > MAX_LOGO_BYTES:
|
||||
logo_problem = f"Das Logo ist zu gross (maximal {MAX_LOGO_BYTES // 1024} KB)."
|
||||
elif not any(head.startswith(magic) for magic in LOGO_MAGIC):
|
||||
logo_problem = "Die Datei ist kein Bild."
|
||||
else:
|
||||
mime = LOGO_MIME[suffix]
|
||||
values["vendor_logo_base64"] = (
|
||||
f"data:{mime};base64," + base64.b64encode(data).decode("ascii")
|
||||
)
|
||||
|
||||
with transaction():
|
||||
changed = extension.settings.set_many(conn, values, actor=current_user.username)
|
||||
if logo_problem:
|
||||
# Ein zu grosses Logo verwirft nur das Logo -- die uebrigen Angaben
|
||||
# werden trotzdem gespeichert.
|
||||
flash(logo_problem, "danger")
|
||||
log_event(
|
||||
"settings.vendor_saved", detail=", ".join(changed), severity="notice", scope="anbieter"
|
||||
)
|
||||
flash("Anbieterdaten gespeichert." if changed else "Keine Aenderungen.",
|
||||
"success" if changed else "info")
|
||||
return redirect(url_for("settings.vendor"))
|
||||
|
||||
|
||||
@bp.post("/anbieter/logo-entfernen")
|
||||
@permission_required("settings_vendor.edit")
|
||||
def vendor_logo_remove():
|
||||
with transaction() as conn:
|
||||
core().settings.set(conn, "vendor_logo_base64", "", actor=current_user.username)
|
||||
flash("Logo entfernt.", "success")
|
||||
return redirect(url_for("settings.vendor"))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# E-Mail
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@bp.get("/email")
|
||||
@permission_required("settings_mail.view")
|
||||
def mail_settings():
|
||||
extension = core()
|
||||
conn = db()
|
||||
return render_template(
|
||||
"license/settings_mail.html",
|
||||
values=extension.settings.get_many(conn, [spec.key for spec in mail.SETTINGS]),
|
||||
specs={spec.key: spec for spec in mail.SETTINGS},
|
||||
has_secret=mail.has_secret(conn),
|
||||
ready=mail.is_enabled(conn),
|
||||
can_secrets=current_user.has("settings_mail.secrets"),
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/email")
|
||||
@permission_required("settings_mail.edit")
|
||||
def mail_save():
|
||||
extension = core()
|
||||
conn = db()
|
||||
values: dict[str, Any] = {}
|
||||
for spec in mail.SETTINGS:
|
||||
if spec.kind == "bool":
|
||||
values[spec.key] = request.form.get(spec.key) == "1"
|
||||
elif spec.key in request.form:
|
||||
values[spec.key] = (request.form.get(spec.key) or "").strip()
|
||||
|
||||
with transaction():
|
||||
changed = extension.settings.set_many(conn, values, actor=current_user.username)
|
||||
if current_user.has("settings_mail.secrets") and request.form.get("client_secret"):
|
||||
mail.store_secret(conn, request.form.get("client_secret"))
|
||||
changed.append("client_secret")
|
||||
log_event("settings.mail_saved", detail=", ".join(changed), severity="notice", scope="mail")
|
||||
flash("E-Mail-Einstellungen gespeichert." if changed else "Keine Aenderungen.",
|
||||
"success" if changed else "info")
|
||||
return redirect(url_for("settings.mail_settings"))
|
||||
|
||||
|
||||
@bp.post("/email/test")
|
||||
@permission_required("settings_mail.execute")
|
||||
def mail_test():
|
||||
result = mail.test_connection(db())
|
||||
flash(result["message"], "success" if result["ok"] else "danger")
|
||||
return redirect(url_for("settings.mail_settings"))
|
||||
@@ -0,0 +1,238 @@
|
||||
"""Tickets: der dauerhafte Anspruch eines Kunden auf eine Lizenz."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from flask import Blueprint, abort, flash, redirect, render_template, request, url_for
|
||||
from tesm_core.auth.models import current_user, permission_required
|
||||
from tesm_core.extension import core, db, transaction
|
||||
|
||||
import tesm_licensing as lic
|
||||
|
||||
from ..services import issuance, reporting
|
||||
from .common import (
|
||||
can_issue_license_server_licenses,
|
||||
customer_facing_url,
|
||||
log_event,
|
||||
master_endpoint,
|
||||
module_labels,
|
||||
role_labels,
|
||||
type_labels,
|
||||
)
|
||||
|
||||
bp = Blueprint("tickets", __name__, url_prefix="/tickets")
|
||||
|
||||
|
||||
@bp.get("/")
|
||||
@permission_required("tickets.view")
|
||||
def overview():
|
||||
conn = db()
|
||||
tickets = issuance.list_tickets(conn)
|
||||
licenses = {
|
||||
str(row["ticket_id"]): {**row, **issuance.display_state(row)}
|
||||
for row in issuance.list_licenses(conn)
|
||||
if row["status"] in {"issued", "active"}
|
||||
}
|
||||
return render_template(
|
||||
"license/tickets.html",
|
||||
endpoint_missing=_endpoint_missing(),
|
||||
tickets=tickets,
|
||||
open_licenses=licenses,
|
||||
type_labels=type_labels(),
|
||||
role_labels=role_labels(),
|
||||
module_labels=module_labels(),
|
||||
)
|
||||
|
||||
|
||||
@bp.get("/neu")
|
||||
@permission_required("tickets.create")
|
||||
def new():
|
||||
conn = db()
|
||||
extension = core()
|
||||
return render_template(
|
||||
"license/ticket_new.html",
|
||||
endpoint_missing=_endpoint_missing(),
|
||||
customers=issuance.list_customers(conn),
|
||||
modules=lic.ALL_MODULES,
|
||||
module_labels=module_labels(),
|
||||
types=lic.LICENSE_TYPES,
|
||||
type_labels=type_labels(),
|
||||
default_valid_days=int(extension.settings.get(conn, "default_valid_days") or 365),
|
||||
may_create_license_server=can_issue_license_server_licenses(),
|
||||
preselected_customer=request.args.get("kunde", type=int),
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/neu")
|
||||
@permission_required("tickets.create")
|
||||
def create():
|
||||
conn = db()
|
||||
# Rolle: jeder Versuch ausserhalb der strukturellen Erlaubnis wird still auf
|
||||
# "customer" zurueckgestuft -- nicht nur in der Oberflaeche verborgen.
|
||||
role = request.form.get("role", "customer")
|
||||
if role != "customer" and not can_issue_license_server_licenses():
|
||||
role = "customer"
|
||||
|
||||
try:
|
||||
with transaction():
|
||||
ticket_id = issuance.create_ticket(
|
||||
conn,
|
||||
customer_id=int(request.form.get("customer_id") or 0),
|
||||
license_type=request.form.get("type", "standard"),
|
||||
modules=request.form.getlist("modules"),
|
||||
valid_days=int(request.form.get("valid_days") or 365),
|
||||
lifetime=request.form.get("lifetime") == "1",
|
||||
role=role,
|
||||
notes=request.form.get("notes", ""),
|
||||
actor=current_user.username,
|
||||
)
|
||||
log_event(
|
||||
"ticket.created",
|
||||
target=ticket_id[:12],
|
||||
detail=f"Typ {request.form.get('type')}, Rolle {role}",
|
||||
severity="notice",
|
||||
scope="ticket",
|
||||
)
|
||||
flash("Ticket angelegt.", "success")
|
||||
return redirect(url_for("tickets.detail", ticket_id=ticket_id))
|
||||
except (issuance.IssuanceError, ValueError) as exc:
|
||||
flash(str(exc), "danger")
|
||||
return redirect(url_for("tickets.new"))
|
||||
|
||||
|
||||
def _endpoint_missing() -> str:
|
||||
"""Meldung, falls ohne Endpunkt gearbeitet wird -- sonst leer."""
|
||||
if master_endpoint():
|
||||
return ""
|
||||
return (
|
||||
"Es ist kein oeffentlicher Endpunkt hinterlegt (Einstellungen -> Anbieter). "
|
||||
"Solange er fehlt, koennen keine Lizenzen ausgestellt werden: der Endpunkt wird "
|
||||
"in die Lizenz signiert und laesst sich spaeter nicht nachtragen."
|
||||
)
|
||||
|
||||
|
||||
@bp.get("/<ticket_id>")
|
||||
@permission_required("tickets.view")
|
||||
def detail(ticket_id: str):
|
||||
conn = db()
|
||||
ticket = issuance.get_ticket(conn, ticket_id)
|
||||
if ticket is None:
|
||||
abort(404)
|
||||
licenses = [
|
||||
{**row, **issuance.display_state(row)}
|
||||
for row in issuance.list_licenses(conn, ticket_id=ticket_id)
|
||||
]
|
||||
extension = core()
|
||||
self_service_enabled = bool(extension.settings.get(conn, "self_service_enabled"))
|
||||
return render_template(
|
||||
"license/ticket_detail.html",
|
||||
endpoint_missing=_endpoint_missing(),
|
||||
ticket=ticket,
|
||||
ticket_modules=json.loads(str(ticket["modules"] or "[]")),
|
||||
licenses=licenses,
|
||||
has_open_license=issuance.ticket_has_open_license(conn, ticket_id),
|
||||
modules=lic.ALL_MODULES,
|
||||
module_labels=module_labels(),
|
||||
types=lic.LICENSE_TYPES,
|
||||
type_labels=type_labels(),
|
||||
role_labels=role_labels(),
|
||||
trial_days=lic.TRIAL_DEFAULT_DAYS,
|
||||
# Self-Service gibt es nur fuer Kundentickets -- fuer andere Rollen ist
|
||||
# der Weg vollstaendig gesperrt.
|
||||
self_service_url=(
|
||||
customer_facing_url("selfservice.overview", ticket_id=ticket_id)
|
||||
if self_service_enabled and str(ticket["role"]) == "customer"
|
||||
else ""
|
||||
),
|
||||
portal_url=customer_facing_url("portal.request_link"),
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/<ticket_id>")
|
||||
@permission_required("tickets.edit")
|
||||
def update(ticket_id: str):
|
||||
conn = db()
|
||||
if issuance.get_ticket(conn, ticket_id) is None:
|
||||
abort(404)
|
||||
try:
|
||||
with transaction():
|
||||
changes = issuance.update_ticket(
|
||||
conn,
|
||||
ticket_id,
|
||||
license_type=request.form.get("type", "standard"),
|
||||
modules=request.form.getlist("modules"),
|
||||
valid_days=int(request.form.get("valid_days") or 365),
|
||||
lifetime=request.form.get("lifetime") == "1",
|
||||
notes=request.form.get("notes", ""),
|
||||
)
|
||||
if changes:
|
||||
log_event(
|
||||
"ticket.updated",
|
||||
target=ticket_id[:12],
|
||||
detail="; ".join(changes),
|
||||
severity="notice",
|
||||
scope="ticket",
|
||||
)
|
||||
reporting.report_soon(core())
|
||||
flash(
|
||||
"Gespeichert. Die Aenderung wirkt auf die naechste Ausstellung."
|
||||
if changes
|
||||
else "Keine Aenderungen.",
|
||||
"success" if changes else "info",
|
||||
)
|
||||
except (issuance.IssuanceError, ValueError) as exc:
|
||||
flash(str(exc), "danger")
|
||||
return redirect(url_for("tickets.detail", ticket_id=ticket_id))
|
||||
|
||||
|
||||
@bp.post("/<ticket_id>/sperre")
|
||||
@permission_required("tickets.edit")
|
||||
def toggle_block(ticket_id: str):
|
||||
conn = db()
|
||||
ticket = issuance.get_ticket(conn, ticket_id)
|
||||
if ticket is None:
|
||||
abort(404)
|
||||
new_status = "open" if str(ticket["status"]) == "blocked" else "blocked"
|
||||
with transaction():
|
||||
issuance.set_ticket_status(conn, ticket_id, new_status)
|
||||
log_event(
|
||||
"ticket.blocked" if new_status == "blocked" else "ticket.unblocked",
|
||||
target=ticket_id[:12],
|
||||
detail=str(ticket["customer_name"]),
|
||||
severity="warning" if new_status == "blocked" else "notice",
|
||||
scope="ticket",
|
||||
)
|
||||
reporting.report_soon(core())
|
||||
flash(
|
||||
"Ticket gesperrt -- es lassen sich keine neuen Lizenzen mehr ausstellen."
|
||||
if new_status == "blocked"
|
||||
else "Ticket entsperrt.",
|
||||
"success",
|
||||
)
|
||||
return redirect(url_for("tickets.detail", ticket_id=ticket_id))
|
||||
|
||||
|
||||
@bp.post("/<ticket_id>/loeschen")
|
||||
@permission_required("tickets.delete")
|
||||
def delete(ticket_id: str):
|
||||
conn = db()
|
||||
ticket = issuance.get_ticket(conn, ticket_id)
|
||||
if ticket is None:
|
||||
abort(404)
|
||||
try:
|
||||
with transaction():
|
||||
issuance.delete_ticket(conn, ticket_id)
|
||||
log_event(
|
||||
"ticket.deleted",
|
||||
target=ticket_id[:12],
|
||||
detail=str(ticket["customer_name"]),
|
||||
severity="warning",
|
||||
scope="ticket",
|
||||
)
|
||||
reporting.report_soon(core())
|
||||
flash("Ticket und seine Lizenzhistorie geloescht.", "success")
|
||||
return redirect(url_for("tickets.overview"))
|
||||
except issuance.IssuanceError as exc:
|
||||
flash(str(exc), "danger")
|
||||
return redirect(url_for("tickets.detail", ticket_id=ticket_id))
|
||||
@@ -0,0 +1,493 @@
|
||||
"""Kommandozeilenwerkzeuge des Lizenzservers.
|
||||
|
||||
Enthaelt insbesondere ``bootstrap`` -- die eigene Lizenz der Wurzelinstanz.
|
||||
|
||||
Warum das ein lokales Skript ist und keine Funktion in der Oberflaeche: die
|
||||
Master-Lizenz ist die einzige Lizenz, fuer die es **keine hoehere Instanz gibt**,
|
||||
die sie bestaetigen koennte. Ein Aktivierungs-Handshake gegen sich selbst waere
|
||||
Theater. Das Skript signiert die Datei mit dem eigenen Schluessel und setzt den
|
||||
Aktivierungszustand direkt -- und nur so kann eine Lizenzdatei je die Rolle
|
||||
``master`` tragen. Die Ticket-Oberflaeche bietet diese Rolle an keiner Stelle an.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import getpass
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import tesm_licensing as lic
|
||||
import tesm_licensing_server as licsrv
|
||||
from tesm_core import audit
|
||||
from tesm_core.cli_web import add_web_setup, run_web_setup
|
||||
from tesm_core.db import Database
|
||||
from tesm_core.extension import core
|
||||
from tesm_core.rbac import service as rbac
|
||||
from tesm_core.security import passwords
|
||||
|
||||
from . import create_app, master_key_path
|
||||
from .services import legacy_import, master_key
|
||||
|
||||
|
||||
def _app_and_core() -> tuple[Any, Any]:
|
||||
app = create_app()
|
||||
return app, core(app)
|
||||
|
||||
|
||||
def cmd_init_key(args: argparse.Namespace) -> int:
|
||||
app, extension = _app_and_core()
|
||||
path = master_key_path(extension.config)
|
||||
if path.exists() and not args.force:
|
||||
key = master_key.load(path)
|
||||
print(f"Signaturschluessel existiert bereits: {key.key_id} ({path})")
|
||||
print("Ein Austausch macht ALLE bereits ausgestellten Lizenzen unpruefbar.")
|
||||
return 0
|
||||
if path.exists() and args.force:
|
||||
print(
|
||||
"ABBRUCH: --force auf einen bestehenden Signaturschluessel wird nicht unterstuetzt.\n"
|
||||
"Ein neuer Schluessel entwertet jede ausgestellte Lizenz. Bitte die Datei bewusst\n"
|
||||
"von Hand beiseite legen, wenn das wirklich gewollt ist.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
key = master_key.ensure(path)
|
||||
print(f"Signaturschluessel erzeugt: {key.key_id}")
|
||||
print(f"Datei: {path}")
|
||||
print("Bitte SOFORT sichern -- ohne sie ist keine ausgestellte Lizenz mehr pruefbar.")
|
||||
print(f"Oeffentlicher Schluessel (zum Pinnen beim Kunden):\n{key.public_key}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_bootstrap(args: argparse.Namespace) -> int:
|
||||
"""Stellt die eigene Lizenz dieser Wurzelinstanz aus."""
|
||||
app, extension = _app_and_core()
|
||||
key = master_key.ensure(master_key_path(extension.config))
|
||||
|
||||
with app.app_context(), extension.database.session() as conn:
|
||||
vendor = {
|
||||
"name": str(extension.settings.get(conn, "vendor_name") or "Eigenbetrieb"),
|
||||
"email": str(extension.settings.get(conn, "vendor_email") or ""),
|
||||
"phone": str(extension.settings.get(conn, "vendor_phone") or ""),
|
||||
"address": str(extension.settings.get(conn, "vendor_address") or ""),
|
||||
"logo_base64": "",
|
||||
}
|
||||
bundle, _ = licsrv.issue_license(
|
||||
customer={"name": args.name, "contact_email": vendor["email"]},
|
||||
license_type="enterprise",
|
||||
modules=[],
|
||||
valid_days=int(args.days),
|
||||
master_private_key_b64=key.private_key,
|
||||
master_public_key_b64=key.public_key,
|
||||
# Bewusst leer: es gibt keinen uebergeordneten Endpunkt. Damit
|
||||
# laeuft der Handshake fuer diese Lizenz nie ins Netz.
|
||||
master_endpoint="",
|
||||
vendor=vendor,
|
||||
role="master",
|
||||
)
|
||||
document, client_key = lic.split_bundle(bundle)
|
||||
|
||||
config = extension.config
|
||||
config.license_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
config.license_path.write_text(
|
||||
json.dumps(document, indent=2, sort_keys=True, ensure_ascii=False) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
try:
|
||||
config.license_path.chmod(0o640)
|
||||
except OSError:
|
||||
pass
|
||||
from tesm_core.keystore import write_private_file
|
||||
|
||||
write_private_file(
|
||||
config.license_key_path,
|
||||
json.dumps({"license_id": document["license_id"], "client_key": client_key}) + "\n",
|
||||
)
|
||||
|
||||
with extension.database.transaction(conn):
|
||||
conn.execute(
|
||||
"INSERT INTO trusted_issuer_keys (key_id, public_key, label, source, added_at, "
|
||||
"added_by) VALUES (?,?,?,'bootstrap',datetime('now'),'cli') "
|
||||
"ON CONFLICT(key_id) DO NOTHING",
|
||||
(key.key_id, key.public_key, "Eigener Signaturschluessel"),
|
||||
)
|
||||
# Es gibt keine hoehere Instanz, die diese Lizenz aktivieren koennte.
|
||||
extension.settings.set(conn, "license_activation_status", "active", actor="cli")
|
||||
extension.settings.set(conn, "license_pending_request", "", actor="cli")
|
||||
audit.log(
|
||||
conn,
|
||||
actor="cli",
|
||||
action="own_license.bootstrapped",
|
||||
target=document["license_id"],
|
||||
detail=f"Rolle master, {args.days} Tage, Schluessel {key.key_id}",
|
||||
severity="warning",
|
||||
)
|
||||
state = extension.license.reload(conn) if extension.license else None
|
||||
|
||||
print(f"Master-Lizenz ausgestellt: {document['license_id']}")
|
||||
print(f"Gueltig bis: {document['expires_at']}")
|
||||
print(f"Datei: {config.license_path}")
|
||||
if state is not None:
|
||||
print(f"Zustand: gueltig={state.valid}, aktiviert={state.activated}, Rolle={state.role}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_create_admin(args: argparse.Namespace) -> int:
|
||||
app, extension = _app_and_core()
|
||||
username = args.username or input("Benutzername: ").strip()
|
||||
if not username:
|
||||
print("Kein Benutzername angegeben.", file=sys.stderr)
|
||||
return 2
|
||||
password = args.password or getpass.getpass("Passwort: ")
|
||||
if not args.password and password != getpass.getpass("Passwort wiederholen: "):
|
||||
print("Die Passwoerter stimmen nicht ueberein.", file=sys.stderr)
|
||||
return 2
|
||||
problems = extension.password_policy.check(password, username=username)
|
||||
if problems and not args.force:
|
||||
for problem in problems:
|
||||
print(f"- {problem}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
with app.app_context(), extension.database.session() as conn:
|
||||
existing = rbac.find_user_by_username(conn, username, include_deleted=True)
|
||||
with extension.database.transaction(conn):
|
||||
if existing is not None:
|
||||
conn.execute(
|
||||
"UPDATE users SET password_hash=?, is_admin=1, is_locked=0, deleted_at=NULL, "
|
||||
"must_change_password=0, updated_at=datetime('now') WHERE id=?",
|
||||
(passwords.hash_password(password), existing["id"]),
|
||||
)
|
||||
action = "aktualisiert"
|
||||
else:
|
||||
conn.execute(
|
||||
"INSERT INTO users (username, password_hash, is_admin, auth_source, "
|
||||
"created_at, updated_at, password_changed_at) "
|
||||
"VALUES (?,?,1,'local',datetime('now'),datetime('now'),datetime('now'))",
|
||||
(username, passwords.hash_password(password)),
|
||||
)
|
||||
action = "angelegt"
|
||||
audit.log(
|
||||
conn,
|
||||
actor="cli",
|
||||
action="user.admin_created",
|
||||
target=username,
|
||||
severity="warning",
|
||||
)
|
||||
print(f"Administrator {username!r} {action}.")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_status(args: argparse.Namespace) -> int:
|
||||
app, extension = _app_and_core()
|
||||
with app.app_context(), extension.database.session() as conn:
|
||||
chain = audit.verify_chain(conn, limit=5000)
|
||||
counts = {
|
||||
"kunden": Database.value(conn, "SELECT COUNT(*) FROM license_customers", default=0),
|
||||
"tickets": Database.value(conn, "SELECT COUNT(*) FROM license_tickets", default=0),
|
||||
"lizenzen": Database.value(conn, "SELECT COUNT(*) FROM licenses", default=0),
|
||||
"aktiv": Database.value(
|
||||
conn, "SELECT COUNT(*) FROM licenses WHERE status='active'", default=0
|
||||
),
|
||||
"administratoren": rbac.count_active_admins(conn),
|
||||
}
|
||||
state = extension.license.reload(conn) if extension.license else None
|
||||
key_health = master_key.health(master_key_path(extension.config))
|
||||
|
||||
payload = {
|
||||
"datenbank": str(extension.config.db_path),
|
||||
"bestand": counts,
|
||||
"audit_kette": chain,
|
||||
"signaturschluessel": key_health,
|
||||
"eigene_lizenz": (
|
||||
{
|
||||
"vorhanden": state.present,
|
||||
"gueltig": state.valid,
|
||||
"aktiviert": state.activated,
|
||||
"rolle": state.role,
|
||||
"fehler": state.error,
|
||||
}
|
||||
if state
|
||||
else None
|
||||
),
|
||||
}
|
||||
if args.json:
|
||||
print(json.dumps(payload, indent=2, ensure_ascii=False, default=str))
|
||||
return 0
|
||||
|
||||
print(f"Datenbank : {payload['datenbank']}")
|
||||
for key, value in counts.items():
|
||||
print(f"{key.capitalize():<17}: {value}")
|
||||
print(
|
||||
"Audit-Kette : "
|
||||
+ ("unversehrt" if chain["ok"] else f"GEBROCHEN bei {chain['broken_at']}")
|
||||
)
|
||||
print(
|
||||
"Signaturschluessel: "
|
||||
+ (str(key_health.get("key_id")) if key_health.get("present") else "FEHLT")
|
||||
)
|
||||
for problem in key_health.get("problems", []):
|
||||
print(f" ! {problem}", file=sys.stderr)
|
||||
if state:
|
||||
print(
|
||||
"Eigene Lizenz : "
|
||||
+ ("aktiv" if state.valid and state.activated else "nicht aktiv")
|
||||
+ f", Rolle {state.role}"
|
||||
)
|
||||
if not counts["administratoren"]:
|
||||
print("\nWARNUNG: kein entsperrtes Administratorkonto vorhanden.", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_housekeeping(_args: argparse.Namespace) -> int:
|
||||
app, extension = _app_and_core()
|
||||
from tesm_core.security import ratelimit
|
||||
from tesm_core.security import sessions as session_store
|
||||
|
||||
from .services import activation
|
||||
|
||||
with app.app_context(), extension.database.session() as conn:
|
||||
with extension.database.transaction(conn):
|
||||
sessions = session_store.prune(conn)
|
||||
events = ratelimit.prune(conn)
|
||||
nonces = activation.prune_nonces(conn)
|
||||
archived = audit.archive_old_rows(conn, extension.audit_archive_dir)
|
||||
expired_tokens = conn.execute(
|
||||
"DELETE FROM portal_access_tokens WHERE expires_at < datetime('now', '-7 days')"
|
||||
).rowcount
|
||||
print(
|
||||
f"{sessions} Sitzungen, {events} Ratenereignisse, {nonces} Nonces, "
|
||||
f"{expired_tokens or 0} Portal-Token entfernt, {len(archived)} Protokolltage archiviert."
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_web_setup(args: argparse.Namespace) -> int:
|
||||
app, extension = _app_and_core()
|
||||
return run_web_setup(app, extension, args)
|
||||
|
||||
|
||||
def cmd_migrate_legacy(args: argparse.Namespace) -> int:
|
||||
"""Uebernimmt Signaturschluessel, Kunden, Tickets und Lizenzen der Vorgaengerfassung."""
|
||||
root = Path(args.source)
|
||||
app, extension = _app_and_core()
|
||||
|
||||
try:
|
||||
legacy = legacy_import.open_legacy(root)
|
||||
except legacy_import.LegacyImportError as exc:
|
||||
print(str(exc), file=sys.stderr)
|
||||
return 2
|
||||
|
||||
key_path = master_key_path(extension.config)
|
||||
report = legacy_import.Report()
|
||||
|
||||
try:
|
||||
# -- Schluessel ---------------------------------------------------- #
|
||||
# Der alte Schluessel liegt als 0600 root. Gelesen wird er nur, wenn er
|
||||
# wirklich gebraucht wird -- naemlich beim Uebernehmen. Fuer den blossen
|
||||
# Vergleich genuegt die oeffentliche Haelfte, und fehlt sie, ist das
|
||||
# kein Grund abzubrechen: der Vergleich ist eine Auskunft, keine
|
||||
# Voraussetzung.
|
||||
issuer_changes = False
|
||||
legacy_public = ""
|
||||
try:
|
||||
_, legacy_public = legacy_import.read_legacy_key(root)
|
||||
except legacy_import.LegacyImportError as exc:
|
||||
if args.adopt_key:
|
||||
raise
|
||||
print(f"Hinweis: alter Signaturschluessel nicht lesbar ({exc}).")
|
||||
|
||||
if key_path.is_file():
|
||||
existing = master_key.load(key_path)
|
||||
report.key_id = existing.key_id
|
||||
if legacy_public and existing.public_key == legacy_public:
|
||||
print(f"Signaturschluessel stimmt bereits ueberein ({existing.key_id}).")
|
||||
elif not legacy_public:
|
||||
issuer_changes = True
|
||||
print(
|
||||
f"Es wird mit dem vorhandenen Signaturschluessel {existing.key_id} "
|
||||
"gearbeitet."
|
||||
)
|
||||
else:
|
||||
# Nicht ueberschreiben: jede mit dem vorhandenen Schluessel
|
||||
# ausgestellte Lizenz waere danach unpruefbar.
|
||||
issuer_changes = True
|
||||
print(
|
||||
f"Diese Installation hat einen eigenen Signaturschluessel "
|
||||
f"({existing.key_id}); der alte war {lic.key_id(legacy_public)}. "
|
||||
"Es wird mit dem vorhandenen weitergearbeitet."
|
||||
)
|
||||
elif args.adopt_key:
|
||||
legacy_private, legacy_public = legacy_import.read_legacy_key(root)
|
||||
report.key_id = legacy_import.adopt_key(legacy_private, legacy_public, key_path)
|
||||
report.key_adopted = True
|
||||
print(f"Signaturschluessel uebernommen: {report.key_id}")
|
||||
else:
|
||||
print(
|
||||
"Diese Installation hat noch keinen Signaturschluessel. Mit --adopt-key "
|
||||
"wird der alte uebernommen -- dann bleibt die Anbieteridentitaet erhalten "
|
||||
"und Kunden muessen keinen Ausstellerwechsel bestaetigen.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
|
||||
key = master_key.load(key_path)
|
||||
|
||||
with app.app_context(), extension.database.session() as conn:
|
||||
# Anbieterdaten und Endpunkt zuerst: bei einem Upgrade ist die neue
|
||||
# Installation frisch, dort steht also nichts, und die Pruefung
|
||||
# weiter unten haette jedes Upgrade abgebrochen. Beides gehoert zur
|
||||
# Anbieteridentitaet -- genau die soll ein Upgrade nicht wechseln.
|
||||
# Uebernommen wird nur, was hier leer ist.
|
||||
if not args.dry_run:
|
||||
with extension.database.transaction(conn):
|
||||
uebernommen = legacy_import.adopt_vendor_settings(
|
||||
legacy,
|
||||
lambda key: extension.settings.get(conn, key),
|
||||
lambda key, wert: extension.settings.set(
|
||||
conn, key, wert, actor="migration"
|
||||
),
|
||||
)
|
||||
# Nicht "key" als Laufvariable: darueber steht der geladene
|
||||
# Signaturschluessel mit demselben Namen, und ihn zu
|
||||
# ueberschreiben lief in ein AttributeError mitten in der
|
||||
# Uebernahme.
|
||||
for einstellung, wert in uebernommen.items():
|
||||
gekuerzt = wert if len(wert) <= 60 else wert[:57] + "..."
|
||||
print(f"Uebernommen: {einstellung} = {gekuerzt}")
|
||||
|
||||
vendor = {
|
||||
"name": str(extension.settings.get(conn, "vendor_name") or "Eigenbetrieb"),
|
||||
"email": str(extension.settings.get(conn, "vendor_email") or ""),
|
||||
"phone": str(extension.settings.get(conn, "vendor_phone") or ""),
|
||||
"address": str(extension.settings.get(conn, "vendor_address") or ""),
|
||||
"logo_base64": "",
|
||||
}
|
||||
endpoint = str(extension.settings.get(conn, "master_endpoint") or "")
|
||||
if not endpoint and not args.dry_run:
|
||||
print(
|
||||
"Es ist kein Endpunkt hinterlegt, und die alte Installation nennt "
|
||||
"auch keinen. Bitte unter Einstellungen -> Anbieter eintragen: ohne "
|
||||
"ihn koennen uebernommene Lizenzen sich spaeter nicht aktivieren.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
|
||||
with extension.database.transaction(conn):
|
||||
result = legacy_import.import_all(
|
||||
legacy,
|
||||
conn,
|
||||
keystore=extension.keystore,
|
||||
master_private_key=key.private_key,
|
||||
master_public_key=key.public_key,
|
||||
master_endpoint=endpoint,
|
||||
vendor=vendor,
|
||||
actor="migration",
|
||||
reissue=not args.dry_run,
|
||||
)
|
||||
result.key_adopted = report.key_adopted
|
||||
result.key_id = report.key_id
|
||||
if issuer_changes:
|
||||
result.warnings.append(
|
||||
"Die Lizenzen sind mit einem anderen Schluessel signiert als "
|
||||
"frueher. Kunden, die den alten Aussteller gepinnt haben, muessen "
|
||||
"beim Einspielen einmal 'Wechsel des Ausstellers ausdruecklich "
|
||||
"zulassen' bestaetigen."
|
||||
)
|
||||
if args.dry_run:
|
||||
# Nichts festschreiben -- der Lauf war nur zur Ansicht.
|
||||
raise _DryRun(result)
|
||||
except _DryRun as preview:
|
||||
print()
|
||||
print("== Probelauf, nichts gespeichert ==")
|
||||
print(preview.report.as_text())
|
||||
return 0
|
||||
except legacy_import.LegacyImportError as exc:
|
||||
print(str(exc), file=sys.stderr)
|
||||
return 2
|
||||
finally:
|
||||
legacy.close()
|
||||
|
||||
print()
|
||||
print(result.as_text())
|
||||
return 0
|
||||
|
||||
|
||||
class _DryRun(Exception):
|
||||
"""Bricht die Transaktion eines Probelaufs ab -- ohne Fehler zu sein."""
|
||||
|
||||
def __init__(self, report: Any) -> None:
|
||||
super().__init__("Probelauf")
|
||||
self.report = report
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="tesm-license-admin", description="Verwaltungswerkzeuge des TESM-Lizenzservers"
|
||||
)
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
init_key = sub.add_parser("init-key", help="Signaturschluessel erzeugen (einmalig)")
|
||||
init_key.add_argument("--force", action="store_true")
|
||||
init_key.set_defaults(func=cmd_init_key)
|
||||
|
||||
bootstrap = sub.add_parser(
|
||||
"bootstrap", help="Eigene Master-Lizenz dieser Wurzelinstanz ausstellen"
|
||||
)
|
||||
bootstrap.add_argument("--days", type=int, default=3650)
|
||||
bootstrap.add_argument("--name", default="Lizenzserver (Eigenbetrieb)")
|
||||
bootstrap.set_defaults(func=cmd_bootstrap)
|
||||
|
||||
admin = sub.add_parser("create-admin", help="Administratorkonto anlegen oder zuruecksetzen")
|
||||
admin.add_argument("--username")
|
||||
admin.add_argument("--password")
|
||||
admin.add_argument("--force", action="store_true")
|
||||
admin.set_defaults(func=cmd_create_admin)
|
||||
|
||||
status = sub.add_parser("status", help="Zustand ausgeben")
|
||||
status.add_argument("--json", action="store_true")
|
||||
status.set_defaults(func=cmd_status)
|
||||
|
||||
house = sub.add_parser("housekeeping", help="Laufzeitdaten aufraeumen")
|
||||
house.set_defaults(func=cmd_housekeeping)
|
||||
|
||||
migrate = sub.add_parser(
|
||||
"migrate-legacy",
|
||||
help="Lizenzserver der Vorgaengerfassung uebernehmen",
|
||||
description=(
|
||||
"Uebernimmt Signaturschluessel, Kunden, Tickets und Lizenzen aus einer alten "
|
||||
"Installation. Die alte Installation wird nur gelesen. Jede Lizenz wird im "
|
||||
"neuen Format neu signiert -- die Kunden brauchen danach eine neue "
|
||||
"Lizenzdatei, weil die alte Signatur nicht mehr gilt."
|
||||
),
|
||||
)
|
||||
migrate.add_argument(
|
||||
"--source", default="/srv/tesm-license", help="Verzeichnis der alten Installation"
|
||||
)
|
||||
migrate.add_argument(
|
||||
"--adopt-key",
|
||||
action="store_true",
|
||||
help="Den alten Signaturschluessel uebernehmen. Nur moeglich, solange diese "
|
||||
"Installation noch keinen eigenen hat -- ein vorhandener wird nie ueberschrieben.",
|
||||
)
|
||||
migrate.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Nur anzeigen, was passieren wuerde -- nichts schreiben",
|
||||
)
|
||||
migrate.set_defaults(func=cmd_migrate_legacy)
|
||||
|
||||
add_web_setup(sub, cmd_web_setup)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = build_parser().parse_args(argv)
|
||||
return int(args.func(args))
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,188 @@
|
||||
"""Navigationsbaum des Lizenzservers.
|
||||
|
||||
Kunde, Ticket und Lizenz sind drei **flache** Top-Level-Punkte, jeder einzeln
|
||||
ueber die Navigation erreichbar. Ein Zwischenstand des Vorgaengerprojekts hatte
|
||||
Tickets und Lizenzen tief in die Kundenseite verschachtelt -- das war
|
||||
nachweislich unbrauchbar. Die Kundendetailseite existiert trotzdem, aber als
|
||||
reiner Lese-Ueberblick.
|
||||
|
||||
Settings und Protokolle bleiben zweistufige Gruppen -- das ist keine Ausnahme
|
||||
von der Flachheit, sondern die Standardkonvention fuer mehrseitige Bereiche.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from tesm_core.extension import core
|
||||
from tesm_core.nav import NavItem, NavTree
|
||||
|
||||
|
||||
def _is_master(_user: Any) -> bool:
|
||||
"""Nur eine Master-Instanz sieht die Berichte der Sub-Lizenzserver."""
|
||||
manager = core().license
|
||||
if manager is None:
|
||||
return True
|
||||
return manager.state.role == "master"
|
||||
|
||||
|
||||
NAVIGATION = NavTree(
|
||||
[
|
||||
NavItem(key="index", label="Uebersicht", icon="grid", endpoint="dashboard.index"),
|
||||
NavItem(
|
||||
key="customers",
|
||||
label="Kunden",
|
||||
icon="building",
|
||||
endpoint="customers.overview",
|
||||
permissions=("customers.view",),
|
||||
),
|
||||
NavItem(
|
||||
key="tickets",
|
||||
label="Tickets",
|
||||
icon="ticket",
|
||||
endpoint="tickets.overview",
|
||||
permissions=("tickets.view",),
|
||||
),
|
||||
NavItem(
|
||||
key="licenses",
|
||||
label="Lizenzen",
|
||||
icon="shield",
|
||||
endpoint="licenses.overview",
|
||||
permissions=("licenses.view",),
|
||||
),
|
||||
NavItem(
|
||||
key="offline_code",
|
||||
label="Offline-Aktivierung",
|
||||
icon="transfer",
|
||||
endpoint="licenses.manual_code",
|
||||
permissions=("licenses.execute",),
|
||||
),
|
||||
NavItem(
|
||||
key="reports",
|
||||
label="Sub-Lizenzserver",
|
||||
icon="share",
|
||||
endpoint="licenses.reports",
|
||||
permissions=("reports.view",),
|
||||
condition=_is_master,
|
||||
),
|
||||
NavItem(
|
||||
key="observability",
|
||||
label="Protokolle",
|
||||
icon="terminal",
|
||||
children=(
|
||||
NavItem(
|
||||
key="logs_live",
|
||||
label="Laufend",
|
||||
icon="terminal",
|
||||
endpoint="logs.live",
|
||||
permissions=("logs_live.view",),
|
||||
),
|
||||
NavItem(
|
||||
key="logs_history",
|
||||
label="Verlauf",
|
||||
icon="clock",
|
||||
endpoint="logs.history",
|
||||
permissions=("logs_history.view", "logs_history.export"),
|
||||
),
|
||||
NavItem(
|
||||
key="logs_audit",
|
||||
label="Audit",
|
||||
icon="history",
|
||||
endpoint="logs.audit_view",
|
||||
permissions=("logs_audit.view", "logs_audit.export"),
|
||||
),
|
||||
),
|
||||
),
|
||||
NavItem(
|
||||
key="administration",
|
||||
label="Verwaltung",
|
||||
icon="sliders",
|
||||
children=(
|
||||
NavItem(
|
||||
key="users",
|
||||
label="Benutzer",
|
||||
icon="users",
|
||||
endpoint="admin.users",
|
||||
permissions=("users.view",),
|
||||
),
|
||||
NavItem(
|
||||
key="groups",
|
||||
label="Gruppen und Rechte",
|
||||
icon="shield-users",
|
||||
endpoint="admin.groups",
|
||||
permissions=("groups.view",),
|
||||
),
|
||||
NavItem(
|
||||
key="settings_vendor",
|
||||
label="Anbieterdaten",
|
||||
icon="building",
|
||||
endpoint="settings.vendor",
|
||||
permissions=("settings_vendor.view",),
|
||||
),
|
||||
NavItem(
|
||||
key="settings_mail",
|
||||
label="E-Mail-Versand",
|
||||
icon="mail",
|
||||
endpoint="settings.mail_settings",
|
||||
permissions=("settings_mail.view",),
|
||||
),
|
||||
NavItem(
|
||||
key="settings_system",
|
||||
label="System",
|
||||
icon="sliders",
|
||||
endpoint="settings.system",
|
||||
permissions=("settings_system.view",),
|
||||
),
|
||||
NavItem(
|
||||
key="settings_network",
|
||||
label="Netzwerk",
|
||||
icon="network",
|
||||
endpoint="hostsettings.network",
|
||||
permissions=("settings_network.view",),
|
||||
),
|
||||
NavItem(
|
||||
key="settings_web",
|
||||
label="Webserver und TLS",
|
||||
icon="server",
|
||||
endpoint="hostsettings.web",
|
||||
permissions=("settings_web.view",),
|
||||
),
|
||||
NavItem(
|
||||
key="settings_directory",
|
||||
label="Verzeichnisdienst",
|
||||
icon="users",
|
||||
endpoint="hostsettings.directory_settings",
|
||||
permissions=("settings_directory.view",),
|
||||
),
|
||||
NavItem(
|
||||
key="settings_license",
|
||||
label="Eigene Lizenz",
|
||||
icon="key",
|
||||
endpoint="own_license.overview",
|
||||
permissions=("settings_license.view",),
|
||||
),
|
||||
NavItem(
|
||||
key="backup",
|
||||
label="Sicherung",
|
||||
icon="transfer",
|
||||
endpoint="backup.overview",
|
||||
permissions=("backup.view", "backup.export", "backup.execute"),
|
||||
),
|
||||
NavItem(
|
||||
key="trash",
|
||||
label="Papierkorb",
|
||||
icon="trash",
|
||||
endpoint="trash.overview",
|
||||
permissions=("trash.view",),
|
||||
),
|
||||
NavItem(
|
||||
key="diagnostics",
|
||||
label="Diagnose",
|
||||
icon="info",
|
||||
endpoint="diagnostics.overview",
|
||||
permissions=("diagnostics.view",),
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
)
|
||||
@@ -0,0 +1,183 @@
|
||||
"""Rechtematrix des Lizenzservers.
|
||||
|
||||
Dieselbe Systematik wie in TESM: festes Aktionsvokabular, Loeschen als eigenes
|
||||
Recht, jede Seite mit eigenem Rechtepaar.
|
||||
|
||||
Zwei Nuancen, die der Vorgaenger nachtraeglich korrigieren musste und die hier
|
||||
von Anfang an so gebaut sind:
|
||||
|
||||
* **Tickets und Lizenzen sind getrennte Rechte.** "Ticket sperren" und "Lizenz
|
||||
ausstellen" sind verschiedene Vorgaenge und lassen sich unabhaengig vergeben.
|
||||
* **Die eigene Lizenzseite hat ihr eigenes Recht** (``settings_license``) und
|
||||
laeuft nicht ueber ``settings_system`` mit.
|
||||
|
||||
Und eine Grenze, die ausdruecklich **kein** Recht ist: das Ausstellen von
|
||||
Lizenzserver-Lizenzen. Es haengt an der Rolle der eigenen Lizenzdatei
|
||||
(``master``) und am Admin-Kennzeichen -- nicht an einer vergebbaren Permission.
|
||||
Ein lokaler Administrator eines Sub-Lizenzservers koennte sich ein Recht selbst
|
||||
einraeumen; eine von woanders signierte Lizenzdatei nicht.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from tesm_core.rbac.common import administration_area, observability_area
|
||||
from tesm_core.rbac.model import Action, Area, PermissionTree, Resource, RolePreset
|
||||
|
||||
A = Action
|
||||
|
||||
CUSTOMERS = Resource(
|
||||
key="customers",
|
||||
label="Kunden",
|
||||
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE),
|
||||
hint="Stammdaten der Kundeninstanzen",
|
||||
action_hints={A.DELETE: "Nur moeglich, solange kein Ticket am Kunden haengt."},
|
||||
)
|
||||
|
||||
TICKETS = Resource(
|
||||
key="tickets",
|
||||
label="Tickets",
|
||||
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE),
|
||||
hint="Dauerhafte Anspruechte auf eine Lizenz",
|
||||
action_hints={
|
||||
A.EDIT: "Parameter aendern und Ticket sperren/entsperren. Wirkt erst auf die "
|
||||
"naechste Ausstellung -- bereits ausgestellte Lizenzen bleiben unveraendert.",
|
||||
A.DELETE: "Nur moeglich, wenn keine offene Lizenz mehr existiert.",
|
||||
},
|
||||
)
|
||||
|
||||
LICENSES = Resource(
|
||||
key="licenses",
|
||||
label="Lizenzen",
|
||||
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE, A.EXECUTE, A.EXPORT),
|
||||
hint="Konkrete, signierte Lizenzdateien",
|
||||
action_hints={
|
||||
A.CREATE: "Aus einem Ticket eine Lizenz ausstellen.",
|
||||
A.EDIT: "Lizenz widerrufen oder eine neue Revision signieren.",
|
||||
A.EXECUTE: "Offline-Aktivierungscodes verarbeiten.",
|
||||
A.EXPORT: "Lizenzdatei herunterladen oder per E-Mail versenden.",
|
||||
},
|
||||
)
|
||||
|
||||
PORTAL = Resource(
|
||||
key="portal",
|
||||
label="Kundenportal",
|
||||
actions=(A.VIEW, A.EXECUTE),
|
||||
hint="Zugangslinks fuer Kunden",
|
||||
action_hints={A.EXECUTE: "Zugangslink erzeugen und versenden."},
|
||||
)
|
||||
|
||||
REPORTS = Resource(
|
||||
key="reports",
|
||||
label="Berichte der Sub-Lizenzserver",
|
||||
actions=(A.VIEW,),
|
||||
hint="Von untergeordneten Lizenzservern gemeldete Kunden, Tickets und Lizenzen",
|
||||
)
|
||||
|
||||
MAIL = Resource(
|
||||
key="settings_mail",
|
||||
label="E-Mail-Versand",
|
||||
actions=(A.VIEW, A.EDIT, A.EXECUTE, A.SECRETS),
|
||||
hint="Microsoft Graph (App-only)",
|
||||
action_hints={
|
||||
A.EXECUTE: "Verbindungstest ausfuehren.",
|
||||
A.SECRETS: "Client-Secret setzen.",
|
||||
},
|
||||
)
|
||||
|
||||
VENDOR = Resource(
|
||||
key="settings_vendor",
|
||||
label="Anbieterdaten",
|
||||
actions=(A.VIEW, A.EDIT),
|
||||
hint="Name, Kontakt, Logo und oeffentlicher Endpunkt -- reisen in jeder Lizenz mit",
|
||||
)
|
||||
|
||||
LICENSING_AREA = Area(
|
||||
key="licensing",
|
||||
label="Lizenzierung",
|
||||
icon="key",
|
||||
hint="Kunden, Tickets, Lizenzen. Ohne diesen Zugang bleibt der gesamte Bereich verborgen.",
|
||||
resources=(CUSTOMERS, TICKETS, LICENSES, PORTAL, REPORTS),
|
||||
)
|
||||
|
||||
PERMISSIONS = PermissionTree(
|
||||
[
|
||||
LICENSING_AREA,
|
||||
observability_area(),
|
||||
administration_area(MAIL, VENDOR),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
ROLE_PRESETS: tuple[RolePreset, ...] = (
|
||||
RolePreset(
|
||||
key="viewer",
|
||||
name="Benutzer",
|
||||
description="Standardgruppe: nur das laufende Protokoll lesen.",
|
||||
grants=(("logs_live", (A.VIEW,)),),
|
||||
is_default=True,
|
||||
),
|
||||
RolePreset(
|
||||
key="sales",
|
||||
name="Vertrieb",
|
||||
description="Kunden und Tickets pflegen, Lizenzen ausstellen und versenden.",
|
||||
grants=(
|
||||
("customers", "*"),
|
||||
("tickets", "*"),
|
||||
("licenses", (A.VIEW, A.CREATE, A.EDIT, A.EXPORT)),
|
||||
("portal", "*"),
|
||||
("logs_live", (A.VIEW,)),
|
||||
("logs_audit", (A.VIEW,)),
|
||||
),
|
||||
),
|
||||
RolePreset(
|
||||
key="support",
|
||||
name="Support",
|
||||
description="Lesen und Offline-Aktivierungen verarbeiten.",
|
||||
grants=(
|
||||
("customers", (A.VIEW,)),
|
||||
("tickets", (A.VIEW,)),
|
||||
("licenses", (A.VIEW, A.EXECUTE)),
|
||||
("reports", (A.VIEW,)),
|
||||
("logs_live", (A.VIEW,)),
|
||||
),
|
||||
),
|
||||
RolePreset(
|
||||
key="auditor",
|
||||
name="Revision",
|
||||
description="Nur lesen -- inklusive Aenderungsprotokoll und Diagnose.",
|
||||
grants=(
|
||||
("customers", (A.VIEW,)),
|
||||
("tickets", (A.VIEW,)),
|
||||
("licenses", (A.VIEW,)),
|
||||
("reports", (A.VIEW,)),
|
||||
("users", (A.VIEW,)),
|
||||
("groups", (A.VIEW,)),
|
||||
("logs_live", (A.VIEW,)),
|
||||
("logs_history", (A.VIEW, A.EXPORT)),
|
||||
("logs_audit", (A.VIEW, A.EXPORT)),
|
||||
("diagnostics", (A.VIEW,)),
|
||||
),
|
||||
),
|
||||
RolePreset(
|
||||
key="administration",
|
||||
name="Administration",
|
||||
description="Benutzer, Gruppen und Einstellungen -- ohne Admin-Kennzeichen.",
|
||||
grants=(
|
||||
("users", "*"),
|
||||
("groups", "*"),
|
||||
("settings_system", "*"),
|
||||
("settings_license", "*"),
|
||||
("settings_directory", (A.VIEW, A.EDIT, A.EXECUTE)),
|
||||
("settings_web", "*"),
|
||||
("settings_network", "*"),
|
||||
("settings_mail", (A.VIEW, A.EDIT, A.EXECUTE)),
|
||||
("settings_vendor", "*"),
|
||||
("backup", (A.VIEW, A.EXECUTE, A.EXPORT)),
|
||||
("trash", "*"),
|
||||
("diagnostics", (A.VIEW,)),
|
||||
("logs_audit", (A.VIEW,)),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
__all__ = ["PERMISSIONS", "ROLE_PRESETS"]
|
||||
@@ -0,0 +1,216 @@
|
||||
"""Migrationen der Komponente ``tesm_license``.
|
||||
|
||||
Das Datenmodell folgt der fachlichen Hierarchie **Kunde -> Ticket -> Lizenz**:
|
||||
|
||||
* Ein **Kunde** ist eine Firma mit Stammdaten.
|
||||
* Ein **Ticket** ist der dauerhafte Anspruch dieses Kunden auf eine Lizenz mit
|
||||
bestimmten Parametern (Typ, Module, Laufzeit).
|
||||
* Eine **Lizenz** ist die konkrete, signierte Datei, die aus einem Ticket
|
||||
ausgestellt wurde.
|
||||
|
||||
Der Vorgaenger hatte in einem frueheren Stand nur "Lizenz" in "Ticket"
|
||||
umbenannt, ohne die Trennung tatsaechlich zu bauen -- das wird hier nicht
|
||||
wiederholt.
|
||||
|
||||
Aenderungen gegenueber dem Vorgaenger:
|
||||
|
||||
* **Kein ``portal_token``.** Der permanente, unbegrenzt gueltige Magic-Link war
|
||||
eine erkannte Schwachstelle und wurde durch einen zweistufigen Flow ersetzt
|
||||
(Anfragelink 30 Minuten, einmalig -> Sitzung 60 Minuten). Spalte und Index des
|
||||
alten Verfahrens entstehen hier gar nicht erst.
|
||||
* **Nonce-Speicher** (``license_nonces``): jede Aktivierungs-/Heartbeat-Anfrage
|
||||
darf genau einmal verarbeitet werden. Der Vorgaenger pruefte Nonces nicht --
|
||||
eine abgefangene Anfrage war unbegrenzt wiederverwendbar.
|
||||
* **Zustellprotokoll** (``license_deliveries``): wer wann welche Lizenzdatei
|
||||
bekommen hat.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from tesm_core.migrations import Migration
|
||||
|
||||
V1 = """
|
||||
CREATE TABLE IF NOT EXISTS license_customers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL COLLATE NOCASE,
|
||||
contact_email TEXT NOT NULL DEFAULT '',
|
||||
contact_phone TEXT NOT NULL DEFAULT '',
|
||||
contact_person TEXT NOT NULL DEFAULT '',
|
||||
-- Adresse ausdruecklich in Einzelfeldern, damit sie z. B. fuer Rechnungen
|
||||
-- weiterverwendbar bleibt.
|
||||
street TEXT NOT NULL DEFAULT '',
|
||||
house_number TEXT NOT NULL DEFAULT '',
|
||||
postal_code TEXT NOT NULL DEFAULT '',
|
||||
city TEXT NOT NULL DEFAULT '',
|
||||
notes TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
created_by TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_customers_email ON license_customers(contact_email);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS license_tickets (
|
||||
-- Die ID ist zugleich der Schluessel des Self-Service-Links: 192 Bit
|
||||
-- Zufall, nicht ratbar.
|
||||
id TEXT PRIMARY KEY,
|
||||
customer_id INTEGER NOT NULL REFERENCES license_customers(id) ON DELETE RESTRICT,
|
||||
type TEXT NOT NULL,
|
||||
modules TEXT NOT NULL DEFAULT '[]',
|
||||
valid_days INTEGER NOT NULL DEFAULT 365,
|
||||
lifetime INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'open',
|
||||
role TEXT NOT NULL DEFAULT 'customer',
|
||||
notes TEXT NOT NULL DEFAULT '',
|
||||
created_by TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_customer ON license_tickets(customer_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_status ON license_tickets(status);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS licenses (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
license_id TEXT NOT NULL UNIQUE,
|
||||
ticket_id TEXT REFERENCES license_tickets(id) ON DELETE CASCADE,
|
||||
customer_id INTEGER NOT NULL REFERENCES license_customers(id) ON DELETE RESTRICT,
|
||||
type TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'customer',
|
||||
modules TEXT NOT NULL DEFAULT '[]',
|
||||
lifetime INTEGER NOT NULL DEFAULT 0,
|
||||
seq INTEGER NOT NULL DEFAULT 1,
|
||||
issued_at TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
license_pubkey TEXT NOT NULL,
|
||||
license_document TEXT NOT NULL,
|
||||
-- Der private Client-Schluessel dieser Lizenz, AES-256-GCM-verschluesselt und
|
||||
-- an die Lizenz-ID gebunden. Er muss erneut ausliefer-bar sein (verlorene
|
||||
-- Mail, neuer Ansprechpartner), darf aber nie im Klartext in der Datenbank
|
||||
-- liegen. Die Herausgabe verlangt Recht, frische Passwortbestaetigung und
|
||||
-- landet im Aenderungsprotokoll.
|
||||
client_key_encrypted TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'issued',
|
||||
fingerprint TEXT,
|
||||
hostname TEXT,
|
||||
activated_at TEXT,
|
||||
deactivated_at TEXT,
|
||||
last_heartbeat_at TEXT,
|
||||
revoked_at TEXT,
|
||||
revoke_reason TEXT NOT NULL DEFAULT '',
|
||||
created_by TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_licenses_customer ON licenses(customer_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_licenses_status ON licenses(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_licenses_fingerprint ON licenses(fingerprint);
|
||||
|
||||
-- Hoechstens eine offene Lizenz pro Ticket -- auf Datenbankebene, nicht nur im
|
||||
-- Python-Code. Damit ist der Doppelklick-Fall (zwei Tabs, zwei Anfragen) sauber
|
||||
-- abgefangen: der verlierende INSERT scheitert und der Aufrufer gibt die
|
||||
-- bereits erzeugte Lizenz zurueck.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_one_open_license_per_ticket
|
||||
ON licenses(ticket_id) WHERE status IN ('issued','active');
|
||||
|
||||
-- Verbrauchte Nonces des Aktivierungsprotokolls. Verhindert Replay.
|
||||
CREATE TABLE IF NOT EXISTS license_nonces (
|
||||
nonce TEXT PRIMARY KEY,
|
||||
license_id TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
seen_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_nonces_seen ON license_nonces(seen_at);
|
||||
|
||||
-- Wer hat wann welche Lizenzdatei erhalten.
|
||||
CREATE TABLE IF NOT EXISTS license_deliveries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
license_id TEXT NOT NULL,
|
||||
channel TEXT NOT NULL,
|
||||
recipient TEXT NOT NULL DEFAULT '',
|
||||
delivered_at TEXT NOT NULL,
|
||||
delivered_by TEXT NOT NULL DEFAULT '',
|
||||
detail TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_deliveries_license ON license_deliveries(license_id);
|
||||
|
||||
-- Zeitlich begrenzte Zugangslinks des Kundenportals: einmalig verwendbar.
|
||||
CREATE TABLE IF NOT EXISTS portal_access_tokens (
|
||||
token TEXT PRIMARY KEY,
|
||||
customer_id INTEGER NOT NULL REFERENCES license_customers(id) ON DELETE CASCADE,
|
||||
created_at TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
used_at TEXT,
|
||||
created_ip TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_portal_tokens_customer ON portal_access_tokens(customer_id);
|
||||
|
||||
-- Von Sub-Lizenzservern gemeldeter Bestand. Rein informativ; dieser Master
|
||||
-- verwaltet ihn nicht selbst. Jeder Bericht ersetzt alle Zeilen seiner Quelle
|
||||
-- vollstaendig (Schnappschuss statt Delta) -- so verschwindet ein beim
|
||||
-- Sub-Lizenzserver geloeschtes Ticket automatisch auch hier.
|
||||
CREATE TABLE IF NOT EXISTS reported_customers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
source_license_id TEXT NOT NULL,
|
||||
remote_ref TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
contact_email TEXT NOT NULL DEFAULT '',
|
||||
contact_person TEXT NOT NULL DEFAULT '',
|
||||
city TEXT NOT NULL DEFAULT '',
|
||||
last_reported_at TEXT NOT NULL,
|
||||
UNIQUE(source_license_id, remote_ref)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_reported_customers_src ON reported_customers(source_license_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS reported_tickets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
source_license_id TEXT NOT NULL,
|
||||
remote_ref TEXT NOT NULL,
|
||||
customer_ref TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
modules TEXT NOT NULL DEFAULT '[]',
|
||||
valid_days INTEGER,
|
||||
lifetime INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'open',
|
||||
role TEXT NOT NULL DEFAULT 'customer',
|
||||
created_at TEXT,
|
||||
last_reported_at TEXT NOT NULL,
|
||||
UNIQUE(source_license_id, remote_ref)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_reported_tickets_src ON reported_tickets(source_license_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS reported_licenses (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
source_license_id TEXT NOT NULL,
|
||||
remote_ref TEXT NOT NULL,
|
||||
ticket_ref TEXT,
|
||||
type TEXT NOT NULL,
|
||||
modules TEXT NOT NULL DEFAULT '[]',
|
||||
lifetime INTEGER NOT NULL DEFAULT 0,
|
||||
issued_at TEXT,
|
||||
expires_at TEXT,
|
||||
status TEXT NOT NULL,
|
||||
fingerprint TEXT,
|
||||
hostname TEXT,
|
||||
activated_at TEXT,
|
||||
last_heartbeat_at TEXT,
|
||||
last_reported_at TEXT NOT NULL,
|
||||
UNIQUE(source_license_id, remote_ref)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_reported_licenses_src ON reported_licenses(source_license_id);
|
||||
"""
|
||||
|
||||
|
||||
# Version 2 -----------------------------------------------------------------
|
||||
#
|
||||
# Wann wurde zuletzt eine neue Revision signiert? Aus diesem Zeitstempel und
|
||||
# ``last_heartbeat_at`` ergibt sich, ob der Client sie schon hat. Ein eigener
|
||||
# Status waere hier falsch: eine Lizenz kann aktiv sein *und* eine ausstehende
|
||||
# Revision haben.
|
||||
V2 = """
|
||||
ALTER TABLE licenses ADD COLUMN revision_at TEXT;
|
||||
CREATE INDEX IF NOT EXISTS idx_licenses_revision ON licenses(revision_at);
|
||||
"""
|
||||
|
||||
|
||||
LICENSE_MIGRATIONS: tuple[Migration, ...] = (
|
||||
Migration(version=1, name="baseline", sql=V1, allow_integrity_error=True),
|
||||
Migration(version=2, name="revision-timestamp", sql=V2),
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
"""Fachdienste des Lizenzservers -- Ausstellung, Aktivierung, Mail, Berichte."""
|
||||
@@ -0,0 +1,345 @@
|
||||
"""Aktivierung, Deaktivierung, Heartbeat -- die Serverseite des Protokolls.
|
||||
|
||||
Ein Satz Verarbeitungsfunktionen, zwei Transportwege: die JSON-API
|
||||
(``/api/v1/licenses/*``) und der Offline-Code (Copy-Paste). Beide landen hier --
|
||||
es gibt keinen zweiten, abweichenden Pfad, an dem eine Pruefung fehlen koennte.
|
||||
|
||||
Durchgesetzte Regeln:
|
||||
|
||||
* Signatur der Anfrage gegen den in der Datenbank gespeicherten
|
||||
Lizenz-Public-Key -- nicht gegen etwas aus der Anfrage selbst.
|
||||
* **Replay-Schutz**: jede Nonce genau einmal, plus Frischefenster auf dem
|
||||
Zeitstempel. Der Vorgaenger pruefte beides nicht.
|
||||
* **Bindung an die Maschine ueber den Fingerprint**, nie ueber den Hostnamen.
|
||||
Der Hostname ist ein rein informatives Feld; er wird per ``COALESCE`` nur
|
||||
ueberschrieben, wenn tatsaechlich ein Wert kommt.
|
||||
* **Eine aktive Lizenz pro Maschine**: aktiviert jemand eine neue Lizenz auf
|
||||
einem Fingerprint, der schon eine andere aktive traegt, wird die alte
|
||||
automatisch deaktiviert. Pro Fingerprint, nicht pro Kunde -- ein Kunde darf
|
||||
mehrere Maschinen gleichzeitig lizenziert haben.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sqlite3
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
import tesm_licensing as lic
|
||||
import tesm_licensing_server as licsrv
|
||||
from tesm_core import audit
|
||||
from tesm_core.db import Database
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
NONCE_RETENTION_DAYS = 30
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Outcome:
|
||||
"""Ergebnis einer Verarbeitung -- HTTP-Status inklusive."""
|
||||
|
||||
status_code: int
|
||||
response: dict[str, Any]
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
return self.status_code == 200
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def _error(status_code: int, code: str, message: str) -> Outcome:
|
||||
"""Fehlerantworten sind bewusst **unsigniert** und knapp.
|
||||
|
||||
Sie enthalten nichts, was einem Angreifer beim Raten hilft -- insbesondere
|
||||
keine Auskunft darueber, ob eine Lizenz-ID existiert, sobald die Signatur
|
||||
nicht passt.
|
||||
"""
|
||||
return Outcome(status_code, {"error": code, "message": message})
|
||||
|
||||
|
||||
def _signed_response(
|
||||
conn: sqlite3.Connection,
|
||||
request: dict[str, Any],
|
||||
*,
|
||||
master_private_key: str,
|
||||
status: str,
|
||||
detail: str = "",
|
||||
license_update: dict[str, Any] | None = None,
|
||||
) -> Outcome:
|
||||
response = licsrv.build_master_response(
|
||||
str(request["action"]),
|
||||
license_id=str(request["license_id"]),
|
||||
fingerprint=str(request["fingerprint"]),
|
||||
request_nonce=str(request["nonce"]),
|
||||
master_private_key=master_private_key,
|
||||
status=status,
|
||||
detail=detail,
|
||||
license_update=license_update,
|
||||
)
|
||||
return Outcome(200, response)
|
||||
|
||||
|
||||
def _remember_nonce(conn: sqlite3.Connection, request: dict[str, Any]) -> None:
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO license_nonces (nonce, license_id, action, seen_at) "
|
||||
"VALUES (?,?,?,?)",
|
||||
(
|
||||
str(request["nonce"]),
|
||||
str(request["license_id"]),
|
||||
str(request["action"]),
|
||||
_now(),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def prune_nonces(conn: sqlite3.Connection) -> int:
|
||||
horizon = (datetime.now(timezone.utc) - timedelta(days=NONCE_RETENTION_DAYS)).isoformat(
|
||||
timespec="seconds"
|
||||
)
|
||||
cursor = conn.execute("DELETE FROM license_nonces WHERE seen_at < ?", (horizon,))
|
||||
return cursor.rowcount or 0
|
||||
|
||||
|
||||
def _verify(
|
||||
conn: sqlite3.Connection, request: dict[str, Any], *, expected_action: str | None
|
||||
) -> tuple[dict[str, Any] | None, Outcome | None]:
|
||||
"""Sucht die Lizenz und prueft die Anfrage. Rueckgabe: ``(zeile, fehler)``."""
|
||||
if not isinstance(request, dict):
|
||||
return None, _error(400, "bad_request", "Anfrage ist kein Objekt.")
|
||||
license_id = str(request.get("license_id") or "")
|
||||
row = Database.one(conn, "SELECT * FROM licenses WHERE license_id=?", (license_id,))
|
||||
if row is None:
|
||||
return None, _error(404, "unknown_license", "Unbekannte Lizenz.")
|
||||
|
||||
def seen(nonce: str) -> bool:
|
||||
return (
|
||||
Database.value(conn, "SELECT 1 FROM license_nonces WHERE nonce=?", (nonce,))
|
||||
is not None
|
||||
)
|
||||
|
||||
try:
|
||||
licsrv.verify_client_request(
|
||||
request,
|
||||
str(row["license_pubkey"]),
|
||||
expected_action=expected_action,
|
||||
seen_nonce=seen,
|
||||
)
|
||||
except lic.SignatureInvalid:
|
||||
return None, _error(400, "invalid_signature", "Signatur der Anfrage ist ungueltig.")
|
||||
except lic.ReplayDetected as exc:
|
||||
return None, _error(409, "replay", str(exc))
|
||||
except lic.ProtocolViolation as exc:
|
||||
return None, _error(400, "bad_request", str(exc))
|
||||
return row, None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Aktionen
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def process_activate(
|
||||
conn: sqlite3.Connection, request: dict[str, Any], *, master_private_key: str
|
||||
) -> Outcome:
|
||||
row, error = _verify(conn, request, expected_action="activate")
|
||||
if error is not None or row is None:
|
||||
return error or _error(400, "bad_request", "Ungueltige Anfrage.")
|
||||
|
||||
fingerprint = str(request["fingerprint"])
|
||||
hostname = str(request.get("hostname") or "")
|
||||
status = str(row["status"])
|
||||
|
||||
if status == "revoked":
|
||||
_remember_nonce(conn, request)
|
||||
return _signed_response(
|
||||
conn,
|
||||
request,
|
||||
master_private_key=master_private_key,
|
||||
status="revoked",
|
||||
detail="Diese Lizenz wurde widerrufen.",
|
||||
)
|
||||
if status == "active" and str(row["fingerprint"] or "") not in {"", fingerprint}:
|
||||
_remember_nonce(conn, request)
|
||||
return _error(
|
||||
409,
|
||||
"already_bound_elsewhere",
|
||||
"Diese Lizenz ist bereits auf einer anderen Maschine aktiv. "
|
||||
"Bitte dort zuerst deaktivieren.",
|
||||
)
|
||||
|
||||
# Eine Maschine traegt hoechstens eine aktive Lizenz: eine andere aktive
|
||||
# Lizenz auf demselben Fingerprint wird verdraengt.
|
||||
superseded = Database.all(
|
||||
conn,
|
||||
"SELECT license_id FROM licenses WHERE fingerprint=? AND status='active' "
|
||||
"AND license_id<>?",
|
||||
(fingerprint, str(row["license_id"])),
|
||||
)
|
||||
for other in superseded:
|
||||
conn.execute(
|
||||
"UPDATE licenses SET status='deactivated', deactivated_at=? WHERE license_id=?",
|
||||
(_now(), other["license_id"]),
|
||||
)
|
||||
|
||||
conn.execute(
|
||||
"UPDATE licenses SET status='active', fingerprint=?, "
|
||||
"hostname=COALESCE(NULLIF(?, ''), hostname), activated_at=?, deactivated_at=NULL "
|
||||
"WHERE license_id=?",
|
||||
(fingerprint, hostname, _now(), str(row["license_id"])),
|
||||
)
|
||||
_remember_nonce(conn, request)
|
||||
audit.log(
|
||||
conn,
|
||||
actor="api",
|
||||
action="license.activated",
|
||||
target=str(row["license_id"]),
|
||||
detail=(
|
||||
f"Fingerprint {fingerprint[:16]}..., Host {hostname or 'unbekannt'}"
|
||||
+ (f", verdraengt {len(superseded)} Lizenz(en)" if superseded else "")
|
||||
),
|
||||
severity="notice",
|
||||
)
|
||||
return _signed_response(
|
||||
conn,
|
||||
request,
|
||||
master_private_key=master_private_key,
|
||||
status="ok",
|
||||
detail="Aktivierung bestaetigt.",
|
||||
)
|
||||
|
||||
|
||||
def process_deactivate(
|
||||
conn: sqlite3.Connection, request: dict[str, Any], *, master_private_key: str
|
||||
) -> Outcome:
|
||||
row, error = _verify(conn, request, expected_action="deactivate")
|
||||
if error is not None or row is None:
|
||||
return error or _error(400, "bad_request", "Ungueltige Anfrage.")
|
||||
|
||||
fingerprint = str(request["fingerprint"])
|
||||
if str(row["status"]) != "active" or str(row["fingerprint"] or "") != fingerprint:
|
||||
_remember_nonce(conn, request)
|
||||
return _error(
|
||||
409,
|
||||
"not_active_on_this_fingerprint",
|
||||
"Diese Lizenz ist auf dieser Maschine nicht aktiv.",
|
||||
)
|
||||
|
||||
conn.execute(
|
||||
"UPDATE licenses SET status='deactivated', deactivated_at=? WHERE license_id=?",
|
||||
(_now(), str(row["license_id"])),
|
||||
)
|
||||
_remember_nonce(conn, request)
|
||||
audit.log(
|
||||
conn,
|
||||
actor="api",
|
||||
action="license.deactivated",
|
||||
target=str(row["license_id"]),
|
||||
detail=f"Fingerprint {fingerprint[:16]}...",
|
||||
severity="notice",
|
||||
)
|
||||
return _signed_response(
|
||||
conn,
|
||||
request,
|
||||
master_private_key=master_private_key,
|
||||
status="ok",
|
||||
detail="Deaktivierung bestaetigt. Die Lizenz kann auf einer anderen Maschine aktiviert werden.",
|
||||
)
|
||||
|
||||
|
||||
def process_heartbeat(
|
||||
conn: sqlite3.Connection, request: dict[str, Any], *, master_private_key: str
|
||||
) -> Outcome:
|
||||
row, error = _verify(conn, request, expected_action="heartbeat")
|
||||
if error is not None or row is None:
|
||||
return error or _error(400, "bad_request", "Ungueltige Anfrage.")
|
||||
|
||||
fingerprint = str(request["fingerprint"])
|
||||
hostname = str(request.get("hostname") or "")
|
||||
|
||||
if str(row["status"]) == "revoked":
|
||||
# Auch hier eine signierte Antwort: der Client loescht seine Lizenzdatei
|
||||
# erst, wenn er die Signatur selbst geprueft hat.
|
||||
_remember_nonce(conn, request)
|
||||
return _signed_response(
|
||||
conn,
|
||||
request,
|
||||
master_private_key=master_private_key,
|
||||
status="revoked",
|
||||
detail="Diese Lizenz wurde widerrufen.",
|
||||
)
|
||||
|
||||
if str(row["status"]) != "active" or str(row["fingerprint"] or "") != fingerprint:
|
||||
_remember_nonce(conn, request)
|
||||
return _error(409, "not_active", "Diese Lizenz ist auf dieser Maschine nicht aktiv.")
|
||||
|
||||
conn.execute(
|
||||
"UPDATE licenses SET last_heartbeat_at=?, hostname=COALESCE(NULLIF(?, ''), hostname) "
|
||||
"WHERE license_id=?",
|
||||
(_now(), hostname, str(row["license_id"])),
|
||||
)
|
||||
_remember_nonce(conn, request)
|
||||
|
||||
report = request.get("report")
|
||||
if report is not None:
|
||||
from .reporting import ingest_report
|
||||
|
||||
# Nur Lizenzen, die in **dieser** Datenbank als Lizenzserver eingetragen
|
||||
# sind, duerfen berichten. Was der Client behauptet, ist unerheblich.
|
||||
if str(row["role"]) == "license_server":
|
||||
ingested = ingest_report(conn, str(row["license_id"]), report)
|
||||
audit.log(
|
||||
conn,
|
||||
actor="api",
|
||||
action="license.report_ingested",
|
||||
target=str(row["license_id"]),
|
||||
detail=(
|
||||
f"{ingested['customers']} Kunden, {ingested['tickets']} Tickets, "
|
||||
f"{ingested['licenses']} Lizenzen"
|
||||
),
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"Bericht von Lizenz %s verworfen -- Rolle ist %s, nicht license_server",
|
||||
row["license_id"],
|
||||
row["role"],
|
||||
)
|
||||
|
||||
# Nachtraegliche Aenderungen (z. B. Modulerweiterung) erreichen den Client
|
||||
# ueber den Heartbeat -- ohne erneute Aktivierung.
|
||||
document = json.loads(str(row["license_document"]))
|
||||
client_seq = int(request.get("seq") or 0)
|
||||
update = document if int(document.get("seq") or 1) > client_seq else None
|
||||
|
||||
return _signed_response(
|
||||
conn,
|
||||
request,
|
||||
master_private_key=master_private_key,
|
||||
status="ok",
|
||||
detail="Verbindung bestaetigt.",
|
||||
license_update=update,
|
||||
)
|
||||
|
||||
|
||||
PROCESSORS = {
|
||||
"activate": process_activate,
|
||||
"deactivate": process_deactivate,
|
||||
"heartbeat": process_heartbeat,
|
||||
}
|
||||
|
||||
|
||||
def process(
|
||||
conn: sqlite3.Connection, request: dict[str, Any], *, master_private_key: str
|
||||
) -> Outcome:
|
||||
"""Verteilt anhand des ``action``-Feldes -- fuer den Offline-Code-Weg."""
|
||||
action = str((request or {}).get("action") or "")
|
||||
processor = PROCESSORS.get(action)
|
||||
if processor is None:
|
||||
return _error(400, "bad_action", "Unbekannte Aktion.")
|
||||
return processor(conn, request, master_private_key=master_private_key)
|
||||
@@ -0,0 +1,766 @@
|
||||
"""Kunden, Tickets und die Ausstellung von Lizenzen."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import secrets
|
||||
import sqlite3
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Iterable, Sequence
|
||||
|
||||
import tesm_licensing as lic
|
||||
import tesm_licensing_server as licsrv
|
||||
from tesm_core.db import Database
|
||||
|
||||
TICKET_TYPES = lic.LICENSE_TYPES
|
||||
TICKET_STATES = ("open", "blocked")
|
||||
LICENSE_STATES = ("issued", "active", "deactivated", "revoked")
|
||||
|
||||
#: Ampel-Schwellen der Anzeige. Bewusst getrennt von der Kulanzlogik in
|
||||
#: ``tesm_licensing`` -- gleiche Zahl (30), anderes Konzept.
|
||||
GREEN_ABOVE_DAYS = 90
|
||||
RED_BELOW_DAYS = 30
|
||||
|
||||
EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
||||
|
||||
|
||||
class IssuanceError(Exception):
|
||||
"""Eingabe- oder Regelverstoss mit Klartextmeldung."""
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Kunden
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def validate_email(value: str, *, required: bool) -> str:
|
||||
text = (value or "").strip()
|
||||
if not text:
|
||||
if required:
|
||||
raise IssuanceError("Eine E-Mail-Adresse ist erforderlich.")
|
||||
# Leer bleibt erlaubt: Altkunden von vor der Pflichtfeld-Einfuehrung
|
||||
# sollen weiter bearbeitbar bleiben, statt ausgesperrt zu werden.
|
||||
return ""
|
||||
if not EMAIL_RE.match(text):
|
||||
raise IssuanceError(f"{text!r} ist keine gueltige E-Mail-Adresse.")
|
||||
return text
|
||||
|
||||
|
||||
def list_customers(conn: sqlite3.Connection) -> list[dict[str, Any]]:
|
||||
return Database.all(
|
||||
conn,
|
||||
"""
|
||||
SELECT c.*,
|
||||
(SELECT COUNT(*) FROM license_tickets t WHERE t.customer_id = c.id) AS ticket_count,
|
||||
(SELECT COUNT(*) FROM licenses l
|
||||
WHERE l.customer_id = c.id AND l.status IN ('issued','active')) AS open_licenses,
|
||||
(SELECT l.license_id FROM licenses l
|
||||
WHERE l.customer_id = c.id AND l.status='active' AND l.role='license_server'
|
||||
LIMIT 1) AS license_server_id
|
||||
FROM license_customers c
|
||||
ORDER BY c.name COLLATE NOCASE
|
||||
""",
|
||||
)
|
||||
|
||||
|
||||
def get_customer(conn: sqlite3.Connection, customer_id: int) -> dict[str, Any] | None:
|
||||
return Database.one(conn, "SELECT * FROM license_customers WHERE id=?", (customer_id,))
|
||||
|
||||
|
||||
def create_customer(conn: sqlite3.Connection, *, actor: str, **fields: Any) -> int:
|
||||
name = str(fields.get("name") or "").strip()
|
||||
if not name:
|
||||
raise IssuanceError("Der Firmenname darf nicht leer sein.")
|
||||
email = validate_email(str(fields.get("contact_email") or ""), required=True)
|
||||
cursor = conn.execute(
|
||||
"INSERT INTO license_customers (name, contact_email, contact_phone, contact_person, "
|
||||
"street, house_number, postal_code, city, notes, created_at, updated_at, created_by) "
|
||||
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(
|
||||
name,
|
||||
email,
|
||||
str(fields.get("contact_phone") or "").strip(),
|
||||
str(fields.get("contact_person") or "").strip(),
|
||||
str(fields.get("street") or "").strip(),
|
||||
str(fields.get("house_number") or "").strip(),
|
||||
str(fields.get("postal_code") or "").strip(),
|
||||
str(fields.get("city") or "").strip(),
|
||||
str(fields.get("notes") or "").strip(),
|
||||
_now(),
|
||||
_now(),
|
||||
actor,
|
||||
),
|
||||
)
|
||||
return int(cursor.lastrowid or 0)
|
||||
|
||||
|
||||
def update_customer(conn: sqlite3.Connection, customer_id: int, **fields: Any) -> list[str]:
|
||||
row = get_customer(conn, customer_id)
|
||||
if row is None:
|
||||
raise IssuanceError("Dieser Kunde existiert nicht.")
|
||||
name = str(fields.get("name") or "").strip()
|
||||
if not name:
|
||||
raise IssuanceError("Der Firmenname darf nicht leer sein.")
|
||||
email = validate_email(str(fields.get("contact_email") or ""), required=False)
|
||||
|
||||
columns = {
|
||||
"name": name,
|
||||
"contact_email": email,
|
||||
"contact_phone": str(fields.get("contact_phone") or "").strip(),
|
||||
"contact_person": str(fields.get("contact_person") or "").strip(),
|
||||
"street": str(fields.get("street") or "").strip(),
|
||||
"house_number": str(fields.get("house_number") or "").strip(),
|
||||
"postal_code": str(fields.get("postal_code") or "").strip(),
|
||||
"city": str(fields.get("city") or "").strip(),
|
||||
"notes": str(fields.get("notes") or "").strip(),
|
||||
}
|
||||
changes = [key for key, value in columns.items() if str(row.get(key) or "") != value]
|
||||
if not changes:
|
||||
return []
|
||||
assignments = ", ".join(f"{key}=?" for key in columns)
|
||||
conn.execute(
|
||||
f"UPDATE license_customers SET {assignments}, updated_at=? WHERE id=?",
|
||||
(*columns.values(), _now(), customer_id),
|
||||
)
|
||||
return changes
|
||||
|
||||
|
||||
def delete_customer(conn: sqlite3.Connection, customer_id: int) -> None:
|
||||
tickets = Database.value(
|
||||
conn, "SELECT COUNT(*) FROM license_tickets WHERE customer_id=?", (customer_id,), default=0
|
||||
)
|
||||
if int(tickets or 0):
|
||||
raise IssuanceError(
|
||||
f"An diesem Kunden haengen noch {tickets} Ticket(s). Bitte zuerst entfernen."
|
||||
)
|
||||
conn.execute("DELETE FROM license_customers WHERE id=?", (customer_id,))
|
||||
|
||||
|
||||
def find_customer_by_email(conn: sqlite3.Connection, email: str) -> dict[str, Any] | None:
|
||||
return Database.one(
|
||||
conn,
|
||||
"SELECT * FROM license_customers WHERE contact_email <> '' "
|
||||
"AND lower(contact_email)=lower(?)",
|
||||
((email or "").strip(),),
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Tickets
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def list_tickets(
|
||||
conn: sqlite3.Connection, *, customer_id: int | None = None
|
||||
) -> list[dict[str, Any]]:
|
||||
clause = "WHERE t.customer_id = ?" if customer_id else ""
|
||||
params = (customer_id,) if customer_id else ()
|
||||
return Database.all(
|
||||
conn,
|
||||
f"""
|
||||
SELECT t.*, c.name AS customer_name, c.contact_email,
|
||||
(SELECT COUNT(*) FROM licenses l WHERE l.ticket_id = t.id) AS license_count,
|
||||
(SELECT l.license_id FROM licenses l
|
||||
WHERE l.ticket_id = t.id AND l.status IN ('issued','active') LIMIT 1) AS open_license_id
|
||||
FROM license_tickets t
|
||||
JOIN license_customers c ON c.id = t.customer_id
|
||||
{clause}
|
||||
ORDER BY t.created_at DESC
|
||||
""",
|
||||
params,
|
||||
)
|
||||
|
||||
|
||||
def get_ticket(conn: sqlite3.Connection, ticket_id: str) -> dict[str, Any] | None:
|
||||
return Database.one(
|
||||
conn,
|
||||
"SELECT t.*, c.name AS customer_name, c.contact_email, c.contact_person "
|
||||
"FROM license_tickets t JOIN license_customers c ON c.id = t.customer_id WHERE t.id=?",
|
||||
(ticket_id,),
|
||||
)
|
||||
|
||||
|
||||
def normalize_modules(license_type: str, role: str, modules: Iterable[str]) -> list[str]:
|
||||
"""Bestimmt den Modulsatz eines Tickets.
|
||||
|
||||
Regeln (bewusst hier zentral, nicht in der Oberflaeche):
|
||||
* ``role != "customer"``: immer leer -- das TESM-Modulkonzept ist fuer einen
|
||||
Lizenzserver bedeutungslos.
|
||||
* ``trial``/``standard``: leer (Trial bekommt den vollen Satz erst bei der
|
||||
Ausstellung als Trial-Override).
|
||||
* ``enterprise``: alle Module.
|
||||
* ``custom``: die uebergebene Auswahl.
|
||||
"""
|
||||
if role != "customer":
|
||||
return []
|
||||
if license_type == "enterprise":
|
||||
return sorted(lic.ALL_MODULES)
|
||||
if license_type == "custom":
|
||||
return sorted(set(modules) & set(lic.ALL_MODULES))
|
||||
return []
|
||||
|
||||
|
||||
def create_ticket(
|
||||
conn: sqlite3.Connection,
|
||||
*,
|
||||
customer_id: int,
|
||||
license_type: str,
|
||||
modules: Sequence[str],
|
||||
valid_days: int,
|
||||
lifetime: bool,
|
||||
role: str,
|
||||
notes: str = "",
|
||||
actor: str = "",
|
||||
) -> str:
|
||||
if get_customer(conn, customer_id) is None:
|
||||
raise IssuanceError("Dieser Kunde existiert nicht.")
|
||||
if license_type not in TICKET_TYPES:
|
||||
raise IssuanceError("Unbekannter Lizenztyp.")
|
||||
if role not in lic.LICENSE_ROLES or role == "master":
|
||||
# "master" entsteht ausschliesslich lokal per Bootstrap-Skript.
|
||||
raise IssuanceError("Unzulaessige Rolle.")
|
||||
|
||||
# Trial + unbefristet ist ein sinnloser Zustand: eine permanente Lizenz ohne
|
||||
# Module. Diese Regel wird an drei Stellen geprueft (hier, beim Bearbeiten
|
||||
# und unmittelbar vor dem Signieren).
|
||||
if lifetime and license_type == "trial":
|
||||
raise IssuanceError("Eine Testlizenz kann nicht unbefristet sein.")
|
||||
if not lifetime and int(valid_days) <= 0:
|
||||
raise IssuanceError("Die Laufzeit muss positiv sein.")
|
||||
|
||||
ticket_id = secrets.token_urlsafe(24)
|
||||
conn.execute(
|
||||
"INSERT INTO license_tickets (id, customer_id, type, modules, valid_days, lifetime, "
|
||||
"status, role, notes, created_by, created_at, updated_at) "
|
||||
"VALUES (?,?,?,?,?,?,'open',?,?,?,?,?)",
|
||||
(
|
||||
ticket_id,
|
||||
customer_id,
|
||||
license_type,
|
||||
json.dumps(normalize_modules(license_type, role, modules)),
|
||||
int(valid_days),
|
||||
int(bool(lifetime)),
|
||||
role,
|
||||
(notes or "").strip(),
|
||||
actor,
|
||||
_now(),
|
||||
_now(),
|
||||
),
|
||||
)
|
||||
return ticket_id
|
||||
|
||||
|
||||
def update_ticket(
|
||||
conn: sqlite3.Connection,
|
||||
ticket_id: str,
|
||||
*,
|
||||
license_type: str,
|
||||
modules: Sequence[str],
|
||||
valid_days: int,
|
||||
lifetime: bool,
|
||||
notes: str = "",
|
||||
) -> list[str]:
|
||||
"""Aendert die Parameter eines Tickets.
|
||||
|
||||
Wirkt ausschliesslich auf die **naechste** Ausstellung. Bereits ausgestellte
|
||||
Lizenzen behalten die Werte, mit denen sie signiert wurden -- eine
|
||||
nachtraegliche Aenderung waere sonst eine Signaturaenderung ohne neue Datei.
|
||||
|
||||
Kunde und Rolle sind nach der Anlage unveraenderlich: das Self-Service-Ticket
|
||||
ist an genau diesen Kunden gebunden, und ein Rollenwechsel wuerde eine
|
||||
Vertrauensgrenze verschieben.
|
||||
"""
|
||||
ticket = get_ticket(conn, ticket_id)
|
||||
if ticket is None:
|
||||
raise IssuanceError("Dieses Ticket existiert nicht.")
|
||||
if license_type not in TICKET_TYPES:
|
||||
raise IssuanceError("Unbekannter Lizenztyp.")
|
||||
if lifetime and license_type == "trial":
|
||||
raise IssuanceError("Eine Testlizenz kann nicht unbefristet sein.")
|
||||
|
||||
role = str(ticket["role"])
|
||||
new_modules = json.dumps(normalize_modules(license_type, role, modules))
|
||||
changes: list[str] = []
|
||||
if license_type != str(ticket["type"]):
|
||||
changes.append(f"Typ: {ticket['type']} -> {license_type}")
|
||||
if new_modules != str(ticket["modules"]):
|
||||
changes.append("Module geaendert")
|
||||
if int(valid_days) != int(ticket["valid_days"]):
|
||||
changes.append(f"Laufzeit: {ticket['valid_days']} -> {valid_days} Tage")
|
||||
if bool(lifetime) != bool(ticket["lifetime"]):
|
||||
changes.append(f"Unbefristet: {bool(ticket['lifetime'])} -> {bool(lifetime)}")
|
||||
|
||||
conn.execute(
|
||||
"UPDATE license_tickets SET type=?, modules=?, valid_days=?, lifetime=?, notes=?, "
|
||||
"updated_at=? WHERE id=?",
|
||||
(
|
||||
license_type,
|
||||
new_modules,
|
||||
int(valid_days),
|
||||
int(bool(lifetime)),
|
||||
(notes or "").strip(),
|
||||
_now(),
|
||||
ticket_id,
|
||||
),
|
||||
)
|
||||
return changes
|
||||
|
||||
|
||||
def set_ticket_status(conn: sqlite3.Connection, ticket_id: str, status: str) -> None:
|
||||
if status not in TICKET_STATES:
|
||||
raise IssuanceError("Unbekannter Ticketzustand.")
|
||||
conn.execute(
|
||||
"UPDATE license_tickets SET status=?, updated_at=? WHERE id=?",
|
||||
(status, _now(), ticket_id),
|
||||
)
|
||||
|
||||
|
||||
def ticket_has_open_license(conn: sqlite3.Connection, ticket_id: str) -> bool:
|
||||
return (
|
||||
Database.value(
|
||||
conn,
|
||||
"SELECT COUNT(*) FROM licenses WHERE ticket_id=? AND status IN ('issued','active')",
|
||||
(ticket_id,),
|
||||
default=0,
|
||||
)
|
||||
or 0
|
||||
) > 0
|
||||
|
||||
|
||||
def delete_ticket(conn: sqlite3.Connection, ticket_id: str) -> None:
|
||||
if ticket_has_open_license(conn, ticket_id):
|
||||
raise IssuanceError(
|
||||
"Zu diesem Ticket existiert noch eine offene Lizenz. Bitte zuerst widerrufen."
|
||||
)
|
||||
# Historische Lizenzzeilen haengen per ON DELETE CASCADE mit -- sonst blieben
|
||||
# verwaiste Zeilen mit toter ticket_id zurueck.
|
||||
conn.execute("DELETE FROM license_tickets WHERE id=?", (ticket_id,))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Lizenzen
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def list_licenses(
|
||||
conn: sqlite3.Connection,
|
||||
*,
|
||||
customer_id: int | None = None,
|
||||
ticket_id: str | None = None,
|
||||
status: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
clauses: list[str] = []
|
||||
params: list[Any] = []
|
||||
if customer_id:
|
||||
clauses.append("l.customer_id = ?")
|
||||
params.append(customer_id)
|
||||
if ticket_id:
|
||||
clauses.append("l.ticket_id = ?")
|
||||
params.append(ticket_id)
|
||||
if status:
|
||||
clauses.append("l.status = ?")
|
||||
params.append(status)
|
||||
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
|
||||
return Database.all(
|
||||
conn,
|
||||
f"""
|
||||
SELECT l.*, c.name AS customer_name, c.contact_email
|
||||
FROM licenses l JOIN license_customers c ON c.id = l.customer_id
|
||||
{where}
|
||||
ORDER BY l.created_at DESC
|
||||
""",
|
||||
tuple(params),
|
||||
)
|
||||
|
||||
|
||||
def get_license(conn: sqlite3.Connection, license_id: str) -> dict[str, Any] | None:
|
||||
return Database.one(
|
||||
conn,
|
||||
"SELECT l.*, c.name AS customer_name, c.contact_email, c.contact_person "
|
||||
"FROM licenses l JOIN license_customers c ON c.id = l.customer_id WHERE l.license_id=?",
|
||||
(license_id,),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class IssuedLicense:
|
||||
license_id: str
|
||||
bundle: dict[str, Any]
|
||||
document: dict[str, Any]
|
||||
already_existed: bool = False
|
||||
|
||||
|
||||
def client_key_aad(license_id: str) -> str:
|
||||
return f"licenses.client_key:{license_id}"
|
||||
|
||||
|
||||
def issue_from_ticket(
|
||||
conn: sqlite3.Connection,
|
||||
ticket_id: str,
|
||||
*,
|
||||
master_private_key: str,
|
||||
master_public_key: str,
|
||||
master_endpoint: str,
|
||||
vendor: dict[str, Any],
|
||||
actor: str,
|
||||
keystore: Any,
|
||||
as_trial: bool = False,
|
||||
) -> IssuedLicense:
|
||||
"""Stellt aus einem Ticket eine Lizenz aus.
|
||||
|
||||
``as_trial`` ist ein reiner **Ausstellungs-Override**: 30 Tage, alle Module,
|
||||
Typ ``trial``. Das Ticket selbst bleibt dabei unveraendert -- nur diese eine
|
||||
Datei traegt Trial-Konditionen. Fuer Tickets mit einer anderen Rolle als
|
||||
``customer`` ist der Override sinnlos und wird abgewiesen.
|
||||
"""
|
||||
ticket = get_ticket(conn, ticket_id)
|
||||
if ticket is None:
|
||||
raise IssuanceError("Dieses Ticket existiert nicht.")
|
||||
if str(ticket["status"]) != "open":
|
||||
raise IssuanceError("Dieses Ticket ist gesperrt.")
|
||||
|
||||
role = str(ticket["role"])
|
||||
if as_trial and role != "customer":
|
||||
raise IssuanceError(
|
||||
"Eine Testlizenz gibt es nur fuer Kundeninstanzen, nicht fuer Lizenzserver."
|
||||
)
|
||||
|
||||
if as_trial:
|
||||
license_type = "trial"
|
||||
modules = sorted(lic.ALL_MODULES)
|
||||
valid_days = lic.TRIAL_DEFAULT_DAYS
|
||||
lifetime = False
|
||||
else:
|
||||
license_type = str(ticket["type"])
|
||||
modules = json.loads(str(ticket["modules"] or "[]"))
|
||||
valid_days = int(ticket["valid_days"])
|
||||
lifetime = bool(ticket["lifetime"])
|
||||
|
||||
# Dritte Absicherung derselben Regel, unmittelbar vor dem Signieren.
|
||||
if lifetime and license_type == "trial":
|
||||
raise IssuanceError("Eine Testlizenz kann nicht unbefristet sein.")
|
||||
|
||||
# Der Endpunkt wird mitsigniert. Ohne ihn entsteht eine Lizenz, die sich
|
||||
# nie online aktivieren laesst -- und das laesst sich nachtraeglich nur
|
||||
# durch eine neue Signatur beheben, nicht durch eine Einstellung. Deshalb
|
||||
# hier, an der einzigen Stelle, durch die jede Ausstellung laeuft.
|
||||
if not str(master_endpoint or "").strip():
|
||||
raise IssuanceError(
|
||||
"Es ist kein oeffentlicher Endpunkt hinterlegt (Einstellungen -> Anbieter). "
|
||||
"Ohne ihn koennte sich diese Lizenz nie online aktivieren -- der Endpunkt "
|
||||
"wird in die Lizenz signiert und laesst sich spaeter nicht nachtragen."
|
||||
)
|
||||
|
||||
customer = get_customer(conn, int(ticket["customer_id"]))
|
||||
assert customer is not None
|
||||
bundle, license_pub = licsrv.issue_license(
|
||||
customer={
|
||||
"name": str(customer["name"]),
|
||||
"contact_email": str(customer["contact_email"] or ""),
|
||||
"customer_ref": str(customer["id"]),
|
||||
},
|
||||
license_type=license_type,
|
||||
modules=modules,
|
||||
valid_days=valid_days,
|
||||
master_private_key_b64=master_private_key,
|
||||
master_public_key_b64=master_public_key,
|
||||
master_endpoint=master_endpoint,
|
||||
vendor=vendor,
|
||||
role=role,
|
||||
lifetime=lifetime,
|
||||
)
|
||||
document = bundle["license"]
|
||||
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT INTO licenses (license_id, ticket_id, customer_id, type, role, modules, "
|
||||
"lifetime, seq, issued_at, expires_at, license_pubkey, license_document, "
|
||||
"client_key_encrypted, status, created_by, created_at) "
|
||||
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,'issued',?,?)",
|
||||
(
|
||||
document["license_id"],
|
||||
ticket_id,
|
||||
int(ticket["customer_id"]),
|
||||
license_type,
|
||||
role,
|
||||
json.dumps(modules),
|
||||
int(bool(lifetime)),
|
||||
int(document["seq"]),
|
||||
document["issued_at"],
|
||||
document["expires_at"],
|
||||
license_pub,
|
||||
json.dumps(document),
|
||||
keystore.encrypt(
|
||||
str(bundle["client_key"]), aad=client_key_aad(str(document["license_id"]))
|
||||
),
|
||||
actor,
|
||||
_now(),
|
||||
),
|
||||
)
|
||||
except sqlite3.IntegrityError:
|
||||
# Der partielle Unique-Index hat zugeschlagen: ein zweiter Klick oder ein
|
||||
# zweiter Tab war schneller. Wir geben die tatsaechlich erzeugte Lizenz
|
||||
# zurueck statt einen Fehler zu zeigen.
|
||||
existing = Database.one(
|
||||
conn,
|
||||
"SELECT license_id, license_document FROM licenses "
|
||||
"WHERE ticket_id=? AND status IN ('issued','active')",
|
||||
(ticket_id,),
|
||||
)
|
||||
if existing is None:
|
||||
raise
|
||||
return IssuedLicense(
|
||||
license_id=str(existing["license_id"]),
|
||||
bundle={},
|
||||
document=json.loads(str(existing["license_document"])),
|
||||
already_existed=True,
|
||||
)
|
||||
|
||||
return IssuedLicense(
|
||||
license_id=str(document["license_id"]), bundle=bundle, document=document
|
||||
)
|
||||
|
||||
|
||||
def delivery_bundle(conn: sqlite3.Connection, license_id: str, *, keystore: Any) -> dict[str, Any]:
|
||||
"""Baut das Auslieferungs-Bundle inklusive Client-Schluessel neu auf.
|
||||
|
||||
Der Schluessel liegt verschluesselt in der Datenbank; hier wird er genau
|
||||
fuer die Auslieferung entschluesselt. Fehlt er (Lizenz aus einer aelteren
|
||||
Version), enthaelt das Bundle nur das Dokument -- der Kunde braucht dann eine
|
||||
neu ausgestellte Lizenz.
|
||||
"""
|
||||
row = get_license(conn, license_id)
|
||||
if row is None:
|
||||
raise IssuanceError("Diese Lizenz existiert nicht.")
|
||||
document = json.loads(str(row["license_document"]))
|
||||
bundle: dict[str, Any] = {
|
||||
"kind": "tesm-license-bundle",
|
||||
"fmt": lic.FORMAT_VERSION,
|
||||
"license": document,
|
||||
}
|
||||
encrypted = str(row["client_key_encrypted"] or "")
|
||||
if encrypted:
|
||||
try:
|
||||
bundle["client_key"] = keystore.decrypt(encrypted, aad=client_key_aad(license_id))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise IssuanceError(
|
||||
"Der Client-Schluessel dieser Lizenz konnte nicht entschluesselt werden. "
|
||||
"Bitte eine neue Lizenz ausstellen."
|
||||
) from exc
|
||||
return bundle
|
||||
|
||||
|
||||
def revoke_license(conn: sqlite3.Connection, license_id: str, *, reason: str = "") -> None:
|
||||
"""Widerruft eine Lizenz.
|
||||
|
||||
Das Ticket bleibt bewusst offen: ein Widerruf ist Routine (Systemwechsel,
|
||||
Neuinstallation), keine Sperre. Wer den Kunden aussperren will, sperrt das
|
||||
Ticket.
|
||||
"""
|
||||
row = get_license(conn, license_id)
|
||||
if row is None:
|
||||
raise IssuanceError("Diese Lizenz existiert nicht.")
|
||||
if str(row["status"]) == "revoked":
|
||||
raise IssuanceError("Diese Lizenz ist bereits widerrufen.")
|
||||
conn.execute(
|
||||
"UPDATE licenses SET status='revoked', revoked_at=?, revoke_reason=? WHERE license_id=?",
|
||||
(_now(), (reason or "").strip()[:400], license_id),
|
||||
)
|
||||
|
||||
|
||||
def delete_license(conn: sqlite3.Connection, license_id: str) -> None:
|
||||
row = get_license(conn, license_id)
|
||||
if row is None:
|
||||
raise IssuanceError("Diese Lizenz existiert nicht.")
|
||||
if str(row["status"]) not in {"revoked", "issued"}:
|
||||
raise IssuanceError(
|
||||
"Eine aktive Lizenz kann nicht geloescht werden. Bitte zuerst widerrufen."
|
||||
)
|
||||
conn.execute("DELETE FROM licenses WHERE license_id=?", (license_id,))
|
||||
|
||||
|
||||
def resign_license(
|
||||
conn: sqlite3.Connection,
|
||||
license_id: str,
|
||||
*,
|
||||
master_private_key: str,
|
||||
changes: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""Signiert eine neue Revision -- z. B. nach einer Modulerweiterung.
|
||||
|
||||
Der Client erhaelt sie beim naechsten Heartbeat. ``seq`` steigt dabei; eine
|
||||
aeltere Revision kann ihm damit nicht mehr eingespielt werden.
|
||||
"""
|
||||
row = get_license(conn, license_id)
|
||||
if row is None:
|
||||
raise IssuanceError("Diese Lizenz existiert nicht.")
|
||||
document = json.loads(str(row["license_document"]))
|
||||
updated = licsrv.resign_license(
|
||||
document, master_private_key_b64=master_private_key, changes=changes
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE licenses SET license_document=?, seq=?, type=?, modules=?, lifetime=?, "
|
||||
"expires_at=?, revision_at=? WHERE license_id=?",
|
||||
(
|
||||
json.dumps(updated),
|
||||
int(updated["seq"]),
|
||||
str(updated["type"]),
|
||||
json.dumps(updated["modules"]),
|
||||
int(bool(updated["lifetime"])),
|
||||
str(updated["expires_at"]),
|
||||
_now(),
|
||||
license_id,
|
||||
),
|
||||
)
|
||||
return updated
|
||||
|
||||
|
||||
def record_delivery(
|
||||
conn: sqlite3.Connection,
|
||||
*,
|
||||
license_id: str,
|
||||
channel: str,
|
||||
recipient: str,
|
||||
actor: str,
|
||||
detail: str = "",
|
||||
) -> None:
|
||||
conn.execute(
|
||||
"INSERT INTO license_deliveries (license_id, channel, recipient, delivered_at, "
|
||||
"delivered_by, detail) VALUES (?,?,?,?,?,?)",
|
||||
(license_id, channel, recipient, _now(), actor, detail[:400]),
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Anzeige
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def revision_pending(row: Any) -> bool:
|
||||
"""Wurde seit der letzten Revision noch kein Kontakt vom Client gesehen?
|
||||
|
||||
Widerrufene Lizenzen zaehlen nicht -- dort ist nichts mehr abzuholen.
|
||||
"""
|
||||
def field(name: str) -> Any:
|
||||
# Die Zeile kommt aus verschiedenen Abfragen; nicht jede waehlt alles.
|
||||
# Eine fehlende Spalte darf keine Ausnahme werfen -- die Funktion sitzt
|
||||
# in Listen, Detailseiten und im Kundenportal.
|
||||
try:
|
||||
return row[name]
|
||||
except (KeyError, IndexError, TypeError):
|
||||
return None
|
||||
|
||||
revision_at = field("revision_at")
|
||||
if not revision_at or str(field("status") or "") == "revoked":
|
||||
return False
|
||||
last = field("last_heartbeat_at") or field("activated_at")
|
||||
if not last:
|
||||
return True
|
||||
# Beide Zeitstempel stammen aus ``_now()`` und sind damit gleich formatiert.
|
||||
return str(last) < str(revision_at)
|
||||
|
||||
|
||||
def display_state(row: dict[str, Any], *, now: datetime | None = None) -> dict[str, Any]:
|
||||
"""Ampelfarbe und Resttage einer Lizenzzeile.
|
||||
|
||||
Genau eine Stelle im Code, von jeder Oberflaeche benutzt (Dashboard,
|
||||
Listen, Detailseiten, Self-Service, Kundenportal, gemeldete Bestaende der
|
||||
Sub-Lizenzserver).
|
||||
|
||||
Grenzen: mehr als 90 Tage = gruen, 30 bis 90 Tage = orange, unter 30 Tage
|
||||
(auch abgelaufen) = rot. Unbefristete Lizenzen sind immer gruen.
|
||||
"""
|
||||
moment = now or datetime.now(timezone.utc)
|
||||
result = {
|
||||
"color": "green",
|
||||
"days_left": None,
|
||||
"is_expired": False,
|
||||
"lifetime": bool(row.get("lifetime")),
|
||||
# Kein eigener Status: eine Lizenz kann aktiv sein *und* eine noch
|
||||
# nicht abgeholte Revision haben.
|
||||
"revision_pending": revision_pending(row),
|
||||
}
|
||||
if row.get("lifetime"):
|
||||
return result
|
||||
raw = row.get("expires_at")
|
||||
if not raw:
|
||||
return {**result, "color": "red"}
|
||||
try:
|
||||
expires = lic.parse_iso(str(raw))
|
||||
except ValueError:
|
||||
# Fail-safe: unlesbares Datum gilt als kritisch, nicht als in Ordnung.
|
||||
return {**result, "color": "red"}
|
||||
|
||||
delta = expires - moment
|
||||
days_left = int(delta.total_seconds() // 86400)
|
||||
expired = delta.total_seconds() <= 0
|
||||
if expired:
|
||||
color = "red"
|
||||
elif days_left < RED_BELOW_DAYS:
|
||||
color = "red"
|
||||
elif days_left <= GREEN_ABOVE_DAYS:
|
||||
color = "orange"
|
||||
else:
|
||||
color = "green"
|
||||
return {
|
||||
**result,
|
||||
"color": color,
|
||||
"days_left": max(0, days_left),
|
||||
"is_expired": expired,
|
||||
"lifetime": False,
|
||||
}
|
||||
|
||||
|
||||
def dashboard_summary(conn: sqlite3.Connection) -> dict[str, Any]:
|
||||
rows = list_licenses(conn)
|
||||
buckets: dict[str, list[dict[str, Any]]] = {"red": [], "orange": [], "green": []}
|
||||
for row in rows:
|
||||
if str(row["status"]) in {"revoked", "deactivated"}:
|
||||
continue
|
||||
state = display_state(row)
|
||||
entry = {**row, **state}
|
||||
buckets[state["color"]].append(entry)
|
||||
return {
|
||||
"buckets": buckets,
|
||||
"counts": {
|
||||
"customers": int(
|
||||
Database.value(conn, "SELECT COUNT(*) FROM license_customers", default=0) or 0
|
||||
),
|
||||
"tickets_open": int(
|
||||
Database.value(
|
||||
conn, "SELECT COUNT(*) FROM license_tickets WHERE status='open'", default=0
|
||||
)
|
||||
or 0
|
||||
),
|
||||
"tickets_blocked": int(
|
||||
Database.value(
|
||||
conn, "SELECT COUNT(*) FROM license_tickets WHERE status='blocked'", default=0
|
||||
)
|
||||
or 0
|
||||
),
|
||||
"licenses_active": int(
|
||||
Database.value(
|
||||
conn, "SELECT COUNT(*) FROM licenses WHERE status='active'", default=0
|
||||
)
|
||||
or 0
|
||||
),
|
||||
"licenses_issued": int(
|
||||
Database.value(
|
||||
conn, "SELECT COUNT(*) FROM licenses WHERE status='issued'", default=0
|
||||
)
|
||||
or 0
|
||||
),
|
||||
"licenses_revoked": int(
|
||||
Database.value(
|
||||
conn, "SELECT COUNT(*) FROM licenses WHERE status='revoked'", default=0
|
||||
)
|
||||
or 0
|
||||
),
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,450 @@
|
||||
"""Uebernahme eines Lizenzservers der Vorgaengerfassung.
|
||||
|
||||
Die alten Lizenzdateien sind mit der neuen Fassung **nicht** verwendbar, und
|
||||
das ist Absicht: dort wurde ohne Domaenentrennung signiert (die Signatur ueber
|
||||
eine Lizenz war damit auch als Signatur ueber eine Protokollantwort gueltig),
|
||||
es fehlten Format-Version, Sequenznummer und Gueltigkeitsbeginn. Beides
|
||||
gleichzeitig zu akzeptieren hiesse, die Haertung wieder aufzugeben -- eine
|
||||
Uebergangsfrist waere hier eine dauerhaft offene Tuer.
|
||||
|
||||
Was **sehr wohl** uebernommen wird, ist alles andere:
|
||||
|
||||
* **Der Signaturschluessel.** Damit bleibt die Anbieteridentitaet dieselbe. Bei
|
||||
Kunden, die den Aussteller bereits gepinnt haben, passt der Schluessel
|
||||
weiterhin -- sie muessen keinen Ausstellerwechsel bestaetigen.
|
||||
* **Kunden, Tickets und Lizenzen** aus der alten Datenbank.
|
||||
|
||||
Jede uebernommene Lizenz wird im neuen Format **neu signiert**. Der Kunde
|
||||
bekommt dadurch eine neue Lizenzdatei; das laesst sich nicht vermeiden, denn
|
||||
die alte traegt eine Signatur, die es in dieser Form nicht mehr gibt. Der
|
||||
Bericht am Ende sagt genau, wer eine neue Datei braucht.
|
||||
|
||||
Der Vorgang ist **lesend** gegenueber der alten Installation. Sie wird nicht
|
||||
angefasst, nicht angehalten und nicht veraendert.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import json
|
||||
import sqlite3
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import tesm_licensing as lic
|
||||
import tesm_licensing_server as licsrv
|
||||
from tesm_core.db import Database
|
||||
from tesm_core.keystore import Keystore
|
||||
|
||||
from . import issuance
|
||||
|
||||
|
||||
class LegacyImportError(Exception):
|
||||
"""Die alte Installation ist nicht lesbar oder passt nicht."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class Report:
|
||||
"""Was die Uebernahme getan hat -- vollstaendig und nachpruefbar."""
|
||||
|
||||
key_adopted: bool = False
|
||||
key_id: str = ""
|
||||
customers: int = 0
|
||||
tickets: int = 0
|
||||
licenses: int = 0
|
||||
reissued: list[str] = field(default_factory=list)
|
||||
skipped: list[str] = field(default_factory=list)
|
||||
warnings: list[str] = field(default_factory=list)
|
||||
|
||||
def as_text(self) -> str:
|
||||
lines = [
|
||||
f"Signaturschluessel uebernommen: {'ja' if self.key_adopted else 'nein'}"
|
||||
+ (f" ({self.key_id})" if self.key_id else ""),
|
||||
f"Kunden : {self.customers}",
|
||||
f"Tickets : {self.tickets}",
|
||||
f"Lizenzen : {self.licenses}",
|
||||
]
|
||||
if self.reissued:
|
||||
lines.append("")
|
||||
lines.append("Neu signiert -- diese Kunden brauchen eine neue Lizenzdatei:")
|
||||
lines.extend(f" * {entry}" for entry in self.reissued)
|
||||
if self.skipped:
|
||||
lines.append("")
|
||||
lines.append("Uebersprungen:")
|
||||
lines.extend(f" * {entry}" for entry in self.skipped)
|
||||
if self.warnings:
|
||||
lines.append("")
|
||||
lines.append("Hinweise:")
|
||||
lines.extend(f" * {entry}" for entry in self.warnings)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Quelle
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def open_legacy(root: Path) -> sqlite3.Connection:
|
||||
"""Oeffnet die alte Datenbank **nur lesend**."""
|
||||
path = Path(root) / "sqlite.db"
|
||||
if not path.is_file():
|
||||
raise LegacyImportError(f"Unter {path} liegt keine Datenbank der Vorgaengerfassung.")
|
||||
try:
|
||||
conn = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
|
||||
except sqlite3.Error as exc:
|
||||
raise LegacyImportError(f"Die alte Datenbank ist nicht lesbar: {exc}") from exc
|
||||
conn.row_factory = sqlite3.Row
|
||||
|
||||
tables = {row[0] for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")}
|
||||
missing = {"license_customers", "license_tickets", "licenses"} - tables
|
||||
if missing:
|
||||
conn.close()
|
||||
raise LegacyImportError(
|
||||
f"Das sieht nicht nach einem Lizenzserver aus -- es fehlen: {', '.join(sorted(missing))}"
|
||||
)
|
||||
return conn
|
||||
|
||||
|
||||
def _standard_b64(wert: str, feld: str) -> str:
|
||||
"""Nimmt Standard- **und** URL-Alphabet an, gibt Standard-Base64 zurueck.
|
||||
|
||||
Der Vorgaenger kodierte seine Schluessel mit ``urlsafe_b64encode``: dort
|
||||
stehen ``-`` und ``_`` an den Stellen von ``+`` und ``/``. Das neue
|
||||
Protokoll prueft Base64 streng -- richtig so, aber beim Lesen einer alten
|
||||
Datei fuehrte es dazu, dass die Uebernahme mit einem Traceback ueber
|
||||
"kein gueltiges Base64" abbrach, bei jedem echten Altbestand.
|
||||
"""
|
||||
roh = wert.strip()
|
||||
if not roh:
|
||||
raise LegacyImportError(f"Im alten Signaturschluessel fehlt {feld!r}.")
|
||||
# Fehlende Polsterung ergaenzen -- der Vorgaenger schrieb sie mit, andere
|
||||
# Werkzeuge lassen sie weg.
|
||||
gepolstert = roh + "=" * (-len(roh) % 4)
|
||||
# Erst das Standard-, dann das URL-Alphabet -- beide mit Pruefung. Ohne
|
||||
# ``validate`` verschluckt der Decoder unbekannte Zeichen einfach, und aus
|
||||
# "###" wuerde eine leere Ausgabe statt einer Fehlermeldung.
|
||||
versuche = (gepolstert, gepolstert.replace("-", "+").replace("_", "/"))
|
||||
for kandidat in versuche:
|
||||
try:
|
||||
rohbytes = base64.b64decode(kandidat, validate=True)
|
||||
except (ValueError, binascii.Error):
|
||||
continue
|
||||
if len(rohbytes) != 32:
|
||||
raise LegacyImportError(
|
||||
f"{feld} im alten Signaturschluessel ist {len(rohbytes)} Byte lang, "
|
||||
"erwartet werden 32 (Ed25519)."
|
||||
)
|
||||
return base64.b64encode(rohbytes).decode("ascii")
|
||||
raise LegacyImportError(f"{feld} im alten Signaturschluessel ist kein Base64.")
|
||||
|
||||
|
||||
def read_legacy_key(root: Path) -> tuple[str, str]:
|
||||
"""Liest den alten Signaturschluessel. Rueckgabe: ``(privat, oeffentlich)``."""
|
||||
path = Path(root) / "master_signing_key.json"
|
||||
if not path.is_file():
|
||||
raise LegacyImportError(f"Der alte Signaturschluessel {path} fehlt.")
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError) as exc:
|
||||
raise LegacyImportError(f"Der alte Signaturschluessel ist unlesbar: {exc}") from exc
|
||||
|
||||
# Die Vorgaengerfassung nannte die Felder "private"/"public" und kodierte
|
||||
# sie URL-sicher.
|
||||
private = str(data.get("private") or data.get("private_key") or "")
|
||||
public = str(data.get("public") or data.get("public_key") or "")
|
||||
if not private or not public:
|
||||
raise LegacyImportError("Der alte Signaturschluessel ist unvollstaendig.")
|
||||
private = _standard_b64(private, "private")
|
||||
public = _standard_b64(public, "public")
|
||||
# Alles, was aus der Protokollschicht kommt, wird hier zu einer Meldung im
|
||||
# Klartext. Ein Traceback ueber Base64 mitten in einer Uebernahme sagt dem
|
||||
# Betreiber nichts -- und die Uebernahme bricht ohne --adopt-key gar nicht
|
||||
# ab, sie merkt sich nur den alten Aussteller nicht.
|
||||
try:
|
||||
passt = licsrv.public_from_private(private) == public
|
||||
except (ValueError, TypeError) as exc:
|
||||
raise LegacyImportError(f"Der alte Signaturschluessel ist unbrauchbar: {exc}") from exc
|
||||
if not passt:
|
||||
raise LegacyImportError(
|
||||
"Im alten Signaturschluessel passen privater und oeffentlicher Teil nicht "
|
||||
"zueinander. Die Datei ist beschaedigt."
|
||||
)
|
||||
return private, public
|
||||
|
||||
|
||||
def adopt_key(private: str, public: str, target: Path) -> str:
|
||||
"""Uebernimmt den alten Schluessel in das Format der neuen Fassung.
|
||||
|
||||
Damit bleibt die Anbieteridentitaet erhalten: Kunden, die den Aussteller
|
||||
schon gepinnt haben, sehen weiterhin denselben Schluessel und muessen
|
||||
keinen Wechsel bestaetigen.
|
||||
"""
|
||||
from tesm_core.keystore import write_private_file
|
||||
|
||||
if Path(target).is_file():
|
||||
raise LegacyImportError(
|
||||
f"Unter {target} liegt bereits ein Signaturschluessel. Er wird nicht "
|
||||
"ueberschrieben -- das wuerde jede damit ausgestellte Lizenz unbrauchbar "
|
||||
"machen. Fuer eine Uebernahme bitte eine frische Installation verwenden."
|
||||
)
|
||||
payload = {
|
||||
"private_key": private,
|
||||
"public_key": public,
|
||||
"key_id": lic.key_id(public),
|
||||
"created_at": _now(),
|
||||
"origin": "Uebernommen aus der Vorgaengerfassung.",
|
||||
}
|
||||
write_private_file(Path(target), json.dumps(payload, indent=2, sort_keys=True) + "\n")
|
||||
return lic.key_id(public)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Uebernahme
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _remaining_days(expires_at: str) -> int:
|
||||
"""Restlaufzeit in Tagen, mindestens 1 bei noch gueltiger Lizenz.
|
||||
|
||||
Die neue Ausstellung rechnet in Tagen ab jetzt. Wer noch drei Monate
|
||||
hatte, soll auch nach der Uebernahme drei Monate haben -- nicht ploetzlich
|
||||
ein volles Jahr und nicht weniger als vorher.
|
||||
"""
|
||||
try:
|
||||
moment = lic.parse_iso(expires_at)
|
||||
except (ValueError, TypeError):
|
||||
return 0
|
||||
remaining = (moment - datetime.now(timezone.utc)).days
|
||||
return remaining if remaining > 0 else 0
|
||||
|
||||
|
||||
def _modules(raw: Any) -> list[str]:
|
||||
try:
|
||||
values = json.loads(str(raw or "[]"))
|
||||
except ValueError:
|
||||
return []
|
||||
return sorted(set(str(value) for value in values) & set(lic.ALL_MODULES))
|
||||
|
||||
|
||||
|
||||
#: Einstellungen, die zur Anbieteridentitaet gehoeren und in beiden Fassungen
|
||||
#: gleich heissen. ``master_endpoint`` ist darunter das entscheidende: ohne ihn
|
||||
#: findet eine uebernommene Lizenz ihren Server spaeter nicht wieder.
|
||||
VENDOR_SETTINGS = (
|
||||
"master_endpoint",
|
||||
"vendor_name",
|
||||
"vendor_email",
|
||||
"vendor_phone",
|
||||
"vendor_address",
|
||||
"vendor_logo_base64",
|
||||
)
|
||||
|
||||
|
||||
def read_vendor_settings(legacy: sqlite3.Connection) -> dict[str, str]:
|
||||
"""Liest die Anbietereinstellungen der alten Installation."""
|
||||
try:
|
||||
rows = legacy.execute(
|
||||
"SELECT key, value FROM settings WHERE key IN ({})".format(
|
||||
",".join("?" for _ in VENDOR_SETTINGS)
|
||||
),
|
||||
VENDOR_SETTINGS,
|
||||
).fetchall()
|
||||
except sqlite3.Error: # pragma: no cover - alte Fassung ohne settings
|
||||
return {}
|
||||
return {str(row["key"]): str(row["value"] or "") for row in rows if str(row["value"] or "")}
|
||||
|
||||
|
||||
def adopt_vendor_settings(
|
||||
legacy: sqlite3.Connection,
|
||||
read: Any,
|
||||
write: Any,
|
||||
) -> dict[str, str]:
|
||||
"""Fuellt leere Anbietereinstellungen aus der alten Installation.
|
||||
|
||||
``read(key)`` und ``write(key, wert)`` kommen von aussen, damit diese
|
||||
Funktion nichts ueber den Einstellungsspeicher wissen muss. Rueckgabe: was
|
||||
tatsaechlich uebernommen wurde -- der Aufrufer soll es benennen koennen.
|
||||
"""
|
||||
alt = read_vendor_settings(legacy)
|
||||
uebernommen: dict[str, str] = {}
|
||||
for key, wert in alt.items():
|
||||
if str(read(key) or "").strip():
|
||||
continue # was hier schon steht, gilt
|
||||
write(key, wert)
|
||||
uebernommen[key] = wert
|
||||
return uebernommen
|
||||
|
||||
|
||||
def import_all(
|
||||
legacy: sqlite3.Connection,
|
||||
conn: sqlite3.Connection,
|
||||
*,
|
||||
keystore: Keystore,
|
||||
master_private_key: str,
|
||||
master_public_key: str,
|
||||
master_endpoint: str,
|
||||
vendor: dict[str, Any],
|
||||
actor: str = "migration",
|
||||
reissue: bool = True,
|
||||
) -> Report:
|
||||
"""Uebernimmt Kunden, Tickets und Lizenzen. Erwartet eine offene Transaktion."""
|
||||
report = Report()
|
||||
|
||||
# -- Kunden ------------------------------------------------------------ #
|
||||
customer_map: dict[int, int] = {}
|
||||
for row in legacy.execute("SELECT * FROM license_customers ORDER BY id"):
|
||||
name = str(row["name"] or "").strip()
|
||||
if not name:
|
||||
report.skipped.append(f"Kunde ohne Namen (alte Nummer {row['id']})")
|
||||
continue
|
||||
existing = Database.one(
|
||||
conn, "SELECT id FROM license_customers WHERE name=? COLLATE NOCASE", (name,)
|
||||
)
|
||||
if existing is not None:
|
||||
customer_map[int(row["id"])] = int(existing["id"])
|
||||
report.warnings.append(f"Kunde {name!r} war schon vorhanden -- nicht doppelt angelegt.")
|
||||
continue
|
||||
new_id = issuance.create_customer(
|
||||
conn,
|
||||
actor=actor,
|
||||
name=name,
|
||||
contact_email=str(row["contact_email"] or ""),
|
||||
contact_phone=str(row["contact_phone"] or ""),
|
||||
contact_person=str(row["contact_person"] or ""),
|
||||
street=str(row["street"] or ""),
|
||||
house_number=str(row["house_number"] or ""),
|
||||
postal_code=str(row["postal_code"] or ""),
|
||||
city=str(row["city"] or ""),
|
||||
notes=str(row["notes"] or ""),
|
||||
)
|
||||
customer_map[int(row["id"])] = new_id
|
||||
report.customers += 1
|
||||
|
||||
# -- Tickets ----------------------------------------------------------- #
|
||||
for row in legacy.execute("SELECT * FROM license_tickets ORDER BY created_at"):
|
||||
customer_id = customer_map.get(int(row["customer_id"] or 0))
|
||||
if customer_id is None:
|
||||
report.skipped.append(f"Ticket {str(row['id'])[:12]} -- Kunde nicht uebernommen")
|
||||
continue
|
||||
if Database.one(conn, "SELECT id FROM license_tickets WHERE id=?", (row["id"],)):
|
||||
continue
|
||||
conn.execute(
|
||||
"INSERT INTO license_tickets (id, customer_id, type, modules, valid_days, lifetime, "
|
||||
"status, role, notes, created_by, created_at, updated_at) "
|
||||
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(
|
||||
str(row["id"]),
|
||||
customer_id,
|
||||
str(row["type"] or "standard"),
|
||||
json.dumps(_modules(row["modules"])),
|
||||
int(row["valid_days"] or 365),
|
||||
int(row["lifetime"] or 0),
|
||||
str(row["status"] or "open"),
|
||||
str(row["role"] or "customer"),
|
||||
"Aus der Vorgaengerfassung uebernommen.",
|
||||
str(row["created_by"] or actor),
|
||||
str(row["created_at"] or _now()),
|
||||
_now(),
|
||||
),
|
||||
)
|
||||
report.tickets += 1
|
||||
|
||||
# -- Lizenzen ---------------------------------------------------------- #
|
||||
for row in legacy.execute("SELECT * FROM licenses ORDER BY created_at"):
|
||||
license_id = str(row["license_id"] or "")
|
||||
customer_id = customer_map.get(int(row["customer_id"] or 0))
|
||||
if not license_id or customer_id is None:
|
||||
report.skipped.append(f"Lizenz {license_id[:12] or '?'} -- Kunde nicht uebernommen")
|
||||
continue
|
||||
if Database.one(conn, "SELECT id FROM licenses WHERE license_id=?", (license_id,)):
|
||||
continue
|
||||
|
||||
customer = issuance.get_customer(conn, customer_id)
|
||||
status = str(row["status"] or "issued")
|
||||
modules = _modules(row["modules"])
|
||||
|
||||
if not reissue:
|
||||
report.skipped.append(f"{customer['name']} / {license_id[:12]} -- nur gezaehlt")
|
||||
report.licenses += 1
|
||||
continue
|
||||
|
||||
# Neu ausstellen: gleiche Kennung, gleiche Eckdaten, neues Format und
|
||||
# ein frisches Clientschluesselpaar. Das alte Paar gehoert zu einer
|
||||
# Signatur, die es nicht mehr gibt.
|
||||
lifetime = bool(row["lifetime"])
|
||||
valid_days = _remaining_days(str(row["expires_at"] or ""))
|
||||
if not lifetime and valid_days <= 0:
|
||||
report.skipped.append(
|
||||
f"{customer['name']} / {license_id[:12]} -- abgelaufen am "
|
||||
f"{str(row['expires_at'])[:10]}, nicht uebernommen"
|
||||
)
|
||||
continue
|
||||
|
||||
bundle, license_public = licsrv.issue_license(
|
||||
customer={
|
||||
"name": str(customer["name"]),
|
||||
"contact_email": str(customer["contact_email"] or ""),
|
||||
"customer_ref": str(customer["id"]),
|
||||
},
|
||||
license_type=str(row["type"] or "standard"),
|
||||
modules=modules,
|
||||
valid_days=valid_days,
|
||||
master_private_key_b64=master_private_key,
|
||||
master_public_key_b64=master_public_key,
|
||||
master_endpoint=master_endpoint,
|
||||
vendor=vendor,
|
||||
license_id=license_id,
|
||||
role=str(row["role"] or "customer"),
|
||||
lifetime=lifetime,
|
||||
)
|
||||
document = bundle["license"]
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO licenses (license_id, ticket_id, customer_id, type, role, modules, "
|
||||
"lifetime, seq, issued_at, expires_at, license_pubkey, license_document, "
|
||||
"client_key_encrypted, status, created_by, created_at) "
|
||||
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(
|
||||
license_id,
|
||||
str(row["ticket_id"] or ""),
|
||||
customer_id,
|
||||
str(row["type"] or "standard"),
|
||||
str(row["role"] or "customer"),
|
||||
json.dumps(modules),
|
||||
int(lifetime),
|
||||
int(document["seq"]),
|
||||
document["issued_at"],
|
||||
document["expires_at"],
|
||||
license_public,
|
||||
json.dumps(document),
|
||||
keystore.encrypt(
|
||||
str(bundle["client_key"]), aad=issuance.client_key_aad(license_id)
|
||||
),
|
||||
# Kein Zustand, der eine Maschine nennt: die alte Bindung gilt
|
||||
# fuer die alte Datei, nicht fuer diese.
|
||||
"revoked" if status == "revoked" else "issued",
|
||||
str(row["created_by"] or actor),
|
||||
str(row["created_at"] or _now()),
|
||||
),
|
||||
)
|
||||
report.licenses += 1
|
||||
if status != "revoked":
|
||||
report.reissued.append(
|
||||
f"{customer['name']} -- {license_id} "
|
||||
f"({'aktiv gewesen' if status == 'active' else status})"
|
||||
)
|
||||
|
||||
if report.reissued:
|
||||
report.warnings.append(
|
||||
"Die neu signierten Lizenzen sind noch nicht aktiviert: die Bindung an eine "
|
||||
"Maschine gilt nur fuer die alte Datei. Nach dem Einspielen aktiviert der "
|
||||
"Kunde erneut."
|
||||
)
|
||||
return report
|
||||
@@ -0,0 +1,213 @@
|
||||
"""E-Mail-Versand ueber die Microsoft-Graph-API (App-only).
|
||||
|
||||
Kein SMTP und keine zusaetzliche Abhaengigkeit: der Client-Credentials-Flow ist
|
||||
ein Token-Abruf und ein POST -- das geht mit ``urllib`` aus der
|
||||
Standardbibliothek. Eine Bibliothek wie ``msal`` waere hier reiner Ballast.
|
||||
|
||||
Das Client-Secret liegt AES-GCM-verschluesselt in ``service_accounts``, an den
|
||||
Zweck gebunden. Der Vorgaenger hielt es unverschluesselt in der
|
||||
``settings``-Tabelle.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import sqlite3
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from tesm_core.db import Database
|
||||
from tesm_core.extension import core
|
||||
from tesm_core.settings_store import SettingSpec
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SERVICE_PURPOSE = "graph_client_secret"
|
||||
AAD_GRAPH_SECRET = "service_accounts.graph_client_secret"
|
||||
|
||||
TOKEN_URL = "https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token"
|
||||
SEND_URL = "https://graph.microsoft.com/v1.0/users/{sender}/sendMail"
|
||||
SCOPE = "https://graph.microsoft.com/.default"
|
||||
|
||||
SETTINGS: tuple[SettingSpec, ...] = (
|
||||
SettingSpec("graph_enabled", False, "bool", "E-Mail-Versand aktiv", section="mail"),
|
||||
SettingSpec("graph_tenant_id", "", "str", "Verzeichnis-ID (Tenant)", section="mail"),
|
||||
SettingSpec("graph_client_id", "", "str", "Anwendungs-ID (Client)", section="mail"),
|
||||
SettingSpec("graph_sender", "", "str", "Absender-Postfach", section="mail"),
|
||||
SettingSpec(
|
||||
"graph_reply_to", "", "str", "Antwortadresse (optional)", section="mail"
|
||||
),
|
||||
)
|
||||
|
||||
_token_cache: dict[str, tuple[str, float]] = {}
|
||||
|
||||
|
||||
class MailError(Exception):
|
||||
"""Versandfehler mit Klartextmeldung."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Attachment:
|
||||
name: str
|
||||
content: bytes
|
||||
content_type: str = "application/json"
|
||||
|
||||
|
||||
def store_secret(conn: sqlite3.Connection, secret: str | None) -> None:
|
||||
existing = Database.one(
|
||||
conn, "SELECT password_encrypted FROM service_accounts WHERE purpose=?", (SERVICE_PURPOSE,)
|
||||
)
|
||||
encrypted = str(existing.get("password_encrypted") or "") if existing else ""
|
||||
if secret:
|
||||
encrypted = core().keystore.encrypt(secret, aad=AAD_GRAPH_SECRET)
|
||||
conn.execute(
|
||||
"INSERT INTO service_accounts (purpose, username, password_encrypted, updated_at) "
|
||||
"VALUES (?,'',?,?) ON CONFLICT(purpose) DO UPDATE SET "
|
||||
"password_encrypted=excluded.password_encrypted, updated_at=excluded.updated_at",
|
||||
(SERVICE_PURPOSE, encrypted, datetime.now(timezone.utc).isoformat(timespec="seconds")),
|
||||
)
|
||||
|
||||
|
||||
def has_secret(conn: sqlite3.Connection) -> bool:
|
||||
value = Database.value(
|
||||
conn, "SELECT password_encrypted FROM service_accounts WHERE purpose=?", (SERVICE_PURPOSE,)
|
||||
)
|
||||
return bool(value)
|
||||
|
||||
|
||||
def _secret(conn: sqlite3.Connection) -> str:
|
||||
value = Database.value(
|
||||
conn, "SELECT password_encrypted FROM service_accounts WHERE purpose=?", (SERVICE_PURPOSE,)
|
||||
)
|
||||
if not value:
|
||||
raise MailError("Es ist kein Client-Secret hinterlegt.")
|
||||
try:
|
||||
return core().keystore.decrypt(str(value), aad=AAD_GRAPH_SECRET)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise MailError(
|
||||
"Das Client-Secret konnte nicht entschluesselt werden -- bitte neu eintragen."
|
||||
) from exc
|
||||
|
||||
|
||||
def _config(conn: sqlite3.Connection) -> dict[str, Any]:
|
||||
return core().settings.get_many(conn, [spec.key for spec in SETTINGS])
|
||||
|
||||
|
||||
def is_enabled(conn: sqlite3.Connection) -> bool:
|
||||
config = _config(conn)
|
||||
return bool(
|
||||
config.get("graph_enabled")
|
||||
and config.get("graph_tenant_id")
|
||||
and config.get("graph_client_id")
|
||||
and config.get("graph_sender")
|
||||
and has_secret(conn)
|
||||
)
|
||||
|
||||
|
||||
def _post(url: str, data: bytes, headers: dict[str, str], *, timeout: int = 20) -> bytes:
|
||||
request = urllib.request.Request(url, data=data, headers=headers, method="POST") # noqa: S310
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310
|
||||
return response.read()
|
||||
except urllib.error.HTTPError as exc:
|
||||
body = exc.read().decode("utf-8", errors="replace")
|
||||
detail = body
|
||||
try:
|
||||
parsed = json.loads(body)
|
||||
detail = str(parsed.get("error", {}).get("message") or parsed.get("error_description") or body)
|
||||
except ValueError:
|
||||
pass
|
||||
raise MailError(f"Graph antwortete mit HTTP {exc.code}: {detail[:400]}") from exc
|
||||
except urllib.error.URLError as exc:
|
||||
raise MailError(f"Graph ist nicht erreichbar: {exc.reason}") from exc
|
||||
|
||||
|
||||
def fetch_token(conn: sqlite3.Connection) -> str:
|
||||
config = _config(conn)
|
||||
tenant = str(config["graph_tenant_id"])
|
||||
client_id = str(config["graph_client_id"])
|
||||
cache_key = f"{tenant}:{client_id}"
|
||||
cached = _token_cache.get(cache_key)
|
||||
if cached and cached[1] > time.time() + 60:
|
||||
return cached[0]
|
||||
|
||||
payload = urllib.parse.urlencode(
|
||||
{
|
||||
"client_id": client_id,
|
||||
"client_secret": _secret(conn),
|
||||
"scope": SCOPE,
|
||||
"grant_type": "client_credentials",
|
||||
}
|
||||
).encode("utf-8")
|
||||
raw = _post(
|
||||
TOKEN_URL.format(tenant=urllib.parse.quote(tenant)),
|
||||
payload,
|
||||
{"Content-Type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
data = json.loads(raw.decode("utf-8"))
|
||||
token = str(data.get("access_token") or "")
|
||||
if not token:
|
||||
raise MailError("Graph hat kein Zugriffstoken geliefert.")
|
||||
_token_cache[cache_key] = (token, time.time() + int(data.get("expires_in") or 3000))
|
||||
return token
|
||||
|
||||
|
||||
def test_connection(conn: sqlite3.Connection) -> dict[str, Any]:
|
||||
"""Holt nur ein Token -- verschickt bewusst keine echte Nachricht."""
|
||||
if not is_enabled(conn):
|
||||
return {"ok": False, "message": "Der E-Mail-Versand ist nicht vollstaendig konfiguriert."}
|
||||
try:
|
||||
fetch_token(conn)
|
||||
except MailError as exc:
|
||||
return {"ok": False, "message": str(exc)}
|
||||
return {"ok": True, "message": "Anmeldung an Microsoft Graph erfolgreich."}
|
||||
|
||||
|
||||
def send(
|
||||
conn: sqlite3.Connection,
|
||||
*,
|
||||
to: str,
|
||||
subject: str,
|
||||
body: str,
|
||||
attachment: Attachment | None = None,
|
||||
) -> None:
|
||||
if not is_enabled(conn):
|
||||
raise MailError("Der E-Mail-Versand ist nicht konfiguriert.")
|
||||
config = _config(conn)
|
||||
message: dict[str, Any] = {
|
||||
"message": {
|
||||
"subject": subject,
|
||||
# Reiner Text: kein HTML, damit nichts aus Kundendaten interpretiert wird.
|
||||
"body": {"contentType": "Text", "content": body},
|
||||
"toRecipients": [{"emailAddress": {"address": to}}],
|
||||
},
|
||||
"saveToSentItems": True,
|
||||
}
|
||||
reply_to = str(config.get("graph_reply_to") or "")
|
||||
if reply_to:
|
||||
message["message"]["replyTo"] = [{"emailAddress": {"address": reply_to}}]
|
||||
if attachment is not None:
|
||||
message["message"]["attachments"] = [
|
||||
{
|
||||
"@odata.type": "#microsoft.graph.fileAttachment",
|
||||
"name": attachment.name,
|
||||
"contentType": attachment.content_type,
|
||||
"contentBytes": base64.b64encode(attachment.content).decode("ascii"),
|
||||
}
|
||||
]
|
||||
|
||||
token = fetch_token(conn)
|
||||
_post(
|
||||
SEND_URL.format(sender=urllib.parse.quote(str(config["graph_sender"]))),
|
||||
json.dumps(message).encode("utf-8"),
|
||||
{"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
|
||||
timeout=30,
|
||||
)
|
||||
logger.info("E-Mail an %s versendet: %s", to, subject)
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Signaturschluessel des Lizenzservers.
|
||||
|
||||
Ein Ed25519-Schluesselpaar pro Installation. Der private Teil verlaesst diese
|
||||
Maschine nie und wird fuer genau zwei Dinge benutzt:
|
||||
|
||||
1. Lizenzdateien signieren,
|
||||
2. Antworten auf Aktivierungs-/Deaktivierungs-/Heartbeat-Anfragen signieren.
|
||||
|
||||
Der oeffentliche Teil reist in jeder ausgestellten Lizenz mit -- damit kann der
|
||||
Client Antworten vollstaendig offline pruefen, ohne je einen Schluessel abholen
|
||||
zu muessen.
|
||||
|
||||
Die Datei liegt mit Modus 0600 und ist aus dem Deployment-Abgleich
|
||||
ausgeschlossen: ein Update darf sie nie ueberschreiben. Ginge sie verloren,
|
||||
waere **jede** ausgestellte Lizenz nicht mehr verifizierbar.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import tesm_licensing as lic
|
||||
import tesm_licensing_server as licsrv
|
||||
from tesm_core.keystore import check_private_file, write_private_file
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MasterKeyError(Exception):
|
||||
"""Schluessel fehlt oder ist unlesbar."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class MasterKey:
|
||||
private_key: str
|
||||
public_key: str
|
||||
created_at: str
|
||||
|
||||
@property
|
||||
def key_id(self) -> str:
|
||||
return lic.key_id(self.public_key)
|
||||
|
||||
|
||||
def load(path: Path) -> MasterKey:
|
||||
if not path.is_file():
|
||||
raise MasterKeyError(
|
||||
f"Der Signaturschluessel {path} fehlt. Ohne ihn kann keine Lizenz ausgestellt "
|
||||
"werden. Erzeugen Sie ihn mit 'tesm-license-admin init-key'."
|
||||
)
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError) as exc:
|
||||
raise MasterKeyError(f"Der Signaturschluessel ist unlesbar: {exc}") from exc
|
||||
|
||||
private = str(data.get("private_key") or "")
|
||||
public = str(data.get("public_key") or "")
|
||||
if not private or not public:
|
||||
raise MasterKeyError("Der Signaturschluessel ist unvollstaendig.")
|
||||
if licsrv.public_from_private(private) != public:
|
||||
raise MasterKeyError(
|
||||
"Der oeffentliche Teil des Signaturschluessels passt nicht zum privaten. "
|
||||
"Die Datei ist beschaedigt oder wurde manipuliert."
|
||||
)
|
||||
return MasterKey(
|
||||
private_key=private, public_key=public, created_at=str(data.get("created_at") or "")
|
||||
)
|
||||
|
||||
|
||||
def ensure(path: Path) -> MasterKey:
|
||||
"""Erzeugt den Schluessel beim ersten Aufruf, laedt ihn danach nur noch.
|
||||
|
||||
**Niemals** neu erzeugen, wenn die Datei existiert -- das wuerde alle
|
||||
bestehenden Lizenzen unbrauchbar machen.
|
||||
"""
|
||||
if path.is_file():
|
||||
return load(path)
|
||||
private, public = licsrv.generate_keypair()
|
||||
payload = {
|
||||
"private_key": private,
|
||||
"public_key": public,
|
||||
"key_id": lic.key_id(public),
|
||||
"created_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||
}
|
||||
write_private_file(path, json.dumps(payload, indent=2, sort_keys=True) + "\n")
|
||||
logger.warning(
|
||||
"Neuer Signaturschluessel erzeugt (%s). Diese Datei ist unwiederbringlich -- "
|
||||
"bitte sofort gesichert ablegen: %s",
|
||||
lic.key_id(public),
|
||||
path,
|
||||
)
|
||||
return load(path)
|
||||
|
||||
|
||||
def health(path: Path) -> dict[str, object]:
|
||||
"""Zustand fuer die Diagnoseseite."""
|
||||
if not path.is_file():
|
||||
return {"present": False, "problems": ["Signaturschluessel fehlt"]}
|
||||
problems = check_private_file(path)
|
||||
try:
|
||||
key = load(path)
|
||||
except MasterKeyError as exc:
|
||||
return {"present": True, "problems": [*problems, str(exc)]}
|
||||
return {
|
||||
"present": True,
|
||||
"key_id": key.key_id,
|
||||
"public_key": key.public_key,
|
||||
"created_at": key.created_at,
|
||||
"problems": problems,
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Laufendes Protokoll des Lizenzservers.
|
||||
|
||||
Anders als TESM hat der Lizenzserver keinen Hintergrundprozess, der Telemetrie
|
||||
erzeugt -- es gibt keine Geraete zu pingen. Damit die Ansicht "Laufend"
|
||||
trotzdem Inhalt hat, spiegelt sie hierher denselben Vorgangsstrom, der auch im
|
||||
Aenderungsprotokoll landet: Ausstellung, Aktivierung, Widerruf, Heartbeat,
|
||||
Mailversand.
|
||||
|
||||
Das ist ausdruecklich kein zweites Protokollkonzept, sondern eine
|
||||
menschenlesbare Fortschreibung -- die belastbare Quelle bleibt ``audit_log``
|
||||
mit seiner Hash-Kette.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LiveLog:
|
||||
def __init__(self, path: Path) -> None:
|
||||
self.path = Path(path)
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def write(self, level: str, scope: str, message: str) -> None:
|
||||
line = (
|
||||
f"{datetime.now().strftime('%Y-%m-%d %H:%M:%S')} {level:<7} {scope:<12} {message}\n"
|
||||
)
|
||||
try:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with self._lock, self.path.open("a", encoding="utf-8", newline="\n") as stream:
|
||||
stream.write(line)
|
||||
except OSError as exc: # pragma: no cover
|
||||
logger.warning("Konnte nicht ins laufende Protokoll schreiben: %s", exc)
|
||||
|
||||
def info(self, scope: str, message: str) -> None:
|
||||
self.write("INFO", scope, message)
|
||||
|
||||
def warning(self, scope: str, message: str) -> None:
|
||||
self.write("WARNUNG", scope, message)
|
||||
|
||||
def error(self, scope: str, message: str) -> None:
|
||||
self.write("FEHLER", scope, message)
|
||||
|
||||
def tail(self, lines: int = 400, *, needle: str = "") -> list[str]:
|
||||
if not self.path.is_file():
|
||||
return []
|
||||
try:
|
||||
size = self.path.stat().st_size
|
||||
block = min(size, max(8192, lines * 200))
|
||||
with self.path.open("rb") as stream:
|
||||
stream.seek(max(0, size - block))
|
||||
content = stream.read().decode("utf-8", errors="replace")
|
||||
except OSError:
|
||||
return []
|
||||
result = content.splitlines()
|
||||
if needle:
|
||||
lowered = needle.lower()
|
||||
result = [line for line in result if lowered in line.lower()]
|
||||
return result[-lines:]
|
||||
@@ -0,0 +1,274 @@
|
||||
"""Sub-Lizenzserver-Berichte: eigenen Bestand melden, fremden aufnehmen.
|
||||
|
||||
Ein Lizenzserver, der selbst mit einer ``role="license_server"``-Lizenz laeuft,
|
||||
meldet seinen kompletten Kunden-/Ticket-/Lizenzbestand an seinen Master --
|
||||
huckepack auf dem bestehenden Heartbeat, zusaetzlich sofort nach jeder
|
||||
Aenderung.
|
||||
|
||||
**Vollstaendiger Schnappschuss, kein Delta.** Jeder Bericht ersetzt alle Zeilen
|
||||
seiner Quelle. Das ist bei diesen Datenmengen deutlich einfacher korrekt zu
|
||||
halten: ein beim Sub-Lizenzserver geloeschtes Ticket verschwindet automatisch
|
||||
auch beim Master, ohne dass je ein "geloescht"-Ereignis uebertragen werden muss.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sqlite3
|
||||
import threading
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
import tesm_licensing as lic
|
||||
from tesm_core.db import Database
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MAX_ROWS = lic.MAX_REPORT_ROWS
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Eigenen Bestand melden (nur als Sub-Lizenzserver relevant)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def build_own_report(conn: sqlite3.Connection) -> dict[str, Any]:
|
||||
"""Baut den Bericht ueber den eigenen Bestand.
|
||||
|
||||
Die eigene Bootstrap-Lizenz ist nicht Teil davon -- sie steht in
|
||||
``license.json``, nicht in diesen Tabellen.
|
||||
"""
|
||||
customers = Database.all(
|
||||
conn,
|
||||
"SELECT id, name, contact_email, contact_person, city FROM license_customers "
|
||||
"ORDER BY id LIMIT ?",
|
||||
(MAX_ROWS,),
|
||||
)
|
||||
tickets = Database.all(
|
||||
conn,
|
||||
"SELECT id, customer_id, type, modules, valid_days, lifetime, status, role, created_at "
|
||||
"FROM license_tickets ORDER BY created_at LIMIT ?",
|
||||
(MAX_ROWS,),
|
||||
)
|
||||
licenses = Database.all(
|
||||
conn,
|
||||
"SELECT license_id, ticket_id, type, modules, lifetime, issued_at, expires_at, status, "
|
||||
"fingerprint, hostname, activated_at, last_heartbeat_at FROM licenses "
|
||||
"ORDER BY created_at LIMIT ?",
|
||||
(MAX_ROWS,),
|
||||
)
|
||||
return {
|
||||
"customers": [
|
||||
{
|
||||
"ref": str(row["id"]),
|
||||
"name": str(row["name"]),
|
||||
"contact_email": str(row["contact_email"] or ""),
|
||||
"contact_person": str(row["contact_person"] or ""),
|
||||
"city": str(row["city"] or ""),
|
||||
}
|
||||
for row in customers
|
||||
],
|
||||
"tickets": [
|
||||
{
|
||||
"ref": str(row["id"]),
|
||||
"customer_ref": str(row["customer_id"]),
|
||||
"type": str(row["type"]),
|
||||
"modules": json.loads(str(row["modules"] or "[]")),
|
||||
"valid_days": int(row["valid_days"] or 0),
|
||||
"lifetime": bool(row["lifetime"]),
|
||||
"status": str(row["status"]),
|
||||
"role": str(row["role"]),
|
||||
"created_at": str(row["created_at"] or ""),
|
||||
}
|
||||
for row in tickets
|
||||
],
|
||||
"licenses": [
|
||||
{
|
||||
"ref": str(row["license_id"]),
|
||||
"ticket_ref": str(row["ticket_id"] or ""),
|
||||
"type": str(row["type"]),
|
||||
"modules": json.loads(str(row["modules"] or "[]")),
|
||||
"lifetime": bool(row["lifetime"]),
|
||||
"issued_at": str(row["issued_at"] or ""),
|
||||
"expires_at": str(row["expires_at"] or ""),
|
||||
"status": str(row["status"]),
|
||||
"fingerprint": str(row["fingerprint"] or ""),
|
||||
"hostname": str(row["hostname"] or ""),
|
||||
"activated_at": str(row["activated_at"] or ""),
|
||||
"last_heartbeat_at": str(row["last_heartbeat_at"] or ""),
|
||||
}
|
||||
for row in licenses
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def report_soon(extension: Any) -> None:
|
||||
"""Meldet den eigenen Bestand im Hintergrund an den Master.
|
||||
|
||||
Best effort: schlaegt der Versand fehl, bleibt es beim regulaeren Heartbeat.
|
||||
Der Master ist damit hoechstens sechs Stunden hinterher, nie inkonsistent.
|
||||
"""
|
||||
manager = extension.license
|
||||
if manager is None or manager.state.role != "license_server":
|
||||
return
|
||||
|
||||
def worker() -> None:
|
||||
try:
|
||||
manager.run_heartbeat()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.info("Sofortmeldung an den Master fehlgeschlagen: %s", exc)
|
||||
|
||||
threading.Thread(target=worker, name="report-to-master", daemon=True).start()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Fremden Bestand aufnehmen (Master-Seite)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def ingest_report(
|
||||
conn: sqlite3.Connection, source_license_id: str, report: dict[str, Any]
|
||||
) -> dict[str, int]:
|
||||
"""Uebernimmt einen Bericht. Ersetzt alle bisherigen Zeilen dieser Quelle."""
|
||||
counts = {"customers": 0, "tickets": 0, "licenses": 0}
|
||||
stamp = _now()
|
||||
|
||||
for table in ("reported_customers", "reported_tickets", "reported_licenses"):
|
||||
conn.execute(f"DELETE FROM {table} WHERE source_license_id=?", (source_license_id,))
|
||||
|
||||
for entry in (report.get("customers") or [])[:MAX_ROWS]:
|
||||
conn.execute(
|
||||
"INSERT INTO reported_customers (source_license_id, remote_ref, name, contact_email, "
|
||||
"contact_person, city, last_reported_at) VALUES (?,?,?,?,?,?,?)",
|
||||
(
|
||||
source_license_id,
|
||||
str(entry.get("ref") or ""),
|
||||
str(entry.get("name") or ""),
|
||||
str(entry.get("contact_email") or ""),
|
||||
str(entry.get("contact_person") or ""),
|
||||
str(entry.get("city") or ""),
|
||||
stamp,
|
||||
),
|
||||
)
|
||||
counts["customers"] += 1
|
||||
|
||||
for entry in (report.get("tickets") or [])[:MAX_ROWS]:
|
||||
conn.execute(
|
||||
"INSERT INTO reported_tickets (source_license_id, remote_ref, customer_ref, type, "
|
||||
"modules, valid_days, lifetime, status, role, created_at, last_reported_at) "
|
||||
"VALUES (?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(
|
||||
source_license_id,
|
||||
str(entry.get("ref") or ""),
|
||||
str(entry.get("customer_ref") or ""),
|
||||
str(entry.get("type") or ""),
|
||||
json.dumps(entry.get("modules") or []),
|
||||
int(entry.get("valid_days") or 0),
|
||||
int(bool(entry.get("lifetime"))),
|
||||
str(entry.get("status") or "open"),
|
||||
str(entry.get("role") or "customer"),
|
||||
str(entry.get("created_at") or ""),
|
||||
stamp,
|
||||
),
|
||||
)
|
||||
counts["tickets"] += 1
|
||||
|
||||
for entry in (report.get("licenses") or [])[:MAX_ROWS]:
|
||||
conn.execute(
|
||||
"INSERT INTO reported_licenses (source_license_id, remote_ref, ticket_ref, type, "
|
||||
"modules, lifetime, issued_at, expires_at, status, fingerprint, hostname, "
|
||||
"activated_at, last_heartbeat_at, last_reported_at) "
|
||||
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(
|
||||
source_license_id,
|
||||
str(entry.get("ref") or ""),
|
||||
str(entry.get("ticket_ref") or ""),
|
||||
str(entry.get("type") or ""),
|
||||
json.dumps(entry.get("modules") or []),
|
||||
int(bool(entry.get("lifetime"))),
|
||||
str(entry.get("issued_at") or ""),
|
||||
str(entry.get("expires_at") or ""),
|
||||
str(entry.get("status") or ""),
|
||||
str(entry.get("fingerprint") or ""),
|
||||
str(entry.get("hostname") or ""),
|
||||
str(entry.get("activated_at") or ""),
|
||||
str(entry.get("last_heartbeat_at") or ""),
|
||||
stamp,
|
||||
),
|
||||
)
|
||||
counts["licenses"] += 1
|
||||
|
||||
return counts
|
||||
|
||||
|
||||
def reported_for_customer(conn: sqlite3.Connection, customer_id: int) -> dict[str, Any]:
|
||||
"""Gemeldeter Bestand eines Kunden, der selbst Lizenzserver ist."""
|
||||
source = Database.value(
|
||||
conn,
|
||||
"SELECT license_id FROM licenses WHERE customer_id=? AND role='license_server' "
|
||||
"AND status='active' LIMIT 1",
|
||||
(customer_id,),
|
||||
)
|
||||
if source is None:
|
||||
return {"source_license_id": "", "customers": [], "last_reported_at": ""}
|
||||
|
||||
customers = Database.all(
|
||||
conn,
|
||||
"SELECT * FROM reported_customers WHERE source_license_id=? ORDER BY name COLLATE NOCASE",
|
||||
(str(source),),
|
||||
)
|
||||
tickets = Database.all(
|
||||
conn, "SELECT * FROM reported_tickets WHERE source_license_id=?", (str(source),)
|
||||
)
|
||||
licenses = Database.all(
|
||||
conn, "SELECT * FROM reported_licenses WHERE source_license_id=?", (str(source),)
|
||||
)
|
||||
|
||||
from .issuance import display_state
|
||||
|
||||
tickets_by_customer: dict[str, list[dict[str, Any]]] = {}
|
||||
for ticket in tickets:
|
||||
tickets_by_customer.setdefault(str(ticket["customer_ref"]), []).append(dict(ticket))
|
||||
licenses_by_ticket: dict[str, list[dict[str, Any]]] = {}
|
||||
for entry in licenses:
|
||||
licenses_by_ticket.setdefault(str(entry["ticket_ref"]), []).append(
|
||||
{**dict(entry), **display_state(dict(entry))}
|
||||
)
|
||||
|
||||
result = []
|
||||
for customer in customers:
|
||||
own_tickets = tickets_by_customer.get(str(customer["remote_ref"]), [])
|
||||
for ticket in own_tickets:
|
||||
ticket["licenses"] = licenses_by_ticket.get(str(ticket["remote_ref"]), [])
|
||||
result.append({**dict(customer), "tickets": own_tickets})
|
||||
|
||||
return {
|
||||
"source_license_id": str(source),
|
||||
"customers": result,
|
||||
"last_reported_at": max(
|
||||
[str(row["last_reported_at"]) for row in customers] or [""],
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def reporting_sources(conn: sqlite3.Connection) -> list[dict[str, Any]]:
|
||||
"""Alle Sub-Lizenzserver, die schon einmal berichtet haben."""
|
||||
return Database.all(
|
||||
conn,
|
||||
"""
|
||||
SELECT l.license_id, l.hostname, l.last_heartbeat_at, c.name AS customer_name, c.id AS customer_id,
|
||||
(SELECT COUNT(*) FROM reported_customers r WHERE r.source_license_id = l.license_id) AS customers,
|
||||
(SELECT COUNT(*) FROM reported_tickets r WHERE r.source_license_id = l.license_id) AS tickets,
|
||||
(SELECT COUNT(*) FROM reported_licenses r WHERE r.source_license_id = l.license_id) AS licenses,
|
||||
(SELECT MAX(r.last_reported_at) FROM reported_customers r
|
||||
WHERE r.source_license_id = l.license_id) AS last_reported_at
|
||||
FROM licenses l JOIN license_customers c ON c.id = l.customer_id
|
||||
WHERE l.role='license_server'
|
||||
ORDER BY c.name COLLATE NOCASE
|
||||
""",
|
||||
)
|
||||
@@ -0,0 +1,420 @@
|
||||
"""Sicherung des Lizenzservers: Kunden, Tickets, Lizenzen, Konten, Einstellungen.
|
||||
|
||||
Der verschluesselte Umschlag liegt im Kern (``tesm_core.transfer``). Hier steht,
|
||||
was gesichert wird -- und was bewusst nicht.
|
||||
|
||||
Bewusst **nicht** in der Sicherung:
|
||||
|
||||
* Der Signaturschluessel (``master_signing_key.json``). Er ist der Kern des
|
||||
Vertrauens und gehoert in eine getrennte, bewusst verwahrte Sicherung -- nicht
|
||||
in dieselbe Datei wie die Kundendaten.
|
||||
* Verbrauchte Protokoll-Nonces und Portal-Token: reiner Laufzeitzustand.
|
||||
* Der eigene Lizenz- und Aktivierungszustand: er gehoert zur Maschine.
|
||||
|
||||
Die **verschluesselten Client-Schluessel** der ausgestellten Lizenzen sind
|
||||
enthalten -- allerdings weiterhin verschluesselt mit dem Datenschluessel dieser
|
||||
Installation. Eine Sicherung ohne ``data.keys`` gibt sie also nicht her; das ist
|
||||
gewollt und in der Oberflaeche vermerkt.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from typing import Any, Callable, Iterable
|
||||
|
||||
from tesm_core.db import Database
|
||||
from tesm_core.transfer import (
|
||||
MIN_PASSPHRASE_LENGTH,
|
||||
PREVIEW_TTL_MINUTES,
|
||||
Category,
|
||||
TransferError,
|
||||
drop_preview,
|
||||
load_preview,
|
||||
seal as _seal,
|
||||
store_preview,
|
||||
summarize as _summarize,
|
||||
unseal as _unseal,
|
||||
)
|
||||
|
||||
APP_TAG = "tesm-license"
|
||||
|
||||
CATEGORIES: tuple[Category, ...] = (
|
||||
Category("customers", "Kunden", "Stammdaten der Kundeninstanzen"),
|
||||
Category("tickets", "Tickets", "Anspruechte inklusive Self-Service-Schluessel", sensitive=True),
|
||||
Category(
|
||||
"licenses",
|
||||
"Lizenzen",
|
||||
"Ausgestellte Lizenzen mit signiertem Dokument und verschluesseltem Client-Schluessel",
|
||||
sensitive=True,
|
||||
),
|
||||
Category("users", "Benutzer", "Lokale Konten mit Passwort-Hash", admin_only=True),
|
||||
Category("groups", "Gruppen und Rechte", "Rechtematrix", admin_only=True),
|
||||
Category("settings", "Einstellungen", "Anbieterdaten, Portal, Mailversand", admin_only=True),
|
||||
)
|
||||
|
||||
CATEGORY_BY_KEY = {category.key: category for category in CATEGORIES}
|
||||
|
||||
|
||||
def seal(payload: dict[str, Any], passphrase: str) -> str:
|
||||
return _seal(payload, passphrase, app_tag=APP_TAG)
|
||||
|
||||
|
||||
def unseal(raw: bytes | str, passphrase: str) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
return _unseal(raw, passphrase, app_tag=APP_TAG)
|
||||
|
||||
|
||||
def summarize(payload: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
return _summarize(payload, CATEGORIES)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Export
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def build_export(conn: sqlite3.Connection, *, categories: Iterable[str]) -> dict[str, Any]:
|
||||
wanted = {key for key in categories if key in CATEGORY_BY_KEY}
|
||||
payload: dict[str, Any] = {}
|
||||
|
||||
if "customers" in wanted:
|
||||
payload["customers"] = Database.all(
|
||||
conn,
|
||||
"SELECT id, name, contact_email, contact_phone, contact_person, street, "
|
||||
"house_number, postal_code, city, notes, created_at FROM license_customers",
|
||||
)
|
||||
if "tickets" in wanted:
|
||||
payload["tickets"] = Database.all(
|
||||
conn,
|
||||
"SELECT id, customer_id, type, modules, valid_days, lifetime, status, role, notes, "
|
||||
"created_by, created_at FROM license_tickets",
|
||||
)
|
||||
if "licenses" in wanted:
|
||||
payload["licenses"] = Database.all(
|
||||
conn,
|
||||
"SELECT license_id, ticket_id, customer_id, type, role, modules, lifetime, seq, "
|
||||
"issued_at, expires_at, license_pubkey, license_document, client_key_encrypted, "
|
||||
"status, fingerprint, hostname, activated_at, deactivated_at, last_heartbeat_at, "
|
||||
"revoked_at, revoke_reason, created_by, created_at FROM licenses",
|
||||
)
|
||||
if "users" in wanted:
|
||||
payload["users"] = Database.all(
|
||||
conn,
|
||||
"SELECT u.username, u.password_hash, u.is_admin, u.is_locked, u.first_name, "
|
||||
"u.last_name, u.email, u.must_change_password, "
|
||||
"(SELECT group_concat(g.name, char(10)) FROM user_groups ug "
|
||||
" JOIN groups g ON g.id = ug.group_id WHERE ug.user_id = u.id) AS group_names "
|
||||
"FROM users u WHERE u.deleted_at IS NULL AND u.auth_source='local'",
|
||||
)
|
||||
if "groups" in wanted:
|
||||
payload["groups"] = [
|
||||
{
|
||||
"name": row["name"],
|
||||
"description": row["description"],
|
||||
"is_default": row["is_default"],
|
||||
"is_system": row["is_system"],
|
||||
"preset_key": row["preset_key"],
|
||||
"permissions": [
|
||||
str(entry["permission"])
|
||||
for entry in Database.all(
|
||||
conn,
|
||||
"SELECT permission FROM group_permissions WHERE group_id=? "
|
||||
"ORDER BY permission",
|
||||
(row["id"],),
|
||||
)
|
||||
],
|
||||
}
|
||||
for row in Database.all(conn, "SELECT * FROM groups WHERE deleted_at IS NULL")
|
||||
]
|
||||
if "settings" in wanted:
|
||||
payload["settings"] = {
|
||||
str(row["key"]): str(row["value"])
|
||||
for row in Database.all(conn, "SELECT key, value FROM settings")
|
||||
if not str(row["key"]).startswith("license_")
|
||||
}
|
||||
return payload
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Uebernahme
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def apply_import(
|
||||
conn: sqlite3.Connection,
|
||||
payload: dict[str, Any],
|
||||
*,
|
||||
categories: Iterable[str],
|
||||
set_setting: Callable[[str, Any], None],
|
||||
) -> dict[str, dict[str, int]]:
|
||||
"""Uebernimmt ausgewaehlte Kategorien.
|
||||
|
||||
Abgleich nach natuerlichem Schluessel. Kunden werden ueber ihren Namen
|
||||
zusammengefuehrt; Tickets und Lizenzen ueber ihre ID, weil die zugleich
|
||||
kryptographisch relevant ist (Self-Service-Link bzw. signierte Lizenz-ID).
|
||||
"""
|
||||
wanted = {key for key in categories if key in CATEGORY_BY_KEY}
|
||||
report: dict[str, dict[str, int]] = {}
|
||||
stamp = _stamp()
|
||||
|
||||
def bump(category: str, field: str) -> None:
|
||||
report.setdefault(category, {"created": 0, "updated": 0, "skipped": 0})[field] += 1
|
||||
|
||||
customer_map: dict[str, int] = {}
|
||||
|
||||
if "customers" in wanted:
|
||||
for entry in payload.get("customers", []):
|
||||
name = str(entry.get("name") or "").strip()
|
||||
if not name:
|
||||
continue
|
||||
existing = Database.one(
|
||||
conn,
|
||||
"SELECT id FROM license_customers WHERE name=? COLLATE NOCASE",
|
||||
(name,),
|
||||
)
|
||||
columns = (
|
||||
str(entry.get("contact_email") or ""),
|
||||
str(entry.get("contact_phone") or ""),
|
||||
str(entry.get("contact_person") or ""),
|
||||
str(entry.get("street") or ""),
|
||||
str(entry.get("house_number") or ""),
|
||||
str(entry.get("postal_code") or ""),
|
||||
str(entry.get("city") or ""),
|
||||
str(entry.get("notes") or ""),
|
||||
)
|
||||
if existing is None:
|
||||
cursor = conn.execute(
|
||||
"INSERT INTO license_customers (name, contact_email, contact_phone, "
|
||||
"contact_person, street, house_number, postal_code, city, notes, "
|
||||
"created_at, updated_at, created_by) VALUES (?,?,?,?,?,?,?,?,?,?,?,'import')",
|
||||
(name, *columns, stamp, stamp),
|
||||
)
|
||||
customer_id = int(cursor.lastrowid or 0)
|
||||
bump("customers", "created")
|
||||
else:
|
||||
customer_id = int(existing["id"])
|
||||
conn.execute(
|
||||
"UPDATE license_customers SET contact_email=?, contact_phone=?, "
|
||||
"contact_person=?, street=?, house_number=?, postal_code=?, city=?, notes=?, "
|
||||
"updated_at=? WHERE id=?",
|
||||
(*columns, stamp, customer_id),
|
||||
)
|
||||
bump("customers", "updated")
|
||||
customer_map[str(entry.get("id") or "")] = customer_id
|
||||
|
||||
def resolve_customer(reference: Any) -> int | None:
|
||||
mapped = customer_map.get(str(reference or ""))
|
||||
if mapped:
|
||||
return mapped
|
||||
# Ohne mitgesicherte Kundenliste kann ein Ticket nicht zugeordnet werden.
|
||||
value = Database.value(
|
||||
conn, "SELECT id FROM license_customers WHERE id=?", (reference,)
|
||||
)
|
||||
return int(value) if value is not None else None
|
||||
|
||||
if "tickets" in wanted:
|
||||
for entry in payload.get("tickets", []):
|
||||
ticket_id = str(entry.get("id") or "")
|
||||
customer_id = resolve_customer(entry.get("customer_id"))
|
||||
if not ticket_id or customer_id is None:
|
||||
bump("tickets", "skipped")
|
||||
continue
|
||||
existing = Database.one(
|
||||
conn, "SELECT id FROM license_tickets WHERE id=?", (ticket_id,)
|
||||
)
|
||||
values = (
|
||||
customer_id,
|
||||
str(entry.get("type") or "standard"),
|
||||
json.dumps(entry.get("modules") if isinstance(entry.get("modules"), list)
|
||||
else json.loads(str(entry.get("modules") or "[]"))),
|
||||
int(entry.get("valid_days") or 365),
|
||||
int(bool(entry.get("lifetime"))),
|
||||
str(entry.get("status") or "open"),
|
||||
str(entry.get("role") or "customer"),
|
||||
str(entry.get("notes") or ""),
|
||||
)
|
||||
if existing is None:
|
||||
conn.execute(
|
||||
"INSERT INTO license_tickets (id, customer_id, type, modules, valid_days, "
|
||||
"lifetime, status, role, notes, created_by, created_at, updated_at) "
|
||||
"VALUES (?,?,?,?,?,?,?,?,?,'import',?,?)",
|
||||
(ticket_id, *values, stamp, stamp),
|
||||
)
|
||||
bump("tickets", "created")
|
||||
else:
|
||||
conn.execute(
|
||||
"UPDATE license_tickets SET customer_id=?, type=?, modules=?, valid_days=?, "
|
||||
"lifetime=?, status=?, role=?, notes=?, updated_at=? WHERE id=?",
|
||||
(*values, stamp, ticket_id),
|
||||
)
|
||||
bump("tickets", "updated")
|
||||
|
||||
if "licenses" in wanted:
|
||||
for entry in payload.get("licenses", []):
|
||||
license_id = str(entry.get("license_id") or "")
|
||||
customer_id = resolve_customer(entry.get("customer_id"))
|
||||
if not license_id or customer_id is None:
|
||||
bump("licenses", "skipped")
|
||||
continue
|
||||
if Database.one(
|
||||
conn, "SELECT 1 FROM licenses WHERE license_id=?", (license_id,)
|
||||
):
|
||||
# Eine signierte Lizenz wird nie ueberschrieben: ihr Inhalt ist
|
||||
# kryptographisch festgelegt.
|
||||
bump("licenses", "skipped")
|
||||
continue
|
||||
ticket_id = str(entry.get("ticket_id") or "") or None
|
||||
if ticket_id and not Database.one(
|
||||
conn, "SELECT 1 FROM license_tickets WHERE id=?", (ticket_id,)
|
||||
):
|
||||
ticket_id = None
|
||||
conn.execute(
|
||||
"INSERT INTO licenses (license_id, ticket_id, customer_id, type, role, modules, "
|
||||
"lifetime, seq, issued_at, expires_at, license_pubkey, license_document, "
|
||||
"client_key_encrypted, status, fingerprint, hostname, activated_at, "
|
||||
"deactivated_at, last_heartbeat_at, revoked_at, revoke_reason, created_by, "
|
||||
"created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'import',?)",
|
||||
(
|
||||
license_id,
|
||||
ticket_id,
|
||||
customer_id,
|
||||
str(entry.get("type") or "standard"),
|
||||
str(entry.get("role") or "customer"),
|
||||
json.dumps(entry.get("modules") if isinstance(entry.get("modules"), list)
|
||||
else json.loads(str(entry.get("modules") or "[]"))),
|
||||
int(bool(entry.get("lifetime"))),
|
||||
int(entry.get("seq") or 1),
|
||||
str(entry.get("issued_at") or stamp),
|
||||
str(entry.get("expires_at") or stamp),
|
||||
str(entry.get("license_pubkey") or ""),
|
||||
str(entry.get("license_document") or "{}"),
|
||||
str(entry.get("client_key_encrypted") or ""),
|
||||
str(entry.get("status") or "issued"),
|
||||
entry.get("fingerprint"),
|
||||
entry.get("hostname"),
|
||||
entry.get("activated_at"),
|
||||
entry.get("deactivated_at"),
|
||||
entry.get("last_heartbeat_at"),
|
||||
entry.get("revoked_at"),
|
||||
str(entry.get("revoke_reason") or ""),
|
||||
stamp,
|
||||
),
|
||||
)
|
||||
bump("licenses", "created")
|
||||
|
||||
if "groups" in wanted:
|
||||
for entry in payload.get("groups", []):
|
||||
name = str(entry.get("name") or "").strip()
|
||||
if not name:
|
||||
continue
|
||||
existing = Database.one(
|
||||
conn,
|
||||
"SELECT id FROM groups WHERE name=? COLLATE NOCASE AND deleted_at IS NULL",
|
||||
(name,),
|
||||
)
|
||||
if existing is None:
|
||||
cursor = conn.execute(
|
||||
"INSERT INTO groups (name, description, is_default, is_system, preset_key, "
|
||||
"created_at, updated_at) VALUES (?,?,?,?,?,?,?)",
|
||||
(
|
||||
name,
|
||||
str(entry.get("description") or ""),
|
||||
int(bool(entry.get("is_default"))),
|
||||
int(bool(entry.get("is_system"))),
|
||||
str(entry.get("preset_key") or ""),
|
||||
stamp,
|
||||
stamp,
|
||||
),
|
||||
)
|
||||
group_id = int(cursor.lastrowid or 0)
|
||||
bump("groups", "created")
|
||||
else:
|
||||
group_id = int(existing["id"])
|
||||
bump("groups", "updated")
|
||||
conn.execute("DELETE FROM group_permissions WHERE group_id=?", (group_id,))
|
||||
conn.executemany(
|
||||
"INSERT OR IGNORE INTO group_permissions (group_id, permission) VALUES (?,?)",
|
||||
[(group_id, str(permission)) for permission in entry.get("permissions", [])],
|
||||
)
|
||||
|
||||
if "users" in wanted:
|
||||
for entry in payload.get("users", []):
|
||||
username = str(entry.get("username") or "").strip()
|
||||
if not username:
|
||||
continue
|
||||
existing = Database.one(
|
||||
conn, "SELECT id, deleted_at FROM users WHERE username=? COLLATE NOCASE", (username,)
|
||||
)
|
||||
if existing is not None and existing["deleted_at"]:
|
||||
bump("users", "skipped")
|
||||
continue
|
||||
if existing is None:
|
||||
cursor = conn.execute(
|
||||
"INSERT INTO users (username, password_hash, is_admin, is_locked, "
|
||||
"auth_source, first_name, last_name, email, must_change_password, "
|
||||
"created_at, updated_at) VALUES (?,?,?,?,'local',?,?,?,?,?,?)",
|
||||
(
|
||||
username,
|
||||
str(entry.get("password_hash") or ""),
|
||||
int(bool(entry.get("is_admin"))),
|
||||
int(bool(entry.get("is_locked"))),
|
||||
str(entry.get("first_name") or ""),
|
||||
str(entry.get("last_name") or ""),
|
||||
str(entry.get("email") or ""),
|
||||
int(bool(entry.get("must_change_password"))),
|
||||
stamp,
|
||||
stamp,
|
||||
),
|
||||
)
|
||||
user_id = int(cursor.lastrowid or 0)
|
||||
bump("users", "created")
|
||||
else:
|
||||
user_id = int(existing["id"])
|
||||
bump("users", "updated")
|
||||
names = [n for n in str(entry.get("group_names") or "").split("\n") if n.strip()]
|
||||
if names:
|
||||
conn.execute("DELETE FROM user_groups WHERE user_id=?", (user_id,))
|
||||
for name in names:
|
||||
group_id = Database.value(
|
||||
conn,
|
||||
"SELECT id FROM groups WHERE name=? COLLATE NOCASE AND deleted_at IS NULL",
|
||||
(name.strip(),),
|
||||
)
|
||||
if group_id is not None:
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?,?)",
|
||||
(user_id, int(group_id)),
|
||||
)
|
||||
|
||||
if "settings" in wanted:
|
||||
for key, value in (payload.get("settings") or {}).items():
|
||||
if str(key).startswith("license_"):
|
||||
continue
|
||||
set_setting(str(key), value)
|
||||
bump("settings", "updated")
|
||||
|
||||
return report
|
||||
|
||||
|
||||
def _stamp() -> str:
|
||||
from datetime import datetime, timezone
|
||||
|
||||
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
__all__ = [
|
||||
"APP_TAG",
|
||||
"CATEGORIES",
|
||||
"CATEGORY_BY_KEY",
|
||||
"MIN_PASSPHRASE_LENGTH",
|
||||
"PREVIEW_TTL_MINUTES",
|
||||
"TransferError",
|
||||
"apply_import",
|
||||
"build_export",
|
||||
"drop_preview",
|
||||
"load_preview",
|
||||
"seal",
|
||||
"store_preview",
|
||||
"summarize",
|
||||
"unseal",
|
||||
]
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Laufzeiteinstellungen des Lizenzservers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from tesm_core.settings_store import SettingSpec
|
||||
|
||||
from .services.mail import SETTINGS as MAIL_SETTINGS
|
||||
|
||||
VENDOR_SETTINGS: tuple[SettingSpec, ...] = (
|
||||
SettingSpec("vendor_name", "", "str", "Anbietername", section="vendor"),
|
||||
SettingSpec("vendor_email", "", "str", "Kontakt-E-Mail", section="vendor"),
|
||||
SettingSpec("vendor_phone", "", "str", "Telefon", section="vendor"),
|
||||
SettingSpec("vendor_address", "", "str", "Adresse", section="vendor"),
|
||||
SettingSpec(
|
||||
"vendor_logo_base64",
|
||||
"",
|
||||
"str",
|
||||
"Logo (Data-URI)",
|
||||
hint="Reist in jeder ausgestellten Lizenz mit und wird beim Kunden angezeigt. "
|
||||
"Maximal 200 KB.",
|
||||
section="vendor",
|
||||
export=False,
|
||||
),
|
||||
SettingSpec(
|
||||
"master_endpoint",
|
||||
"",
|
||||
"str",
|
||||
"Oeffentlicher Endpunkt dieses Lizenzservers",
|
||||
hint="Basis-URL, unter der Kundeninstanzen diesen Server erreichen -- z. B. "
|
||||
"https://lizenz.firma.de. Wird in jede Lizenz eingebettet und fuer alle "
|
||||
"kundengerichteten Links verwendet.",
|
||||
section="vendor",
|
||||
),
|
||||
)
|
||||
|
||||
PORTAL_SETTINGS: tuple[SettingSpec, ...] = (
|
||||
SettingSpec(
|
||||
"portal_link_minutes",
|
||||
30,
|
||||
"int",
|
||||
"Gueltigkeit des Zugangslinks (Minuten)",
|
||||
hint="Der Link ist zusaetzlich nur einmal verwendbar.",
|
||||
section="portal",
|
||||
minimum=5,
|
||||
maximum=1440,
|
||||
),
|
||||
SettingSpec(
|
||||
"portal_session_minutes",
|
||||
60,
|
||||
"int",
|
||||
"Gueltigkeit der Portalsitzung (Minuten)",
|
||||
hint="Zweites, unabhaengiges Zeitfenster: eines fuer das Anklicken des Links, "
|
||||
"eines fuer die daraus entstehende Sitzung.",
|
||||
section="portal",
|
||||
minimum=5,
|
||||
maximum=1440,
|
||||
),
|
||||
SettingSpec(
|
||||
"portal_enabled", True, "bool", "Kundenportal aktiv", section="portal"
|
||||
),
|
||||
SettingSpec(
|
||||
"self_service_enabled",
|
||||
True,
|
||||
"bool",
|
||||
"Self-Service-Link je Ticket aktiv",
|
||||
hint="Erlaubt dem Kunden, seine Lizenz selbst herunterzuladen, zu widerrufen und "
|
||||
"neu auszustellen -- ohne Anmeldung, allein ueber den unratbaren Ticket-Link.",
|
||||
section="portal",
|
||||
),
|
||||
)
|
||||
|
||||
DEFAULT_TICKET_SETTINGS: tuple[SettingSpec, ...] = (
|
||||
SettingSpec(
|
||||
"default_valid_days",
|
||||
365,
|
||||
"int",
|
||||
"Vorgeschlagene Laufzeit neuer Tickets (Tage)",
|
||||
section="licensing",
|
||||
minimum=1,
|
||||
maximum=36500,
|
||||
),
|
||||
SettingSpec(
|
||||
"allow_customer_revoke",
|
||||
True,
|
||||
"bool",
|
||||
"Kunden duerfen ihre Lizenz selbst widerrufen",
|
||||
hint="Notwendig fuer einen Systemwechsel ohne Support-Anruf.",
|
||||
section="licensing",
|
||||
),
|
||||
)
|
||||
|
||||
LICENSE_SERVER_SETTINGS: tuple[SettingSpec, ...] = (
|
||||
*VENDOR_SETTINGS,
|
||||
*PORTAL_SETTINGS,
|
||||
*DEFAULT_TICKET_SETTINGS,
|
||||
*MAIL_SETTINGS,
|
||||
)
|
||||
@@ -0,0 +1,132 @@
|
||||
{% extends "tesm_core/base.html" %}
|
||||
{% from "tesm_core/macros.html" import field, alert, pill %}
|
||||
|
||||
{% block page_title %}Sicherung und Übertragung{% endblock %}
|
||||
{% block page_sub %}Verschlüsselter Export · zweistufiger Import{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{{ alert('Der Export wird mit Argon2id aus Ihrer Passphrase verschlüsselt (AES-256-GCM). Ohne
|
||||
diese Passphrase ist die Datei nicht wiederherstellbar -- es gibt keine Hintertür.', 'warning',
|
||||
'Passphrase') }}
|
||||
|
||||
{{ alert('Der Signaturschlüssel (' ~ signing_key_path ~ ') ist bewusst
|
||||
nicht Teil dieser Sicherung. Er ist der Kern des Vertrauens und gehört in eine
|
||||
getrennte, bewusst verwahrte Sicherung -- nicht in dieselbe Datei wie die Kundendaten. Ohne ihn
|
||||
ist nach einer Wiederherstellung keine ausgestellte Lizenz mehr prüfbar.', 'danger',
|
||||
'Wichtig: Schlüssel getrennt sichern') }}
|
||||
|
||||
<div class="grid grid--split">
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('download', size=17) }} Sicherung erstellen</div>
|
||||
<div class="card__sub">Verlangt eine frische Passwortbestätigung</div>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" action="{{ url_for('backup.export') }}" data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<div class="card__body stack">
|
||||
<fieldset class="fieldset">
|
||||
<legend>Kategorien</legend>
|
||||
<div class="stack stack--tight">
|
||||
{% for category in all_categories %}
|
||||
{% set allowed = category in categories %}
|
||||
<label class="check {{ 'check--disabled' if not allowed }}">
|
||||
<input type="checkbox" name="categories" value="{{ category.key }}"
|
||||
{% if allowed %}checked{% endif %} {% if not allowed %}disabled{% endif %}>
|
||||
<span>
|
||||
{{ category.label }}
|
||||
{% if category.sensitive %}{{ pill('sensibel', 'danger') }}{% endif %}
|
||||
{% if category.admin_only %}{{ pill('nur Admin', 'accent') }}{% endif %}
|
||||
<div class="fs-xs faint">{{ category.hint }}</div>
|
||||
{% if not allowed %}
|
||||
<div class="fs-xs danger-text">Dafür fehlt Ihnen das Recht.</div>
|
||||
{% endif %}
|
||||
</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</fieldset>
|
||||
<div class="form-grid">
|
||||
{{ field('passphrase', 'Passphrase', type='password', required=True,
|
||||
autocomplete='new-password',
|
||||
hint_text='Mindestens ' ~ min_passphrase ~ ' Zeichen.') }}
|
||||
{{ field('passphrase_repeat', 'Passphrase wiederholen', type='password', required=True,
|
||||
autocomplete='new-password') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card__footer">
|
||||
<button class="btn btn--primary" type="submit">Sicherung herunterladen</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<div class="stack">
|
||||
{% if preview %}
|
||||
<section class="card card--accent">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('upload', size=17) }} Geöffnete Sicherung</div>
|
||||
<div class="card__sub">Gültig für {{ preview_ttl }} Minuten</div>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" action="{{ url_for('backup.import_apply') }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<div class="card__body">
|
||||
<div class="table-wrap">
|
||||
<table class="table table--compact">
|
||||
<thead><tr><th></th><th>Kategorie</th><th class="numeric">Einträge</th></tr></thead>
|
||||
<tbody>
|
||||
{% for entry in preview.summary %}
|
||||
<tr>
|
||||
<td><label class="check">
|
||||
<input type="checkbox" name="categories" value="{{ entry.key }}" checked>
|
||||
<span class="sr-only">{{ entry.label }}</span></label></td>
|
||||
<td>{{ entry.label }}</td>
|
||||
<td class="numeric">{{ entry.count }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{ alert('Bereits vorhandene Lizenzen werden nie überschrieben -- ihr Inhalt ist
|
||||
kryptographisch festgelegt. Kunden werden über ihren Namen zusammengeführt.',
|
||||
'info') }}
|
||||
</div>
|
||||
<div class="card__footer modal__footer--split">
|
||||
<button class="btn btn--sm btn--ghost" type="submit"
|
||||
formaction="{{ url_for('backup.import_discard') }}">Verwerfen</button>
|
||||
<button class="btn btn--primary" type="submit"
|
||||
data-confirm="Ausgewählte Kategorien jetzt übernehmen?"
|
||||
data-confirm-title="Sicherung übernehmen" data-confirm-ok="Übernehmen"
|
||||
data-confirm-style="primary">Übernehmen</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
{% else %}
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('upload', size=17) }} Sicherung einspielen</div>
|
||||
<div class="card__sub">Erst öffnen und prüfen, dann übernehmen</div>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" enctype="multipart/form-data" data-no-guard
|
||||
action="{{ url_for('backup.import_preview') }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<div class="card__body stack">
|
||||
<input class="input" type="file" name="backup_file" accept=".json" required>
|
||||
{{ field('passphrase', 'Passphrase der Datei', type='password', required=True,
|
||||
autocomplete='off') }}
|
||||
</div>
|
||||
<div class="card__footer">
|
||||
<button class="btn btn--primary" type="submit" data-busy-label="Öffne ...">
|
||||
Datei öffnen
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,219 @@
|
||||
{% extends "tesm_core/base.html" %}
|
||||
{% from "tesm_core/macros.html" import field, textarea_field, pill, empty_state, alert %}
|
||||
|
||||
{% block page_title %}{{ customer.name }}{% endblock %}
|
||||
{% block page_sub %}
|
||||
{{ tickets | length }} Ticket(s) · {{ licenses | length }} Lizenz(en)
|
||||
{% endblock %}
|
||||
|
||||
{% block breadcrumb %}
|
||||
<nav class="breadcrumb">
|
||||
<span><a href="{{ url_for('customers.overview') }}">Kunden</a></span><span>{{ customer.name }}</span>
|
||||
</nav>
|
||||
{% endblock %}
|
||||
|
||||
{% block topbar_extra %}
|
||||
{% if current_user.has('tickets.create') %}
|
||||
<a class="btn btn--primary btn--sm" href="{{ url_for('tickets.new', kunde=customer.id) }}">
|
||||
{{ icon('plus', size=15) }} Ticket anlegen
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="grid grid--split">
|
||||
<div class="stack">
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('ticket', size=17) }} Tickets</div>
|
||||
<div class="card__sub">Verwaltung auf der eigenen Ticket-Seite</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if tickets %}
|
||||
<div class="table-wrap">
|
||||
<table class="table table--compact">
|
||||
<thead><tr><th>Ticket</th><th>Typ</th><th>Rolle</th><th>Laufzeit</th><th>Zustand</th></tr></thead>
|
||||
<tbody>
|
||||
{% for ticket in tickets %}
|
||||
<tr>
|
||||
<td class="mono fs-sm">
|
||||
<a href="{{ url_for('tickets.detail', ticket_id=ticket.id) }}">
|
||||
{{ ticket.id[:12] }}…</a>
|
||||
</td>
|
||||
<td class="fs-sm">{{ ticket.type }}</td>
|
||||
<td class="fs-sm">
|
||||
{% if ticket.role != 'customer' %}{{ pill(ticket.role, 'accent') }}
|
||||
{% else %}Kunde{% endif %}
|
||||
</td>
|
||||
<td class="fs-sm">
|
||||
{% if ticket.lifetime %}unbefristet{% else %}{{ ticket.valid_days }} Tage{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if ticket.status == 'blocked' %}{{ pill('gesperrt', 'danger') }}
|
||||
{% else %}{{ pill('offen', 'online') }}{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
{{ empty_state('Keine Tickets', 'Ohne Ticket kann keine Lizenz ausgestellt werden.', 'ticket') }}
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1"><div class="card__title">{{ icon('shield', size=17) }} Lizenzen</div></div>
|
||||
</div>
|
||||
{% if licenses %}
|
||||
<div class="table-wrap">
|
||||
<table class="table table--compact">
|
||||
<thead><tr><th>Lizenz</th><th>Typ</th><th>Zustand</th><th>Läuft ab</th><th>Host</th></tr></thead>
|
||||
<tbody>
|
||||
{% for entry in licenses %}
|
||||
<tr>
|
||||
<td class="mono fs-sm">
|
||||
<a href="{{ url_for('licenses.detail', license_id=entry.license_id) }}">
|
||||
{{ entry.license_id[:12] }}…</a>
|
||||
</td>
|
||||
<td class="fs-sm">{{ entry.type }}</td>
|
||||
<td>
|
||||
{% if entry.status == 'active' %}{{ pill('aktiv', 'online') }}
|
||||
{% elif entry.status == 'issued' %}{{ pill('ausgestellt', 'info') }}
|
||||
{% elif entry.status == 'revoked' %}{{ pill('widerrufen', 'danger') }}
|
||||
{% else %}{{ pill('deaktiviert', '') }}{% endif %}
|
||||
</td>
|
||||
<td class="fs-sm">
|
||||
{% if entry.lifetime %}unbefristet
|
||||
{% else %}
|
||||
{{ entry.expires_at | date }}
|
||||
{% if entry.color == 'red' %}{{ pill('kritisch', 'danger') }}
|
||||
{% elif entry.color == 'orange' %}{{ pill('bald', 'warning') }}{% endif %}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="fs-sm">{{ entry.hostname or '–' | safe }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
{{ empty_state('Keine Lizenzen', '', 'shield') }}
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
{% if reported.customers %}
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('share', size=17) }} Von diesem Lizenzserver verwaltete Kunden</div>
|
||||
<div class="card__sub">
|
||||
Gemeldeter Bestand · Quelle {{ reported.source_license_id[:12] }}…
|
||||
{% if reported.last_reported_at %}
|
||||
· <span data-relative-time="{{ reported.last_reported_at }}">{{ reported.last_reported_at | dt }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card__body stack">
|
||||
{{ alert('Diese Daten verwaltet der Sub-Lizenzserver selbst. Sie sind hier rein
|
||||
informativ und werden bei jedem Bericht vollständig ersetzt.', 'info') }}
|
||||
{% for entry in reported.customers %}
|
||||
<details class="card">
|
||||
<summary class="card__header" style="cursor:pointer">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ entry.name }}</div>
|
||||
<div class="card__sub">
|
||||
{{ entry.city or 'ohne Ort' }} · {{ entry.tickets | length }} Ticket(s)
|
||||
</div>
|
||||
</div>
|
||||
{{ icon('chevron-down', size=15) }}
|
||||
</summary>
|
||||
<div class="card__body">
|
||||
{% for ticket in entry.tickets %}
|
||||
<div class="stack stack--tight mb-3">
|
||||
<div class="row row--tight fs-sm">
|
||||
<span class="mono">{{ ticket.remote_ref[:12] }}…</span>
|
||||
<span>{{ ticket.type }}</span>
|
||||
{% if ticket.status == 'blocked' %}{{ pill('gesperrt', 'danger') }}
|
||||
{% else %}{{ pill('offen', 'online') }}{% endif %}
|
||||
</div>
|
||||
{% for entry_license in ticket.licenses %}
|
||||
<div class="row row--tight fs-xs faint" style="padding-left:var(--sp-4)">
|
||||
<span class="mono">{{ entry_license.remote_ref[:12] }}…</span>
|
||||
<span>{{ entry_license.status }}</span>
|
||||
{% if entry_license.lifetime %}{{ pill('unbefristet', 'online') }}
|
||||
{% elif entry_license.days_left is not none %}
|
||||
{{ pill(entry_license.days_left ~ ' Tage',
|
||||
'danger' if entry_license.color == 'red' else
|
||||
('warning' if entry_license.color == 'orange' else 'online')) }}
|
||||
{% endif %}
|
||||
<span>{{ entry_license.hostname or '' }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</details>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1"><div class="card__title">{{ icon('building', size=17) }} Stammdaten</div></div>
|
||||
</div>
|
||||
<form method="post" action="{{ url_for('customers.update', customer_id=customer.id) }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<div class="card__body stack">
|
||||
{{ field('name', 'Firmenname', value=customer.name, required=True,
|
||||
disabled=not current_user.has('customers.edit')) }}
|
||||
{{ field('contact_email', 'E-Mail', value=customer.contact_email, type='email',
|
||||
disabled=not current_user.has('customers.edit')) }}
|
||||
{{ field('contact_person', 'Ansprechpartner', value=customer.contact_person,
|
||||
disabled=not current_user.has('customers.edit')) }}
|
||||
{{ field('contact_phone', 'Telefon', value=customer.contact_phone,
|
||||
disabled=not current_user.has('customers.edit')) }}
|
||||
<div class="form-grid">
|
||||
{{ field('street', 'Strasse', value=customer.street,
|
||||
disabled=not current_user.has('customers.edit')) }}
|
||||
{{ field('house_number', 'Nr.', value=customer.house_number,
|
||||
disabled=not current_user.has('customers.edit')) }}
|
||||
{{ field('postal_code', 'PLZ', value=customer.postal_code,
|
||||
disabled=not current_user.has('customers.edit')) }}
|
||||
{{ field('city', 'Ort', value=customer.city,
|
||||
disabled=not current_user.has('customers.edit')) }}
|
||||
</div>
|
||||
{{ textarea_field('notes', 'Notizen', value=customer.notes, rows=3,
|
||||
extra_class='field--full') }}
|
||||
<div class="divider"></div>
|
||||
<dl class="kv fs-xs">
|
||||
<dt>Angelegt</dt><dd>{{ customer.created_at | dt }} von {{ customer.created_by or '-' }}</dd>
|
||||
<dt>Geändert</dt><dd>{{ customer.updated_at | dt }}</dd>
|
||||
<dt>Portal-Zugang</dt>
|
||||
<dd><span class="mono break-all">{{ portal_url }}</span>
|
||||
<button class="btn btn--sm btn--ghost" type="button" data-copy="{{ portal_url }}">
|
||||
{{ icon('copy', size=13) }}</button></dd>
|
||||
</dl>
|
||||
</div>
|
||||
{% if current_user.has('customers.edit') %}
|
||||
<div class="card__footer modal__footer--split">
|
||||
{% if current_user.has('customers.delete') %}
|
||||
<button class="btn btn--sm btn--ghost danger-text" type="submit"
|
||||
formaction="{{ url_for('customers.delete', customer_id=customer.id) }}"
|
||||
data-confirm="Kunde {{ customer.name }} löschen? Das ist nur möglich, solange kein Ticket mehr an ihm hängt."
|
||||
data-confirm-title="Kunde löschen" data-confirm-ok="Löschen">
|
||||
Löschen
|
||||
</button>
|
||||
{% else %}<span></span>{% endif %}
|
||||
<button class="btn btn--primary" type="submit">Speichern</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,86 @@
|
||||
{% extends "tesm_core/base.html" %}
|
||||
{% from "tesm_core/macros.html" import field, textarea_field, pill, search_box, modal,
|
||||
empty_state %}
|
||||
|
||||
{% macro customer_form(customer=None) %}
|
||||
<div class="form-grid">
|
||||
{{ field('name', 'Firmenname', value=customer.name if customer else '', required=True) }}
|
||||
{{ field('contact_email', 'E-Mail', value=customer.contact_email if customer else '',
|
||||
type='email', required=not customer,
|
||||
hint_text='Pflicht bei der Neuanlage -- über diese Adresse läuft das Kundenportal.') }}
|
||||
{{ field('contact_person', 'Ansprechpartner', value=customer.contact_person if customer else '') }}
|
||||
{{ field('contact_phone', 'Telefon', value=customer.contact_phone if customer else '') }}
|
||||
{{ field('street', 'Strasse', value=customer.street if customer else '') }}
|
||||
{{ field('house_number', 'Hausnummer', value=customer.house_number if customer else '') }}
|
||||
{{ field('postal_code', 'PLZ', value=customer.postal_code if customer else '') }}
|
||||
{{ field('city', 'Ort', value=customer.city if customer else '') }}
|
||||
{{ textarea_field('notes', 'Notizen', value=customer.notes if customer else '', rows=2,
|
||||
extra_class='field--full') }}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% block page_title %}Kunden{% endblock %}
|
||||
{% block page_sub %}{{ customers | length }} Kunden{% endblock %}
|
||||
|
||||
{% block topbar_extra %}
|
||||
{{ search_box('customers-table', 'Firma, Ort, Ansprechpartner ...') }}
|
||||
{% if current_user.has('customers.create') %}
|
||||
<button class="btn btn--primary btn--sm" type="button" data-dialog-open="customer-new">
|
||||
{{ icon('plus', size=15) }} Kunde anlegen
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="card">
|
||||
<div class="table-wrap">
|
||||
<table class="table" id="customers-table" data-behavior="sortable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="sortable">Firma</th>
|
||||
<th class="sortable">Ansprechpartner</th>
|
||||
<th class="sortable">E-Mail</th>
|
||||
<th class="sortable">Ort</th>
|
||||
<th class="sortable numeric">Tickets</th>
|
||||
<th class="sortable numeric">Offene Lizenzen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for customer in customers %}
|
||||
<tr data-filter-text="{{ customer.name }} {{ customer.city }} {{ customer.contact_person }} {{ customer.contact_email }}">
|
||||
<td>
|
||||
<a class="fw-600" href="{{ url_for('customers.detail', customer_id=customer.id) }}">
|
||||
{{ customer.name }}
|
||||
</a>
|
||||
{% if customer.license_server_id %}{{ pill('Lizenzserver', 'accent') }}{% endif %}
|
||||
</td>
|
||||
<td class="fs-sm">{{ customer.contact_person or '–' | safe }}</td>
|
||||
<td class="fs-sm">{{ customer.contact_email or '–' | safe }}</td>
|
||||
<td class="fs-sm">{{ customer.city or '–' | safe }}</td>
|
||||
<td class="numeric">{{ customer.ticket_count }}</td>
|
||||
<td class="numeric">{{ customer.open_licenses }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% if not customers %}
|
||||
{{ empty_state('Keine Kunden', 'Legen Sie den ersten Kunden an.', 'building') }}
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
{% block dialogs %}
|
||||
{% if current_user.has('customers.create') %}
|
||||
{% call modal('customer-new', 'Kunde anlegen', 'Adresse in Einzelfeldern -- für Rechnungen weiterverwendbar', 'wide') %}
|
||||
<form method="post" action="{{ url_for('customers.create') }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<div class="modal__body">{{ customer_form() }}</div>
|
||||
<div class="modal__footer">
|
||||
<button class="btn" type="button" data-dialog-close>Abbrechen</button>
|
||||
<button class="btn btn--primary" type="submit">Anlegen</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endcall %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,124 @@
|
||||
{% extends "tesm_core/base.html" %}
|
||||
{% from "tesm_core/macros.html" import pill, empty_state, alert, stat %}
|
||||
|
||||
{% block page_title %}Übersicht{% endblock %}
|
||||
{% block page_sub %}
|
||||
{% if summary %}
|
||||
{{ summary.counts.customers }} Kunden · {{ summary.counts.licenses_active }} aktive Lizenzen
|
||||
{% else %}Willkommen{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% if not summary %}
|
||||
{{ empty_state('Keine Lizenzrechte',
|
||||
'Ihrem Konto fehlt das Recht, Lizenzen zu sehen. Wenden Sie sich an die Administration.',
|
||||
'lock') }}
|
||||
{% else %}
|
||||
<div class="grid grid--4">
|
||||
{{ stat('Kunden', summary.counts.customers, href=url_for('customers.overview')) }}
|
||||
{{ stat('Offene Tickets', summary.counts.tickets_open, href=url_for('tickets.overview')) }}
|
||||
{{ stat('Aktive Lizenzen', summary.counts.licenses_active, kind='ok',
|
||||
href=url_for('licenses.overview', status='active')) }}
|
||||
{{ stat('Widerrufen', summary.counts.licenses_revoked, kind='muted',
|
||||
href=url_for('licenses.overview', status='revoked')) }}
|
||||
</div>
|
||||
|
||||
{% if summary.counts.tickets_blocked %}
|
||||
{{ alert(summary.counts.tickets_blocked ~ ' Ticket(s) sind gesperrt -- für diese Kunden
|
||||
lassen sich keine neuen Lizenzen ausstellen.', 'warning') }}
|
||||
{% endif %}
|
||||
|
||||
{% for key, label, kind, hint in [
|
||||
('red', 'Kritisch', 'danger', 'Weniger als 30 Tage Restlaufzeit oder bereits abgelaufen'),
|
||||
('orange', 'Bald fällig', 'warning', '30 bis 90 Tage Restlaufzeit'),
|
||||
('green', 'In Ordnung', 'ok', 'Mehr als 90 Tage oder unbefristet')] %}
|
||||
{% set entries = summary.buckets[key] %}
|
||||
{% if entries %}
|
||||
<details class="card" data-persist="dash-{{ key }}" {% if key != 'green' %}open{% endif %}>
|
||||
<summary class="card__header" style="cursor:pointer">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">
|
||||
{{ pill(entries | length, kind if kind != 'ok' else 'online') }} {{ label }}
|
||||
</div>
|
||||
<div class="card__sub">{{ hint }}</div>
|
||||
</div>
|
||||
{{ icon('chevron-down', size=16) }}
|
||||
</summary>
|
||||
<div class="card__body">
|
||||
<div class="grid grid--tiles">
|
||||
{% for entry in entries %}
|
||||
<a class="card" style="padding:var(--sp-4)" data-filter-item
|
||||
href="{{ url_for('licenses.detail', license_id=entry.license_id) }}">
|
||||
<div class="row row--between" style="align-items:flex-start">
|
||||
<div class="flex-1" style="min-width:0">
|
||||
<div class="fw-600 truncate">{{ entry.customer_name }}</div>
|
||||
<div class="fs-xs faint truncate">
|
||||
{{ entry.hostname or 'kein Host gemeldet' }}
|
||||
</div>
|
||||
</div>
|
||||
{% if entry.lifetime %}{{ pill('unbefristet', 'online') }}
|
||||
{% elif entry.is_expired %}{{ pill('abgelaufen', 'danger') }}
|
||||
{% else %}{{ pill(entry.days_left ~ ' Tage', kind if kind != 'ok' else 'online') }}
|
||||
{% endif %}
|
||||
</div>
|
||||
<dl class="kv fs-xs mt-3">
|
||||
<dt>Typ</dt><dd>{{ entry.type }}{% if entry.role != 'customer' %}
|
||||
· {{ entry.role }}{% endif %}</dd>
|
||||
<dt>Zustand</dt><dd>{{ entry.status }}</dd>
|
||||
{% if entry.last_heartbeat_at %}
|
||||
<dt>Heartbeat</dt>
|
||||
<dd><span data-relative-time="{{ entry.last_heartbeat_at }}">{{ entry.last_heartbeat_at | dt }}</span></dd>
|
||||
{% endif %}
|
||||
</dl>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if not summary.buckets.red and not summary.buckets.orange and not summary.buckets.green %}
|
||||
{{ empty_state('Noch keine Lizenzen',
|
||||
'Legen Sie einen Kunden an, erstellen Sie ein Ticket und stellen Sie daraus eine Lizenz aus.',
|
||||
'shield') }}
|
||||
{% endif %}
|
||||
|
||||
{% if sources %}
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('share', size=17) }} Sub-Lizenzserver</div>
|
||||
<div class="card__sub">Von untergeordneten Lizenzservern gemeldeter Bestand</div>
|
||||
</div>
|
||||
<a class="btn btn--sm" href="{{ url_for('licenses.reports') }}">Alle anzeigen</a>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table class="table table--compact">
|
||||
<thead><tr><th>Kunde</th><th>Host</th><th class="numeric">Kunden</th>
|
||||
<th class="numeric">Tickets</th><th class="numeric">Lizenzen</th><th>Gemeldet</th></tr></thead>
|
||||
<tbody>
|
||||
{% for source in sources %}
|
||||
<tr>
|
||||
<td class="fw-600">
|
||||
<a href="{{ url_for('licenses.report_detail', license_id=source.license_id) }}">
|
||||
{{ source.customer_name }}</a>
|
||||
</td>
|
||||
<td class="fs-sm">{{ source.hostname or '–' | safe }}</td>
|
||||
<td class="numeric">{{ source.customers }}</td>
|
||||
<td class="numeric">{{ source.tickets }}</td>
|
||||
<td class="numeric">{{ source.licenses }}</td>
|
||||
<td class="fs-sm">
|
||||
{% if source.last_reported_at %}
|
||||
<span data-relative-time="{{ source.last_reported_at }}">{{ source.last_reported_at | dt }}</span>
|
||||
{% else %}<span class="faint">noch nie</span>{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,258 @@
|
||||
{% extends "tesm_core/base.html" %}
|
||||
{% from "tesm_core/macros.html" import field, checkbox, pill, alert, empty_state, modal %}
|
||||
|
||||
{% block page_title %}Lizenz {{ license.license_id[:12] }}…{% endblock %}
|
||||
{% block page_sub %}
|
||||
{{ license.customer_name }} · {{ type_labels.get(license.type, license.type) }}
|
||||
{% if license.role != 'customer' %}· {{ role_labels.get(license.role, license.role) }}{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block breadcrumb %}
|
||||
<nav class="breadcrumb">
|
||||
<span><a href="{{ url_for('licenses.overview') }}">Lizenzen</a></span>
|
||||
<span><a href="{{ url_for('customers.detail', customer_id=license.customer_id) }}">{{ license.customer_name }}</a></span>
|
||||
<span>Lizenz</span>
|
||||
</nav>
|
||||
{% endblock %}
|
||||
|
||||
{% block topbar_extra %}
|
||||
{% if current_user.has('licenses.export') %}
|
||||
<a class="btn btn--sm btn--primary" href="{{ url_for('licenses.download', license_id=license.license_id) }}">
|
||||
{{ icon('download', size=15) }} Datei
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% if state.revision_pending %}
|
||||
{{ alert('Es wurde eine neue Revision signiert, die der Kunde noch nicht abgeholt hat.
|
||||
Eine erreichbare Installation holt sie beim nächsten Heartbeat; eine Installation ohne
|
||||
Verbindung braucht dafür den Offline-Weg -- der Kunde findet ihn in seinem Portal.',
|
||||
'warning', 'Revision ausstehend') }}
|
||||
{% endif %}
|
||||
|
||||
{% if license.status == 'revoked' %}
|
||||
{{ alert('Diese Lizenz ist widerrufen. Der Client erfährt das beim nächsten Heartbeat und
|
||||
schaltet seine lizenzpflichtigen Funktionen ab.' ~ ((' Grund: ' ~ license.revoke_reason) if
|
||||
license.revoke_reason else ''), 'danger', 'Widerrufen') }}
|
||||
{% endif %}
|
||||
|
||||
<div class="grid grid--split">
|
||||
<div class="stack">
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">
|
||||
{{ icon('shield', size=17) }} Zustand
|
||||
{% if license.status == 'active' %}{{ pill('aktiv', 'online') }}
|
||||
{% elif license.status == 'issued' %}{{ pill('ausgestellt', 'info') }}
|
||||
{% elif license.status == 'revoked' %}{{ pill('widerrufen', 'danger') }}
|
||||
{% else %}{{ pill('deaktiviert', '') }}{% endif %}
|
||||
{% if state.revision_pending %}
|
||||
{{ pill('Revision ausstehend', 'warning') }}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card__body">
|
||||
<dl class="kv fs-sm">
|
||||
<dt>Lizenz-ID</dt><dd class="mono break-all">{{ license.license_id }}</dd>
|
||||
<dt>Revision</dt><dd>{{ license.seq }}</dd>
|
||||
<dt>Ticket</dt>
|
||||
<dd>
|
||||
{% if license.ticket_id %}
|
||||
<a class="mono" href="{{ url_for('tickets.detail', ticket_id=license.ticket_id) }}">
|
||||
{{ license.ticket_id[:12] }}…</a>
|
||||
{% else %}<span class="faint">kein Ticket (importiert)</span>{% endif %}
|
||||
</dd>
|
||||
<dt>Module</dt>
|
||||
<dd>
|
||||
{% if modules %}
|
||||
{% for module in modules %}{{ pill(module_labels.get(module, module), 'online') }}{% endfor %}
|
||||
{% else %}<span class="faint">keine</span>{% endif %}
|
||||
</dd>
|
||||
<dt>Ausgestellt</dt><dd>{{ license.issued_at | dt }} von {{ license.created_by or '-' }}</dd>
|
||||
<dt>Läuft ab</dt>
|
||||
<dd>
|
||||
{% if license.lifetime %}{{ pill('unbefristet', 'online') }}
|
||||
{% else %}
|
||||
{{ license.expires_at | dt }}
|
||||
{% if state.is_expired %}{{ pill('abgelaufen', 'danger') }}
|
||||
{% elif state.color == 'red' %}{{ pill(state.days_left ~ ' Tage', 'danger') }}
|
||||
{% elif state.color == 'orange' %}{{ pill(state.days_left ~ ' Tage', 'warning') }}
|
||||
{% else %}{{ pill(state.days_left ~ ' Tage', 'online') }}{% endif %}
|
||||
{% endif %}
|
||||
</dd>
|
||||
<dt>Maschine</dt>
|
||||
<dd>
|
||||
{% if license.fingerprint %}
|
||||
<span class="mono fs-xs break-all">{{ license.fingerprint }}</span>
|
||||
{% else %}<span class="faint">noch nicht gebunden</span>{% endif %}
|
||||
</dd>
|
||||
<dt>Host (gemeldet)</dt><dd>{{ license.hostname or '–' | safe }}</dd>
|
||||
<dt>Aktiviert</dt><dd>{{ license.activated_at | dt }}</dd>
|
||||
<dt>Letzter Heartbeat</dt>
|
||||
<dd>
|
||||
{% if license.last_heartbeat_at %}
|
||||
<span data-relative-time="{{ license.last_heartbeat_at }}">{{ license.last_heartbeat_at | dt }}</span>
|
||||
{% else %}<span class="faint">nie</span>{% endif %}
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% if current_user.has('licenses.edit') and license.status != 'revoked' %}
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('refresh', size=17) }} Nachträglich anpassen</div>
|
||||
<div class="card__sub">
|
||||
Signiert eine neue Revision. Der Client erhält sie beim nächsten Heartbeat --
|
||||
ohne erneute Aktivierung.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" action="{{ url_for('licenses.resign', license_id=license.license_id) }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<div class="card__body stack">
|
||||
{% if license.role == 'customer' %}
|
||||
<fieldset class="fieldset">
|
||||
<legend>Module</legend>
|
||||
<div class="stack stack--tight">
|
||||
{% for module in all_modules %}
|
||||
{{ checkbox('modules', module_labels[module], value=module,
|
||||
checked=module in modules) }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</fieldset>
|
||||
{% endif %}
|
||||
{{ field('extend_days', 'Laufzeit verlängern (Tage)', type='number',
|
||||
placeholder='0',
|
||||
hint_text='Wird auf das aktülle Ablaufdatum aufgeschlagen.') }}
|
||||
</div>
|
||||
<div class="card__footer">
|
||||
<button class="btn btn--primary" type="submit">Neue Revision signieren</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('transfer', size=17) }} Zustellungen</div>
|
||||
<div class="card__sub">{{ deliveries | length }} Vorgänge</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if deliveries %}
|
||||
<div class="table-wrap">
|
||||
<table class="table table--compact">
|
||||
<thead><tr><th>Zeit</th><th>Weg</th><th>Empfänger</th><th>Durch</th><th>Detail</th></tr></thead>
|
||||
<tbody>
|
||||
{% for entry in deliveries %}
|
||||
<tr>
|
||||
<td class="fs-sm nowrap">{{ entry.delivered_at | dt }}</td>
|
||||
<td class="fs-sm">{{ entry.channel }}</td>
|
||||
<td class="fs-sm">{{ entry.recipient or '–' | safe }}</td>
|
||||
<td class="fs-sm">{{ entry.delivered_by }}</td>
|
||||
<td class="fs-xs muted">{{ entry.detail }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
{{ empty_state('Noch nicht zugestellt', 'Die Datei wurde bisher nicht herausgegeben.', 'transfer') }}
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<details class="card" data-persist="license-document">
|
||||
<summary class="card__header" style="cursor:pointer">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('file-text', size=17) }} Signiertes Dokument</div>
|
||||
<div class="card__sub">Genau so, wie es signiert wurde -- jede Änderung bricht die Signatur</div>
|
||||
</div>
|
||||
{{ icon('chevron-down', size=16) }}
|
||||
</summary>
|
||||
<pre class="log-view">{{ document_json }}</pre>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<div class="stack">
|
||||
{% if current_user.has('licenses.export') %}
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('mail', size=17) }} Per E-Mail versenden</div>
|
||||
<div class="card__sub">
|
||||
{% if mail_ready %}Über Microsoft Graph{% else %}Nicht konfiguriert{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" action="{{ url_for('licenses.send_mail', license_id=license.license_id) }}"
|
||||
data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<div class="card__body">
|
||||
{{ field('recipient', 'Empfänger', value=license.contact_email, type='email',
|
||||
disabled=not mail_ready) }}
|
||||
</div>
|
||||
<div class="card__footer">
|
||||
<button class="btn btn--primary" type="submit" {% if not mail_ready %}disabled{% endif %}
|
||||
data-busy-label="Sende ...">Versenden</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if current_user.has('licenses.edit') or current_user.has('licenses.delete') %}
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1"><div class="card__title">{{ icon('alert', size=17) }} Lebenszyklus</div></div>
|
||||
</div>
|
||||
<div class="card__body stack stack--tight">
|
||||
{% if current_user.has('licenses.edit') and license.status != 'revoked' %}
|
||||
<button class="btn btn--sm btn--danger btn--block" type="button"
|
||||
data-dialog-open="revoke-dialog">Widerrufen</button>
|
||||
<p class="fs-xs faint">
|
||||
Das Ticket bleibt offen -- eine neue Lizenz kann sofort ausgestellt werden. Genau so
|
||||
läuft ein Systemwechsel.
|
||||
</p>
|
||||
{% endif %}
|
||||
{% if current_user.has('licenses.delete') %}
|
||||
<form method="post" action="{{ url_for('licenses.delete', license_id=license.license_id) }}"
|
||||
data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<button class="btn btn--sm btn--ghost danger-text btn--block" type="submit"
|
||||
{% if license.status not in ('revoked', 'issued') %}disabled
|
||||
title="Eine aktive Lizenz muss erst widerrufen werden"{% endif %}
|
||||
data-confirm="Lizenz endgültig aus der Datenbank entfernen? Die Historie geht verloren."
|
||||
data-confirm-title="Lizenz löschen" data-confirm-ok="Löschen">
|
||||
Endgültig löschen
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block dialogs %}
|
||||
{% if current_user.has('licenses.edit') and license.status != 'revoked' %}
|
||||
{% call modal('revoke-dialog', 'Lizenz widerrufen', license.customer_name, 'narrow') %}
|
||||
<form method="post" action="{{ url_for('licenses.revoke', license_id=license.license_id) }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<div class="modal__body">
|
||||
{{ alert('Der Client erfährt den Widerruf beim nächsten Heartbeat und schaltet
|
||||
lizenzpflichtige Funktionen ab. Das Ticket bleibt offen.', 'warning') }}
|
||||
{{ field('reason', 'Grund (erscheint im Protokoll)', placeholder='Systemwechsel') }}
|
||||
</div>
|
||||
<div class="modal__footer">
|
||||
<button class="btn" type="button" data-dialog-close>Abbrechen</button>
|
||||
<button class="btn btn--danger" type="submit">Widerrufen</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endcall %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,77 @@
|
||||
{% extends "tesm_core/base.html" %}
|
||||
{% from "tesm_core/macros.html" import pill, search_box, empty_state %}
|
||||
|
||||
{% block page_title %}Lizenzen{% endblock %}
|
||||
{% block page_sub %}{{ licenses | length }} Lizenzen{% if active_filter %} · Filter: {{ active_filter }}{% endif %}{% endblock %}
|
||||
|
||||
{% block topbar_extra %}
|
||||
{{ search_box('licenses-table', 'Kunde, Lizenz-ID, Host ...') }}
|
||||
{% if current_user.has('licenses.execute') %}
|
||||
<a class="btn btn--sm" href="{{ url_for('licenses.manual_code') }}">
|
||||
{{ icon('transfer', size=15) }} Offline-Code
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="card">
|
||||
<div class="table-toolbar">
|
||||
<div class="btn-group">
|
||||
<a class="btn btn--sm {{ 'btn--subtle' if not active_filter }}" href="{{ url_for('licenses.overview') }}">alle</a>
|
||||
{% for state in states %}
|
||||
<a class="btn btn--sm {{ 'btn--subtle' if active_filter == state }}"
|
||||
href="{{ url_for('licenses.overview', status=state) }}">{{ state }}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table class="table" id="licenses-table" data-behavior="sortable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="sortable">Kunde</th>
|
||||
<th class="sortable">Lizenz</th>
|
||||
<th class="sortable">Typ</th>
|
||||
<th class="sortable">Zustand</th>
|
||||
<th class="sortable">Restlaufzeit</th>
|
||||
<th class="sortable">Host</th>
|
||||
<th class="sortable">Heartbeat</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for entry in licenses %}
|
||||
<tr data-filter-text="{{ entry.customer_name }} {{ entry.license_id }} {{ entry.hostname }} {{ entry.type }}">
|
||||
<td class="fw-600">
|
||||
<a href="{{ url_for('customers.detail', customer_id=entry.customer_id) }}">{{ entry.customer_name }}</a>
|
||||
{% if entry.role != 'customer' %}{{ pill(role_labels.get(entry.role, entry.role), 'accent') }}{% endif %}
|
||||
</td>
|
||||
<td class="mono fs-sm">
|
||||
<a href="{{ url_for('licenses.detail', license_id=entry.license_id) }}">{{ entry.license_id[:12] }}…</a>
|
||||
</td>
|
||||
<td class="fs-sm">{{ type_labels.get(entry.type, entry.type) }}</td>
|
||||
<td>
|
||||
{% if entry.status == 'active' %}{{ pill('aktiv', 'online') }}
|
||||
{% elif entry.status == 'issued' %}{{ pill('ausgestellt', 'info') }}
|
||||
{% elif entry.status == 'revoked' %}{{ pill('widerrufen', 'danger') }}
|
||||
{% else %}{{ pill('deaktiviert', '') }}{% endif %}
|
||||
</td>
|
||||
<td data-sort-value="{{ entry.days_left if entry.days_left is not none else 99999 }}">
|
||||
{% if entry.lifetime %}{{ pill('unbefristet', 'online') }}
|
||||
{% elif entry.is_expired %}{{ pill('abgelaufen', 'danger') }}
|
||||
{% elif entry.color == 'red' %}{{ pill(entry.days_left ~ ' Tage', 'danger') }}
|
||||
{% elif entry.color == 'orange' %}{{ pill(entry.days_left ~ ' Tage', 'warning') }}
|
||||
{% else %}{{ pill(entry.days_left ~ ' Tage', 'online') }}{% endif %}
|
||||
</td>
|
||||
<td class="fs-sm">{{ entry.hostname or '–' | safe }}</td>
|
||||
<td class="fs-sm" data-sort-value="{{ entry.last_heartbeat_at or '' }}">
|
||||
{% if entry.last_heartbeat_at %}
|
||||
<span data-relative-time="{{ entry.last_heartbeat_at }}">{{ entry.last_heartbeat_at | dt }}</span>
|
||||
{% else %}<span class="faint">nie</span>{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% if not licenses %}{{ empty_state('Keine Lizenzen', 'Zu diesem Filter gibt es nichts.', 'shield') }}{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,68 @@
|
||||
{% extends "tesm_core/base.html" %}
|
||||
{% from "tesm_core/macros.html" import alert %}
|
||||
|
||||
{% block page_title %}Offline-Aktivierung{% endblock %}
|
||||
{% block page_sub %}Code des Kunden verarbeiten und Antwortcode zurückgeben{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{{ alert('Beide Codes sind signierte Protokollobjekte -- inhaltlich identisch mit dem
|
||||
Online-Weg, nur als kopierbarer Text. Die Prüfungen (Signatur, Frische, Nonce, Fingerprint)
|
||||
laufen exakt gleich; es gibt hier keine Abkürzung.', 'info') }}
|
||||
|
||||
<div class="grid grid--split">
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1"><div class="card__title">{{ icon('upload', size=17) }} Code des Kunden</div></div>
|
||||
</div>
|
||||
<form method="post">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<div class="card__body">
|
||||
<label class="field">
|
||||
<span class="field__label">Anfragecode</span>
|
||||
<textarea class="textarea input--mono" name="code" rows="8" required
|
||||
placeholder="Vom Kunden erhaltenen Code einfügen ..."></textarea>
|
||||
<span class="field__hint">Umbrüche und Leerzeichen sind erlaubt.</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="card__footer">
|
||||
<button class="btn btn--primary" type="submit" data-busy-label="Verarbeite ...">
|
||||
Verarbeiten
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<div class="stack">
|
||||
{% if summary %}
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1"><div class="card__title">{{ icon('info', size=17) }} Inhalt der Anfrage</div></div>
|
||||
</div>
|
||||
<div class="card__body">
|
||||
<dl class="kv fs-sm">
|
||||
{% for key, value in summary.items() %}
|
||||
<dt>{{ key }}</dt><dd class="mono break-all">{{ value }}</dd>
|
||||
{% endfor %}
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% if response_code %}
|
||||
<section class="card card--accent">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('download', size=17) }} Antwortcode</div>
|
||||
<div class="card__sub">{{ message }}</div>
|
||||
</div>
|
||||
<button class="btn btn--sm" type="button" data-copy="#response-code">
|
||||
{{ icon('copy', size=14) }} Kopieren
|
||||
</button>
|
||||
</div>
|
||||
<div class="card__body">
|
||||
<pre class="code-block code-block--wrap" id="response-code">{{ response_code }}</pre>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,241 @@
|
||||
{% extends "tesm_core/base.html" %}
|
||||
{% from "tesm_core/macros.html" import field, checkbox, alert, pill, empty_state %}
|
||||
|
||||
{% block page_title %}Eigene Lizenz{% endblock %}
|
||||
{% block page_sub %}
|
||||
{% if state and state.valid and state.activated %}
|
||||
aktiv · Rolle {{ role_labels.get(state.role, state.role) }}
|
||||
{% elif state and state.valid %}gültig, nicht aktiviert
|
||||
{% elif state and state.present %}ungültig
|
||||
{% else %}keine Lizenz installiert{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block topbar_extra %}
|
||||
<a class="btn btn--sm" href="{{ url_for('own_license.signing_key_info') }}">
|
||||
{{ icon('key', size=15) }} Signaturschlüssel
|
||||
</a>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% if signing_key_health.problems %}
|
||||
{% for problem in signing_key_health.problems %}{{ alert(problem, 'danger') }}{% endfor %}
|
||||
{% endif %}
|
||||
{% if state and state.error %}{{ alert(state.error, 'danger') }}{% endif %}
|
||||
|
||||
{% if state and state.role == 'master' %}
|
||||
{{ alert('Diese Instanz ist die Wurzelinstanz. Nur sie darf Lizenzen für
|
||||
weitere Lizenzserver ausstellen -- diese Grenze hängt an der signierten Rolle dieser
|
||||
Lizenzdatei, nicht an einem Gruppenrecht. Ein lokaler Administrator eines Sub-Lizenzservers
|
||||
kann sie sich damit nicht selbst einräumen.', 'info', 'Rolle: Master') }}
|
||||
{% elif state and state.role == 'license_server' %}
|
||||
{{ alert('Diese Instanz ist ein Sub-Lizenzserver. Sie darf eigene Kunden,
|
||||
Tickets und Lizenzen verwalten -- aber keine weiteren Lizenzserver-Lizenzen ausstellen. Der
|
||||
eigene Bestand wird automatisch an den übergeordneten Master gemeldet.', 'info',
|
||||
'Rolle: Lizenzserver') }}
|
||||
{% endif %}
|
||||
|
||||
<div class="grid grid--split">
|
||||
<div class="stack">
|
||||
{% if state and state.valid and state.status %}
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">
|
||||
{{ icon('key', size=17) }} Installierte Lizenz
|
||||
{% if state.activated %}{{ pill('aktiviert', 'online') }}
|
||||
{% else %}{{ pill('nicht aktiviert', 'warning') }}{% endif %}
|
||||
{% if state.issuer_pinned %}{{ pill('Aussteller gepinnt', 'info') }}{% endif %}
|
||||
</div>
|
||||
<div class="card__sub">
|
||||
{{ type_labels.get(state.status.type, state.status.type) }} ·
|
||||
{{ role_labels.get(state.role, state.role) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card__body">
|
||||
<dl class="kv fs-sm">
|
||||
<dt>Inhaber</dt><dd>{{ state.customer_name or '–' | safe }}</dd>
|
||||
<dt>Lizenz-ID</dt><dd class="mono fs-xs break-all">{{ state.status.license_id }}</dd>
|
||||
<dt>Revision</dt><dd>{{ state.status.seq }}</dd>
|
||||
<dt>Läuft ab</dt>
|
||||
<dd>
|
||||
{% if state.status.lifetime %}{{ pill('unbefristet', 'online') }}
|
||||
{% else %}
|
||||
{{ state.status.expires_at | dt }}
|
||||
{% if state.status.expired %}
|
||||
{{ pill('abgelaufen', 'danger') }}
|
||||
{% if state.status.grace_active %}
|
||||
<div class="fs-xs warning-text">
|
||||
Kulanzfrist läuft ({{ grace_days }} Tage).
|
||||
</div>
|
||||
{% endif %}
|
||||
{% elif state.status.expiring_soon %}
|
||||
{{ pill('in ' ~ state.status.days_left ~ ' Tagen', 'warning') }}
|
||||
{% else %}
|
||||
{{ pill('in ' ~ state.status.days_left ~ ' Tagen', 'online') }}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</dd>
|
||||
<dt>Übergeordneter Server</dt>
|
||||
<dd class="break-all">
|
||||
{{ state.status.master_endpoint or 'keiner (Wurzelinstanz oder Offline-Betrieb)' }}
|
||||
</dd>
|
||||
<dt>Letzter Heartbeat</dt>
|
||||
<dd>
|
||||
{{ state.last_heartbeat_at | dt }}
|
||||
{% if state.heartbeat_error %}
|
||||
<div class="fs-xs danger-text">{{ state.heartbeat_error }}</div>
|
||||
{% endif %}
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
{% if current_user.has('settings_license.edit') %}
|
||||
<div class="card__footer">
|
||||
{% if not state.activated %}
|
||||
<form method="post" action="{{ url_for('own_license.activate') }}" data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<button class="btn btn--primary" type="submit">Aktivieren</button>
|
||||
</form>
|
||||
{% elif state.status.master_endpoint %}
|
||||
<form method="post" action="{{ url_for('own_license.heartbeat') }}" data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<button class="btn btn--sm" type="submit">
|
||||
{{ icon('refresh', size=14) }} Jetzt melden
|
||||
</button>
|
||||
</form>
|
||||
<form method="post" action="{{ url_for('own_license.deactivate') }}" data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<button class="btn btn--sm btn--ghost danger-text" type="submit"
|
||||
data-confirm="Eigene Lizenz deaktivieren? Lizenzpflichtige Funktionen werden abgeschaltet."
|
||||
data-confirm-title="Deaktivieren" data-confirm-ok="Deaktivieren">
|
||||
Deaktivieren
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% else %}
|
||||
{{ empty_state('Keine gültige Lizenz',
|
||||
'Spielen Sie eine Lizenzdatei ein oder erzeugen Sie mit "tesm-license-admin bootstrap"
|
||||
die eigene Lizenz der Wurzelinstanz.', 'key') }}
|
||||
{% endif %}
|
||||
|
||||
{% if pending %}
|
||||
<section class="card card--accent">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('transfer', size=17) }} Offene Anfrage (Offline-Weg)</div>
|
||||
<div class="card__sub">Aktion: {{ pending.action }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" action="{{ url_for('own_license.offline_response') }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<div class="card__body">
|
||||
<label class="field">
|
||||
<span class="field__label">Antwortcode des übergeordneten Servers</span>
|
||||
<textarea class="textarea input--mono" name="code" rows="4" required></textarea>
|
||||
</label>
|
||||
</div>
|
||||
<div class="card__footer">
|
||||
<button class="btn btn--primary" type="submit">Antwort übernehmen</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if current_user.has('settings_license.edit') %}
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('upload', size=17) }} Lizenzdatei einspielen</div>
|
||||
<div class="card__sub">
|
||||
Für einen Sub-Lizenzserver: die Datei, die der Master ausgestellt hat
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" enctype="multipart/form-data" data-no-guard
|
||||
action="{{ url_for('own_license.upload') }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<div class="card__body stack">
|
||||
<input class="input" type="file" name="license_file" accept=".json,.lic" required>
|
||||
{% if trusted_keys and current_user.is_admin %}
|
||||
{{ checkbox('allow_new_issuer', 'Wechsel des Ausstellers ausdrücklich zulassen') }}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card__footer">
|
||||
<button class="btn btn--primary" type="submit">Einspielen</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="stack">
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('hard-drive', size=17) }} Diese Maschine</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card__body">
|
||||
<dl class="kv fs-sm">
|
||||
<dt>Hostname</dt><dd class="mono">{{ hostname }}</dd>
|
||||
<dt>Fingerprint</dt><dd class="mono fs-xs break-all">{{ fingerprint }}</dd>
|
||||
<dt>Signaturschlüssel</dt>
|
||||
<dd class="mono">{{ signing_key_health.get('key_id') or 'fehlt' }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('shield', size=17) }} Vertrauenswürdige Aussteller</div>
|
||||
<div class="card__sub">{{ trusted_keys | length }} gepinnte Schlüssel</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card__body">
|
||||
{% if trusted_keys %}
|
||||
<dl class="kv fs-sm">
|
||||
{% for key in trusted_keys %}
|
||||
<dt class="mono">{{ key.key_id }}</dt>
|
||||
<dd>{{ key.label }} <span class="faint">({{ key.source | issuer_source }}, {{ key.added_at | date }})</span></dd>
|
||||
{% endfor %}
|
||||
</dl>
|
||||
{% else %}
|
||||
<p class="muted fs-sm">Noch kein Aussteller gepinnt.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('puzzle', size=17) }} Was die Lizenz freischaltet</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card__body">
|
||||
<ul class="stack stack--tight fs-sm">
|
||||
{% for key, label in features.items() %}
|
||||
<li class="row row--tight">
|
||||
{% if feature_allowed(key) %}
|
||||
<span class="success-text">{{ icon('check', size=14) }}</span>
|
||||
{% else %}
|
||||
<span class="faint">{{ icon('lock', size=14) }}</span>
|
||||
{% endif %}
|
||||
<span>{{ label }}</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% if may_issue_license_server %}
|
||||
<div class="divider"></div>
|
||||
<p class="fs-sm success-text">
|
||||
{{ icon('check', size=14) }} Diese Instanz darf Lizenzserver-Lizenzen ausstellen.
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,11 @@
|
||||
{% extends "tesm_core/standalone.html" %}
|
||||
{% block heading %}Kundenportal{% endblock %}
|
||||
{% block content %}
|
||||
<div class="alert alert--info">
|
||||
{{ icon('info', size=18) }}
|
||||
<div class="alert__body">
|
||||
Das Kundenportal ist bei diesem Anbieter derzeit nicht aktiv. Bitte wenden Sie sich an
|
||||
Ihren Ansprechpartner.
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,96 @@
|
||||
{% extends "tesm_core/standalone.html" %}
|
||||
{% from "tesm_core/macros.html" import field, pill %}
|
||||
|
||||
{% block card_class %}auth-card--wide{% endblock %}
|
||||
{% block heading %}{{ customer.name }}{% endblock %}
|
||||
{% block subheading %}Ihre Lizenzen und Stammdaten{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="alert alert--info">
|
||||
{{ icon('clock', size=18) }}
|
||||
<div class="alert__body">
|
||||
Diese Sitzung endet
|
||||
<span data-relative-time="{{ expires_at }}">{{ expires_at | dt }}</span>. Danach fordern Sie
|
||||
einfach einen neuen Zugangslink an.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('shield', size=17) }} Ihre Lizenzen</div>
|
||||
<div class="card__sub">{{ tickets | length }} Vertrag/Verträge</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card__body stack stack--tight">
|
||||
{% for ticket in tickets %}
|
||||
{% set entry = licenses.get(ticket.id) %}
|
||||
<div class="card" style="padding:var(--sp-4)">
|
||||
<div class="row row--between">
|
||||
<div>
|
||||
<div class="fw-600">{{ ticket.type | capitalize }}</div>
|
||||
<div class="fs-xs faint">
|
||||
{% if ticket.lifetime %}unbefristet{% else %}{{ ticket.valid_days }} Tage Laufzeit{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% if ticket.status == 'blocked' %}
|
||||
{{ pill('gesperrt', 'danger') }}
|
||||
{% elif entry %}
|
||||
{% if entry.lifetime %}{{ pill('unbefristet', 'online') }}
|
||||
{% elif entry.is_expired %}{{ pill('abgelaufen', 'danger') }}
|
||||
{% elif entry.color == 'red' %}{{ pill('noch ' ~ entry.days_left ~ ' Tage', 'danger') }}
|
||||
{% elif entry.color == 'orange' %}{{ pill('noch ' ~ entry.days_left ~ ' Tage', 'warning') }}
|
||||
{% else %}{{ pill('noch ' ~ entry.days_left ~ ' Tage', 'online') }}{% endif %}
|
||||
{% else %}
|
||||
{{ pill('keine Lizenz', 'warning') }}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if self_service_enabled and ticket.role == 'customer' %}
|
||||
<a class="btn btn--sm mt-3" href="{{ url_for('selfservice.overview', ticket_id=ticket.id) }}">
|
||||
Lizenz verwalten
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="muted fs-sm">Zu Ihrem Konto ist noch kein Vertrag hinterlegt.</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1"><div class="card__title">{{ icon('building', size=17) }} Ihre Angaben</div></div>
|
||||
</div>
|
||||
<form method="post">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<div class="card__body stack">
|
||||
<div class="form-grid">
|
||||
{{ field('contact_person', 'Ansprechpartner', value=customer.contact_person) }}
|
||||
{{ field('contact_phone', 'Telefon', value=customer.contact_phone) }}
|
||||
{{ field('contact_email', 'E-Mail', value=customer.contact_email, type='email',
|
||||
hint_text='Über diese Adresse läuft der Zugang zu diesem Portal.') }}
|
||||
{{ field('street', 'Strasse', value=customer.street) }}
|
||||
{{ field('house_number', 'Hausnummer', value=customer.house_number) }}
|
||||
{{ field('postal_code', 'PLZ', value=customer.postal_code) }}
|
||||
{{ field('city', 'Ort', value=customer.city) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card__footer">
|
||||
<button class="btn btn--primary" type="submit">Speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<form method="post" action="{{ url_for('portal.logout') }}" data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<button class="btn btn--block" type="submit">Abmelden</button>
|
||||
</form>
|
||||
|
||||
{% if vendor.name %}
|
||||
<p class="auth-footnote">
|
||||
Fragen? {{ vendor.name }}
|
||||
{% if vendor.phone %} · {{ vendor.phone }}{% endif %}
|
||||
{% if vendor.email %} · {{ vendor.email }}{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,28 @@
|
||||
{% extends "tesm_core/standalone.html" %}
|
||||
|
||||
{% block heading %}Kundenportal{% endblock %}
|
||||
{% block subheading %}Zugangslink anfordern{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<p class="fs-sm muted center">
|
||||
Geben Sie die bei uns hinterlegte E-Mail-Adresse ein. Sie erhalten einen Link, der
|
||||
{{ minutes }} Minuten gültig ist und sich einmal verwenden lässt.
|
||||
</p>
|
||||
|
||||
<form method="post" class="stack" data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<label class="field">
|
||||
<span class="field__label">E-Mail-Adresse</span>
|
||||
<input class="input" type="email" name="email" required autofocus autocomplete="email"
|
||||
placeholder="ihre.adresse@firma.de">
|
||||
</label>
|
||||
<button class="btn btn--primary btn--lg btn--block" type="submit" data-busy-label="Sende ...">
|
||||
Zugangslink anfordern
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="auth-footnote">
|
||||
Aus Datenschutzgründen bekommen Sie immer dieselbe Rückmeldung -- unabhängig davon, ob die
|
||||
Adresse bei uns bekannt ist.
|
||||
</p>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,77 @@
|
||||
{% extends "tesm_core/base.html" %}
|
||||
{% from "tesm_core/macros.html" import empty_state, pill, alert %}
|
||||
|
||||
{% block page_title %}{{ license.customer_name }}{% endblock %}
|
||||
{% block page_sub %}Gemeldeter Bestand · Lizenz {{ license.license_id[:12] }}…{% endblock %}
|
||||
|
||||
{% block breadcrumb %}
|
||||
<nav class="breadcrumb">
|
||||
<span><a href="{{ url_for('licenses.reports') }}">Sub-Lizenzserver</a></span>
|
||||
<span>{{ license.customer_name }}</span>
|
||||
</nav>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{{ alert('Diese Daten verwaltet der Sub-Lizenzserver selbst. Sie sind hier rein informativ und
|
||||
werden bei jedem Bericht vollständig ersetzt.', 'info') }}
|
||||
|
||||
{% if reported.customers %}
|
||||
<div class="stack">
|
||||
{% for entry in reported.customers %}
|
||||
<details class="card" open>
|
||||
<summary class="card__header" style="cursor:pointer">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ entry.name }}</div>
|
||||
<div class="card__sub">
|
||||
{{ entry.contact_email or 'ohne E-Mail' }}
|
||||
{% if entry.city %}· {{ entry.city }}{% endif %}
|
||||
· {{ entry.tickets | length }} Ticket(s)
|
||||
</div>
|
||||
</div>
|
||||
{{ icon('chevron-down', size=16) }}
|
||||
</summary>
|
||||
<div class="table-wrap">
|
||||
<table class="table table--compact">
|
||||
<thead><tr><th>Ticket</th><th>Typ</th><th>Zustand</th><th>Lizenzen</th></tr></thead>
|
||||
<tbody>
|
||||
{% for ticket in entry.tickets %}
|
||||
<tr>
|
||||
<td class="mono fs-sm">{{ ticket.remote_ref[:12] }}…</td>
|
||||
<td class="fs-sm">{{ ticket.type }}</td>
|
||||
<td>
|
||||
{% if ticket.status == 'blocked' %}{{ pill('gesperrt', 'danger') }}
|
||||
{% else %}{{ pill('offen', 'online') }}{% endif %}
|
||||
</td>
|
||||
<td class="fs-sm">
|
||||
{% for entry_license in ticket.licenses %}
|
||||
<div class="row row--tight">
|
||||
<span class="mono fs-xs">{{ entry_license.remote_ref[:10] }}…</span>
|
||||
<span>{{ entry_license.status }}</span>
|
||||
{% if entry_license.lifetime %}{{ pill('unbefristet', 'online') }}
|
||||
{% elif entry_license.days_left is not none %}
|
||||
{{ pill(entry_license.days_left ~ ' Tage',
|
||||
'danger' if entry_license.color == 'red' else
|
||||
('warning' if entry_license.color == 'orange' else 'online')) }}
|
||||
{% endif %}
|
||||
{% if entry_license.hostname %}
|
||||
<span class="fs-xs faint">{{ entry_license.hostname }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<span class="faint">keine</span>
|
||||
{% endfor %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
{{ empty_state('Noch kein Bericht',
|
||||
'Dieser Lizenzserver hat noch nichts gemeldet. Der erste Bericht kommt mit dem nächsten
|
||||
Heartbeat.', 'share') }}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,49 @@
|
||||
{% extends "tesm_core/base.html" %}
|
||||
{% from "tesm_core/macros.html" import empty_state, alert, pill %}
|
||||
|
||||
{% block page_title %}Sub-Lizenzserver{% endblock %}
|
||||
{% block page_sub %}{{ sources | length }} untergeordnete Lizenzserver{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{{ alert('Ein Sub-Lizenzserver meldet seinen kompletten Bestand -- huckepack auf dem
|
||||
' ~ heartbeat_hours ~ '-Stunden-Heartbeat und zusätzlich sofort nach jeder Änderung. Jeder
|
||||
Bericht ist ein vollständiger Schnappschuss: Gelöschtes verschwindet automatisch auch hier.',
|
||||
'info') }}
|
||||
|
||||
{% if sources %}
|
||||
<section class="card">
|
||||
<div class="table-wrap">
|
||||
<table class="table" data-behavior="sortable">
|
||||
<thead><tr>
|
||||
<th class="sortable">Kunde</th><th class="sortable">Lizenz</th><th class="sortable">Host</th>
|
||||
<th class="sortable numeric">Kunden</th><th class="sortable numeric">Tickets</th>
|
||||
<th class="sortable numeric">Lizenzen</th><th class="sortable">Gemeldet</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{% for source in sources %}
|
||||
<tr>
|
||||
<td class="fw-600">
|
||||
<a href="{{ url_for('licenses.report_detail', license_id=source.license_id) }}">
|
||||
{{ source.customer_name }}</a>
|
||||
</td>
|
||||
<td class="mono fs-sm">{{ source.license_id[:12] }}…</td>
|
||||
<td class="fs-sm">{{ source.hostname or '–' | safe }}</td>
|
||||
<td class="numeric">{{ source.customers }}</td>
|
||||
<td class="numeric">{{ source.tickets }}</td>
|
||||
<td class="numeric">{{ source.licenses }}</td>
|
||||
<td class="fs-sm" data-sort-value="{{ source.last_reported_at or '' }}">
|
||||
{% if source.last_reported_at %}
|
||||
<span data-relative-time="{{ source.last_reported_at }}">{{ source.last_reported_at | dt }}</span>
|
||||
{% else %}{{ pill('noch nie', 'warning') }}{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{% else %}
|
||||
{{ empty_state('Keine Sub-Lizenzserver',
|
||||
'Es wurde noch keine Lizenz mit der Rolle "Lizenzserver" ausgestellt.', 'share') }}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,135 @@
|
||||
{% extends "tesm_core/standalone.html" %}
|
||||
{% from "tesm_core/macros.html" import pill %}
|
||||
|
||||
{% block card_class %}auth-card--wide{% endblock %}
|
||||
{% block heading %}Ihre TESM-Lizenz{% endblock %}
|
||||
{% block subheading %}{{ ticket.customer_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% if blocked %}
|
||||
<div class="alert alert--danger">
|
||||
{{ icon('alert', size=18) }}
|
||||
<div class="alert__body">
|
||||
Dieser Vertrag ist derzeit gesperrt. Bitte wenden Sie sich an Ihren Ansprechpartner.
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if current %}
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">
|
||||
{{ icon('shield', size=17) }} Aktülle Lizenz
|
||||
{% if current.status == 'active' %}{{ pill('aktiv', 'online') }}
|
||||
{% else %}{{ pill('bereit zum Einspielen', 'info') }}{% endif %}
|
||||
</div>
|
||||
<div class="card__sub">
|
||||
{{ type_labels.get(current.type, current.type) }} ·
|
||||
{% if current.lifetime %}unbefristet
|
||||
{% elif current.is_expired %}abgelaufen
|
||||
{% else %}noch {{ current.days_left }} Tage{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card__body stack">
|
||||
<dl class="kv fs-sm">
|
||||
<dt>Gültig bis</dt>
|
||||
<dd>{% if current.lifetime %}unbefristet{% else %}{{ current.expires_at | date }}{% endif %}</dd>
|
||||
{% if current.hostname %}<dt>Installiert auf</dt><dd>{{ current.hostname }}</dd>{% endif %}
|
||||
<dt>Ausgestellt</dt><dd>{{ current.issued_at | date }}</dd>
|
||||
</dl>
|
||||
|
||||
<a class="btn btn--primary btn--block"
|
||||
href="{{ url_for('selfservice.download', ticket_id=ticket.id, license_id=current.license_id) }}">
|
||||
{{ icon('download', size=16) }} Lizenzdatei herunterladen
|
||||
</a>
|
||||
{% if offline_needed %}
|
||||
<a class="btn btn--block mt-2"
|
||||
href="{{ url_for('selfservice.offline', ticket_id=ticket.id) }}">
|
||||
{{ icon('transfer', size=16) }} Offline aktivieren
|
||||
</a>
|
||||
<p class="fs-xs faint mt-2">
|
||||
Nutzen Sie das, wenn Ihre TESM-Installation uns nicht direkt erreicht.
|
||||
</p>
|
||||
{% endif %}
|
||||
<p class="fs-xs faint">
|
||||
Spielen Sie die Datei in Ihrer TESM-Installation unter
|
||||
<strong>Verwaltung → Lizenz</strong> ein und aktivieren Sie sie dort.
|
||||
</p>
|
||||
|
||||
{% if allow_revoke %}
|
||||
<div class="divider"></div>
|
||||
<form method="post" data-no-guard
|
||||
action="{{ url_for('selfservice.revoke', ticket_id=ticket.id, license_id=current.license_id) }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<button class="btn btn--ghost danger-text btn--block" type="submit"
|
||||
data-confirm="Lizenz freigeben? Ihre bisherige Installation verliert damit die lizenzpflichtigen Funktionen. Danach können Sie sofort eine neue Lizenz für Ihr neues System ausstellen."
|
||||
data-confirm-title="Lizenz freigeben" data-confirm-ok="Freigeben">
|
||||
Lizenz freigeben (Systemwechsel)
|
||||
</button>
|
||||
</form>
|
||||
<p class="fs-xs faint">
|
||||
Nutzen Sie das, wenn Sie TESM auf einem anderen Server neu aufsetzen. Ihr Vertrag
|
||||
bleibt bestehen.
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
{% else %}
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('shield', size=17) }} Noch keine Lizenz</div>
|
||||
<div class="card__sub">Sie können Ihre Lizenz jetzt erstellen</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card__body">
|
||||
<form method="post" action="{{ url_for('selfservice.issue', ticket_id=ticket.id) }}"
|
||||
data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<button class="btn btn--primary btn--block" type="submit" {% if blocked %}disabled{% endif %}
|
||||
data-busy-label="Erstelle ...">
|
||||
Lizenz erstellen
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if history %}
|
||||
<details class="card">
|
||||
<summary class="card__header" style="cursor:pointer">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('history', size=17) }} Früher ausgestellt</div>
|
||||
<div class="card__sub">{{ history | length }} Einträge</div>
|
||||
</div>
|
||||
{{ icon('chevron-down', size=16) }}
|
||||
</summary>
|
||||
<div class="table-wrap">
|
||||
<table class="table table--compact">
|
||||
<thead><tr><th>Ausgestellt</th><th>Zustand</th><th>System</th></tr></thead>
|
||||
<tbody>
|
||||
{% for entry in history %}
|
||||
<tr>
|
||||
<td class="fs-sm">{{ entry.issued_at | date }}</td>
|
||||
<td class="fs-sm">
|
||||
{% if entry.status == 'revoked' %}freigegeben
|
||||
{% elif entry.status == 'deactivated' %}abgelöst
|
||||
{% else %}{{ entry.status }}{% endif %}
|
||||
</td>
|
||||
<td class="fs-sm">{{ entry.hostname or '–' | safe }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
{% endif %}
|
||||
|
||||
<p class="auth-footnote">
|
||||
<a href="{{ portal_url }}">Stammdaten pflegen</a>
|
||||
{% if vendor.name %} · {{ vendor.name }}{% endif %}
|
||||
{% if vendor.phone %} · {{ vendor.phone }}{% endif %}
|
||||
</p>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,51 @@
|
||||
{% extends "tesm_core/standalone.html" %}
|
||||
{% from "tesm_core/macros.html" import alert %}
|
||||
|
||||
{% block card_class %}auth-card--wide{% endblock %}
|
||||
{% block heading %}Offline aktivieren{% endblock %}
|
||||
{% block subheading %}{{ ticket.customer_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="card">
|
||||
<div class="card__body stack">
|
||||
{{ alert('In Ihrer TESM-Installation unter Verwaltung -- Lizenz auf "Lizenz aktivieren"
|
||||
klicken. Erscheint dort ein Anfragecode, fügen Sie ihn hier ein. Sie bekommen einen
|
||||
Antwortcode zurück, den Sie in TESM eintragen. Beide Codes sind signiert und lassen
|
||||
sich unterwegs nicht verändern.', 'info', 'So geht es') }}
|
||||
|
||||
{% if response_code %}
|
||||
<div class="field">
|
||||
<span class="field__label">Antwortcode -- in TESM eintragen</span>
|
||||
<pre class="log-view" id="offline-answer" style="max-height:16dvh">{{ response_code }}</pre>
|
||||
<div class="row row--end mt-2">
|
||||
<button class="btn btn--sm" type="button" data-copy="#offline-answer">
|
||||
Code kopieren
|
||||
</button>
|
||||
</div>
|
||||
<p class="fs-sm muted mt-2">
|
||||
Tragen Sie ihn in TESM unter Verwaltung -- Lizenz bei
|
||||
„Antwortcode des Lizenzservers“ ein.
|
||||
{% if summary.hostname %}
|
||||
Die Anfrage kam von <span class="mono">{{ summary.hostname }}</span>.
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<form method="post">
|
||||
<label class="field">
|
||||
<span class="field__label">Anfragecode aus Ihrer TESM-Installation</span>
|
||||
<textarea class="textarea input--mono" name="code" rows="6" required
|
||||
placeholder="Code einfügen ..."></textarea>
|
||||
</label>
|
||||
<div class="row row--end mt-3">
|
||||
<button class="btn btn--primary" type="submit">Antwortcode erzeugen</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
<p class="fs-xs faint">
|
||||
<a href="{{ url_for('selfservice.overview', ticket_id=ticket.id) }}">Zurück zur Übersicht</a>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,81 @@
|
||||
{% extends "tesm_core/base.html" %}
|
||||
{% from "tesm_core/macros.html" import field, checkbox, alert, pill %}
|
||||
|
||||
{% block page_title %}E-Mail-Versand{% endblock %}
|
||||
{% block page_sub %}
|
||||
Microsoft Graph (App-only) ·
|
||||
{% if ready %}einsatzbereit{% else %}nicht vollständig konfiguriert{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block topbar_extra %}
|
||||
{% if current_user.has('settings_mail.execute') %}
|
||||
<form method="post" action="{{ url_for('settings.mail_test') }}" data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<button class="btn btn--sm" type="submit" data-busy-label="Teste ...">
|
||||
{{ icon('activity', size=15) }} Verbindung testen
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{{ alert('Der Versand läuft über den Client-Credentials-Flow von Microsoft Graph -- kein SMTP,
|
||||
kein zusätzliches Paket. Die Anwendung braucht in Azure die Anwendungsberechtigung
|
||||
Mail.Send. Der Verbindungstest holt nur ein Token und verschickt
|
||||
bewusst keine echte Nachricht.', 'info') }}
|
||||
|
||||
<div class="grid grid--split">
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1"><div class="card__title">{{ icon('mail', size=17) }} Zugang</div></div>
|
||||
</div>
|
||||
<form method="post" action="{{ url_for('settings.mail_save') }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<div class="card__body stack">
|
||||
{{ checkbox('graph_enabled', 'E-Mail-Versand aktiv', checked=values.graph_enabled,
|
||||
disabled=not current_user.has('settings_mail.edit')) }}
|
||||
<div class="form-grid">
|
||||
{{ field('graph_tenant_id', 'Verzeichnis-ID (Tenant)', value=values.graph_tenant_id,
|
||||
disabled=not current_user.has('settings_mail.edit')) }}
|
||||
{{ field('graph_client_id', 'Anwendungs-ID (Client)', value=values.graph_client_id,
|
||||
disabled=not current_user.has('settings_mail.edit')) }}
|
||||
{{ field('graph_sender', 'Absender-Postfach', value=values.graph_sender, type='email',
|
||||
disabled=not current_user.has('settings_mail.edit')) }}
|
||||
{{ field('graph_reply_to', 'Antwortadresse', value=values.graph_reply_to, type='email',
|
||||
disabled=not current_user.has('settings_mail.edit')) }}
|
||||
</div>
|
||||
<fieldset class="fieldset">
|
||||
<legend>Client-Secret {% if has_secret %}{{ pill('gesetzt', 'online') }}{% else %}{{ pill('fehlt', 'warning') }}{% endif %}</legend>
|
||||
{{ field('client_secret', 'Neues Secret (leer = unverändert)', type='password',
|
||||
autocomplete='new-password', disabled=not can_secrets) }}
|
||||
{% if not can_secrets %}
|
||||
<p class="fs-xs faint mt-2">
|
||||
Zum Setzen des Secrets fehlt Ihnen das Recht „Geheimnisse“.
|
||||
</p>
|
||||
{% endif %}
|
||||
</fieldset>
|
||||
</div>
|
||||
{% if current_user.has('settings_mail.edit') %}
|
||||
<div class="card__footer"><button class="btn btn--primary" type="submit">Speichern</button></div>
|
||||
{% endif %}
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1"><div class="card__title">{{ icon('info', size=17) }} Wofür der Versand genutzt wird</div></div>
|
||||
</div>
|
||||
<div class="card__body">
|
||||
<ul class="stack stack--tight fs-sm muted">
|
||||
<li>Lizenzdatei an den Kunden senden (mit Anhang).</li>
|
||||
<li>Zugangslink für das Kundenportal.</li>
|
||||
</ul>
|
||||
<div class="divider"></div>
|
||||
<p class="fs-sm muted">
|
||||
Das Secret liegt AES-256-GCM-verschlüsselt in der Datenbank, gebunden an seinen
|
||||
Verwendungszweck. Ohne die Schlüsseldatei dieser Installation ist es nicht lesbar.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,137 @@
|
||||
{% extends "tesm_core/base.html" %}
|
||||
{% from "tesm_core/macros.html" import field, select_field, checkbox, alert %}
|
||||
|
||||
{% block page_title %}Systemeinstellungen{% endblock %}
|
||||
{% block page_sub %}Portal, Self-Service, Protokolle, Host{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% if not sysops_available %}
|
||||
{{ alert('Systemhelfer nicht verfügbar -- Hostname und Zeitzone können hier nur angezeigt,
|
||||
nicht geändert werden.', 'info') }}
|
||||
{% endif %}
|
||||
|
||||
<div class="grid grid--split">
|
||||
<div class="stack">
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('users', size=17) }} Kundenportal und Self-Service</div>
|
||||
<div class="card__sub">Zwei unabhängige Zeitfenster: Link und Sitzung</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 stack">
|
||||
{{ checkbox('portal_enabled', 'Kundenportal aktiv', checked=values.portal_enabled) }}
|
||||
<div class="form-grid">
|
||||
{{ field('portal_link_minutes', 'Zugangslink gültig (Minuten)',
|
||||
value=values.portal_link_minutes, type='number',
|
||||
hint_text=specs['portal_link_minutes'].hint) }}
|
||||
{{ field('portal_session_minutes', 'Portalsitzung gültig (Minuten)',
|
||||
value=values.portal_session_minutes, type='number',
|
||||
hint_text=specs['portal_session_minutes'].hint) }}
|
||||
</div>
|
||||
<div class="divider"></div>
|
||||
{{ checkbox('self_service_enabled', 'Self-Service-Link je Ticket aktiv',
|
||||
checked=values.self_service_enabled,
|
||||
hint_text=specs['self_service_enabled'].hint) }}
|
||||
{{ checkbox('allow_customer_revoke', 'Kunden dürfen ihre Lizenz selbst freigeben',
|
||||
checked=values.allow_customer_revoke,
|
||||
hint_text=specs['allow_customer_revoke'].hint) }}
|
||||
</div>
|
||||
<div class="card__footer"><button class="btn btn--primary" type="submit">Speichern</button></div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('ticket', size=17) }} Vorgaben und Protokolle</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('default_valid_days', 'Vorgeschlagene Laufzeit neuer Tickets (Tage)',
|
||||
value=values.default_valid_days, type='number') }}
|
||||
{{ select_field('log_rotate_interval', 'Rotationsintervall',
|
||||
specs['log_rotate_interval'].choices,
|
||||
selected=values.log_rotate_interval) }}
|
||||
{{ field('log_rotate_keep', 'Aufbewahrte Rotationen', value=values.log_rotate_keep,
|
||||
type='number') }}
|
||||
{{ field('session_idle_minutes', 'Automatische Abmeldung (Minuten)',
|
||||
value=values.session_idle_minutes, type='number',
|
||||
hint_text=specs['session_idle_minutes'].hint) }}
|
||||
{{ field('trash_retention_days', 'Papierkorb-Aufbewahrung (Tage)',
|
||||
value=values.trash_retention_days, type='number') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card__footer"><button class="btn btn--primary" type="submit">Speichern</button></div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="stack">
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1"><div class="card__title">{{ icon('server', size=17) }} Host</div></div>
|
||||
</div>
|
||||
<div class="card__body stack">
|
||||
<form method="post" action="{{ url_for('settings.set_hostname') }}" class="stack stack--tight">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
{{ field('hostname', 'Hostname', value=hostname, disabled=not sysops_available) }}
|
||||
<button class="btn btn--sm" type="submit" {% if not sysops_available %}disabled{% endif %}>
|
||||
Hostname setzen
|
||||
</button>
|
||||
</form>
|
||||
<div class="divider"></div>
|
||||
<form method="post" action="{{ url_for('settings.set_timezone') }}" class="stack stack--tight">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<label class="field">
|
||||
<span class="field__label">Zeitzone</span>
|
||||
<select class="select" name="timezone" {% if not sysops_available %}disabled{% endif %}>
|
||||
{% for zone in timezones %}
|
||||
<option value="{{ zone }}" {% if zone == timezone %}selected{% endif %}>{{ zone }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<button class="btn btn--sm" type="submit" {% if not sysops_available %}disabled{% endif %}>
|
||||
Zeitzone setzen
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% if current_user.is_admin %}
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('sliders', size=17) }} Navigationsreihenfolge</div>
|
||||
<div class="card__sub">Gilt 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 %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,84 @@
|
||||
{% extends "tesm_core/base.html" %}
|
||||
{% from "tesm_core/macros.html" import field, textarea_field, alert %}
|
||||
|
||||
{% block page_title %}Anbieterdaten{% endblock %}
|
||||
{% block page_sub %}Reisen in jeder ausgestellten Lizenz mit{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{{ alert('Diese Angaben werden in jede Lizenzdatei eingebettet und beim Kunden auf der
|
||||
Lizenzseite angezeigt -- inklusive Logo. Der öffentliche Endpunkt entscheidet zusätzlich,
|
||||
welche Basis-URL in kundengerichteten Links steht.', 'info') }}
|
||||
|
||||
<div class="grid grid--split">
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1"><div class="card__title">{{ icon('building', size=17) }} Kontakt</div></div>
|
||||
</div>
|
||||
<form method="post" action="{{ url_for('settings.vendor_save') }}" enctype="multipart/form-data">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<div class="card__body stack">
|
||||
<div class="form-grid">
|
||||
{{ field('vendor_name', 'Anbietername', value=values.vendor_name,
|
||||
disabled=not current_user.has('settings_vendor.edit')) }}
|
||||
{{ field('vendor_email', 'E-Mail', value=values.vendor_email, type='email',
|
||||
disabled=not current_user.has('settings_vendor.edit')) }}
|
||||
{{ field('vendor_phone', 'Telefon', value=values.vendor_phone,
|
||||
disabled=not current_user.has('settings_vendor.edit')) }}
|
||||
</div>
|
||||
{{ textarea_field('vendor_address', 'Adresse', value=values.vendor_address, rows=3) }}
|
||||
{{ field('master_endpoint', 'Öffentlicher Endpunkt', value=values.master_endpoint,
|
||||
placeholder='https://lizenz.firma.de',
|
||||
disabled=not current_user.has('settings_vendor.edit'),
|
||||
hint_text='Basis-URL, unter der Kundeninstanzen diesen Server erreichen. Wird in
|
||||
jede Lizenz eingebettet und für alle kundengerichteten Links verwendet -- statt
|
||||
des Host-Headers der aktüllen Anfrage, der bei internen Aufrufen falsch wäre.',
|
||||
extra_class='field--full') }}
|
||||
<fieldset class="fieldset">
|
||||
<legend>Logo (maximal {{ max_logo_kb }} KB)</legend>
|
||||
<div class="stack stack--tight">
|
||||
{% if values.vendor_logo_base64 %}
|
||||
<img src="{{ values.vendor_logo_base64 }}" alt="Aktülles Logo"
|
||||
style="max-height:64px;background:var(--surface-2);padding:var(--sp-2);border-radius:var(--r-sm)">
|
||||
{% else %}
|
||||
<p class="faint fs-sm">Kein Logo hinterlegt.</p>
|
||||
{% endif %}
|
||||
<input class="input" type="file" name="vendor_logo" accept="image/png,image/jpeg,image/svg+xml"
|
||||
{% if not current_user.has('settings_vendor.edit') %}disabled{% endif %}>
|
||||
<span class="field__hint">
|
||||
Ein zu grosses Logo wird abgelehnt, die übrigen Angaben werden trotzdem gespeichert.
|
||||
</span>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
{% if current_user.has('settings_vendor.edit') %}
|
||||
<div class="card__footer modal__footer--split">
|
||||
{% if values.vendor_logo_base64 %}
|
||||
<button class="btn btn--sm btn--ghost" type="submit"
|
||||
formaction="{{ url_for('settings.vendor_logo_remove') }}">Logo entfernen</button>
|
||||
{% else %}<span></span>{% endif %}
|
||||
<button class="btn btn--primary" type="submit">Speichern</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1"><div class="card__title">{{ icon('info', size=17) }} Wo das erscheint</div></div>
|
||||
</div>
|
||||
<div class="card__body">
|
||||
<ul class="stack stack--tight fs-sm muted">
|
||||
<li>Auf der Lizenzseite der Kundeninstanz (Name, Kontakt, Logo).</li>
|
||||
<li>In der E-Mail, die eine Lizenz ausliefert.</li>
|
||||
<li>Im Self-Service und im Kundenportal.</li>
|
||||
</ul>
|
||||
<div class="divider"></div>
|
||||
<p class="fs-sm muted">
|
||||
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
|
||||
Revision signiert wird.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,34 @@
|
||||
{% extends "tesm_core/base.html" %}
|
||||
{% from "tesm_core/macros.html" import alert %}
|
||||
{% block page_title %}Signaturschlüssel{% endblock %}
|
||||
{% block page_sub %}Schlüssel-ID {{ key_id }}{% endblock %}
|
||||
{% block breadcrumb %}
|
||||
<nav class="breadcrumb">
|
||||
<span><a href="{{ url_for('own_license.overview') }}">Eigene Lizenz</a></span><span>Signaturschlüssel</span>
|
||||
</nav>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
{{ alert('Der öffentliche Teil ist kein Geheimnis -- er reist in jeder ausgestellten Lizenz mit.
|
||||
Ihn hier anzuzeigen erlaubt es, ihn unabhängig zu vergleichen, bevor ein Kunde ihn als
|
||||
vertrauenswürdig hinterlegt.', 'info') }}
|
||||
{% if health.problems %}
|
||||
{% for problem in health.problems %}{{ alert(problem, 'danger') }}{% endfor %}
|
||||
{% endif %}
|
||||
<section class="card" style="max-width:760px">
|
||||
<div class="card__header">
|
||||
<div class="flex-1"><div class="card__title">{{ icon('key', size=17) }} Öffentlicher Schlüssel</div>
|
||||
<div class="card__sub">Erzeugt {{ created_at | dt }}</div></div>
|
||||
<button class="btn btn--sm" type="button" data-copy="#pubkey">{{ icon('copy', size=14) }} Kopieren</button>
|
||||
</div>
|
||||
<div class="card__body stack">
|
||||
<dl class="kv fs-sm">
|
||||
<dt>Schlüssel-ID</dt><dd class="mono">{{ key_id }}</dd>
|
||||
<dt>Verfahren</dt><dd>Ed25519 (EdDSA)</dd>
|
||||
</dl>
|
||||
<pre class="code-block code-block--wrap" id="pubkey">{{ public_key }}</pre>
|
||||
{{ alert('Der private Teil liegt ausschliesslich auf diesem Host (Modus 0600) und ist aus dem
|
||||
Deployment-Abgleich ausgeschlossen. Ginge er verloren, wäre jede
|
||||
ausgestellte Lizenz nicht mehr prüfbar -- bitte getrennt sichern.', 'warning') }}
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,259 @@
|
||||
{% extends "tesm_core/base.html" %}
|
||||
{% from "tesm_core/macros.html" import field, checkbox, textarea_field, pill, alert, empty_state %}
|
||||
|
||||
{% block page_title %}Ticket {{ ticket.id[:12] }}…{% endblock %}
|
||||
{% block page_sub %}
|
||||
{{ ticket.customer_name }} · {{ type_labels.get(ticket.type, ticket.type) }}
|
||||
{% if ticket.role != 'customer' %}· {{ role_labels.get(ticket.role, ticket.role) }}{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block breadcrumb %}
|
||||
<nav class="breadcrumb">
|
||||
<span><a href="{{ url_for('tickets.overview') }}">Tickets</a></span>
|
||||
<span><a href="{{ url_for('customers.detail', customer_id=ticket.customer_id) }}">{{ ticket.customer_name }}</a></span>
|
||||
<span>Ticket</span>
|
||||
</nav>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% if endpoint_missing %}
|
||||
{{ alert(endpoint_missing, 'danger', 'Keine Ausstellung möglich') }}
|
||||
{% endif %}
|
||||
{% if ticket.status == 'blocked' %}
|
||||
{{ alert('Dieses Ticket ist gesperrt. Es lassen sich keine neuen Lizenzen ausstellen -- weder
|
||||
hier noch über den Self-Service des Kunden. Bereits ausgestellte Lizenzen bleiben gültig.',
|
||||
'danger', 'Gesperrt') }}
|
||||
{% endif %}
|
||||
|
||||
<div class="grid grid--split">
|
||||
<div class="stack">
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('shield', size=17) }} Lizenz ausstellen</div>
|
||||
<div class="card__sub">
|
||||
{% if has_open_license %}
|
||||
Es existiert bereits eine offene Lizenz -- widerrufen Sie sie zuerst.
|
||||
{% else %}
|
||||
Erzeugt eine signierte Lizenzdatei mit den Werten dieses Tickets.
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card__body row row--tight">
|
||||
{% if current_user.has('licenses.create') %}
|
||||
<form method="post" action="{{ url_for('licenses.issue', ticket_id=ticket.id) }}"
|
||||
data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<button class="btn btn--primary" type="submit"
|
||||
{% if has_open_license or ticket.status == 'blocked' %}disabled{% endif %}
|
||||
data-busy-label="Stelle aus ...">
|
||||
{{ icon('plus', size=15) }} Lizenz ausstellen
|
||||
</button>
|
||||
</form>
|
||||
{% if ticket.role == 'customer' %}
|
||||
<form method="post" action="{{ url_for('licenses.issue', ticket_id=ticket.id) }}"
|
||||
data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="as_trial" value="1">
|
||||
<button class="btn" type="submit"
|
||||
{% if has_open_license or ticket.status == 'blocked' %}disabled{% endif %}
|
||||
data-confirm="Testlizenz ausstellen? {{ trial_days }} Tage Laufzeit mit allen Modulen. Das Ticket selbst bleibt unverändert."
|
||||
data-confirm-title="Testlizenz" data-confirm-ok="Ausstellen"
|
||||
data-confirm-style="primary">
|
||||
Als Testlizenz ({{ trial_days }} Tage, alle Module)
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<p class="muted fs-sm">Ihnen fehlt das Recht, Lizenzen auszustellen.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('history', size=17) }} Lizenzen dieses Tickets</div>
|
||||
<div class="card__sub">{{ licenses | length }} insgesamt</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if licenses %}
|
||||
<div class="table-wrap">
|
||||
<table class="table table--compact">
|
||||
<thead><tr><th>Lizenz</th><th>Ausgestellt</th><th>Zustand</th><th>Läuft ab</th><th>Host</th></tr></thead>
|
||||
<tbody>
|
||||
{% for entry in licenses %}
|
||||
<tr>
|
||||
<td class="mono fs-sm">
|
||||
<a href="{{ url_for('licenses.detail', license_id=entry.license_id) }}">
|
||||
{{ entry.license_id[:12] }}…</a>
|
||||
{% if entry.type != ticket.type %}{{ pill(entry.type, 'accent') }}{% endif %}
|
||||
</td>
|
||||
<td class="fs-sm">{{ entry.issued_at | dt }}</td>
|
||||
<td>
|
||||
{% if entry.status == 'active' %}{{ pill('aktiv', 'online') }}
|
||||
{% elif entry.status == 'issued' %}{{ pill('ausgestellt', 'info') }}
|
||||
{% elif entry.status == 'revoked' %}{{ pill('widerrufen', 'danger') }}
|
||||
{% else %}{{ pill('deaktiviert', '') }}{% endif %}
|
||||
</td>
|
||||
<td class="fs-sm">
|
||||
{% if entry.lifetime %}unbefristet{% else %}{{ entry.expires_at | date }}{% endif %}
|
||||
</td>
|
||||
<td class="fs-sm">{{ entry.hostname or '–' | safe }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
{{ empty_state('Noch keine Lizenz', 'Stellen Sie die erste Lizenz aus.', 'shield') }}
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
{% if self_service_url %}
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('link', size=17) }} Self-Service für den Kunden</div>
|
||||
<div class="card__sub">
|
||||
Ohne Anmeldung nutzbar – die Berechtigung ist der Besitz dieses Links
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card__body stack">
|
||||
<div class="input-group">
|
||||
<input class="input input--mono" id="ss-url" value="{{ self_service_url }}" readonly>
|
||||
<button class="btn" type="button" data-copy="#ss-url">{{ icon('copy', size=15) }}</button>
|
||||
</div>
|
||||
<p class="fs-xs faint">
|
||||
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
|
||||
ihn wie ein Passwort.
|
||||
</p>
|
||||
<div class="divider"></div>
|
||||
<div class="input-group">
|
||||
<input class="input input--mono" id="portal-url" value="{{ portal_url }}" readonly>
|
||||
<button class="btn" type="button" data-copy="#portal-url">{{ icon('copy', size=15) }}</button>
|
||||
</div>
|
||||
<p class="fs-xs faint">
|
||||
Kundenportal: der Kunde fordert dort selbst einen zeitlich begrenzten Zugangslink an
|
||||
und pflegt seine Stammdaten.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
{% elif ticket.role != 'customer' %}
|
||||
{{ alert('Für Lizenzserver-Tickets ist der Self-Service vollständig gesperrt -- diese
|
||||
Lizenzen werden ausschliesslich hier verwaltet.', 'info') }}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="stack">
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1"><div class="card__title">{{ icon('sliders', size=17) }} Ticketdaten</div></div>
|
||||
{% if ticket.status == 'blocked' %}{{ pill('gesperrt', 'danger') }}
|
||||
{% else %}{{ pill('offen', 'online') }}{% endif %}
|
||||
</div>
|
||||
<form method="post" action="{{ url_for('tickets.update', ticket_id=ticket.id) }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<div class="card__body stack">
|
||||
{{ alert('Änderungen wirken auf die nächste Ausstellung. Kunde und Rolle sind
|
||||
unveränderlich.', 'info') }}
|
||||
<label class="field">
|
||||
<span class="field__label">Lizenztyp</span>
|
||||
<select class="select" name="type" id="ticket-type"
|
||||
{% if not current_user.has('tickets.edit') %}disabled{% endif %}>
|
||||
{% for value in types %}
|
||||
<option value="{{ value }}" {% if value == ticket.type %}selected{% endif %}>
|
||||
{{ type_labels.get(value, value) }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
{% if ticket.role == 'customer' %}
|
||||
<fieldset class="fieldset" id="module-box">
|
||||
<legend>Module</legend>
|
||||
<div class="stack stack--tight">
|
||||
{% for module in modules %}
|
||||
{{ checkbox('modules', module_labels[module], value=module,
|
||||
checked=module in ticket_modules,
|
||||
disabled=not current_user.has('tickets.edit')) }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</fieldset>
|
||||
{% endif %}
|
||||
{{ field('valid_days', 'Laufzeit (Tage)', value=ticket.valid_days, type='number',
|
||||
disabled=not current_user.has('tickets.edit')) }}
|
||||
{{ checkbox('lifetime', 'Unbefristet', checked=ticket.lifetime,
|
||||
disabled=not current_user.has('tickets.edit')) }}
|
||||
{{ textarea_field('notes', 'Interne Notiz', value=ticket.notes, rows=2) }}
|
||||
<div class="divider"></div>
|
||||
<dl class="kv fs-xs">
|
||||
<dt>Ticket-ID</dt><dd class="mono break-all">{{ ticket.id }}</dd>
|
||||
<dt>Rolle</dt><dd>{{ role_labels.get(ticket.role, ticket.role) }}</dd>
|
||||
<dt>Angelegt</dt><dd>{{ ticket.created_at | dt }} von {{ ticket.created_by or '-' }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
{% if current_user.has('tickets.edit') %}
|
||||
<div class="card__footer">
|
||||
<button class="btn btn--primary" type="submit">Speichern</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{% if current_user.has('tickets.edit') or current_user.has('tickets.delete') %}
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1"><div class="card__title">{{ icon('lock', size=17) }} Sperre und Löschung</div></div>
|
||||
</div>
|
||||
<div class="card__body stack stack--tight">
|
||||
{% if current_user.has('tickets.edit') %}
|
||||
<form method="post" action="{{ url_for('tickets.toggle_block', ticket_id=ticket.id) }}"
|
||||
data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<button class="btn btn--sm btn--block {{ 'btn--danger' if ticket.status != 'blocked' }}"
|
||||
type="submit"
|
||||
data-confirm="{% if ticket.status == 'blocked' %}Ticket entsperren?{% else %}Ticket sperren? Der Kunde kann danach keine neuen Lizenzen mehr ausstellen.{% endif %}"
|
||||
data-confirm-title="Ticketsperre" data-confirm-ok="Bestätigen"
|
||||
data-confirm-style="{{ 'primary' if ticket.status == 'blocked' else 'danger' }}">
|
||||
{{ 'Entsperren' if ticket.status == 'blocked' else 'Sperren' }}
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if current_user.has('tickets.delete') %}
|
||||
<form method="post" action="{{ url_for('tickets.delete', ticket_id=ticket.id) }}"
|
||||
data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<button class="btn btn--sm btn--ghost danger-text btn--block" type="submit"
|
||||
{% if has_open_license %}disabled title="Erst die offene Lizenz widerrufen"{% endif %}
|
||||
data-confirm="Ticket und seine gesamte Lizenzhistorie löschen? Das lässt sich nicht rückgängig machen."
|
||||
data-confirm-title="Ticket löschen" data-confirm-ok="Löschen">
|
||||
Ticket löschen
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
{% if ticket.role == 'customer' %}
|
||||
<script type="module" nonce="{{ csp_nonce }}">
|
||||
const type = document.getElementById("ticket-type");
|
||||
const moduleBox = document.getElementById("module-box");
|
||||
if (type && moduleBox) {
|
||||
const sync = () => {
|
||||
const custom = type.value === "custom";
|
||||
moduleBox.hidden = !custom;
|
||||
};
|
||||
type.addEventListener("change", sync);
|
||||
sync();
|
||||
}
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,139 @@
|
||||
{% extends "tesm_core/base.html" %}
|
||||
{% from "tesm_core/macros.html" import field, checkbox, textarea_field, alert, pill %}
|
||||
|
||||
{% block page_title %}Ticket anlegen{% endblock %}
|
||||
{% block page_sub %}Kunde, Typ, Module und Laufzeit{% endblock %}
|
||||
|
||||
{% block breadcrumb %}
|
||||
<nav class="breadcrumb">
|
||||
<span><a href="{{ url_for('tickets.overview') }}">Tickets</a></span><span>Neu</span>
|
||||
</nav>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<form method="post" action="{{ url_for('tickets.create') }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<div class="grid grid--split">
|
||||
<div class="stack">
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1"><div class="card__title">{{ icon('ticket', size=17) }} Grunddaten</div></div>
|
||||
</div>
|
||||
<div class="card__body stack">
|
||||
<label class="field">
|
||||
<span class="field__label">Kunde <span class="req">*</span></span>
|
||||
<select class="select" name="customer_id" required>
|
||||
<option value="">bitte wählen</option>
|
||||
{% for customer in customers %}
|
||||
<option value="{{ customer.id }}"
|
||||
{% if preselected_customer == customer.id %}selected{% endif %}>
|
||||
{{ customer.name }}{% if customer.city %} · {{ customer.city }}{% endif %}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<span class="field__hint">
|
||||
Der Kunde ist nach dem Anlegen unveränderlich -- der Self-Service-Link ist an ihn
|
||||
gebunden.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span class="field__label">Lizenztyp</span>
|
||||
<select class="select" name="type" id="ticket-type">
|
||||
{% for value in types %}
|
||||
<option value="{{ value }}" {% if value == 'standard' %}selected{% endif %}>
|
||||
{{ type_labels.get(value, value) }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<span class="field__hint">
|
||||
Standard: Grundfunktionen. Individüll: Module frei wählbar. Enterprise: alle
|
||||
Module. Testlizenz: 30 Tage.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<fieldset class="fieldset" id="module-box">
|
||||
<legend>Module (nur bei „Individüll“)</legend>
|
||||
<div class="stack stack--tight">
|
||||
{% for module in modules %}
|
||||
{{ checkbox('modules', module_labels[module], value=module) }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="form-grid">
|
||||
{{ field('valid_days', 'Laufzeit (Tage)', value=default_valid_days, type='number') }}
|
||||
</div>
|
||||
{{ checkbox('lifetime', 'Unbefristet',
|
||||
hint_text='Nicht kombinierbar mit einer Testlizenz -- das wäre eine
|
||||
permanente Lizenz ohne Module.') }}
|
||||
{{ textarea_field('notes', 'Interne Notiz', rows=2) }}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% if may_create_license_server %}
|
||||
<section class="card card--accent">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('share', size=17) }} Lizenzserver-Lizenz</div>
|
||||
<div class="card__sub">
|
||||
Erzeugt eine neue Vertrauensgrenze -- der Empfänger verwaltet danach eigene Kunden
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card__body stack">
|
||||
{{ alert('Ein so lizenzierter Lizenzserver darf alles, was ein Lizenzserver können
|
||||
soll -- ausser selbst weitere Lizenzserver-Lizenzen auszustellen. Er meldet seinen
|
||||
Bestand automatisch hierher zurück. Module sind für diese Rolle bedeutungslos und
|
||||
werden ignoriert. Self-Service und Kundenportal sind für solche Tickets gesperrt.',
|
||||
'warning') }}
|
||||
<label class="check">
|
||||
<input type="checkbox" name="role" value="license_server">
|
||||
<span>Dies ist eine Lizenzserver-Lizenz</span>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1"><div class="card__title">{{ icon('info', size=17) }} Wie es weitergeht</div></div>
|
||||
</div>
|
||||
<div class="card__body">
|
||||
<ol class="stack stack--tight fs-sm muted" style="list-style:decimal;padding-left:1.2em">
|
||||
<li>Ticket anlegen (dieser Schritt).</li>
|
||||
<li>Auf der Ticketseite eine Lizenz ausstellen -- optional als 30-Tage-Testlizenz mit
|
||||
allen Modulen.</li>
|
||||
<li>Lizenzdatei herunterladen oder per E-Mail versenden.</li>
|
||||
<li>Der Kunde spielt sie ein und aktiviert sie; die Aktivierung bindet sie an seine
|
||||
Maschine.</li>
|
||||
</ol>
|
||||
<div class="divider"></div>
|
||||
<p class="fs-sm muted">
|
||||
Der Self-Service-Link des Tickets erlaubt dem Kunden, seine Lizenz selbst
|
||||
herunterzuladen und bei einem Systemwechsel freizugeben -- ohne Support-Anruf.
|
||||
</p>
|
||||
</div>
|
||||
<div class="card__footer">
|
||||
<a class="btn" href="{{ url_for('tickets.overview') }}">Abbrechen</a>
|
||||
<button class="btn btn--primary" type="submit">Ticket anlegen</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script type="module" nonce="{{ csp_nonce }}">
|
||||
const type = document.getElementById("ticket-type");
|
||||
const moduleBox = document.getElementById("module-box");
|
||||
const sync = () => {
|
||||
const custom = type.value === "custom";
|
||||
moduleBox.hidden = !custom;
|
||||
moduleBox.querySelectorAll("input").forEach((box) => (box.disabled = !custom));
|
||||
};
|
||||
type.addEventListener("change", sync);
|
||||
sync();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,91 @@
|
||||
{% extends "tesm_core/base.html" %}
|
||||
{% from "tesm_core/macros.html" import pill, search_box, empty_state, alert %}
|
||||
|
||||
{% block page_title %}Tickets{% endblock %}
|
||||
{% block page_sub %}
|
||||
{{ tickets | length }} Tickets · ein Ticket ist der dauerhafte Anspruch auf eine Lizenz
|
||||
{% endblock %}
|
||||
|
||||
{% block topbar_extra %}
|
||||
{{ search_box('tickets-table', 'Kunde, Typ, ID ...') }}
|
||||
{% if current_user.has('tickets.create') %}
|
||||
<a class="btn btn--primary btn--sm" href="{{ url_for('tickets.new') }}">
|
||||
{{ icon('plus', size=15) }} Ticket anlegen
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% if endpoint_missing %}
|
||||
{{ alert(endpoint_missing, 'danger', 'Keine Ausstellung möglich') }}
|
||||
{% endif %}
|
||||
{{ alert('Ein Ticket beschreibt den Anspruch (Typ, Module, Laufzeit); die Lizenz ist die daraus
|
||||
erzeugte, signierte Datei. Änderungen am Ticket wirken auf die nächste
|
||||
Ausstellung -- bereits ausgestellte Lizenzen bleiben unverändert.', 'info') }}
|
||||
|
||||
<section class="card">
|
||||
<div class="table-wrap">
|
||||
<table class="table" id="tickets-table" data-behavior="sortable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="sortable">Kunde</th>
|
||||
<th class="sortable">Ticket</th>
|
||||
<th class="sortable">Typ</th>
|
||||
<th>Module</th>
|
||||
<th class="sortable">Laufzeit</th>
|
||||
<th class="sortable">Zustand</th>
|
||||
<th>Offene Lizenz</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for ticket in tickets %}
|
||||
<tr data-filter-text="{{ ticket.customer_name }} {{ ticket.type }} {{ ticket.id }} {{ ticket.role }}">
|
||||
<td>
|
||||
<a class="fw-600" href="{{ url_for('customers.detail', customer_id=ticket.customer_id) }}">
|
||||
{{ ticket.customer_name }}
|
||||
</a>
|
||||
{% if ticket.role != 'customer' %}
|
||||
{{ pill(role_labels.get(ticket.role, ticket.role), 'accent') }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="mono fs-sm">
|
||||
<a href="{{ url_for('tickets.detail', ticket_id=ticket.id) }}">{{ ticket.id[:12] }}…</a>
|
||||
</td>
|
||||
<td class="fs-sm">{{ type_labels.get(ticket.type, ticket.type) }}</td>
|
||||
<td class="fs-xs">
|
||||
{% set mods = ticket.modules | from_json %}
|
||||
{% if mods %}{{ mods | map('replace', '_', ' ') | join(', ') }}
|
||||
{% else %}<span class="faint">keine</span>{% endif %}
|
||||
</td>
|
||||
<td class="fs-sm">
|
||||
{% if ticket.lifetime %}{{ pill('unbefristet', 'online') }}
|
||||
{% else %}{{ ticket.valid_days }} Tage{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if ticket.status == 'blocked' %}{{ pill('gesperrt', 'danger') }}
|
||||
{% else %}{{ pill('offen', 'online') }}{% endif %}
|
||||
</td>
|
||||
<td class="fs-sm">
|
||||
{% set entry = open_licenses.get(ticket.id) %}
|
||||
{% if entry %}
|
||||
<a href="{{ url_for('licenses.detail', license_id=entry.license_id) }}">
|
||||
{{ entry.status }}
|
||||
</a>
|
||||
{% if entry.lifetime %}{{ pill('unbefristet', 'online') }}
|
||||
{% elif entry.color == 'red' %}{{ pill(entry.days_left ~ ' Tage', 'danger') }}
|
||||
{% elif entry.color == 'orange' %}{{ pill(entry.days_left ~ ' Tage', 'warning') }}
|
||||
{% else %}{{ pill(entry.days_left ~ ' Tage', 'online') }}{% endif %}
|
||||
{% else %}
|
||||
<span class="faint">keine</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% if not tickets %}
|
||||
{{ empty_state('Keine Tickets', 'Legen Sie das erste Ticket für einen Kunden an.', 'ticket') }}
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,7 @@
|
||||
"""WSGI-Einstiegspunkt: ``gunicorn tesm_license.wsgi:app``."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from . import create_app
|
||||
|
||||
app = create_app()
|
||||
@@ -0,0 +1,614 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# Installation und Aktualisierung von TESM und TESM-Lizenzserver.
|
||||
#
|
||||
# Aufruf (aus dem entpackten Release-Verzeichnis):
|
||||
# sudo bash deploy/install.sh --app tesm
|
||||
# sudo bash deploy/install.sh --app tesm-license
|
||||
# sudo bash deploy/install.sh --app tesm --instance test --port 5100
|
||||
#
|
||||
# Die im Vorgaengerprojekt real aufgetretenen Fallen sind hier von vornherein
|
||||
# vermieden -- jeweils mit Kommentar an der Stelle, wo es zaehlt:
|
||||
#
|
||||
# * Die nginx-Site-Datei wird bei jedem Lauf **inhaltlich verglichen** und bei
|
||||
# Abweichung erneuert. Der Vorgaenger schrieb sie nur, wenn sie fehlte -- ein
|
||||
# einmal falscher alias-Pfad blieb dadurch ueber Jahre stehen und lieferte
|
||||
# alle statischen Dateien als 404.
|
||||
# * pip laeuft immer ueber "python3 -m pip", nie ueber den venv-Shim: dessen
|
||||
# Shebang baeckt einen absoluten Pfad ein, der bei einer Verzeichnis-
|
||||
# umbenennung bricht (live auf POETEST reproduziert).
|
||||
# * Vor jedem Update ein vollstaendiges Backup -- unabhaengig davon, ob sich
|
||||
# das Schema geaendert hat. Ein reiner Code-Fehler soll durch dasselbe Netz
|
||||
# laufen.
|
||||
# * Nach dem Update ein Health-Check mit automatischem Rollback.
|
||||
# * Instanzdaten (Datenbank, Schluessel, Lizenz) sind vom Abgleich
|
||||
# ausgeschlossen und werden nie ueberschrieben.
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
APP=""
|
||||
INSTANCE=""
|
||||
PORT=""
|
||||
HTTP_PORT=""
|
||||
SKIP_PACKAGES=0
|
||||
ASSUME_YES=0
|
||||
HTTPS=0
|
||||
HTTPS_PORT=""
|
||||
DOMAIN=""
|
||||
ACME_EMAIL=""
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
|
||||
COLOR_OK=$'\033[32m'; COLOR_WARN=$'\033[33m'; COLOR_ERR=$'\033[31m'; COLOR_OFF=$'\033[0m'
|
||||
step() { printf '\n%s==>%s %s\n' "$COLOR_OK" "$COLOR_OFF" "$*"; }
|
||||
warn() { printf '%s !!%s %s\n' "$COLOR_WARN" "$COLOR_OFF" "$*"; }
|
||||
fail() { printf '%s !!%s %s\n' "$COLOR_ERR" "$COLOR_OFF" "$*" >&2; exit 1; }
|
||||
info() { printf ' %s\n' "$*"; }
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Verwendung: install.sh --app <tesm|tesm-license> [Optionen]
|
||||
|
||||
--app <name> Welche Anwendung installiert wird (Pflicht)
|
||||
--instance <name> Zusaetzliche Instanz neben der Hauptinstallation.
|
||||
Erzeugt /srv/<app>-<name>, <app>-<name>.service usw.
|
||||
Damit laesst sich eine neue Version testen, ohne die
|
||||
laufende anzufassen.
|
||||
--port <nummer> Port des Anwendungsservers (Standard: 5000 bzw. 5001)
|
||||
--http-port <nr> Port, auf dem nginx diese Instanz anbietet (Standard 80).
|
||||
Fuer eine Testinstanz neben einer laufenden Produktion:
|
||||
auf Port 80 kann nur eine Site die Vorgabe sein.
|
||||
--https HTTPS einrichten: Port 443 mit TLS, Port 80 leitet
|
||||
dorthin um (ausser dem ACME-Pfad), HSTS aktiv,
|
||||
Sitzungscookie mit Secure-Flag. Ohne --domain wird ein
|
||||
selbstsigniertes Zertifikat erzeugt (geschlossene Netze);
|
||||
mit --domain wird zusaetzlich versucht, ein
|
||||
Let's-Encrypt-Zertifikat zu holen.
|
||||
--https-port <nr> Wie --https, aber auf abweichendem Port (Testinstanz).
|
||||
--domain <name> Oeffentlicher Name dieser Installation.
|
||||
--acme-email <a> Kontaktadresse fuer Let's Encrypt.
|
||||
--skip-packages Keine Systempakete installieren
|
||||
--yes Rueckfragen ueberspringen
|
||||
USAGE
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--app) APP="${2:?}"; shift 2 ;;
|
||||
--instance) INSTANCE="${2:?}"; shift 2 ;;
|
||||
--port) PORT="${2:?}"; shift 2 ;;
|
||||
--http-port) HTTP_PORT="${2:?}"; shift 2 ;;
|
||||
--https) HTTPS=1; shift ;;
|
||||
--https-port) HTTPS_PORT="${2:?}"; HTTPS=1; shift 2 ;;
|
||||
--domain) DOMAIN="${2:?}"; shift 2 ;;
|
||||
--acme-email) ACME_EMAIL="${2:?}"; shift 2 ;;
|
||||
--skip-packages) SKIP_PACKAGES=1; shift ;;
|
||||
--yes|-y) ASSUME_YES=1; shift ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) fail "unbekannte Option: $1" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ -n "$APP" ]] || { usage; fail "--app fehlt"; }
|
||||
[[ "$APP" == "tesm" || "$APP" == "tesm-license" ]] || fail "--app muss tesm oder tesm-license sein"
|
||||
# Seit die beiden Anwendungen in getrennten Repositorien liegen, bringt ein
|
||||
# Auscheckout nur die eigene mit. Ohne diese Pruefung waere die Meldung ein
|
||||
# Folgefehler tief im Installationslauf statt eines klaren Satzes hier.
|
||||
[[ -d "$REPO_DIR/apps/$APP" ]] || fail "In diesem Verzeichnis liegt '$APP' nicht ($REPO_DIR/apps/$APP fehlt). Vorhanden: $(
|
||||
ls -1 "$REPO_DIR/apps" 2>/dev/null | tr '
|
||||
' ' ' || echo 'nichts')"
|
||||
[[ "$(id -u)" -eq 0 ]] || fail "bitte mit sudo ausfuehren"
|
||||
[[ -z "$INSTANCE" || "$INSTANCE" =~ ^[a-z0-9-]{1,16}$ ]] || fail "ungueltiger Instanzname"
|
||||
|
||||
# -- Namensraum --------------------------------------------------------------
|
||||
NAME="$APP"
|
||||
[[ -n "$INSTANCE" ]] && NAME="$APP-$INSTANCE"
|
||||
ROOT="/srv/$NAME"
|
||||
LOG_DIR="/var/log/$NAME"
|
||||
SERVICE_USER="$APP"
|
||||
UNIT="$NAME.service"
|
||||
MONITOR_UNIT="$NAME-monitor.service"
|
||||
NGINX_SITE="/etc/nginx/sites-available/$NAME"
|
||||
ENV_PREFIX="TESM_"
|
||||
[[ "$APP" == "tesm-license" ]] && ENV_PREFIX="TESM_LICENSE_"
|
||||
# Bei einer Aktualisierung gilt zuerst, was schon eingerichtet ist. Sonst
|
||||
# verschiebt ein Update ohne Portangaben die Anwendung: eine benannte Instanz
|
||||
# faellt auf 8080/8443 zurueck, auch wenn sie seit der Installation auf Port 80
|
||||
# lief -- und ein Reverse Proxy davor zeigt dann ins Leere. Dieselbe Regel wie
|
||||
# beim Secure-Flag weiter unten: eine Aktualisierung nimmt nichts weg, was
|
||||
# nicht ausdruecklich neu gesetzt wird.
|
||||
ENV_FILE="/etc/tesm/$NAME.env"
|
||||
if [[ -z "$PORT" && -f "$ENV_FILE" ]]; then
|
||||
PORT="$(sed -n "s/^${ENV_PREFIX}BIND=127\.0\.0\.1:\([0-9]\{1,5\}\)$/\1/p" "$ENV_FILE" | head -1)"
|
||||
fi
|
||||
if [[ -f "$NGINX_SITE" ]]; then
|
||||
[[ -z "$HTTP_PORT" ]] && HTTP_PORT="$(sed -n 's/^[[:space:]]*listen[[:space:]]\([0-9]\{1,5\}\);.*/\1/p' "$NGINX_SITE" | head -1)"
|
||||
[[ -z "$HTTPS_PORT" ]] && HTTPS_PORT="$(sed -n 's/^[[:space:]]*listen[[:space:]]\([0-9]\{1,5\}\)[[:space:]]\+ssl.*/\1/p' "$NGINX_SITE" | head -1)"
|
||||
fi
|
||||
|
||||
if [[ -z "$PORT" ]]; then
|
||||
PORT=5000
|
||||
[[ "$APP" == "tesm-license" ]] && PORT=5001
|
||||
[[ -n "$INSTANCE" ]] && PORT=$((PORT + 100))
|
||||
fi
|
||||
if [[ -z "$HTTP_PORT" ]]; then
|
||||
HTTP_PORT=80
|
||||
[[ -n "$INSTANCE" ]] && HTTP_PORT=8080
|
||||
fi
|
||||
if [[ -z "$HTTPS_PORT" ]]; then
|
||||
HTTPS_PORT=443
|
||||
[[ -n "$INSTANCE" ]] && HTTPS_PORT=8443
|
||||
fi
|
||||
# Das Secure-Flag am Sitzungscookie darf erst gesetzt werden, wenn es
|
||||
# tatsaechlich HTTPS gibt -- sonst kommt das Cookie nie beim Browser an und die
|
||||
# Anmeldung schlaegt ohne erkennbaren Grund fehl.
|
||||
# Bei einer Aktualisierung zaehlt, was schon gilt: wer HTTPS eingerichtet hat,
|
||||
# darf es durch ein Update ohne --https nicht verlieren. Ein zurueckgesetztes
|
||||
# Secure-Flag wuerde die Anmeldung stillschweigend unmoeglich machen -- der
|
||||
# Browser sendet das Cookie dann weiter nur ueber HTTPS, die Anwendung setzt es
|
||||
# aber ohne das Flag neu, und beide reden aneinander vorbei.
|
||||
COOKIE_SECURE=0
|
||||
if [[ -f "$ENV_FILE" ]] && grep -q "^${ENV_PREFIX}COOKIE_SECURE=1" "$ENV_FILE"; then
|
||||
COOKIE_SECURE=1
|
||||
fi
|
||||
[[ $HTTPS -eq 1 ]] && COOKIE_SECURE=1
|
||||
[[ -z "$DOMAIN" || "$DOMAIN" =~ ^[A-Za-z0-9]([A-Za-z0-9.-]{0,251}[A-Za-z0-9])?$ ]] || fail "ungueltige Domain: $DOMAIN"
|
||||
|
||||
TIMESTAMP="$(date +%Y%m%d-%H%M%S)"
|
||||
BACKUP_DIR="/srv/$NAME-backup-$TIMESTAMP"
|
||||
FAILED_DIR="/srv/$NAME-failed-$TIMESTAMP"
|
||||
|
||||
# Einziges Signal fuer "schon installiert": die Datenbank ist da.
|
||||
IS_UPDATE=0
|
||||
[[ -f "$ROOT/data/app.db" ]] && IS_UPDATE=1
|
||||
|
||||
step "TESM-Installation"
|
||||
info "Anwendung : $APP${INSTANCE:+ (Instanz $INSTANCE)}"
|
||||
info "Ziel : $ROOT"
|
||||
info "Dienst : $UNIT auf 127.0.0.1:$PORT"
|
||||
if [[ $HTTPS -eq 1 ]]; then
|
||||
info "nginx : Port $HTTPS_PORT (HTTPS), Port $HTTP_PORT leitet um"
|
||||
elif [[ "$COOKIE_SECURE" == "1" ]]; then
|
||||
info "nginx : HTTPS ist bereits eingerichtet und bleibt unveraendert"
|
||||
if [[ -n "$DOMAIN" ]]; then
|
||||
info "Zertifikat : Lets Encrypt fuer $DOMAIN, sonst selbstsigniert"
|
||||
else
|
||||
info "Zertifikat : selbstsigniert (keine Domain angegeben)"
|
||||
fi
|
||||
else
|
||||
info "nginx : Port $HTTP_PORT (nur HTTP)"
|
||||
fi
|
||||
info "Modus : $([[ $IS_UPDATE -eq 1 ]] && echo Aktualisierung || echo Neuinstallation)"
|
||||
|
||||
if [[ $ASSUME_YES -eq 0 ]]; then
|
||||
read -r -p "Fortfahren? [j/N] " answer
|
||||
[[ "$answer" =~ ^[jJyY]$ ]] || fail "abgebrochen"
|
||||
fi
|
||||
|
||||
# -- Systempakete ------------------------------------------------------------
|
||||
if [[ $SKIP_PACKAGES -eq 0 ]]; then
|
||||
step "Systempakete"
|
||||
PACKAGES=(python3 python3-venv python3-pip nginx sqlite3 rsync logrotate ca-certificates curl)
|
||||
if [[ "$APP" == "tesm" ]]; then
|
||||
PACKAGES+=(iputils-ping openssh-client cifs-utils)
|
||||
fi
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq "${PACKAGES[@]}"
|
||||
info "${#PACKAGES[@]} Pakete geprueft"
|
||||
else
|
||||
info "Systempakete uebersprungen"
|
||||
fi
|
||||
|
||||
# -- Dienstbenutzer ----------------------------------------------------------
|
||||
step "Dienstbenutzer"
|
||||
if ! id -u "$SERVICE_USER" >/dev/null 2>&1; then
|
||||
useradd --system --home-dir "$ROOT" --shell /usr/sbin/nologin "$SERVICE_USER"
|
||||
info "Benutzer $SERVICE_USER angelegt"
|
||||
else
|
||||
info "Benutzer $SERVICE_USER existiert"
|
||||
fi
|
||||
# Der Vorgaenger lief als root. Hier laeuft der Dienst unprivilegiert; Rootrechte
|
||||
# gibt es ausschliesslich ueber den Helfer.
|
||||
|
||||
# -- Backup ------------------------------------------------------------------
|
||||
if [[ $IS_UPDATE -eq 1 ]]; then
|
||||
step "Sicherung vor der Aktualisierung"
|
||||
systemctl stop "$UNIT" 2>/dev/null || true
|
||||
[[ "$APP" == "tesm" ]] && systemctl stop "$MONITOR_UNIT" 2>/dev/null || true
|
||||
cp -a "$ROOT" "$BACKUP_DIR"
|
||||
info "Sicherung: $BACKUP_DIR"
|
||||
fi
|
||||
|
||||
# -- Dateien abgleichen ------------------------------------------------------
|
||||
step "Anwendungsdateien"
|
||||
mkdir -p "$ROOT"
|
||||
rsync -a --delete \
|
||||
--exclude 'venv' \
|
||||
--exclude 'data' \
|
||||
--exclude '*.pyc' \
|
||||
--exclude '__pycache__' \
|
||||
--exclude '.git' \
|
||||
--exclude 'tests' \
|
||||
"$REPO_DIR/" "$ROOT/"
|
||||
mkdir -p "$ROOT/data" "$LOG_DIR"
|
||||
info "Dateien abgeglichen (Instanzdaten unter data/ bleiben unangetastet)"
|
||||
|
||||
# -- venv --------------------------------------------------------------------
|
||||
step "Python-Umgebung"
|
||||
if [[ ! -x "$ROOT/venv/bin/python3" ]]; then
|
||||
python3 -m venv "$ROOT/venv"
|
||||
info "venv erzeugt"
|
||||
fi
|
||||
# Immer ueber "python3 -m pip" -- der pip-Shim im venv baeckt einen absoluten
|
||||
# Pfad in seinen Shebang, der nach einer Umbenennung des Verzeichnisses bricht.
|
||||
"$ROOT/venv/bin/python3" -m pip install --quiet --upgrade pip setuptools wheel
|
||||
PAKETE=("$ROOT/packages/tesm-licensing" "$ROOT/packages/tesm-core")
|
||||
# Die Serverhaelfte des Lizenzprotokolls -- ausstellen, erneuern, antworten --
|
||||
# gehoert ausschliesslich zum Lizenzserver. Ein Client verifiziert Lizenzen und
|
||||
# stellt Anfragen; den Code zum Ausstellen soll er nicht einmal mitbringen.
|
||||
if [[ "$APP" == "tesm-license" ]]; then
|
||||
[[ -d "$ROOT/packages/tesm-licensing-server" ]] ||
|
||||
fail "packages/tesm-licensing-server fehlt -- ohne sie kann der Lizenzserver nichts ausstellen"
|
||||
PAKETE+=("$ROOT/packages/tesm-licensing-server")
|
||||
fi
|
||||
"$ROOT/venv/bin/python3" -m pip install --quiet "${PAKETE[@]}" "$ROOT/apps/$APP" gunicorn
|
||||
if [[ "$APP" == "tesm" ]]; then
|
||||
"$ROOT/venv/bin/python3" -m pip install --quiet flask-sock
|
||||
fi
|
||||
info "Abhaengigkeiten installiert"
|
||||
|
||||
# -- Statische Dateien fuer nginx --------------------------------------------
|
||||
step "Statische Dateien"
|
||||
rm -rf "$ROOT/static" "$ROOT/static-core"
|
||||
CORE_STATIC="$("$ROOT/venv/bin/python3" -c \
|
||||
'import tesm_core, pathlib; print(pathlib.Path(tesm_core.__file__).parent / "static" / "tesm_core")')"
|
||||
APP_MODULE="$APP"
|
||||
[[ "$APP" == "tesm-license" ]] && APP_MODULE="tesm_license"
|
||||
APP_STATIC="$("$ROOT/venv/bin/python3" -c \
|
||||
"import $APP_MODULE, pathlib; print(pathlib.Path($APP_MODULE.__file__).parent / 'static')")"
|
||||
cp -a "$CORE_STATIC" "$ROOT/static-core"
|
||||
[[ -d "$APP_STATIC" ]] && cp -a "$APP_STATIC" "$ROOT/static" || mkdir -p "$ROOT/static"
|
||||
info "nginx liefert /core-assets/ und /assets/ direkt aus"
|
||||
|
||||
# -- Privilegierter Helfer ---------------------------------------------------
|
||||
step "Privilegierter Helfer und sudoers"
|
||||
install -d -o root -g root -m 0755 /usr/local/lib/tesm
|
||||
install_if_changed() {
|
||||
local src="$1" dst="$2" mode="$3" owner="${4:-root:root}"
|
||||
if [[ -f "$dst" ]] && cmp -s "$src" "$dst"; then
|
||||
return 1
|
||||
fi
|
||||
install -o "${owner%%:*}" -g "${owner##*:}" -m "$mode" "$src" "$dst"
|
||||
return 0
|
||||
}
|
||||
install_if_changed "$ROOT/deploy/tesm-helper" /usr/local/lib/tesm/tesm-helper 0755 && \
|
||||
info "Helfer aktualisiert" || info "Helfer unveraendert"
|
||||
|
||||
# visudo -c prueft die Datei, bevor sie aktiv wird -- eine kaputte sudoers-Datei
|
||||
# waere sonst ein Totalausfall der Administration.
|
||||
TMP_SUDO="$(mktemp)"
|
||||
cp "$ROOT/deploy/sudoers/$APP" "$TMP_SUDO"
|
||||
visudo -c -f "$TMP_SUDO" >/dev/null || fail "sudoers-Datei ist fehlerhaft"
|
||||
install -o root -g root -m 0440 "$TMP_SUDO" "/etc/sudoers.d/$APP"
|
||||
rm -f "$TMP_SUDO"
|
||||
info "sudoers: $SERVICE_USER darf ausschliesslich tesm-helper als root aufrufen"
|
||||
|
||||
# -- Anmeldebanner (MOTD) ----------------------------------------------------
|
||||
# Das Banner liest die Datenbank dieser Installation und laeuft dabei mit dem
|
||||
# System-Python, ohne das venv und ohne die Anwendung zu importieren. Der
|
||||
# Vorgaenger importierte dafuer die ganze Flask-App -- bei jeder Anmeldung, und
|
||||
# nach einem Wechsel des Quellverzeichnisses zeigte er stillschweigend die
|
||||
# Zahlen einer Datenbank, die niemand mehr fuellte.
|
||||
#
|
||||
# "timeout" verhindert, dass ein haengender Dateizugriff die Anmeldung
|
||||
# blockiert; "|| true" sorgt dafuer, dass ein Fehler hier den Login nie stoert.
|
||||
step "Anmeldebanner"
|
||||
if [[ -d /etc/update-motd.d ]]; then
|
||||
install_if_changed "$ROOT/deploy/tesm-motd" /usr/local/lib/tesm/tesm-motd 0755 >/dev/null \
|
||||
&& info "Bannerskript aktualisiert" || info "Bannerskript unveraendert"
|
||||
TMP_MOTD="$(mktemp)"
|
||||
{
|
||||
echo "#!/bin/sh"
|
||||
echo "# Von TESM erzeugt ($NAME). Aenderungen werden bei der naechsten"
|
||||
echo "# Installation ueberschrieben."
|
||||
echo "timeout 3 /usr/local/lib/tesm/tesm-motd --app $APP --root $ROOT 2>/dev/null || true"
|
||||
} > "$TMP_MOTD"
|
||||
install -o root -g root -m 0755 "$TMP_MOTD" "/etc/update-motd.d/50-$NAME"
|
||||
rm -f "$TMP_MOTD"
|
||||
info "Banner: /etc/update-motd.d/50-$NAME"
|
||||
# Ein Banner der Vorgaengerfassung zeigt Zahlen aus einer Datenbank, die
|
||||
# niemand mehr fuellt. Er wird nur entfernt, wenn er genau darauf zeigt --
|
||||
# nichts anderes wird angefasst.
|
||||
ALT_MOTD="/etc/update-motd.d/50-tesm"
|
||||
if [[ "$ALT_MOTD" != "/etc/update-motd.d/50-$NAME" && -f "$ALT_MOTD" ]] \
|
||||
&& grep -q "motd_banner.py" "$ALT_MOTD" 2>/dev/null; then
|
||||
rm -f "$ALT_MOTD"
|
||||
info "Banner der Vorgaengerfassung entfernt ($ALT_MOTD)"
|
||||
fi
|
||||
else
|
||||
info "Kein /etc/update-motd.d -- Banner uebersprungen"
|
||||
fi
|
||||
|
||||
# -- Rechte ------------------------------------------------------------------
|
||||
step "Dateirechte"
|
||||
chown -R "$SERVICE_USER:$SERVICE_USER" "$ROOT/data" "$LOG_DIR"
|
||||
chown -R root:root "$ROOT/packages" "$ROOT/apps" "$ROOT/deploy" 2>/dev/null || true
|
||||
chmod 0750 "$ROOT/data"
|
||||
chmod 0755 "$LOG_DIR"
|
||||
find "$ROOT/data" -maxdepth 1 -name '*.key*' -o -name 'data.keys' -o -name '*signing_key*' \
|
||||
| xargs -r chmod 0600
|
||||
info "Datenverzeichnis 0750, Schluesseldateien 0600"
|
||||
|
||||
# -- Umgebungsdatei ----------------------------------------------------------
|
||||
step "Umgebung"
|
||||
# Eine Quelle fuer beide Seiten: die systemd-Unit liest sie ueber
|
||||
# EnvironmentFile, die Kommandozeilenwerkzeuge ueber ihren Wrapper. Ohne das
|
||||
# wuesste "tesm-admin" nicht, wo die Instanz liegt, und wuerde sich eine leere
|
||||
# Datenbank im Arbeitsverzeichnis anlegen.
|
||||
install -d -o root -g root -m 0755 /etc/tesm
|
||||
ENV_FILE="/etc/tesm/$NAME.env"
|
||||
TMP_ENV="$(mktemp)"
|
||||
{
|
||||
echo "# Von install.sh erzeugt fuer $NAME -- nicht von Hand bearbeiten."
|
||||
echo "${ENV_PREFIX}BASE_DIR=$ROOT"
|
||||
echo "${ENV_PREFIX}DATA_DIR=$ROOT/data"
|
||||
echo "${ENV_PREFIX}LOG_DIR=$LOG_DIR"
|
||||
# Name dieser Installation auf dem Host. Alles ausserhalb von /srv/<name>
|
||||
# -- nginx-Site, netplan-Datei, logrotate-Regel, Zertifikatsverzeichnis,
|
||||
# Sitzungscookie -- laeuft darueber. Ohne das wuerde eine Testinstanz beim
|
||||
# Speichern der Webserver-Einstellungen die Site der Produktion
|
||||
# ueberschreiben und beide teilten sich dasselbe Cookie.
|
||||
echo "${ENV_PREFIX}SITE_KEY=$NAME"
|
||||
# Genau die Adresse, auf die gunicorn hoert. nginx nimmt sie von hier als
|
||||
# proxy_pass-Ziel -- so koennen Dienst und Reverse Proxy nicht auseinander
|
||||
# laufen. Vorher stand die 5000 fest im Code: eine Instanz auf 5100 erzeugte
|
||||
# damit eine Site, die auf die andere Installation zeigte.
|
||||
echo "${ENV_PREFIX}BIND=127.0.0.1:$PORT"
|
||||
echo "${ENV_PREFIX}COOKIE_SECURE=$COOKIE_SECURE"
|
||||
echo "${ENV_PREFIX}TRUSTED_PROXY_HOPS=1"
|
||||
echo "PYTHONUNBUFFERED=1"
|
||||
} > "$TMP_ENV"
|
||||
install_if_changed "$TMP_ENV" "$ENV_FILE" 0640 "root:$SERVICE_USER" >/dev/null && info "Umgebungsdatei $ENV_FILE geschrieben" || info "Umgebungsdatei unveraendert"
|
||||
rm -f "$TMP_ENV"
|
||||
if [[ "$COOKIE_SECURE" == "1" ]]; then
|
||||
info "COOKIE_SECURE=1 -- die Anmeldung funktioniert damit nur ueber HTTPS."
|
||||
else
|
||||
info "Hinweis: COOKIE_SECURE=0 bis HTTPS eingerichtet ist. Danach: install.sh --https"
|
||||
info " erneut aufrufen oder in $ENV_FILE auf 1 setzen und Dienst neu starten."
|
||||
fi
|
||||
|
||||
# Wrapper, damit die Werkzeuge dieselbe Umgebung sehen wie der Dienst.
|
||||
ADMIN_BIN="tesm-admin"
|
||||
[[ "$APP" == "tesm-license" ]] && ADMIN_BIN="tesm-license-admin"
|
||||
TMP_WRAPPER="$(mktemp)"
|
||||
cat > "$TMP_WRAPPER" <<WRAPPER
|
||||
#!/bin/sh
|
||||
# Von install.sh erzeugt: laedt die Instanzumgebung und ruft das Werkzeug auf.
|
||||
set -eu
|
||||
set -a
|
||||
. "$ENV_FILE"
|
||||
set +a
|
||||
exec "$ROOT/venv/bin/$ADMIN_BIN" "\$@"
|
||||
WRAPPER
|
||||
install_if_changed "$TMP_WRAPPER" "/usr/local/bin/$NAME-admin" 0755 >/dev/null && info "Werkzeug: $NAME-admin" || true
|
||||
rm -f "$TMP_WRAPPER"
|
||||
|
||||
# -- systemd -----------------------------------------------------------------
|
||||
step "systemd"
|
||||
UNITS_CHANGED=0
|
||||
render_unit() {
|
||||
local src="$1" dst="$2"
|
||||
local tmp; tmp="$(mktemp)"
|
||||
sed -e "s#/srv/$APP#$ROOT#g" \
|
||||
-e "s#/var/log/$APP#$LOG_DIR#g" \
|
||||
-e "s#127.0.0.1:5000#127.0.0.1:$PORT#" \
|
||||
-e "s#127.0.0.1:5001#127.0.0.1:$PORT#" \
|
||||
-e "/^WorkingDirectory=/a EnvironmentFile=$ENV_FILE" \
|
||||
"$src" > "$tmp"
|
||||
install_if_changed "$tmp" "$dst" 0644 && UNITS_CHANGED=1
|
||||
rm -f "$tmp"
|
||||
}
|
||||
render_unit "$ROOT/deploy/systemd/$APP.service" "/etc/systemd/system/$UNIT"
|
||||
if [[ "$APP" == "tesm" ]]; then
|
||||
render_unit "$ROOT/deploy/systemd/tesm-monitor.service" "/etc/systemd/system/$MONITOR_UNIT"
|
||||
fi
|
||||
[[ $UNITS_CHANGED -eq 1 ]] && systemctl daemon-reload && info "Units neu geladen" \
|
||||
|| info "Units unveraendert"
|
||||
|
||||
# -- logrotate ---------------------------------------------------------------
|
||||
TMP_ROTATE="$(mktemp)"
|
||||
sed -e "s#/var/log/$APP/#$LOG_DIR/#" "$ROOT/deploy/logrotate/$APP" > "$TMP_ROTATE"
|
||||
install_if_changed "$TMP_ROTATE" "/etc/logrotate.d/$NAME" 0644 >/dev/null && info "logrotate aktualisiert"
|
||||
rm -f "$TMP_ROTATE"
|
||||
|
||||
# -- nginx -------------------------------------------------------------------
|
||||
step "nginx"
|
||||
TMP_SITE="$(mktemp)"
|
||||
sed -e "s#/srv/$APP/#$ROOT/#g" \
|
||||
-e "s#127.0.0.1:5000#127.0.0.1:$PORT#g" \
|
||||
-e "s#127.0.0.1:5001#127.0.0.1:$PORT#g" \
|
||||
-e "s#listen 80;#listen $HTTP_PORT;#" \
|
||||
-e "s#listen \[::\]:80;#listen [::]:$HTTP_PORT;#" \
|
||||
-e "s#/var/www/$APP-acme#/var/www/$NAME-acme#g" \
|
||||
"$ROOT/deploy/nginx/$APP.conf" > "$TMP_SITE"
|
||||
# Auf Port 80 kann nur eine Site die Vorgabe sein. Eine Instanz neben der
|
||||
# Produktion bekommt darum ueber --http-port einen eigenen Port -- sonst
|
||||
# verdeckt sie die laufende Installation (nginx meldet dann
|
||||
# "conflicting server name" und ignoriert eine der beiden).
|
||||
if [[ -n "$INSTANCE" && "$HTTP_PORT" == "80" ]]; then
|
||||
warn "Instanz $INSTANCE auf Port 80: sie verdeckt moeglicherweise die Hauptinstallation."
|
||||
fi
|
||||
|
||||
# Inhaltsvergleich statt reiner Existenzpruefung -- genau der Punkt, an dem der
|
||||
# Vorgaenger einen falschen alias-Pfad jahrelang mitschleppte. Die Anwendung
|
||||
# schreibt diese Datei spaeter ohnehin selbst neu (Verwaltung -> Webserver);
|
||||
# hier geht es nur um einen funktionierenden Erststart.
|
||||
if [[ -f "$NGINX_SITE" ]] && ! cmp -s "$TMP_SITE" "$NGINX_SITE"; then
|
||||
if grep -qE "alias\s+$ROOT/(static|static-core)/;" "$NGINX_SITE"; then
|
||||
info "Vorhandene Site-Konfiguration behalten (alias-Pfade stimmen)"
|
||||
else
|
||||
warn "Vorhandene Site-Konfiguration zeigt auf falsche Pfade -- wird erneuert"
|
||||
cp "$NGINX_SITE" "$NGINX_SITE.bak-$TIMESTAMP"
|
||||
install -m 0644 "$TMP_SITE" "$NGINX_SITE"
|
||||
fi
|
||||
elif [[ ! -f "$NGINX_SITE" ]]; then
|
||||
install -m 0644 "$TMP_SITE" "$NGINX_SITE"
|
||||
info "Site-Konfiguration angelegt"
|
||||
fi
|
||||
TMP_SITE_KEEP="$TMP_SITE"
|
||||
ln -sfn "$NGINX_SITE" "/etc/nginx/sites-enabled/$NAME"
|
||||
rm -f /etc/nginx/sites-enabled/default
|
||||
mkdir -p "/var/www/$NAME-acme"
|
||||
chown -R www-data:www-data "/var/www/$NAME-acme"
|
||||
|
||||
# "alias-Pfade stimmen" heisst noch nicht "nginx akzeptiert die Datei". Eine
|
||||
# von Hand nachgebesserte Site kann syntaktisch kaputt sein -- und dann
|
||||
# scheitert nicht nur diese Installation, sondern jeder Reload des Webservers,
|
||||
# auch fuer alle anderen Sites. Darum: pruefen, und im Zweifel die frisch
|
||||
# erzeugte Vorlage nehmen.
|
||||
if ! nginx -t >/dev/null 2>&1; then
|
||||
warn "nginx lehnt die vorhandene Site-Konfiguration ab -- sie wird durch die Vorlage ersetzt."
|
||||
[[ -f "$NGINX_SITE" ]] && cp "$NGINX_SITE" "$NGINX_SITE.bak-$TIMESTAMP"
|
||||
install -m 0644 "$TMP_SITE_KEEP" "$NGINX_SITE"
|
||||
nginx -t >/dev/null 2>&1 || fail "nginx lehnt auch die Vorlage ab -- bitte 'nginx -t' pruefen. Sicherung: $NGINX_SITE.bak-$TIMESTAMP"
|
||||
fi
|
||||
rm -f "$TMP_SITE_KEEP"
|
||||
systemctl reload nginx
|
||||
info "nginx neu geladen"
|
||||
|
||||
# -- Datenbank und Start -----------------------------------------------------
|
||||
step "Anwendung starten"
|
||||
# Das Schema entsteht beim ersten Start durch die Migrationen -- es gibt kein
|
||||
# separates Init-Skript mehr.
|
||||
systemctl enable --now "$UNIT" >/dev/null
|
||||
if [[ "$APP" == "tesm" ]]; then
|
||||
systemctl enable --now "$MONITOR_UNIT" >/dev/null
|
||||
fi
|
||||
|
||||
# -- Health-Check und Rollback ----------------------------------------------
|
||||
step "Gesundheitspruefung"
|
||||
HEALTHY=0
|
||||
for attempt in $(seq 1 20); do
|
||||
if curl -fsS --max-time 3 "http://127.0.0.1:$PORT/gesundheit" >/dev/null 2>&1; then
|
||||
HEALTHY=1
|
||||
info "Antwort nach ${attempt}x"
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
if [[ $HEALTHY -eq 0 ]]; then
|
||||
warn "Die Anwendung antwortet nicht."
|
||||
if [[ $IS_UPDATE -eq 1 ]]; then
|
||||
warn "Rollback wird ausgefuehrt."
|
||||
systemctl stop "$UNIT" 2>/dev/null || true
|
||||
[[ "$APP" == "tesm" ]] && systemctl stop "$MONITOR_UNIT" 2>/dev/null || true
|
||||
mv "$ROOT" "$FAILED_DIR"
|
||||
cp -a "$BACKUP_DIR" "$ROOT"
|
||||
systemctl start "$UNIT" || true
|
||||
[[ "$APP" == "tesm" ]] && systemctl start "$MONITOR_UNIT" || true
|
||||
fail "Aktualisierung fehlgeschlagen. Alter Stand wiederhergestellt.
|
||||
Fehlgeschlagene Version : $FAILED_DIR
|
||||
Sicherung : $BACKUP_DIR
|
||||
Protokoll : journalctl -u $UNIT -n 100"
|
||||
fi
|
||||
fail "Neuinstallation fehlgeschlagen. Protokoll: journalctl -u $UNIT -n 100"
|
||||
fi
|
||||
|
||||
URL_HINT="http://<host>:$HTTP_PORT/"
|
||||
[[ "$HTTP_PORT" == "80" ]] && URL_HINT="http://<host>/"
|
||||
# HTTPS war schon eingerichtet und wird durch dieses Update nicht angefasst.
|
||||
[[ $HTTPS -eq 0 && "$COOKIE_SECURE" == "1" ]] && URL_HINT="https://<host>/ (unveraendert)"
|
||||
|
||||
# -- HTTPS -------------------------------------------------------------------
|
||||
# Bewusst erst hier: die Anwendung muss laufen, denn sie erzeugt die
|
||||
# TLS-Konfiguration selbst (tesm-admin web-setup). Das Installationsskript
|
||||
# baut sich keine zweite, konkurrierende Vorlage -- genau daran ist der
|
||||
# Vorgaenger gescheitert.
|
||||
if [[ $HTTPS -eq 1 ]]; then
|
||||
step "HTTPS einrichten"
|
||||
WEB_ARGS=(web-setup --https --apply --self-signed
|
||||
--http-port "$HTTP_PORT" --https-port "$HTTPS_PORT")
|
||||
if [[ -n "$DOMAIN" ]]; then
|
||||
# HSTS nur mit einem Zertifikat, dem Browser auch trauen. Bei einem
|
||||
# selbstsignierten waere es eine Falle: der Browser merkt sich fuer Monate,
|
||||
# dass er die Seite nur ueber HTTPS aufrufen darf -- und zeigt bei jedem
|
||||
# Aufruf eine Warnung, die sich nicht mehr wegklicken laesst.
|
||||
WEB_ARGS+=(--domain "$DOMAIN")
|
||||
if [[ -n "$ACME_EMAIL" ]]; then
|
||||
# Let's Encrypt verlangt ohnehin eine Kontaktadresse. Sie ist damit das
|
||||
# eindeutige Signal "hol ein oeffentliches Zertifikat" -- ohne sie bleibt
|
||||
# es bei einem selbstsignierten, auch wenn eine Domain gesetzt ist
|
||||
# (interne Namen wie lizenz.firma.local kann keine CA bestaetigen).
|
||||
WEB_ARGS+=(--acme --acme-email "$ACME_EMAIL" --hsts)
|
||||
info "Versuche Lets Encrypt fuer $DOMAIN; bei Misserfolg selbstsigniert."
|
||||
else
|
||||
info "Domain $DOMAIN, aber keine --acme-email: selbstsigniert, ohne HSTS."
|
||||
fi
|
||||
else
|
||||
info "Ohne Domain: selbstsigniertes Zertifikat, ohne HSTS."
|
||||
fi
|
||||
if sudo -u "$SERVICE_USER" "/usr/local/bin/$NAME-admin" "${WEB_ARGS[@]}"; then
|
||||
info "HTTPS aktiv."
|
||||
URL_HINT="https://<host>/"
|
||||
[[ "$HTTPS_PORT" == "443" ]] || URL_HINT="https://<host>:$HTTPS_PORT/"
|
||||
else
|
||||
warn "HTTPS-Einrichtung fehlgeschlagen -- die Installation laeuft weiter ueber HTTP."
|
||||
warn "Nachtraeglich: sudo -u $SERVICE_USER $NAME-admin web-setup --https --self-signed --hsts --apply"
|
||||
# Ein Secure-Cookie ohne HTTPS macht die Anmeldung unmoeglich. Lieber
|
||||
# zurueckdrehen als eine Installation hinterlassen, an der niemand mehr
|
||||
# ankommt.
|
||||
if grep -q "^${ENV_PREFIX}COOKIE_SECURE=1" "$ENV_FILE"; then
|
||||
sed -i "s/^${ENV_PREFIX}COOKIE_SECURE=1/${ENV_PREFIX}COOKIE_SECURE=0/" "$ENV_FILE"
|
||||
systemctl restart "$UNIT"
|
||||
warn "COOKIE_SECURE auf 0 zurueckgesetzt, damit die Anmeldung weiter funktioniert."
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ $IS_UPDATE -eq 1 ]]; then
|
||||
rm -rf "$BACKUP_DIR"
|
||||
info "Sicherung entfernt (Aktualisierung erfolgreich)"
|
||||
fi
|
||||
|
||||
# -- Abschluss ---------------------------------------------------------------
|
||||
step "Fertig"
|
||||
ADMIN_CMD="tesm-admin"
|
||||
[[ "$APP" == "tesm-license" ]] && ADMIN_CMD="tesm-license-admin"
|
||||
|
||||
cat <<SUMMARY
|
||||
|
||||
Anwendung : $APP${INSTANCE:+ (Instanz $INSTANCE)}
|
||||
Verzeichnis : $ROOT
|
||||
Protokolle : $LOG_DIR
|
||||
Dienst : systemctl status $UNIT
|
||||
Erreichbar : $URL_HINT
|
||||
|
||||
Naechste Schritte:
|
||||
SUMMARY
|
||||
|
||||
if [[ "$APP" == "tesm-license" ]]; then
|
||||
cat <<SUMMARY
|
||||
1. Signaturschluessel erzeugen und sofort sichern:
|
||||
sudo -u $SERVICE_USER $NAME-admin init-key
|
||||
2. Eigene Master-Lizenz ausstellen:
|
||||
sudo -u $SERVICE_USER $NAME-admin bootstrap
|
||||
3. Administrator anlegen:
|
||||
sudo -u $SERVICE_USER $NAME-admin create-admin
|
||||
SUMMARY
|
||||
else
|
||||
cat <<SUMMARY
|
||||
1. Administrator anlegen:
|
||||
sudo -u $SERVICE_USER $NAME-admin create-admin
|
||||
2. Lizenzdatei einspielen unter Verwaltung -> Lizenz
|
||||
SUMMARY
|
||||
fi
|
||||
|
||||
cat <<'SUMMARY'
|
||||
Danach in der Anwendung unter "Verwaltung -> Webserver und TLS" die Domain
|
||||
eintragen, ein Zertifikat anfordern und die Konfiguration uebernehmen --
|
||||
damit laeuft der Zugriff ueber HTTPS auf Port 443 mit Umleitung von 80.
|
||||
|
||||
SUMMARY
|
||||
@@ -0,0 +1,14 @@
|
||||
# Von TESM installiert.
|
||||
# copytruncate, weil keiner der schreibenden Prozesse (Web, Ueberwachung, Kea)
|
||||
# ein Reopen-Signal verarbeitet.
|
||||
/var/log/tesm/*.log {
|
||||
weekly
|
||||
rotate 4
|
||||
compress
|
||||
delaycompress
|
||||
missingok
|
||||
notifempty
|
||||
copytruncate
|
||||
su tesm tesm
|
||||
create 0640 tesm tesm
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
# Von TESM installiert.
|
||||
# copytruncate, weil keiner der schreibenden Prozesse (Web, Ueberwachung, Kea)
|
||||
# ein Reopen-Signal verarbeitet.
|
||||
/var/log/tesm-license/*.log {
|
||||
weekly
|
||||
rotate 4
|
||||
compress
|
||||
delaycompress
|
||||
missingok
|
||||
notifempty
|
||||
copytruncate
|
||||
su tesm-license tesm-license
|
||||
create 0640 tesm-license tesm-license
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
# Erststart-Konfiguration. Ab der ersten Uebernahme in der Anwendung
|
||||
# (Verwaltung -> Webserver und TLS) wird diese Datei vollstaendig neu
|
||||
# geschrieben -- inklusive HTTPS auf 443, Umleitung von 80 und HSTS.
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name _;
|
||||
|
||||
location /.well-known/acme-challenge/ {
|
||||
alias /var/www/tesm-license-acme/.well-known/acme-challenge/;
|
||||
default_type "text/plain";
|
||||
}
|
||||
|
||||
client_max_body_size 160m;
|
||||
|
||||
location /assets/ {
|
||||
alias /srv/tesm-license/static/;
|
||||
expires 7d;
|
||||
access_log off;
|
||||
}
|
||||
|
||||
location /core-assets/ {
|
||||
alias /srv/tesm-license/static-core/;
|
||||
expires 7d;
|
||||
access_log off;
|
||||
}
|
||||
|
||||
|
||||
location / {
|
||||
# $http_host statt $host: $host verwirft den Port. Hinter einem Proxy auf
|
||||
# abweichendem Port wuerde die Origin-Pruefung der Anwendung sonst
|
||||
# fehlschlagen.
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Forwarded-Host $http_host;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_pass http://127.0.0.1:5001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_read_timeout 120s;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
# Erststart-Konfiguration. Ab der ersten Uebernahme in der Anwendung
|
||||
# (Verwaltung -> Webserver und TLS) wird diese Datei vollstaendig neu
|
||||
# geschrieben -- inklusive HTTPS auf 443, Umleitung von 80 und HSTS.
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name _;
|
||||
|
||||
location /.well-known/acme-challenge/ {
|
||||
alias /var/www/tesm-acme/.well-known/acme-challenge/;
|
||||
default_type "text/plain";
|
||||
}
|
||||
|
||||
client_max_body_size 160m;
|
||||
|
||||
location /assets/ {
|
||||
alias /srv/tesm/static/;
|
||||
expires 7d;
|
||||
access_log off;
|
||||
}
|
||||
|
||||
location /core-assets/ {
|
||||
alias /srv/tesm/static-core/;
|
||||
expires 7d;
|
||||
access_log off;
|
||||
}
|
||||
|
||||
location /ws/ {
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
# $http_host statt $host: $host verwirft den Port. Hinter einem Proxy auf
|
||||
# abweichendem Port wuerde die Origin-Pruefung der Anwendung sonst
|
||||
# fehlschlagen.
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Forwarded-Host $http_host;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_pass http://127.0.0.1:5000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
location / {
|
||||
# $http_host statt $host: $host verwirft den Port. Hinter einem Proxy auf
|
||||
# abweichendem Port wuerde die Origin-Pruefung der Anwendung sonst
|
||||
# fehlschlagen.
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Forwarded-Host $http_host;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_pass http://127.0.0.1:5000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_read_timeout 120s;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env bash
|
||||
# Paketiert ein Release aus einem vorhandenen Tag.
|
||||
#
|
||||
# Auf Windows-Buildmaschinen ist -c core.autocrlf=false zwingend -- sonst landen
|
||||
# CRLF-Zeilenenden im Tarball und die Shell-Skripte scheitern auf dem Ziel mit
|
||||
# "bad interpreter". Das ist real passiert.
|
||||
#
|
||||
# Der Name kommt aus dem Repository, nicht aus einer festen Zeichenkette: die
|
||||
# beiden Anwendungen liegen in getrennten Repositorien, und ein
|
||||
# Lizenzserver-Release, das "tesm-..." heisst, verwechselt man genau einmal.
|
||||
set -euo pipefail
|
||||
|
||||
TAG="${1:?Verwendung: release.sh vX.Y.Z [name]}"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
|
||||
# Vorgabe: der Name der einzigen Anwendung im Baum, sonst der Verzeichnisname.
|
||||
if [[ -n "${2:-}" ]]; then
|
||||
NAME="$2"
|
||||
else
|
||||
mapfile -t APPS < <(ls -1 "$REPO_DIR/apps" 2>/dev/null || true)
|
||||
if [[ ${#APPS[@]} -eq 1 ]]; then NAME="${APPS[0]}"; else NAME="$(basename "$REPO_DIR")"; fi
|
||||
fi
|
||||
|
||||
OUT="$NAME-$TAG.tar.gz"
|
||||
git -c core.autocrlf=false -C "$REPO_DIR" archive --format=tar.gz --prefix="$NAME/" -o "$OUT" "$TAG"
|
||||
echo "Erzeugt: $OUT"
|
||||
file "$OUT"
|
||||
echo "Pruefsumme:"; sha256sum "$OUT"
|
||||
@@ -0,0 +1,359 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# Benennt eine Installation um -- aus einer Instanz wird die Hauptinstallation
|
||||
# (oder umgekehrt).
|
||||
#
|
||||
# sudo bash deploy/rename.sh --app tesm --from tesm-opus --to tesm
|
||||
# sudo bash deploy/rename.sh --app tesm-license --from tesm-license-opus \
|
||||
# --to tesm-license
|
||||
#
|
||||
# Der Name steckt an mehr Stellen, als man beim Verschieben eines Verzeichnisses
|
||||
# erwartet, und drei davon sind leicht zu vergessen:
|
||||
#
|
||||
# * ``SITE_KEY`` in der Umgebungsdatei -- daraus entsteht der Name des
|
||||
# Sitzungscookies. Bleibt er alt, heisst das Cookie weiter wie die alte
|
||||
# Instanz.
|
||||
# * ``web_cert_path``/``web_key_path``/``web_acme_webroot`` in der Datenbank.
|
||||
# Sie enthalten den Namen; ein Zertifikat unter dem alten Pfad findet nginx
|
||||
# nach dem Umzug nicht mehr.
|
||||
# * ``server_name`` der nginx-Site. install.sh schreibt die Datei neu, sobald
|
||||
# sich die alias-Pfade aendern -- und die aendern sich hier zwangslaeufig.
|
||||
# Ohne den Namen aus der Datenbank stuenden danach zwei Sites mit ``_`` auf
|
||||
# Port 80 und die erste gewinnt.
|
||||
#
|
||||
# Belegt der Zielname noch eine Installation der Fassung 1.x, wird sie beiseite
|
||||
# geschoben: Verzeichnis nach ``<ziel>-<suffix>``, ihre systemd-Units und ihre
|
||||
# nginx-Site als Kopie hinein. Danach ist der Name frei und nichts auf dem
|
||||
# System zeigt mehr ins Leere.
|
||||
#
|
||||
# Alle angemeldeten Benutzer werden abgemeldet: das Sitzungscookie heisst
|
||||
# danach anders.
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
APP=""
|
||||
FROM=""
|
||||
TO=""
|
||||
SUFFIX="sonnet5"
|
||||
ASSUME_YES=0
|
||||
DRY_RUN=0
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
COLOR_OK=$'\033[32m'; COLOR_WARN=$'\033[33m'; COLOR_ERR=$'\033[31m'; COLOR_OFF=$'\033[0m'
|
||||
step() { printf '\n%s==>%s %s\n' "$COLOR_OK" "$COLOR_OFF" "$*"; }
|
||||
warn() { printf '%s !!%s %s\n' "$COLOR_WARN" "$COLOR_OFF" "$*"; }
|
||||
fail() { printf '%s !!%s %s\n' "$COLOR_ERR" "$COLOR_OFF" "$*" >&2; exit 1; }
|
||||
info() { printf ' %s\n' "$*"; }
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Verwendung: rename.sh --app <tesm|tesm-license> --from <alt> --to <neu> [Optionen]
|
||||
|
||||
--app <name> Welche Anwendung (Pflicht)
|
||||
--from <name> Bisheriger Installationsname, z.B. tesm-opus (Pflicht)
|
||||
--to <name> Neuer Installationsname, z.B. tesm (Pflicht)
|
||||
--suffix <text> Namenszusatz fuer eine beiseite geschobene Installation
|
||||
der Fassung 1.x (Vorgabe: sonnet5)
|
||||
--dry-run Nur zeigen, was geschehen wuerde
|
||||
--yes Keine Rueckfrage
|
||||
USAGE
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--app) APP="${2:?}"; shift 2 ;;
|
||||
--from) FROM="${2:?}"; shift 2 ;;
|
||||
--to) TO="${2:?}"; shift 2 ;;
|
||||
--suffix) SUFFIX="${2:?}"; shift 2 ;;
|
||||
--dry-run) DRY_RUN=1; shift ;;
|
||||
--yes|-y) ASSUME_YES=1; shift ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) usage; fail "unbekannte Option: $1" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ "$APP" == "tesm" || "$APP" == "tesm-license" ]] || { usage; fail "--app fehlt oder ist unbekannt"; }
|
||||
[[ -n "$FROM" && -n "$TO" ]] || { usage; fail "--from und --to sind Pflicht"; }
|
||||
[[ "$FROM" != "$TO" ]] || fail "--from und --to sind gleich"
|
||||
[[ "$TO" =~ ^[a-z0-9-]{1,32}$ ]] || fail "ungueltiger Zielname: $TO"
|
||||
[[ "$TO" == "$APP" || "$TO" == "$APP-"* ]] || fail "der Zielname muss mit '$APP' beginnen"
|
||||
[[ "$(id -u)" -eq 0 ]] || fail "bitte mit sudo ausfuehren"
|
||||
[[ -f "$SCRIPT_DIR/install.sh" ]] || fail "install.sh fehlt neben diesem Skript"
|
||||
[[ -d "/srv/$FROM" ]] || fail "/srv/$FROM gibt es nicht"
|
||||
[[ -f "/srv/$FROM/data/app.db" ]] || fail "/srv/$FROM sieht nicht nach einer Installation dieser Fassung aus"
|
||||
|
||||
ENV_PREFIX="TESM_"
|
||||
[[ "$APP" == "tesm-license" ]] && ENV_PREFIX="TESM_LICENSE_"
|
||||
|
||||
# Alles, was den Namen traegt. Reihenfolge: erst Daten, dann Konfiguration.
|
||||
VERSCHIEBEN=(
|
||||
"/srv/$FROM:/srv/$TO"
|
||||
"/var/log/$FROM:/var/log/$TO"
|
||||
"/etc/tesm/$FROM.env:/etc/tesm/$TO.env"
|
||||
"/var/www/$FROM-acme:/var/www/$TO-acme"
|
||||
"/etc/tesm/certs/$FROM:/etc/tesm/certs/$TO"
|
||||
)
|
||||
ENTFERNEN=(
|
||||
"/etc/systemd/system/$FROM.service"
|
||||
"/etc/systemd/system/$FROM-monitor.service"
|
||||
"/etc/nginx/sites-enabled/$FROM"
|
||||
"/etc/nginx/sites-available/$FROM"
|
||||
"/usr/local/bin/$FROM-admin"
|
||||
"/etc/logrotate.d/$FROM"
|
||||
"/etc/update-motd.d/50-$FROM"
|
||||
)
|
||||
|
||||
# -- Webeinstellungen auslesen, bevor irgendetwas umzieht -------------------- #
|
||||
# Mit dem System-Python und der Standardbibliothek: das venv zieht gleich mit
|
||||
# um, und ein zusaetzliches Werkzeug (sqlite3) ist auf einem schlanken Server
|
||||
# nicht garantiert.
|
||||
lese_einstellung() {
|
||||
python3 - "$1" "/srv/$FROM/data/app.db" <<'PY'
|
||||
import sqlite3, sys
|
||||
schluessel, datenbank = sys.argv[1], sys.argv[2]
|
||||
try:
|
||||
conn = sqlite3.connect(f"file:{datenbank}?mode=ro", uri=True)
|
||||
row = conn.execute("SELECT value FROM settings WHERE key=?", (schluessel,)).fetchone()
|
||||
conn.close()
|
||||
except sqlite3.Error:
|
||||
row = None
|
||||
print((row[0] if row and row[0] is not None else "").strip())
|
||||
PY
|
||||
}
|
||||
|
||||
DOMAIN="$(lese_einstellung web_domain || true)"
|
||||
HTTPS_AN="$(lese_einstellung web_https_enabled || true)"
|
||||
HSTS_AN="$(lese_einstellung web_hsts_enabled || true)"
|
||||
CERT_ALT="$(lese_einstellung web_cert_path || true)"
|
||||
|
||||
step "Vorhaben"
|
||||
info "Anwendung : $APP"
|
||||
info "Alt : $FROM"
|
||||
info "Neu : $TO"
|
||||
info "Domain : ${DOMAIN:-(keine)}"
|
||||
info "HTTPS : $([[ "$HTTPS_AN" == "1" ]] && echo "ja${CERT_ALT:+, Zertifikat $CERT_ALT}" || echo nein)"
|
||||
for paar in "${VERSCHIEBEN[@]}"; do
|
||||
quelle="${paar%%:*}"; ziel="${paar##*:}"
|
||||
[[ -e "$quelle" ]] && info "verschieben: $quelle -> $ziel"
|
||||
done
|
||||
for pfad in "${ENTFERNEN[@]}"; do
|
||||
[[ -e "$pfad" ]] && info "entfernen : $pfad (install.sh legt es unter dem neuen Namen neu an)"
|
||||
done
|
||||
|
||||
# -- Was belegt den Zielnamen? ----------------------------------------------- #
|
||||
# Nicht nur das Verzeichnis unter /srv: eine Installation der Fassung 1.x
|
||||
# hinterlaesst auch /var/log/<name>, /etc/logrotate.d/<name> und den
|
||||
# ACME-Pfad. Der Umzug bricht sonst mitten drin ab -- live genau so passiert.
|
||||
BELEGT=()
|
||||
for paar in "${VERSCHIEBEN[@]}"; do
|
||||
ziel="${paar##*:}"
|
||||
[[ -e "$ziel" ]] && BELEGT+=("$ziel")
|
||||
done
|
||||
for weiteres in "/etc/logrotate.d/$TO" "/etc/update-motd.d/50-$TO"; do
|
||||
[[ -e "$weiteres" ]] && BELEGT+=("$weiteres")
|
||||
done
|
||||
|
||||
BEISEITE=""
|
||||
if [[ ${#BELEGT[@]} -gt 0 ]]; then
|
||||
if [[ -f "/srv/$TO/data/app.db" ]]; then
|
||||
fail "/srv/$TO ist bereits eine Installation dieser Fassung. Welche gewinnt, entscheidet niemand automatisch."
|
||||
fi
|
||||
BEISEITE="/srv/$TO-$SUFFIX"
|
||||
if [[ -e "$BEISEITE" ]]; then
|
||||
# Ein vorhandenes Archiv derselben Verdraengung wird weiterbenutzt -- die
|
||||
# Reste einer Installation gehoeren zusammen und nicht in ein zweites
|
||||
# Verzeichnis. Alles andere unter diesem Namen bleibt unangetastet.
|
||||
if [[ -f "$BEISEITE/sqlite.db" || -d "$BEISEITE/systemd-alt" ]]; then
|
||||
info "Archiv $BEISEITE ist vorhanden und wird weiterbenutzt."
|
||||
else
|
||||
fail "$BEISEITE existiert und sieht nicht nach einem solchen Archiv aus -- bitte --suffix waehlen"
|
||||
fi
|
||||
fi
|
||||
info "Der Zielname ist belegt. Alles davon wandert nach $BEISEITE:"
|
||||
for pfad in "${BELEGT[@]}"; do info " $pfad"; done
|
||||
fi
|
||||
|
||||
if [[ $DRY_RUN -eq 1 ]]; then
|
||||
step "--dry-run: hier ist Schluss."
|
||||
exit 0
|
||||
fi
|
||||
if [[ $ASSUME_YES -eq 0 ]]; then
|
||||
read -r -p "Fortfahren? Alle angemeldeten Benutzer werden abgemeldet. [j/N] " answer
|
||||
[[ "$answer" =~ ^[jJyY]$ ]] || { echo "Abgebrochen."; exit 0; }
|
||||
fi
|
||||
|
||||
# -- 1. Dienste anhalten ----------------------------------------------------- #
|
||||
step "Dienste anhalten"
|
||||
for unit in "$FROM.service" "$FROM-monitor.service"; do
|
||||
if systemctl list-unit-files "$unit" --no-legend 2>/dev/null | grep -q .; then
|
||||
systemctl disable --now "$unit" >/dev/null 2>&1 || true
|
||||
info "$unit gestoppt und deaktiviert"
|
||||
fi
|
||||
done
|
||||
|
||||
# -- 2. Zielnamen freimachen ------------------------------------------------- #
|
||||
if [[ -n "$BEISEITE" ]]; then
|
||||
step "Zielnamen freimachen"
|
||||
# Alles wandert ins Archiv, nichts wird geloescht: unter /var/log/<name>
|
||||
# liegen Protokolle, unter /srv/<name> im Zweifel eine ganze Installation.
|
||||
# Die Units und die Site kommen mit -- sie zeigen auf das alte Verzeichnis
|
||||
# und waeren auf dem System nur noch Fehlerquellen, im Archiv aber ein Beleg.
|
||||
mkdir -p "$BEISEITE"
|
||||
for pfad in "${BELEGT[@]}"; do
|
||||
ziel="$BEISEITE/$(basename "$pfad")"
|
||||
if [[ "$pfad" == "/srv/$TO" ]]; then
|
||||
# Das Verzeichnis selbst wird das Archiv -- sein Inhalt bleibt, wie er ist.
|
||||
for eintrag in "$pfad"/* "$pfad"/.[!.]*; do
|
||||
[[ -e "$eintrag" ]] || continue
|
||||
mv "$eintrag" "$BEISEITE/"
|
||||
done
|
||||
rmdir "$pfad"
|
||||
info "/srv/$TO -> $BEISEITE/"
|
||||
continue
|
||||
fi
|
||||
if [[ -e "$ziel" ]]; then
|
||||
ziel="$ziel-$(date +%Y%m%d-%H%M%S)"
|
||||
warn "Im Archiv lag schon etwas mit diesem Namen -- ablegen als $(basename "$ziel")"
|
||||
fi
|
||||
mv "$pfad" "$ziel"
|
||||
info "$pfad -> $ziel"
|
||||
done
|
||||
mkdir -p "$BEISEITE/systemd-alt"
|
||||
for unit in "/etc/systemd/system/$TO.service" "/etc/systemd/system/$TO-check.service" \
|
||||
"/etc/systemd/system/$TO-check.timer" "/etc/systemd/system/$TO-check-restart.service" \
|
||||
"/etc/systemd/system/$TO-check-restart.timer"; do
|
||||
[[ -f "$unit" ]] || continue
|
||||
systemctl disable --now "$(basename "$unit")" >/dev/null 2>&1 || true
|
||||
mv "$unit" "$BEISEITE/systemd-alt/"
|
||||
info "$(basename "$unit") archiviert"
|
||||
done
|
||||
for site in "/etc/nginx/sites-enabled/$TO" "/etc/nginx/sites-available/$TO"; do
|
||||
[[ -e "$site" ]] || continue
|
||||
[[ -L "$site" ]] && { rm -f "$site"; continue; }
|
||||
mv "$site" "$BEISEITE/systemd-alt/nginx-site"
|
||||
info "nginx-Site archiviert"
|
||||
done
|
||||
for motd in "/etc/update-motd.d/50-$TO"; do
|
||||
[[ -f "$motd" ]] && grep -q "motd_banner.py" "$motd" 2>/dev/null && rm -f "$motd" \
|
||||
&& info "Banner der Fassung 1.x entfernt"
|
||||
done
|
||||
info "Alles Verdraengte liegt unter $BEISEITE"
|
||||
fi
|
||||
|
||||
# -- 3. Umziehen ------------------------------------------------------------- #
|
||||
step "Umziehen"
|
||||
for paar in "${VERSCHIEBEN[@]}"; do
|
||||
quelle="${paar%%:*}"; ziel="${paar##*:}"
|
||||
[[ -e "$quelle" ]] || continue
|
||||
[[ -e "$ziel" ]] && fail "$ziel existiert bereits -- Abbruch, damit nichts vermischt wird"
|
||||
mkdir -p "$(dirname "$ziel")"
|
||||
mv "$quelle" "$ziel"
|
||||
info "$quelle -> $ziel"
|
||||
done
|
||||
|
||||
# -- 4. Namen in den Dateien nachziehen -------------------------------------- #
|
||||
step "Namen nachziehen"
|
||||
ENV_FILE="/etc/tesm/$TO.env"
|
||||
if [[ -f "$ENV_FILE" ]]; then
|
||||
# Der SITE_KEY bestimmt den Namen des Sitzungscookies. Bleibt er alt, heisst
|
||||
# das Cookie weiter wie die alte Instanz -- unauffaellig und falsch.
|
||||
sed -i "s#^${ENV_PREFIX}SITE_KEY=.*#${ENV_PREFIX}SITE_KEY=$TO#" "$ENV_FILE"
|
||||
info "SITE_KEY=$TO"
|
||||
fi
|
||||
|
||||
# Pfade in der Datenbank, die den Namen enthalten.
|
||||
python3 - "$TO" "$FROM" "/srv/$TO/data/app.db" <<'PY'
|
||||
import sqlite3, sys
|
||||
neu, alt, datenbank = sys.argv[1], sys.argv[2], sys.argv[3]
|
||||
schluessel = ("web_cert_path", "web_key_path", "web_acme_webroot")
|
||||
try:
|
||||
conn = sqlite3.connect(datenbank, timeout=5.0)
|
||||
except sqlite3.Error as fehler:
|
||||
print(f" Datenbank nicht schreibbar: {fehler}")
|
||||
raise SystemExit(0)
|
||||
try:
|
||||
with conn:
|
||||
for key in schluessel:
|
||||
row = conn.execute("SELECT value FROM settings WHERE key=?", (key,)).fetchone()
|
||||
if not row or not row[0]:
|
||||
continue
|
||||
wert = str(row[0])
|
||||
ersetzt = wert.replace(alt, neu)
|
||||
if ersetzt != wert:
|
||||
conn.execute("UPDATE settings SET value=? WHERE key=?", (ersetzt, key))
|
||||
print(f" {key}: {wert} -> {ersetzt}")
|
||||
finally:
|
||||
conn.close()
|
||||
PY
|
||||
|
||||
# -- 5. Alte Konfiguration entfernen ----------------------------------------- #
|
||||
step "Alte Konfiguration entfernen"
|
||||
for pfad in "${ENTFERNEN[@]}"; do
|
||||
[[ -e "$pfad" ]] || continue
|
||||
rm -f "$pfad"
|
||||
info "$pfad"
|
||||
done
|
||||
systemctl daemon-reload
|
||||
|
||||
# -- 6. Unter dem neuen Namen einrichten ------------------------------------- #
|
||||
# Das venv wird verworfen und neu gebaut. Es ist abgeleiteter Zustand, und
|
||||
# seine Konsolenskripte tragen den absoluten Pfad im Shebang: nach dem Umzug
|
||||
# zeigt "venv/bin/gunicorn" auf einen Interpreter, den es nicht mehr gibt, und
|
||||
# systemd meldet nur "No such file or directory" -- fuer eine Datei, die
|
||||
# sichtbar da ist. Live auf POETEST genau so passiert.
|
||||
if [[ -d "/srv/$TO/venv" ]]; then
|
||||
step "venv verwerfen (Shebangs tragen den alten Pfad)"
|
||||
rm -rf "/srv/$TO/venv"
|
||||
info "wird von install.sh neu gebaut"
|
||||
fi
|
||||
|
||||
step "Unter dem neuen Namen einrichten"
|
||||
INSTALL_ARGS=(--app "$APP" --yes)
|
||||
[[ "$TO" != "$APP" ]] && INSTALL_ARGS+=(--instance "${TO#"$APP"-}")
|
||||
bash "$SCRIPT_DIR/install.sh" "${INSTALL_ARGS[@]}"
|
||||
|
||||
# -- 7. nginx-Site aus den Einstellungen neu schreiben ----------------------- #
|
||||
# install.sh schreibt die Site aus seiner Vorlage, sobald sich die alias-Pfade
|
||||
# aendern -- und die aendern sich hier zwangslaeufig. Der Name der Site steht
|
||||
# aber in der Datenbank, nicht in der Vorlage. Ohne diesen Schritt stuenden
|
||||
# zwei Sites mit "_" auf Port 80 und die erste gewinnt.
|
||||
if [[ -n "$DOMAIN" ]]; then
|
||||
step "Webserver-Konfiguration erneuern"
|
||||
# --apply nicht vergessen: ohne ihn speichert web-setup nur die Einstellungen
|
||||
# und laesst die Site-Datei stehen, die install.sh aus seiner Vorlage
|
||||
# geschrieben hat -- mit "server_name _" und ohne TLS.
|
||||
WEB_ARGS=(web-setup --apply --domain "$DOMAIN")
|
||||
if [[ "$HTTPS_AN" == "1" ]]; then
|
||||
WEB_ARGS+=(--https)
|
||||
[[ "$HSTS_AN" == "1" ]] && WEB_ARGS+=(--hsts)
|
||||
# Das Zertifikat ist mitgezogen und sein Pfad in der Datenbank nachgefuehrt
|
||||
# -- "web-setup --https" laesst es dann unangetastet. Ein neues erzeugen
|
||||
# wuerde nur eine weitere Browserwarnung produzieren. Nur wenn nach dem
|
||||
# Umzug wirklich keines da ist, wird eines erzeugt.
|
||||
CERT_NEU="${CERT_ALT//$FROM/$TO}"
|
||||
if [[ -n "$CERT_NEU" && -f "$CERT_NEU" ]]; then
|
||||
info "HTTPS mit dem vorhandenen Zertifikat: $CERT_NEU"
|
||||
else
|
||||
WEB_ARGS+=(--self-signed)
|
||||
warn "Zertifikat nicht gefunden (${CERT_NEU:-keins hinterlegt}) -- es wird ein neues"
|
||||
warn "selbstsigniertes erzeugt. Der Browser warnt dann einmal erneut."
|
||||
fi
|
||||
fi
|
||||
if sudo -u "$APP" "/usr/local/bin/$TO-admin" "${WEB_ARGS[@]}"; then
|
||||
info "Site neu geschrieben: server_name $DOMAIN"
|
||||
else
|
||||
warn "web-setup ist fehlgeschlagen -- bitte in der Anwendung unter"
|
||||
warn "'Verwaltung -> Webserver und TLS' die Domain eintragen und uebernehmen."
|
||||
fi
|
||||
fi
|
||||
|
||||
# -- 8. Ergebnis ------------------------------------------------------------- #
|
||||
step "Fertig"
|
||||
info "Verzeichnis : /srv/$TO"
|
||||
info "Dienst : systemctl status $TO.service"
|
||||
info "Werkzeug : $TO-admin"
|
||||
info "Aktualisieren: sudo bash /srv/$TO/deploy/update.sh $APP$([[ "$TO" != "$APP" ]] && echo " latest --instance ${TO#"$APP"-}")"
|
||||
[[ -n "$BEISEITE" ]] && info "Fassung 1.x : $BEISEITE (Units und Site darin unter systemd-alt/)"
|
||||
echo
|
||||
warn "Alle Benutzer sind abgemeldet: das Sitzungscookie heisst jetzt anders."
|
||||
@@ -0,0 +1,9 @@
|
||||
# Von TESM installiert. Der Dienstbenutzer darf genau ein Programm als root
|
||||
# aufrufen -- den Helfer. Dessen Argumentpruefung ist die eigentliche
|
||||
# Sicherheitsgrenze; sie liegt in /usr/local/lib/tesm/tesm-helper und gehoert
|
||||
# root (Modus 0755).
|
||||
#
|
||||
# Bewusst KEIN "ALL=(ALL) NOPASSWD: ALL". Der Vorgaenger loeste dasselbe
|
||||
# Problem, indem die gesamte Anwendung als root lief.
|
||||
Defaults:tesm !requiretty
|
||||
tesm ALL=(root) NOPASSWD: /usr/local/lib/tesm/tesm-helper
|
||||
@@ -0,0 +1,9 @@
|
||||
# Von TESM installiert. Der Dienstbenutzer darf genau ein Programm als root
|
||||
# aufrufen -- den Helfer. Dessen Argumentpruefung ist die eigentliche
|
||||
# Sicherheitsgrenze; sie liegt in /usr/local/lib/tesm/tesm-helper und gehoert
|
||||
# root (Modus 0755).
|
||||
#
|
||||
# Bewusst KEIN "ALL=(ALL) NOPASSWD: ALL". Der Vorgaenger loeste dasselbe
|
||||
# Problem, indem die gesamte Anwendung als root lief.
|
||||
Defaults:tesm-license !requiretty
|
||||
tesm-license ALL=(root) NOPASSWD: /usr/local/lib/tesm/tesm-helper
|
||||
@@ -0,0 +1,78 @@
|
||||
[Unit]
|
||||
Description=TESM-Lizenzserver
|
||||
Documentation=file:/srv/tesm-license/docs/BETRIEB.md
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=notify-reload
|
||||
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=TESM_LICENSE_WEB_PROCESS=1
|
||||
Environment=TESM_LICENSE_COOKIE_SECURE=1
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
ExecStart=/srv/tesm-license/venv/bin/gunicorn \
|
||||
--workers 1 --worker-class gthread --threads 8 \
|
||||
--bind 127.0.0.1:5001 \
|
||||
--access-logfile - --error-logfile - \
|
||||
tesm_license.wsgi:app
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
# -- Haertung ---------------------------------------------------------------
|
||||
# Der Dienst braucht kein Root, keine neuen Privilegien und keinen Zugriff auf
|
||||
# das System ausserhalb seiner eigenen Verzeichnisse. Was er trotzdem braucht
|
||||
# (systemctl, nginx, netplan), laeuft ueber den Helfer und sudo.
|
||||
# Bewusst **kein** NoNewPrivileges: der Dienst ruft den privilegierten Helfer
|
||||
# ueber sudo auf, und sudo ist setuid. Unter NoNewPrivileges verweigert der
|
||||
# Kernel die Rechteerhoehung, sudo bricht ab -- und damit jede Systemaktion aus
|
||||
# der Oberflaeche. Die Sicherheitsgrenze liegt nicht hier, sondern in der
|
||||
# sudoers-Regel (genau ein erlaubter Pfad) und im Helfer selbst (jedes Argument
|
||||
# gegen eine Positivliste). Siehe docs/SICHERHEIT.md.
|
||||
PrivateTmp=yes
|
||||
PrivateDevices=yes
|
||||
# ProtectSystem=full statt strict: "strict" sperrt den **ganzen Prozessbaum**
|
||||
# schreibgeschuetzt -- auch den Helfer, der als root gerade /etc/nginx,
|
||||
# /etc/netplan und /etc/kea schreiben soll. Gemessen: unter "strict" scheitert
|
||||
# schon "nginx -t". Mit "full" bleiben /usr und /boot schreibgeschuetzt, /etc
|
||||
# ist im Namensraum wieder erreichbar -- der unprivilegierte Dienst kommt
|
||||
# trotzdem nicht heran, denn die Dateien gehoeren root. Die Grenze ist damit
|
||||
# die gewoehnliche Dateiberechtigung plus die sudoers-Regel, nicht dieser
|
||||
# Schalter.
|
||||
ProtectSystem=full
|
||||
ProtectHome=yes
|
||||
ProtectKernelTunables=yes
|
||||
ProtectKernelModules=yes
|
||||
ProtectKernelLogs=yes
|
||||
ProtectControlGroups=yes
|
||||
ProtectClock=yes
|
||||
ProtectHostname=yes
|
||||
ProtectProc=invisible
|
||||
RestrictSUIDSGID=yes
|
||||
RestrictRealtime=yes
|
||||
RestrictNamespaces=yes
|
||||
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
|
||||
LockPersonality=yes
|
||||
MemoryDenyWriteExecute=no
|
||||
SystemCallArchitectures=native
|
||||
SystemCallFilter=@system-service
|
||||
SystemCallErrorNumber=EPERM
|
||||
# Bewusst **kein** einschraenkendes CapabilityBoundingSet: sudo braucht
|
||||
# CAP_SETUID/CAP_SETGID, und der Helfer dahinter braucht Root. Was hier fehlt,
|
||||
# kann auch der Helfer nicht mehr erlangen -- gemessen: jede Einschraenkung
|
||||
# fuehrt zu "sudo: unable to change to root gid". Die Grenze ist die
|
||||
# sudoers-Regel und die Argumentpruefung im Helfer, nicht dieser Schalter.
|
||||
UMask=0027
|
||||
# Genau die Pfade, die der privilegierte Helfer schreibt -- nicht mehr.
|
||||
# ProtectSystem=full sperrt /usr, /boot **und /etc**; ohne diese Freigaben
|
||||
# scheitert der Helfer mit "Read-only file system", obwohl er root ist
|
||||
# (gemessen). Der unprivilegierte Dienst kommt hier trotzdem nicht heran: die
|
||||
# Dateien gehoeren root, und sudo laesst nur den einen Helferpfad zu.
|
||||
ReadWritePaths=/srv/tesm-license/data /var/log/tesm-license /etc/nginx /etc/netplan /etc/logrotate.d /etc/tesm /run
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,62 @@
|
||||
[Unit]
|
||||
Description=TESM -- Ueberwachung der PoE-Endgeraete
|
||||
After=network-online.target tesm.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
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-monitor
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
|
||||
# Das Pruefintervall liest der Prozess bei jedem Durchlauf frisch aus der
|
||||
# Datenbank -- der naechtliche Neustart-Timer des Vorgaengers entfaellt damit.
|
||||
# Bewusst **kein** NoNewPrivileges: der Dienst ruft den privilegierten Helfer
|
||||
# ueber sudo auf, und sudo ist setuid. Unter NoNewPrivileges verweigert der
|
||||
# Kernel die Rechteerhoehung, sudo bricht ab -- und damit jede Systemaktion aus
|
||||
# der Oberflaeche. Die Sicherheitsgrenze liegt nicht hier, sondern in der
|
||||
# sudoers-Regel (genau ein erlaubter Pfad) und im Helfer selbst (jedes Argument
|
||||
# gegen eine Positivliste). Siehe docs/SICHERHEIT.md.
|
||||
PrivateTmp=yes
|
||||
# ProtectSystem=full statt strict: "strict" sperrt den **ganzen Prozessbaum**
|
||||
# schreibgeschuetzt -- auch den Helfer, der als root gerade /etc/nginx,
|
||||
# /etc/netplan und /etc/kea schreiben soll. Gemessen: unter "strict" scheitert
|
||||
# schon "nginx -t". Mit "full" bleiben /usr und /boot schreibgeschuetzt, /etc
|
||||
# ist im Namensraum wieder erreichbar -- der unprivilegierte Dienst kommt
|
||||
# trotzdem nicht heran, denn die Dateien gehoeren root. Die Grenze ist damit
|
||||
# die gewoehnliche Dateiberechtigung plus die sudoers-Regel, nicht dieser
|
||||
# Schalter.
|
||||
ProtectSystem=full
|
||||
ProtectHome=yes
|
||||
ProtectKernelTunables=yes
|
||||
ProtectKernelModules=yes
|
||||
ProtectControlGroups=yes
|
||||
RestrictSUIDSGID=yes
|
||||
RestrictNamespaces=yes
|
||||
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
|
||||
LockPersonality=yes
|
||||
SystemCallArchitectures=native
|
||||
UMask=0027
|
||||
# Genau die Pfade, die der privilegierte Helfer schreibt -- nicht mehr.
|
||||
# ProtectSystem=full sperrt /usr, /boot **und /etc**; ohne diese Freigaben
|
||||
# scheitert der Helfer mit "Read-only file system", obwohl er root ist
|
||||
# (gemessen). Der unprivilegierte Dienst kommt hier trotzdem nicht heran: die
|
||||
# Dateien gehoeren root, und sudo laesst nur den einen Helferpfad zu.
|
||||
ReadWritePaths=/srv/tesm/data /var/log/tesm /etc/tesm /run
|
||||
# ping braucht CAP_NET_RAW -- als einzige Ausnahme, und nur diese.
|
||||
AmbientCapabilities=CAP_NET_RAW
|
||||
# Bewusst **kein** einschraenkendes CapabilityBoundingSet: sudo braucht
|
||||
# CAP_SETUID/CAP_SETGID, und der Helfer dahinter braucht Root. Was hier fehlt,
|
||||
# kann auch der Helfer nicht mehr erlangen -- gemessen: jede Einschraenkung
|
||||
# fuehrt zu "sudo: unable to change to root gid". Die Grenze ist die
|
||||
# sudoers-Regel und die Argumentpruefung im Helfer, nicht dieser Schalter.
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,91 @@
|
||||
[Unit]
|
||||
Description=TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten
|
||||
Documentation=file:/srv/tesm/docs/BETRIEB.md
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=notify-reload
|
||||
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=TESM_WEB_PROCESS=1
|
||||
Environment=TESM_COOKIE_SECURE=1
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
ExecStart=/srv/tesm/venv/bin/gunicorn \
|
||||
--workers 1 --worker-class gthread --threads 16 --timeout 0 \
|
||||
--bind 127.0.0.1:5000 \
|
||||
--access-logfile - --error-logfile - \
|
||||
tesm.wsgi:app
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
# -- Haertung ---------------------------------------------------------------
|
||||
# Der Dienst braucht kein Root, keine neuen Privilegien und keinen Zugriff auf
|
||||
# das System ausserhalb seiner eigenen Verzeichnisse. Was er trotzdem braucht
|
||||
# (systemctl, nginx, netplan), laeuft ueber den Helfer und sudo.
|
||||
# Bewusst **kein** NoNewPrivileges: der Dienst ruft den privilegierten Helfer
|
||||
# ueber sudo auf, und sudo ist setuid. Unter NoNewPrivileges verweigert der
|
||||
# Kernel die Rechteerhoehung, sudo bricht ab -- und damit jede Systemaktion aus
|
||||
# der Oberflaeche. Die Sicherheitsgrenze liegt nicht hier, sondern in der
|
||||
# sudoers-Regel (genau ein erlaubter Pfad) und im Helfer selbst (jedes Argument
|
||||
# gegen eine Positivliste). Siehe docs/SICHERHEIT.md.
|
||||
PrivateTmp=yes
|
||||
PrivateDevices=yes
|
||||
# ProtectSystem=full statt strict: "strict" sperrt den **ganzen Prozessbaum**
|
||||
# schreibgeschuetzt -- auch den Helfer, der als root gerade /etc/nginx,
|
||||
# /etc/netplan und /etc/kea schreiben soll. Gemessen: unter "strict" scheitert
|
||||
# schon "nginx -t". Mit "full" bleiben /usr und /boot schreibgeschuetzt, /etc
|
||||
# ist im Namensraum wieder erreichbar -- der unprivilegierte Dienst kommt
|
||||
# trotzdem nicht heran, denn die Dateien gehoeren root. Die Grenze ist damit
|
||||
# die gewoehnliche Dateiberechtigung plus die sudoers-Regel, nicht dieser
|
||||
# Schalter.
|
||||
ProtectSystem=full
|
||||
ProtectHome=yes
|
||||
ProtectKernelTunables=yes
|
||||
ProtectKernelModules=yes
|
||||
ProtectKernelLogs=yes
|
||||
ProtectControlGroups=yes
|
||||
ProtectClock=yes
|
||||
ProtectHostname=yes
|
||||
ProtectProc=invisible
|
||||
RestrictSUIDSGID=yes
|
||||
RestrictRealtime=yes
|
||||
RestrictNamespaces=yes
|
||||
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
|
||||
LockPersonality=yes
|
||||
MemoryDenyWriteExecute=no
|
||||
SystemCallArchitectures=native
|
||||
# "@mount" zusaetzlich zu @system-service: die Dateifreigaben werden ueber
|
||||
# mount.cifs eingebunden, und @system-service enthaelt mount/umount2 nicht.
|
||||
# Ohne diese Ergaenzung scheitert jede Einbindung mit "mount error(1):
|
||||
# Operation not permitted" -- gemessen, und zwar unabhaengig von Rechten,
|
||||
# Zugangsdaten und Mount-Optionen: aus einer normalen Root-Shell gelingt
|
||||
# derselbe Aufruf sofort. Der Umweg ueber nsenter in den Namensraum des Wirts
|
||||
# ist keine Alternative, weil "RestrictNamespaces=yes" setns verbietet.
|
||||
#
|
||||
# Eingebunden wird damit im Mount-Namensraum *dieses* Dienstes. Das ist genau
|
||||
# richtig: nur er liest die Dateien, andere Prozesse haben dort nichts zu
|
||||
# suchen, und mit dem Dienst verschwinden die Einbindungen wieder. Die
|
||||
# Aufraeumaufgabe fuer abgelaufene Einbindungen laeuft deshalb im Webprozess --
|
||||
# der Ueberwachungsdienst koennte sie nicht einmal sehen.
|
||||
SystemCallFilter=@system-service @mount
|
||||
SystemCallErrorNumber=EPERM
|
||||
# Bewusst **kein** einschraenkendes CapabilityBoundingSet: sudo braucht
|
||||
# CAP_SETUID/CAP_SETGID, und der Helfer dahinter braucht Root. Was hier fehlt,
|
||||
# kann auch der Helfer nicht mehr erlangen -- gemessen: jede Einschraenkung
|
||||
# fuehrt zu "sudo: unable to change to root gid". Die Grenze ist die
|
||||
# sudoers-Regel und die Argumentpruefung im Helfer, nicht dieser Schalter.
|
||||
UMask=0027
|
||||
# Genau die Pfade, die der privilegierte Helfer schreibt -- nicht mehr.
|
||||
# ProtectSystem=full sperrt /usr, /boot **und /etc**; ohne diese Freigaben
|
||||
# scheitert der Helfer mit "Read-only file system", obwohl er root ist
|
||||
# (gemessen). Der unprivilegierte Dienst kommt hier trotzdem nicht heran: die
|
||||
# Dateien gehoeren root, und sudo laesst nur den einen Helferpfad zu.
|
||||
ReadWritePaths=/srv/tesm/data /var/log/tesm /mnt/tesm-shares /etc/nginx /etc/netplan /etc/kea /etc/logrotate.d /etc/tesm /var/lib/kea /var/log/kea /run
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,382 @@
|
||||
#!/bin/bash
|
||||
# =============================================================================
|
||||
# tesm-helper -- die einzige Stelle, an der TESM Root-Rechte bekommt.
|
||||
#
|
||||
# Der Webprozess laeuft unprivilegiert. Alles, was Root braucht, geht durch
|
||||
# dieses Skript: es gehoert root, ist per sudoers mit NOPASSWD nur fuer genau
|
||||
# diesen Pfad freigegeben und prueft jedes Argument gegen eine Positivliste.
|
||||
#
|
||||
# Damit ist die Sicherheitsgrenze klein, an einer Stelle und pruefbar -- statt
|
||||
# ueber tausende Zeilen Anwendungscode verteilt. Der Vorgaenger liess die
|
||||
# komplette Flask-Anwendung als root laufen; eine einzige Luecke in irgendeinem
|
||||
# Pfad haette den Host bedeutet.
|
||||
#
|
||||
# Regeln fuer Aenderungen an dieser Datei:
|
||||
# * Jedes neue Verb braucht eine Argumentpruefung. Ohne Pruefung kein Verb.
|
||||
# * Nie "$@" ungeprueft an eine Shell weiterreichen.
|
||||
# * Nie einen Pfad akzeptieren, der nicht gegen eine feste Liste geprueft ist.
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
umask 022
|
||||
|
||||
APP_KEYS_ALLOWED=("tesm" "tesm-license")
|
||||
SERVICES_ALLOWED=(
|
||||
"tesm.service" "tesm-monitor.service"
|
||||
"tesm-license.service"
|
||||
"kea-dhcp4-server" "kea-dhcp4-server.service"
|
||||
"nginx" "nginx.service"
|
||||
)
|
||||
PACKAGES_ALLOWED=("kea-dhcp4-server" "kea-common" "cifs-utils" "certbot" "expect"
|
||||
"samba-common-bin")
|
||||
|
||||
fail() { echo "tesm-helper: $*" >&2; exit 2; }
|
||||
|
||||
require_root() {
|
||||
[[ "$(id -u)" -eq 0 ]] || fail "muss als root laufen (ueber sudo aufrufen)"
|
||||
}
|
||||
|
||||
# -- Argumentpruefungen ------------------------------------------------------
|
||||
|
||||
check_in_list() {
|
||||
local needle="$1"; shift
|
||||
local item
|
||||
for item in "$@"; do
|
||||
[[ "$needle" == "$item" ]] && return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# Name einer Installation auf diesem Host: entweder die Hauptinstallation
|
||||
# ("tesm") oder eine benannte Instanz daneben ("tesm-opus"). Mehr als diese
|
||||
# beiden Formen gibt es nicht -- so kann keine erfundene Zeichenkette einen
|
||||
# Pfad ausserhalb des vorgesehenen Namensraums erzeugen.
|
||||
check_app_key() {
|
||||
local key="$1" app
|
||||
for app in "${APP_KEYS_ALLOWED[@]}"; do
|
||||
[[ "$key" == "$app" ]] && return 0
|
||||
[[ "$key" == "$app-"* && "${key#"$app"-}" =~ ^[a-z0-9][a-z0-9-]{0,15}$ ]] && return 0
|
||||
done
|
||||
fail "unbekannter App-Schluessel: $key"
|
||||
}
|
||||
|
||||
check_service() {
|
||||
check_in_list "$1" "${SERVICES_ALLOWED[@]}" || fail "Dienst nicht erlaubt: $1"
|
||||
}
|
||||
|
||||
check_hostname() {
|
||||
[[ "$1" =~ ^[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?$ ]] || fail "ungueltiger Hostname"
|
||||
}
|
||||
|
||||
check_timezone() {
|
||||
[[ "$1" =~ ^[A-Za-z]+(/[A-Za-z0-9_+-]+){0,2}$ ]] || fail "ungueltige Zeitzone"
|
||||
[[ -f "/usr/share/zoneinfo/$1" ]] || fail "unbekannte Zeitzone"
|
||||
}
|
||||
|
||||
check_domain() {
|
||||
[[ "$1" =~ ^[A-Za-z0-9]([A-Za-z0-9.-]{0,251}[A-Za-z0-9])?$ ]] || fail "ungueltige Domain"
|
||||
}
|
||||
|
||||
check_email() {
|
||||
[[ "$1" =~ ^[^@[:space:]]+@[^@[:space:]]+\.[^@[:space:]]+$ ]] || fail "ungueltige E-Mail"
|
||||
}
|
||||
|
||||
check_unc() {
|
||||
[[ "$1" =~ ^//[A-Za-z0-9._-]+/[^[:space:]]+$ || "$1" =~ ^\\\\[A-Za-z0-9._-]+\\[^[:space:]]+$ ]] \
|
||||
|| fail "ungueltiger Freigabepfad"
|
||||
}
|
||||
|
||||
check_mount_target() {
|
||||
# Nur unterhalb der bekannten Einbindungswurzeln, kein ".." im Pfad.
|
||||
[[ "$1" == /mnt/tesm-shares/* ]] || fail "Einbindungsziel liegt ausserhalb von /mnt/tesm-shares"
|
||||
[[ "$1" != *".."* ]] || fail "unzulaessiger Pfad"
|
||||
}
|
||||
|
||||
check_readable_path() {
|
||||
# Positivliste der Dateien, die die Anwendung lesen darf, obwohl sie ihr
|
||||
# nicht gehoeren. Ohne die ".."-Sperre koennte ein Muster wie
|
||||
# /etc/nginx/sites-available/../../../root/tesm die Liste unterlaufen: der
|
||||
# Basisname waere "tesm" und damit gueltig, gelesen wuerde aber /root/tesm.
|
||||
[[ "$1" != *".."* ]] || fail "unzulaessiger Pfad"
|
||||
case "$1" in
|
||||
/var/log/kea/kea-dhcp4.log) ;;
|
||||
/var/lib/kea/kea-leases4.csv) ;;
|
||||
/etc/netplan/90-*.yaml)
|
||||
check_app_key "$(basename "$1" .yaml | sed 's/^90-//')" ;;
|
||||
/etc/nginx/sites-available/*)
|
||||
check_app_key "$(basename "$1")" ;;
|
||||
*) fail "Datei nicht in der Leseliste: $1" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
read_stdin_limited() {
|
||||
# Maximal 1 MiB aus stdin -- verhindert, dass ein Fehler die Platte fuellt.
|
||||
head -c 1048576
|
||||
}
|
||||
|
||||
write_atomic() {
|
||||
local target="$1" mode="$2" owner="$3"
|
||||
local tmp
|
||||
tmp="$(mktemp "${target}.XXXXXX")"
|
||||
read_stdin_limited > "$tmp"
|
||||
chmod "$mode" "$tmp"
|
||||
[[ -n "$owner" ]] && chown "$owner" "$tmp"
|
||||
mv -f "$tmp" "$target"
|
||||
}
|
||||
|
||||
# -- Verben ------------------------------------------------------------------
|
||||
|
||||
verb="${1:-}"; shift || true
|
||||
require_root
|
||||
|
||||
case "$verb" in
|
||||
|
||||
service-status)
|
||||
check_service "${1:?Dienst fehlt}"
|
||||
systemctl is-active "$1" || true
|
||||
systemctl is-enabled "$1" || true
|
||||
;;
|
||||
|
||||
service-start|service-stop|service-restart|service-enable|service-disable)
|
||||
check_service "${1:?Dienst fehlt}"
|
||||
systemctl "${verb#service-}" "$1"
|
||||
;;
|
||||
|
||||
nginx-test)
|
||||
nginx -t
|
||||
;;
|
||||
|
||||
nginx-reload)
|
||||
nginx -t && systemctl reload nginx
|
||||
;;
|
||||
|
||||
nginx-write)
|
||||
check_app_key "${1:?App-Schluessel fehlt}"
|
||||
target="/etc/nginx/sites-available/$1"
|
||||
write_atomic "$target" 0644 "root:root"
|
||||
ln -sfn "$target" "/etc/nginx/sites-enabled/$1"
|
||||
rm -f /etc/nginx/sites-enabled/default
|
||||
;;
|
||||
|
||||
nginx-apply)
|
||||
# Schreiben, pruefen, neu laden -- als *eine* Operation, mit Ruecknahme.
|
||||
#
|
||||
# Getrennte Verben waren hier ein echter Fehler: "nginx-write" verlinkt die
|
||||
# Datei sofort nach sites-enabled. Schlug das anschliessende "nginx -t"
|
||||
# fehl, blieb eine kaputte Konfiguration aktiviert liegen. Der laufende
|
||||
# nginx merkte davon nichts -- aber der naechste Reload oder Neustart durch
|
||||
# irgendwen (auch fuer eine voellig andere Site) schlug fehl. Aus einem
|
||||
# abgelehnten Formular wurde so ein Ausfall des ganzen Webservers.
|
||||
check_app_key "${1:?App-Schluessel fehlt}"
|
||||
target="/etc/nginx/sites-available/$1"
|
||||
link="/etc/nginx/sites-enabled/$1"
|
||||
backup="$(mktemp /run/tesm-nginx.XXXXXX)"
|
||||
had_file=0; had_link=0
|
||||
[[ -f "$target" ]] && { cp -a "$target" "$backup"; had_file=1; }
|
||||
[[ -L "$link" || -e "$link" ]] && had_link=1
|
||||
|
||||
write_atomic "$target" 0644 "root:root"
|
||||
ln -sfn "$target" "$link"
|
||||
|
||||
if nginx -t 2>&1; then
|
||||
rm -f /etc/nginx/sites-enabled/default
|
||||
systemctl reload nginx
|
||||
rm -f "$backup"
|
||||
echo "uebernommen"
|
||||
else
|
||||
# Genau in den Zustand zurueck, der vorher galt.
|
||||
if [[ $had_file -eq 1 ]]; then
|
||||
cp -a "$backup" "$target"
|
||||
else
|
||||
rm -f "$target"
|
||||
fi
|
||||
[[ $had_link -eq 1 ]] || rm -f "$link"
|
||||
rm -f "$backup"
|
||||
# Nach der Ruecknahme muss die Konfiguration wieder sauber sein. Ist sie
|
||||
# es nicht, war sie schon vorher kaputt -- das gehoert ins Protokoll.
|
||||
nginx -t >/dev/null 2>&1 || echo "Warnung: nginx-Konfiguration war schon vorher fehlerhaft." >&2
|
||||
fail "nginx hat die Konfiguration abgelehnt -- alter Stand wiederhergestellt"
|
||||
fi
|
||||
;;
|
||||
|
||||
netplan-write)
|
||||
check_app_key "${1:?App-Schluessel fehlt}"
|
||||
# Eigene Datei je Anwendung: der Vorgaenger schrieb aus beiden Apps nach
|
||||
# /etc/netplan/90-tesm.yaml und ueberschrieb sich damit gegenseitig.
|
||||
write_atomic "/etc/netplan/90-$1.yaml" 0600 "root:root"
|
||||
;;
|
||||
|
||||
netplan-apply)
|
||||
netplan apply
|
||||
;;
|
||||
|
||||
hostname-set)
|
||||
check_hostname "${1:?Hostname fehlt}"
|
||||
hostnamectl set-hostname "$1"
|
||||
;;
|
||||
|
||||
timezone-set)
|
||||
check_timezone "${1:?Zeitzone fehlt}"
|
||||
timedatectl set-timezone "$1"
|
||||
;;
|
||||
|
||||
kea-write)
|
||||
install -d -o root -g root -m 0755 /etc/kea
|
||||
# Kea laeuft als eigener, unprivilegierter Benutzer und muss seine
|
||||
# Lease-Datei und sein Protokoll schreiben koennen. Vor dem Test anlegen,
|
||||
# sonst beanstandet Kea die fehlenden Verzeichnisse.
|
||||
install -d -o _kea -g _kea -m 0750 /var/lib/kea /var/log/kea 2>/dev/null || true
|
||||
|
||||
# Erst pruefen, dann uebernehmen. Vorher wurde jede Konfiguration
|
||||
# ungeprueft abgelegt -- eine fehlerhafte fiel erst beim naechsten Start
|
||||
# auf, und dann lag der Dienst, bis jemand ins Journal sah.
|
||||
tmp="$(mktemp /etc/kea/kea-dhcp4.conf.XXXXXX)"
|
||||
chmod 0640 "$tmp"; chown root:_kea "$tmp"
|
||||
read_stdin_limited > "$tmp"
|
||||
if command -v kea-dhcp4 >/dev/null 2>&1; then
|
||||
if ! pruefung="$(kea-dhcp4 -t "$tmp" 2>&1)"; then
|
||||
rm -f "$tmp"
|
||||
# Nur die aussagekraeftige Zeile zurueckgeben, nicht das ganze Protokoll.
|
||||
fail "Konfiguration abgelehnt: $(printf '%s' "$pruefung" | grep -m1 -i "error" || printf '%s' "$pruefung" | tail -1)"
|
||||
fi
|
||||
fi
|
||||
mv -f "$tmp" /etc/kea/kea-dhcp4.conf
|
||||
;;
|
||||
|
||||
logrotate-write)
|
||||
check_app_key "${1:?App-Schluessel fehlt}"
|
||||
write_atomic "/etc/logrotate.d/$1" 0644 "root:root"
|
||||
;;
|
||||
|
||||
mount-cifs)
|
||||
check_unc "${1:?Freigabe fehlt}"
|
||||
check_mount_target "${2:?Ziel fehlt}"
|
||||
share="${1//\\//}"
|
||||
target="$2"
|
||||
creds="$(mktemp /run/tesm-cifs.XXXXXX)"
|
||||
chmod 600 "$creds"
|
||||
# Zugangsdaten kommen ueber stdin in eine 0600-Datei -- nie ueber die
|
||||
# Kommandozeile (Prozessliste) und nie ueber die Umgebung
|
||||
# (/proc/<pid>/environ).
|
||||
read_stdin_limited > "$creds"
|
||||
# Eingebunden wird im Mount-Namensraum des aufrufenden Dienstes -- absichtlich:
|
||||
# nur dieser Dienst liest die Dateien, und mit ihm verschwinden die
|
||||
# Einbindungen wieder. Damit der Syscall durchkommt, braucht die Unit
|
||||
# "@mount" im SystemCallFilter. Ohne das scheitert es mit
|
||||
# "mount error(1): Operation not permitted"; gemessen war der Syscall-Filter
|
||||
# die Ursache, nicht der Namensraum. Ein Umweg ueber nsenter in den
|
||||
# Namensraum des Wirts ist keine Option -- "RestrictNamespaces=yes"
|
||||
# verbietet setns, und das Recht dafuer wieder aufzugeben waere teurer als
|
||||
# der Gewinn.
|
||||
mkdir -p "$target"
|
||||
if mount -t cifs "$share" "$target" \
|
||||
-o "credentials=$creds,uid=$(id -u tesm 2>/dev/null || echo 0),gid=$(id -g tesm 2>/dev/null || echo 0),file_mode=0640,dir_mode=0750,vers=3.1.1,seal"
|
||||
then
|
||||
shred -u "$creds" 2>/dev/null || rm -f "$creds"
|
||||
else
|
||||
shred -u "$creds" 2>/dev/null || rm -f "$creds"
|
||||
fail "Einbindung fehlgeschlagen"
|
||||
fi
|
||||
;;
|
||||
|
||||
umount)
|
||||
check_mount_target "${1:?Ziel fehlt}"
|
||||
# Gegenstueck zu mount-cifs, und deshalb im selben Namensraum: loesen kann
|
||||
# nur, wer die Einbindung auch sieht. Ein Aufruf aus einem anderen Dienst
|
||||
# greift ins Leere -- genau deshalb laeuft die Aufraeumaufgabe fuer
|
||||
# abgelaufene Einbindungen im Webprozess und nicht im Ueberwachungsdienst.
|
||||
umount -l "$1" || true
|
||||
rmdir "$1" 2>/dev/null || true
|
||||
;;
|
||||
|
||||
apt-install)
|
||||
[[ $# -gt 0 ]] || fail "kein Paket angegeben"
|
||||
for package in "$@"; do
|
||||
check_in_list "$package" "${PACKAGES_ALLOWED[@]}" || fail "Paket nicht erlaubt: $package"
|
||||
done
|
||||
# apt laeuft ausserhalb des Sandkastens des aufrufenden Dienstes. Der wird
|
||||
# sonst an dpkg und dessen Maintainer-Skripte weitervererbt, und dort
|
||||
# scheitert etwas voellig Beilaeufiges: mit "ProtectKernelLogs=yes" gibt
|
||||
# "dmesg" ein EPERM zurueck, ein Postinst-Skript bricht daran ab, und dpkg
|
||||
# endet mit Code 1 -- die Installation schlaegt fehl, obwohl mit dem Paket
|
||||
# alles in Ordnung ist. Gemessen und mit systemd-run reproduzierbar
|
||||
# behoben: PID 1 startet den Prozess, also ohne unsere Haertung.
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
if command -v systemd-run >/dev/null 2>&1 && [[ -S /run/dbus/system_bus_socket ]]; then
|
||||
# Zwei getrennte Aufrufe, damit keine Shell dazwischen steht: die
|
||||
# Paketnamen gehen als eigene Argumente an apt-get, nicht durch eine
|
||||
# Kommandozeile, die sie erst wieder zerlegen muesste.
|
||||
ausserhalb=(systemd-run --wait --pipe --collect --quiet
|
||||
--setenv=DEBIAN_FRONTEND=noninteractive)
|
||||
"${ausserhalb[@]}" apt-get update -qq
|
||||
"${ausserhalb[@]}" apt-get install -y -qq "$@"
|
||||
else
|
||||
# Kein systemd (Container, Entwicklungsrechner): direkt, wie bisher.
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq "$@"
|
||||
fi
|
||||
;;
|
||||
|
||||
certbot-issue)
|
||||
check_domain "${1:?Domain fehlt}"
|
||||
check_email "${2:?E-Mail fehlt}"
|
||||
webroot="${3:?Webroot fehlt}"
|
||||
[[ "$webroot" == /var/www/* && "$webroot" != *".."* ]] || fail "ungueltiger Webroot"
|
||||
mkdir -p "$webroot"
|
||||
# Bewusst --webroot statt des nginx-Plugins: das Plugin wuerde die von der
|
||||
# Anwendung erzeugte Site-Datei selbst umschreiben.
|
||||
certbot certonly --webroot -w "$webroot" -d "$1" \
|
||||
--email "$2" --agree-tos --non-interactive --keep-until-expiring
|
||||
;;
|
||||
|
||||
selfsigned-issue)
|
||||
# Fuer Installationen ohne oeffentliche Domain: ein selbstsigniertes
|
||||
# Zertifikat, damit Port 443 ueberhaupt bedient werden kann. Bewusst
|
||||
# getrennt vom certbot-Pfad -- hier wird nichts nach draussen gesprochen.
|
||||
check_app_key "${1:?App-Schluessel fehlt}"
|
||||
subject="${2:?Common Name fehlt}"
|
||||
check_domain "$subject"
|
||||
dir="/etc/tesm/certs/$1"
|
||||
install -d -m 0755 /etc/tesm/certs
|
||||
# 0755 auf dem Verzeichnis, nicht 0750: die Anwendung muss das Zertifikat
|
||||
# lesen koennen, um Aussteller und Ablauf anzuzeigen. Geheim ist nur der
|
||||
# private Schluessel -- der bleibt 0640 root:root und wird ausschliesslich
|
||||
# vom nginx-Masterprozess (root) gelesen. Dieselbe Aufteilung wie bei
|
||||
# Let's Encrypt: live/ ist begehbar, privkey.pem nicht lesbar.
|
||||
install -d -m 0755 "$dir"
|
||||
|
||||
# Alternative Namen: alles, was ab Argument 3 kommt -- jeweils geprueft und
|
||||
# nach DNS- bzw. IP-Eintrag sortiert. Kein Argument geht ungeprueft weiter.
|
||||
alt="DNS:$subject"
|
||||
shift 2
|
||||
for name in "$@"; do
|
||||
if [[ "$name" =~ ^[0-9]{1,3}(\.[0-9]{1,3}){3}$ ]]; then
|
||||
alt="$alt,IP:$name"
|
||||
elif [[ "$name" =~ ^[A-Za-z0-9]([A-Za-z0-9.-]{0,251}[A-Za-z0-9])?$ ]]; then
|
||||
[[ "$name" == "$subject" ]] || alt="$alt,DNS:$name"
|
||||
else
|
||||
fail "ungueltiger alternativer Name: $name"
|
||||
fi
|
||||
done
|
||||
|
||||
openssl req -x509 -newkey rsa:3072 -sha256 -days 825 -nodes -keyout "$dir/privkey.pem" -out "$dir/fullchain.pem" -subj "/CN=$subject" -addext "subjectAltName=$alt" -addext "basicConstraints=critical,CA:FALSE" -addext "keyUsage=critical,digitalSignature,keyEncipherment" -addext "extendedKeyUsage=serverAuth" >/dev/null 2>&1 || fail "openssl ist fehlgeschlagen"
|
||||
chown root:root "$dir/privkey.pem" "$dir/fullchain.pem"
|
||||
chmod 0640 "$dir/privkey.pem"
|
||||
# Das Zertifikat selbst ist oeffentlich -- es geht bei jedem
|
||||
# TLS-Handschlag an jeden Client.
|
||||
chmod 0644 "$dir/fullchain.pem"
|
||||
# nginx laeuft als root und liest die Datei selbst -- die Anwendung braucht
|
||||
# den privaten Schluessel nie. Nur der Pfad wird zurueckgegeben.
|
||||
echo "$dir/fullchain.pem"
|
||||
echo "$dir/privkey.pem"
|
||||
;;
|
||||
|
||||
file-read)
|
||||
check_readable_path "${1:?Pfad fehlt}"
|
||||
[[ -f "$1" ]] || exit 0
|
||||
cat "$1"
|
||||
;;
|
||||
|
||||
*)
|
||||
fail "unbekanntes Verb: ${verb:-<leer>}"
|
||||
;;
|
||||
esac
|
||||
@@ -0,0 +1,185 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Live-Banner fuer die SSH-Anmeldung (MOTD).
|
||||
|
||||
Eingebunden ueber ``/etc/update-motd.d/50-<name>``, das ``install.sh``
|
||||
erzeugt. Aufruf mit ``--app`` und ``--root``; alles andere steht in der
|
||||
Datenbank der Installation.
|
||||
|
||||
**Nur Standardbibliothek, kein Flask, kein venv.** Der Vorgaenger importierte
|
||||
dafuer die ganze Anwendung -- bei jeder SSH-Anmeldung. Das ist teuer, und es
|
||||
faellt aus, sobald das venv nicht mehr passt: nach dem Umstieg auf diese
|
||||
Fassung zeigte das alte Banner monatelang eingefrorene Zahlen aus einer
|
||||
Datenbank, die niemand mehr fuellte. Hier wird gelesen, was auf der Platte
|
||||
liegt, und zwar **nur lesend** (``mode=ro``).
|
||||
|
||||
Ein Banner darf eine Anmeldung niemals stoeren: jede Auskunft, die nicht zu
|
||||
holen ist, wird zu einem kurzen Hinweis, und am Ende faengt ``main`` alles ab.
|
||||
Der Aufrufer setzt zusaetzlich ein Zeitlimit.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sqlite3
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
AMBER = "\033[38;2;226;166;60m"
|
||||
DIM = "\033[2m"
|
||||
BOLD = "\033[1m"
|
||||
RESET = "\033[0m"
|
||||
|
||||
#: Kea schreibt seine Leases als CSV; die letzte Zeile je Adresse gilt.
|
||||
#: Dieselbe Datei liest die Anwendung (``services/kea.py``: ``LEASE_FILE``).
|
||||
LEASE_FILE = "/var/lib/kea/kea-leases4.csv"
|
||||
|
||||
#: Zeile unter der Wortmarke. Wortlaut wie in der jeweiligen Anwendung
|
||||
#: (``AUTH_TAGLINE`` in deren ``__init__.py``) -- ein Hygienetest haelt beides
|
||||
#: zusammen.
|
||||
TAGLINE = {
|
||||
"tesm": "ÜBERWACHEN · BOOTEN · ANBINDEN",
|
||||
"tesm-license": "LIZENZIEREN",
|
||||
}
|
||||
|
||||
TITEL = {"tesm": "TimEShepManager", "tesm-license": "Lizenzserver"}
|
||||
|
||||
#: Wortgleich mit ``services/monitor.py``: ``DASHBOARD_COUNT_SQL``. Die
|
||||
#: Statusuebersicht und dieses Banner sollen nicht zwei Wahrheiten haben; ein
|
||||
#: Hygienetest vergleicht die beiden Zeichenketten.
|
||||
DASHBOARD_COUNT_SQL = """
|
||||
SELECT
|
||||
COUNT(*) AS total,
|
||||
SUM(CASE WHEN d.is_active=0 THEN 1 ELSE 0 END) AS disabled,
|
||||
SUM(CASE WHEN d.is_active=1 AND COALESCE(st.state,'unknown')='online' THEN 1 ELSE 0 END) AS online,
|
||||
SUM(CASE WHEN d.is_active=1 AND COALESCE(st.state,'unknown')='offline' THEN 1 ELSE 0 END) AS offline,
|
||||
SUM(CASE WHEN d.is_active=1 AND COALESCE(st.state,'unknown')='unknown' THEN 1 ELSE 0 END) AS unknown,
|
||||
SUM(CASE WHEN d.is_active=1 AND st.state='restarting' THEN 1 ELSE 0 END) AS restarting
|
||||
FROM devices d LEFT JOIN device_status st ON st.device_id = d.id
|
||||
WHERE d.deleted_at IS NULL
|
||||
"""
|
||||
|
||||
|
||||
def _zahl(anzahl: int, singular: str, plural: str) -> str:
|
||||
"""Zahl mit passendem Wort. "1 Clients" liest man jeden Tag einmal zu oft."""
|
||||
return f"{BOLD}{anzahl}{RESET} {singular if anzahl == 1 else plural}"
|
||||
|
||||
|
||||
def _oeffnen(root: Path) -> sqlite3.Connection:
|
||||
pfad = root / "data" / "app.db"
|
||||
conn = sqlite3.connect(f"file:{pfad.as_posix()}?mode=ro", uri=True, timeout=1.0)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
def _flotte(conn: sqlite3.Connection) -> str:
|
||||
row = conn.execute(DASHBOARD_COUNT_SQL).fetchone()
|
||||
online = int(row["online"] or 0)
|
||||
offline = int(row["offline"] or 0)
|
||||
teile = [
|
||||
_zahl(online, "Client online", "Clients online"),
|
||||
f"{BOLD}{offline}{RESET} nicht erreichbar",
|
||||
]
|
||||
# Nur nennen, wenn es sie gibt: eine Null bei "startet neu" ist keine
|
||||
# Auskunft, sondern Fuellmaterial.
|
||||
restarting = int(row["restarting"] or 0)
|
||||
if restarting:
|
||||
teile.append(_zahl(restarting, "startet neu", "starten neu"))
|
||||
unbekannt = int(row["unknown"] or 0)
|
||||
if unbekannt:
|
||||
teile.append(f"{DIM}{unbekannt} ohne Pruefung{RESET}")
|
||||
return " · ".join(teile)
|
||||
|
||||
|
||||
def _dhcp() -> str:
|
||||
"""Aktive Leases aus der Kea-CSV. Laeuft als root, braucht keinen Helfer."""
|
||||
datei = Path(LEASE_FILE)
|
||||
if not datei.is_file():
|
||||
return "keine Lease-Datei"
|
||||
try:
|
||||
zeilen = datei.read_text(encoding="utf-8", errors="replace").splitlines()
|
||||
except OSError:
|
||||
return "Lease-Datei nicht lesbar"
|
||||
if len(zeilen) < 2:
|
||||
return "0 Leases aktiv"
|
||||
kopf = [name.strip().strip('"') for name in zeilen[0].split(",")]
|
||||
try:
|
||||
i_adresse = kopf.index("address")
|
||||
i_ablauf = kopf.index("expire")
|
||||
i_zustand = kopf.index("state")
|
||||
except ValueError:
|
||||
return "Lease-Datei mit unerwarteten Spalten"
|
||||
|
||||
jetzt = int(time.time())
|
||||
letzte: dict[str, tuple[int, str]] = {}
|
||||
for zeile in zeilen[1:]:
|
||||
felder = [feld.strip().strip('"') for feld in zeile.split(",")]
|
||||
if len(felder) < len(kopf):
|
||||
continue
|
||||
try:
|
||||
letzte[felder[i_adresse]] = (int(felder[i_ablauf] or 0), felder[i_zustand] or "0")
|
||||
except ValueError:
|
||||
continue
|
||||
aktiv = sum(1 for ablauf, zustand in letzte.values() if zustand == "0" and ablauf > jetzt)
|
||||
return f"{aktiv} {'Lease' if aktiv == 1 else 'Leases'} aktiv"
|
||||
|
||||
|
||||
def _lizenzen(conn: sqlite3.Connection) -> str:
|
||||
kunden = int(conn.execute("SELECT COUNT(*) FROM license_customers").fetchone()[0] or 0)
|
||||
lizenzen = int(
|
||||
conn.execute(
|
||||
"SELECT COUNT(*) FROM licenses WHERE status IN ('issued','active')"
|
||||
).fetchone()[0]
|
||||
or 0
|
||||
)
|
||||
return (
|
||||
_zahl(kunden, "Kunde", "Kunden")
|
||||
+ " · "
|
||||
+ _zahl(lizenzen, "Lizenz ausgestellt", "Lizenzen ausgestellt")
|
||||
)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(prog="tesm-motd", description="Banner fuer die SSH-Anmeldung")
|
||||
parser.add_argument("--app", required=True, choices=sorted(TITEL))
|
||||
parser.add_argument("--root", required=True, help="Verzeichnis der Installation")
|
||||
parser.add_argument("--no-color", action="store_true")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if args.no_color:
|
||||
globals().update(AMBER="", DIM="", BOLD="", RESET="")
|
||||
|
||||
root = Path(args.root)
|
||||
# Kein isatty()-Test: dieses Skript schreibt beim Login nicht auf ein
|
||||
# Terminal, sondern ueber run-parts in eine Datei. Der Betrachter ist
|
||||
# trotzdem ein echtes Terminal -- deshalb immer Farbe.
|
||||
print(f"{BOLD}{AMBER}TESM{RESET} · {TITEL[args.app]}")
|
||||
if TAGLINE[args.app]:
|
||||
print(f"{DIM}{TAGLINE[args.app]}{RESET}")
|
||||
print()
|
||||
try:
|
||||
conn = _oeffnen(root)
|
||||
except sqlite3.Error:
|
||||
print(f"{DIM}Datenbank nicht lesbar: {root}{RESET}")
|
||||
print()
|
||||
return 0
|
||||
try:
|
||||
if args.app == "tesm":
|
||||
print(f"{_flotte(conn)} · DHCP: {_dhcp()}")
|
||||
else:
|
||||
print(_lizenzen(conn))
|
||||
except sqlite3.Error as fehler:
|
||||
print(f"{DIM}Zustand nicht verfuegbar ({fehler}){RESET}")
|
||||
finally:
|
||||
conn.close()
|
||||
print()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except SystemExit:
|
||||
raise
|
||||
except Exception: # noqa: BLE001 - ein Banner darf keinen Login stoeren
|
||||
sys.exit(0)
|
||||
@@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# Bootstrapper: laedt ein Release herunter und ruft dessen install.sh auf.
|
||||
# Enthaelt selbst keinerlei Installationslogik.
|
||||
#
|
||||
# Zwei Fallen des Vorgaengers sind hier bewusst vermieden:
|
||||
# * Der Tag "latest" wird ueber die API aufgeloest, nie direkt in die
|
||||
# Download-URL geschrieben. Gitea liefert sonst mit HTTP 200 ein
|
||||
# automatisch erzeugtes Quell-Snapshot statt des echten Release-Assets.
|
||||
# * Nach dem Entpacken wird geprueft, dass ein install.sh existiert -- faengt
|
||||
# denselben Fehler ein zweites Mal ab.
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
# Ein Wagenruecklauf im Paket macht jedes Shell-Skript auf dem Ziel
|
||||
# unbrauchbar ("bad interpreter"). Die Pruefung darf dabei nicht selbst
|
||||
# von einem Zusatzpaket abhaengen: mit "file" -- das auf einem schlanken
|
||||
# Server fehlt -- endete sie in "command not found", grep bekam nichts zu
|
||||
# sehen, und das Skript machte weiter, als waere alles in Ordnung.
|
||||
hat_crlf() {
|
||||
local cr
|
||||
cr="$(printf '\r')"
|
||||
# -U (binaer lesen) ist unter Linux ohne Wirkung, auf einer Windows-Buildmaschine
|
||||
# aber entscheidend: dort entfernt grep den Wagenruecklauf beim Lesen und
|
||||
# findet deshalb nie einen.
|
||||
LC_ALL=C grep -qU "$cr" "$1"
|
||||
}
|
||||
|
||||
APP="${1:-}"
|
||||
TAG="${2:-latest}"
|
||||
|
||||
# Alles nach dem Tag geht unveraendert an install.sh weiter. Ohne das liesse
|
||||
# sich eine benannte Instanz nicht aktualisieren: install.sh leitet den Namen
|
||||
# aus "--instance" ab, und ohne den Schalter entstuende neben "tesm-opus" eine
|
||||
# zweite Installation namens "tesm" -- mit eigener Datenbank, eigener
|
||||
# nginx-Site und demselben Port.
|
||||
shift $(( $# > 2 ? 2 : $# ))
|
||||
EXTRA=("$@")
|
||||
# Bezugsquelle. Die Vorgaben zeigen dorthin, wo die Releases tatsaechlich
|
||||
# liegen -- ein Platzhalter wie "gitea.example.invalid" macht das Skript auf
|
||||
# jedem Host unbrauchbar, und genau das war hier der Fall.
|
||||
#
|
||||
# Das Repository heisst wie die Anwendung; getrennte Repositorien, getrennte
|
||||
# Releases. Der Anhang heisst "<repo>-<tag>.tar.gz", siehe unten.
|
||||
BASE="${TESM_RELEASE_BASE:-https://gitea.int.eertmoed.net}"
|
||||
OWNER="${TESM_RELEASE_OWNER:-alientim}"
|
||||
|
||||
[[ "$APP" == "tesm" || "$APP" == "tesm-license" ]] || {
|
||||
echo "Verwendung: update.sh <tesm|tesm-license> [tag] [install.sh-Argumente ...]" >&2
|
||||
echo "Beispiel : update.sh tesm latest --instance opus" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
REPO="${TESM_RELEASE_REPO:-$APP}"
|
||||
|
||||
WORK="$(mktemp -d /tmp/tesm-update-XXXXXX)"
|
||||
trap 'rm -rf "$WORK"' EXIT # anders als im Vorgaenger wird aufgeraeumt
|
||||
|
||||
if [[ "$TAG" == "latest" ]]; then
|
||||
echo "==> Ermittle aktuellen Release-Tag ..."
|
||||
TAG="$(curl -fsS "$BASE/api/v1/repos/$OWNER/$REPO/releases/latest" \
|
||||
| python3 -c 'import json,sys; print(json.load(sys.stdin)["tag_name"])')"
|
||||
echo " $TAG"
|
||||
fi
|
||||
|
||||
ASSET="$REPO-$TAG.tar.gz"
|
||||
echo "==> Lade $ASSET ..."
|
||||
curl -fsSL -o "$WORK/$ASSET" "$BASE/$OWNER/$REPO/releases/download/$TAG/$ASSET"
|
||||
|
||||
echo "==> Entpacke ..."
|
||||
tar -xzf "$WORK/$ASSET" -C "$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 "$WORK" -maxdepth 3 -name install.sh -path '*/deploy/*' -printf '%h/..\n' | head -1)"
|
||||
[[ -n "$SRC" ]] || { echo "Im Paket fehlt deploy/install.sh -- Download unbrauchbar." >&2; exit 1; }
|
||||
SRC="$(cd "$SRC" && pwd)"
|
||||
|
||||
echo "==> Pruefe auf CRLF (Windows-Buildmaschinen) ..."
|
||||
if hat_crlf "$SRC/deploy/install.sh"; then
|
||||
echo "Das Paket enthaelt CRLF-Zeilenenden. Beim Paketieren fehlte" >&2
|
||||
echo "'git -c core.autocrlf=false archive'." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
read -r -p "Version $TAG fuer $APP${EXTRA[0]+ (${EXTRA[*]})} installieren? [j/N] " answer
|
||||
[[ "$answer" =~ ^[jJyY]$ ]] || exit 0
|
||||
|
||||
exec sudo bash "$SRC/deploy/install.sh" --app "$APP" --yes ${EXTRA[0]+"${EXTRA[@]}"}
|
||||
@@ -0,0 +1,288 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# Upgrade vom Stand SONNET5 (Version 1.x) auf diese Fassung -- mit Datenuebernahme.
|
||||
#
|
||||
# Aufruf (aus dem entpackten Release-Verzeichnis):
|
||||
# sudo bash deploy/upgrade.sh --app tesm
|
||||
# sudo bash deploy/upgrade.sh --app tesm-license
|
||||
# sudo bash deploy/upgrade.sh --app tesm --instance opus --source /srv/tesm
|
||||
#
|
||||
# Der Ablauf ist die Vorgabe und braucht keine Schalter:
|
||||
#
|
||||
# 1. **Sichern.** Vollstaendige Kopie der alten Installation nach
|
||||
# /srv/<name>-upgrade-<zeitstempel>/alt. Erst danach wird irgendetwas
|
||||
# angefasst -- und aus dieser Kopie stammen auch alle weiteren Schritte.
|
||||
# 2. **Altes System abschalten.** Dienst und Timer werden gestoppt und
|
||||
# deaktiviert, damit sie nach einem Neustart nicht zurueckkommen.
|
||||
# 3. **Neu installieren.** install.sh dieses Verzeichnisses.
|
||||
# 4. **Exportieren.** Aus der Kopie entsteht eine Exportdatei im **alten**
|
||||
# Format -- genau die, die der Vorgaenger selbst geschrieben haette.
|
||||
# 5. **Uebersetzen.** Daraus eine Datei im neuen Format.
|
||||
# 6. **Einspielen.** Diese Datei geht durch denselben Import wie eine
|
||||
# Sicherung.
|
||||
#
|
||||
# Warum die Sicherung vor dem Export steht und nicht danach: laeuft das
|
||||
# Upgrade **in dasselbe Verzeichnis** (also ohne --instance), ueberschreibt
|
||||
# install.sh die alte Installation. Wer erst exportiert und dann sichert,
|
||||
# sichert im schlechtesten Fall nichts mehr. Gelesen wird ausschliesslich aus
|
||||
# der Kopie -- das Ergebnis ist dasselbe, die Reihenfolge ist die sichere.
|
||||
#
|
||||
# Der Lizenzserver kennt Schritt 4 und 5 nicht: seine Uebernahme liest die alte
|
||||
# Datenbank unmittelbar und **signiert jede Lizenz neu**. Dabei wird der alte
|
||||
# Signaturschluessel uebernommen -- sonst muesste jeder Kunde einen
|
||||
# Ausstellerwechsel bestaetigen. Wer das nicht will: --new-key. Dort ist die
|
||||
# Kopie aus Schritt 1 der Export.
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
APP=""
|
||||
INSTANCE=""
|
||||
SOURCE=""
|
||||
PASSPHRASE=""
|
||||
ASSUME_YES=0
|
||||
DRY_RUN=0
|
||||
WITH_DATA=1
|
||||
STOP_OLD=1
|
||||
ADOPT_KEY=1
|
||||
EXTRA=()
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
|
||||
COLOR_OK=$'\033[32m'; COLOR_WARN=$'\033[33m'; COLOR_ERR=$'\033[31m'; COLOR_OFF=$'\033[0m'
|
||||
step() { printf '\n%s==>%s %s\n' "$COLOR_OK" "$COLOR_OFF" "$*"; }
|
||||
warn() { printf '%s !!%s %s\n' "$COLOR_WARN" "$COLOR_OFF" "$*"; }
|
||||
fail() { printf '%s !!%s %s\n' "$COLOR_ERR" "$COLOR_OFF" "$*" >&2; exit 1; }
|
||||
info() { printf ' %s\n' "$*"; }
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Verwendung: upgrade.sh --app <tesm|tesm-license> [Optionen]
|
||||
|
||||
--app <name> Welche Anwendung (Pflicht)
|
||||
--source <pfad> Verzeichnis der alten Installation
|
||||
(Vorgabe: /srv/tesm bzw. /srv/tesm-license)
|
||||
--instance <name> Neue Installation als benannte Instanz anlegen, statt
|
||||
das alte Verzeichnis zu ueberschreiben. Damit bleibt die
|
||||
alte Installation liegen und ist zurueckholbar.
|
||||
--passphrase <text> Passphrase der Zwischendateien. Ohne Angabe wird eine
|
||||
erzeugt und in der Sicherung abgelegt.
|
||||
--no-data Nur installieren, keine Datenuebernahme
|
||||
--keep-old-running Alten Dienst nicht stoppen (nur mit --instance sinnvoll)
|
||||
--new-key Nur Lizenzserver: einen neuen Signaturschluessel
|
||||
erzeugen, statt den alten zu uebernehmen. Dann muessen
|
||||
alle Kunden den Ausstellerwechsel bestaetigen -- die
|
||||
Vorgabe ist deshalb die Uebernahme.
|
||||
--dry-run Nur zeigen, was geschehen wuerde
|
||||
--yes Keine Rueckfrage
|
||||
-h, --help Diese Hilfe
|
||||
|
||||
Alles weitere wird unveraendert an install.sh weitergegeben, etwa --https
|
||||
oder --domain.
|
||||
|
||||
Beispiele:
|
||||
# In dasselbe Verzeichnis, alte Installation wird ersetzt:
|
||||
sudo bash deploy/upgrade.sh --app tesm
|
||||
|
||||
# Daneben, zum Vergleich, alte Installation bleibt unangetastet:
|
||||
sudo bash deploy/upgrade.sh --app tesm --instance opus --keep-old-running
|
||||
USAGE
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--app) APP="${2:?}"; shift 2 ;;
|
||||
--source) SOURCE="${2:?}"; shift 2 ;;
|
||||
--instance) INSTANCE="${2:?}"; shift 2 ;;
|
||||
--passphrase) PASSPHRASE="${2:?}"; shift 2 ;;
|
||||
--no-data) WITH_DATA=0; shift ;;
|
||||
--keep-old-running) STOP_OLD=0; shift ;;
|
||||
--new-key) ADOPT_KEY=0; shift ;;
|
||||
--adopt-key) ADOPT_KEY=1; shift ;; # Vorgabe, hier nur zur Deutlichkeit
|
||||
--dry-run) DRY_RUN=1; shift ;;
|
||||
--yes|-y) ASSUME_YES=1; shift ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) EXTRA+=("$1"); shift ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ "$APP" == "tesm" || "$APP" == "tesm-license" ]] || { usage; fail "--app fehlt oder ist unbekannt"; }
|
||||
[[ "$(id -u)" -eq 0 ]] || fail "bitte mit sudo ausfuehren"
|
||||
[[ -f "$SCRIPT_DIR/install.sh" ]] || fail "install.sh fehlt neben diesem Skript"
|
||||
|
||||
[[ -n "$SOURCE" ]] || SOURCE="/srv/$APP"
|
||||
[[ -d "$SOURCE" ]] || fail "Die alte Installation $SOURCE gibt es nicht (--source setzen)"
|
||||
[[ -f "$SOURCE/sqlite.db" ]] || fail "In $SOURCE liegt keine sqlite.db -- das ist keine Installation der Vorgaengerfassung"
|
||||
|
||||
NAME="$APP"
|
||||
[[ -n "$INSTANCE" ]] && NAME="$APP-$INSTANCE"
|
||||
ADMIN="$NAME-admin"
|
||||
NEW_ROOT="/srv/$NAME"
|
||||
IN_PLACE=0
|
||||
[[ "$(cd "$SOURCE" && pwd)" == "$NEW_ROOT" ]] && IN_PLACE=1
|
||||
|
||||
STAMP="$(date +%Y%m%d-%H%M%S)"
|
||||
BACKUP="/srv/$NAME-upgrade-$STAMP"
|
||||
ALT_KOPIE="$BACKUP/alt"
|
||||
EXPORT_ALT="$BACKUP/export-alt.json"
|
||||
IMPORT_NEU="$BACKUP/import-neu.json"
|
||||
|
||||
# Alte Einheiten der Vorgaengerfassung. Der Timer gehoert dazu: nur den Dienst
|
||||
# zu stoppen liesse ihn beim naechsten Ablauf wieder anlaufen.
|
||||
if [[ "$APP" == "tesm" ]]; then
|
||||
ALTE_UNITS=(tesm.service tesm-check.timer tesm-check.service
|
||||
tesm-check-restart.timer tesm-check-restart.service)
|
||||
else
|
||||
ALTE_UNITS=(tesm-license.service)
|
||||
fi
|
||||
|
||||
step "Vorhaben"
|
||||
info "Anwendung : $APP${INSTANCE:+ (Instanz $INSTANCE)}"
|
||||
info "Alt : $SOURCE"
|
||||
info "Neu : $NEW_ROOT$([[ $IN_PLACE -eq 1 ]] && echo ' << dasselbe Verzeichnis')"
|
||||
info "Sicherung : $ALT_KOPIE"
|
||||
info "Datenuebernahme: $([[ $WITH_DATA -eq 1 ]] && echo ja || echo nein)"
|
||||
info "Altes System : $([[ $STOP_OLD -eq 1 ]] && echo 'stoppen und deaktivieren' || echo 'laeuft weiter')"
|
||||
if [[ $IN_PLACE -eq 1 && $STOP_OLD -eq 0 ]]; then
|
||||
warn "--keep-old-running bei gleichem Verzeichnis: der alte Dienst laeuft auf Dateien,"
|
||||
warn "die gleich ersetzt werden. Das geht schief. Bitte --instance verwenden."
|
||||
fi
|
||||
|
||||
if [[ $DRY_RUN -eq 1 ]]; then
|
||||
step "--dry-run: hier ist Schluss."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ $ASSUME_YES -eq 0 ]]; then
|
||||
read -r -p "Fortfahren? [j/N] " answer
|
||||
[[ "$answer" =~ ^[jJyY]$ ]] || { echo "Abgebrochen."; exit 0; }
|
||||
fi
|
||||
|
||||
# -- 1. Sichern -------------------------------------------------------------- #
|
||||
step "Sichern"
|
||||
mkdir -p "$ALT_KOPIE"
|
||||
chmod 700 "$BACKUP"
|
||||
# -a: Rechte und Zeiten bleiben, damit die Kopie auch als Rueckweg taugt.
|
||||
# Das venv der alten Installation bleibt aussen vor -- es ist nachbaubar,
|
||||
# gross, und seine Shebangs zeigen ohnehin auf den alten Pfad.
|
||||
tar -C "$SOURCE" --exclude=venv --exclude=__pycache__ -cf - . | tar -C "$ALT_KOPIE" -xf -
|
||||
info "$(du -sh "$ALT_KOPIE" | cut -f1) kopiert (ohne venv)"
|
||||
[[ -f "$ALT_KOPIE/sqlite.db" ]] || fail "In der Kopie fehlt die Datenbank -- Abbruch"
|
||||
|
||||
# -- 2. Altes System abschalten ---------------------------------------------- #
|
||||
if [[ $STOP_OLD -eq 1 ]]; then
|
||||
step "Altes System abschalten"
|
||||
for unit in "${ALTE_UNITS[@]}"; do
|
||||
if systemctl list-unit-files "$unit" --no-legend 2>/dev/null | grep -q .; then
|
||||
systemctl disable --now "$unit" >/dev/null 2>&1 || true
|
||||
info "$unit gestoppt und deaktiviert"
|
||||
fi
|
||||
done
|
||||
else
|
||||
step "Altes System bleibt in Betrieb"
|
||||
fi
|
||||
|
||||
# -- 3. Neu installieren ----------------------------------------------------- #
|
||||
step "Neu installieren"
|
||||
INSTALL_ARGS=(--app "$APP" --yes)
|
||||
[[ -n "$INSTANCE" ]] && INSTALL_ARGS+=(--instance "$INSTANCE")
|
||||
bash "$SCRIPT_DIR/install.sh" "${INSTALL_ARGS[@]}" ${EXTRA[0]+"${EXTRA[@]}"}
|
||||
|
||||
if [[ $WITH_DATA -eq 0 ]]; then
|
||||
step "Fertig -- ohne Datenuebernahme"
|
||||
info "Sicherung der alten Installation: $ALT_KOPIE"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
command -v "$ADMIN" >/dev/null 2>&1 || fail "$ADMIN nicht gefunden -- die Installation ist unvollstaendig"
|
||||
|
||||
if [[ -z "$PASSPHRASE" ]]; then
|
||||
PASSPHRASE="$(head -c 24 /dev/urandom | base64 | tr -d '=+/' | cut -c1-24)"
|
||||
# Die Passphrase liegt neben den Dateien, die sie schuetzt. Das ist hier kein
|
||||
# Widerspruch: das alte Format verlangt eine, das Verzeichnis gehoert root
|
||||
# allein (0700), und ohne sie waeren die beiden Dateien spaeter unbrauchbar --
|
||||
# auch als Rueckweg.
|
||||
printf '%s\n' "$PASSPHRASE" > "$BACKUP/passphrase.txt"
|
||||
chmod 600 "$BACKUP/passphrase.txt"
|
||||
fi
|
||||
|
||||
if [[ "$APP" == "tesm" ]]; then
|
||||
# -- 4. Exportieren -------------------------------------------------------- #
|
||||
step "Daten der alten Installation lesen"
|
||||
"$ADMIN" legacy-export --source "$ALT_KOPIE" --out "$EXPORT_ALT" \
|
||||
--passphrase "$PASSPHRASE" --force
|
||||
chmod 600 "$EXPORT_ALT"
|
||||
|
||||
# -- 5. Uebersetzen -------------------------------------------------------- #
|
||||
step "Uebersetzen"
|
||||
"$ADMIN" convert-legacy "$EXPORT_ALT" --ausgabe "$IMPORT_NEU" \
|
||||
--passphrase "$PASSPHRASE" --neue-passphrase "$PASSPHRASE" --force
|
||||
chmod 600 "$IMPORT_NEU"
|
||||
|
||||
# -- 6. Einspielen --------------------------------------------------------- #
|
||||
step "Einspielen"
|
||||
"$ADMIN" legacy-import "$IMPORT_NEU" --passphrase "$PASSPHRASE" --actor upgrade
|
||||
else
|
||||
# -- 4.-6. in einem: die Uebernahme liest die alte Datenbank unmittelbar --- #
|
||||
step "Lizenzbestand uebernehmen"
|
||||
# Der alte Signaturschluessel wird uebernommen. Fuer ein Upgrade ist das
|
||||
# die einzige sinnvolle Vorgabe: es ist derselbe Server mit neuerer Software,
|
||||
# und ohne den alten Schluessel muesste jeder Kunde den Ausstellerwechsel
|
||||
# bestaetigen. Ueberschrieben wird dabei nichts -- die Uebernahme lehnt ab,
|
||||
# wenn die neue Installation schon einen eigenen Schluessel hat.
|
||||
MIGRATE_ARGS=(migrate-legacy --source "$ALT_KOPIE")
|
||||
[[ $ADOPT_KEY -eq 1 ]] && MIGRATE_ARGS+=(--adopt-key)
|
||||
"$ADMIN" "${MIGRATE_ARGS[@]}"
|
||||
if [[ $ADOPT_KEY -eq 0 ]]; then
|
||||
warn "--new-key: es gilt ein neuer Signaturschluessel. Die Kunden muessen den"
|
||||
warn "Ausstellerwechsel einmal bestaetigen."
|
||||
fi
|
||||
fi
|
||||
|
||||
# -- Abschluss --------------------------------------------------------------- #
|
||||
step "Fertig"
|
||||
info "Sicherung der alten Installation : $ALT_KOPIE"
|
||||
if [[ "$APP" == "tesm" ]]; then
|
||||
info "Export im alten Format : $EXPORT_ALT"
|
||||
info "Uebersetzte Datei : $IMPORT_NEU"
|
||||
[[ -f "$BACKUP/passphrase.txt" ]] && info "Passphrase beider Dateien : $BACKUP/passphrase.txt"
|
||||
fi
|
||||
cat <<TEXT
|
||||
|
||||
Von Hand nachzuziehen -- das laesst sich nicht uebersetzen:
|
||||
* Lokale Konten: der alte Hash ist bcrypt, hier gilt Argon2id. Ein Hash
|
||||
ist nicht umrechenbar; das ist sein Zweck. Erstes Konto anlegen mit
|
||||
sudo -u $APP $ADMIN create-admin
|
||||
* Gruppen und Rechte: die Rechteschluessel wurden neu geschnitten.
|
||||
* Verzeichnisdienst (LDAP), Webserver und Zertifikat.
|
||||
TEXT
|
||||
|
||||
# Der Rueckweg sieht in den beiden Faellen verschieden aus. Ein Hinweis, der
|
||||
# das alte Verzeichnis wiederherstellen laesst, obwohl es nie angefasst wurde,
|
||||
# waere schlimmer als keiner: er wuerde die Daten der neuen Installation mit
|
||||
# einem alten Stand ueberschreiben.
|
||||
if [[ $IN_PLACE -eq 1 ]]; then
|
||||
cat <<TEXT
|
||||
|
||||
Zurueck auf den alten Stand -- das Verzeichnis wurde ersetzt, die Kopie
|
||||
bringt es zurueck:
|
||||
sudo systemctl disable --now $NAME
|
||||
sudo rm -rf $SOURCE && sudo mkdir -p $SOURCE
|
||||
sudo tar -C $ALT_KOPIE -cf - . | sudo tar -C $SOURCE -xf -
|
||||
# venv der alten Installation neu bauen:
|
||||
sudo python3 -m venv $SOURCE/venv
|
||||
sudo $SOURCE/venv/bin/python -m pip install -r $SOURCE/requirements.txt
|
||||
sudo systemctl enable --now ${ALTE_UNITS[0]}
|
||||
|
||||
TEXT
|
||||
else
|
||||
cat <<TEXT
|
||||
|
||||
Zurueck auf den alten Stand -- $SOURCE wurde nicht angefasst, es genuegt,
|
||||
die neue Instanz abzuschalten:
|
||||
sudo systemctl disable --now $NAME $NAME-monitor
|
||||
sudo rm -f /etc/nginx/sites-enabled/$NAME && sudo systemctl reload nginx
|
||||
sudo systemctl enable --now ${ALTE_UNITS[0]}
|
||||
|
||||
TEXT
|
||||
fi
|
||||
+468
@@ -0,0 +1,468 @@
|
||||
# Betrieb
|
||||
|
||||
Dieses Dokument richtet sich an die Person, die die Anlage installiert,
|
||||
aktualisiert und im Fehlerfall wieder zum Laufen bringt. Es beschreibt, was auf
|
||||
dem Host tatsächlich passiert -- nicht, wie die Oberfläche bedient wird.
|
||||
|
||||
---
|
||||
|
||||
## 1. Was wo liegt
|
||||
|
||||
| Zweck | Pfad |
|
||||
|---|---|
|
||||
| Anwendung (Code, venv, statische Dateien) | `/srv/<name>/` |
|
||||
| Instanzdaten (Datenbank, Schlüssel, Lizenz) | `/srv/<name>/data/` |
|
||||
| Protokolle | `/var/log/<name>/` |
|
||||
| Umgebungsdatei | `/etc/tesm/<name>.env` |
|
||||
| Zertifikate (selbstsigniert) | `/etc/tesm/certs/<name>/` |
|
||||
| nginx-Site | `/etc/nginx/sites-available/<name>` |
|
||||
| systemd-Unit | `/etc/systemd/system/<name>.service` |
|
||||
| Privilegierter Helfer | `/usr/local/lib/tesm/tesm-helper` |
|
||||
| Verwaltungswerkzeug | `/usr/local/bin/<name>-admin` |
|
||||
|
||||
`<name>` ist `tesm` bzw. `tesm-license` -- oder, bei einer zusätzlichen
|
||||
Instanz, `tesm-<instanz>`. Dieser Name (der **SITE_KEY**) benennt alles, was
|
||||
ausserhalb von `/srv/<name>` liegt. Deshalb können zwei Installationen
|
||||
derselben Anwendung nebeneinander laufen, ohne sich gegenseitig die
|
||||
nginx-Site, die netplan-Datei oder das Sitzungscookie zu überschreiben.
|
||||
|
||||
Alles, was ein Mensch verlieren würde, liegt unter `data/`. Das Verzeichnis
|
||||
wird von `install.sh` nie angefasst.
|
||||
|
||||
---
|
||||
|
||||
## 2. Installation
|
||||
|
||||
```bash
|
||||
sudo bash deploy/install.sh --app tesm
|
||||
sudo bash deploy/install.sh --app tesm-license
|
||||
```
|
||||
|
||||
Wichtige Optionen:
|
||||
|
||||
| Option | Wirkung |
|
||||
|---|---|
|
||||
| `--https` | Port 443 mit TLS, Port 80 leitet dorthin um. Ohne `--domain` entsteht ein selbstsigniertes Zertifikat. |
|
||||
| `--domain <name>` | Öffentlicher Name. Setzt `server_name`; die Site ist dann **nicht** mehr die Vorgabe-Site. |
|
||||
| `--acme-email <a>` | Erst zusammen mit `--domain` wird ein Let's-Encrypt-Zertifikat angefordert (und HSTS gesetzt). Ohne die Adresse bleibt es selbstsigniert -- interne Namen wie `lizenz.firma.local` kann keine CA bestätigen. |
|
||||
| `--instance <name>` | Zusätzliche Instanz neben der Hauptinstallation. |
|
||||
| `--port` / `--http-port` / `--https-port` | Abweichende Ports (nur für Testinstanzen sinnvoll). |
|
||||
| `--skip-packages` | Keine Systempakete installieren. |
|
||||
|
||||
Danach:
|
||||
|
||||
```bash
|
||||
sudo -u tesm tesm-admin create-admin
|
||||
```
|
||||
|
||||
Beim Lizenzserver zusätzlich, **in dieser Reihenfolge**:
|
||||
|
||||
```bash
|
||||
sudo -u tesm-license tesm-license-admin init-key # Signaturschlüssel -- sofort sichern!
|
||||
sudo -u tesm-license tesm-license-admin bootstrap # eigene Master-Lizenz
|
||||
sudo -u tesm-license tesm-license-admin create-admin
|
||||
```
|
||||
|
||||
> **Der Signaturschlüssel ist nicht wiederherstellbar.** Geht
|
||||
> `data/master_signing_key.json` verloren, ist keine ausgestellte Lizenz mehr
|
||||
> prüfbar. Sichern Sie ihn getrennt vom Server.
|
||||
|
||||
### Zwei Anwendungen auf einem Host
|
||||
|
||||
Beide wollen Port 80 und 443. nginx unterscheidet sie am Namen:
|
||||
|
||||
```bash
|
||||
sudo bash deploy/install.sh --app tesm --https
|
||||
sudo bash deploy/install.sh --app tesm-license --https --domain lizenz.firma.local
|
||||
```
|
||||
|
||||
Die Installation **ohne** Domain wird zur Vorgabe-Site (`default_server`) und
|
||||
beantwortet alles, was zu keinem Namen passt. Zwei namenlose Sites auf
|
||||
demselben Port weist nginx ab -- richtig so, und beim Übernehmen sofort
|
||||
sichtbar.
|
||||
|
||||
---
|
||||
|
||||
## 3. Aktualisierung
|
||||
|
||||
```bash
|
||||
sudo bash deploy/install.sh --app tesm
|
||||
```
|
||||
|
||||
Derselbe Befehl. Das Skript erkennt an der vorhandenen Datenbank, dass es eine
|
||||
Aktualisierung ist, und tut dann zusätzlich:
|
||||
|
||||
1. **Vollständige Sicherung** nach `/srv/<name>-backup-<zeitstempel>` --
|
||||
unabhängig davon, ob sich das Schema geändert hat.
|
||||
2. Dateiabgleich; `data/` bleibt unangetastet.
|
||||
3. Schema-Migrationen beim Start (versioniert, mit Prüfsumme).
|
||||
4. **Gesundheitsprüfung.** Antwortet die Anwendung nicht, wird der alte Stand
|
||||
automatisch zurückgeholt; die fehlgeschlagene Version bleibt unter
|
||||
`/srv/<name>-failed-<zeitstempel>` zur Analyse liegen.
|
||||
|
||||
Eine bereits eingerichtete HTTPS-Konfiguration bleibt erhalten, auch ohne
|
||||
`--https`.
|
||||
|
||||
### Ohne Quellbaum: `update.sh`
|
||||
|
||||
Liegt kein entpacktes Release vor, holt `update.sh` es selbst -- es lädt das
|
||||
Release-Asset, entpackt es und ruft dessen `install.sh` auf:
|
||||
|
||||
```bash
|
||||
sudo bash /opt/tesm/deploy/update.sh tesm
|
||||
sudo bash /opt/tesm-license/deploy/update.sh tesm-license
|
||||
```
|
||||
|
||||
Alles nach dem Tag geht unverändert an `install.sh` weiter -- das ist der Weg,
|
||||
eine **benannte Instanz** zu aktualisieren:
|
||||
|
||||
```bash
|
||||
sudo bash /opt/tesm-opus/deploy/update.sh tesm latest --instance opus
|
||||
```
|
||||
|
||||
Ohne `--instance` entstünde daneben eine zweite Installation namens `tesm`, mit
|
||||
eigener Datenbank und eigener nginx-Site. Die Ports einer bestehenden
|
||||
Installation bleiben erhalten, auch wenn sie von den Vorgaben abweichen: eine
|
||||
Aktualisierung verschiebt nichts, was nicht ausdrücklich neu gesetzt wird.
|
||||
|
||||
Ohne Tag wird `latest` **über die API aufgelöst**, nicht in die URL geschrieben:
|
||||
Gitea liefert unter einem Tag-Namen sonst ein automatisch erzeugtes
|
||||
Quell-Archiv mit HTTP 200 statt des echten Anhangs -- und das enthält kein
|
||||
Release, sondern den Repositoriumsstand.
|
||||
|
||||
Die Bezugsquelle ist voreingestellt und lässt sich über die Umgebung umlenken,
|
||||
etwa auf eine Spiegelung:
|
||||
|
||||
| Variable | Vorgabe |
|
||||
|---|---|
|
||||
| `TESM_RELEASE_BASE` | `https://gitea.int.eertmoed.net` |
|
||||
| `TESM_RELEASE_OWNER` | `alientim` |
|
||||
| `TESM_RELEASE_REPO` | der Anwendungsname (`tesm` bzw. `tesm-license`) |
|
||||
|
||||
Der erwartete Anhang heisst `<repo>-<tag>.tar.gz`.
|
||||
|
||||
### Upgrade vom Stand 1.x (Branch `SONNET5`)
|
||||
|
||||
Ein eigenes Skript, weil dabei mehr passiert als ein Dateiabgleich: die alte
|
||||
Fassung hat ein anderes Schema, andere Rechteschlüssel und ein anderes
|
||||
Exportformat.
|
||||
|
||||
```bash
|
||||
sudo bash deploy/upgrade.sh --app tesm
|
||||
sudo bash deploy/upgrade.sh --app tesm-license
|
||||
```
|
||||
|
||||
Der Ablauf ist die Vorgabe und braucht keine Schalter:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| 1. Sichern | Vollständige Kopie der alten Installation nach `/srv/<name>-upgrade-<zeitstempel>/alt` (ohne `venv`, Verzeichnis 0700) |
|
||||
| 2. Abschalten | Alter Dienst **und seine Timer** werden gestoppt und deaktiviert |
|
||||
| 3. Installieren | `install.sh` dieses Verzeichnisses |
|
||||
| 4. Exportieren | Aus der Kopie entsteht `export-alt.json` im **alten** Format |
|
||||
| 5. Übersetzen | Daraus `import-neu.json` im neuen Format |
|
||||
| 6. Einspielen | Diese Datei geht durch denselben Import wie eine Sicherung |
|
||||
|
||||
Warum die Sicherung **vor** dem Export steht: ohne `--instance` installiert
|
||||
`install.sh` in dasselbe Verzeichnis und ersetzt die alte Installation. Wer
|
||||
erst exportiert und dann sichert, sichert im schlechtesten Fall nichts mehr.
|
||||
Gelesen wird ausschliesslich aus der Kopie -- die alte Installation wird nie
|
||||
verändert.
|
||||
|
||||
Die beiden Zwischendateien bleiben liegen; ihre Passphrase steht daneben in
|
||||
`passphrase.txt` (0600, Verzeichnis 0700). `export-alt.json` ist ein echter
|
||||
Export des Vorgängers und taugt deshalb auch als Rückweg.
|
||||
|
||||
**Daneben statt darüber.** Mit `--instance` entsteht eine zweite Installation,
|
||||
und die alte bleibt vollständig liegen:
|
||||
|
||||
```bash
|
||||
sudo bash deploy/upgrade.sh --app tesm --instance opus --keep-old-running --port 5100 --http-port 8080
|
||||
```
|
||||
|
||||
**Was nicht mitkommt** -- und warum:
|
||||
|
||||
* **Lokale Konten.** Der alte Hash ist bcrypt, hier gilt Argon2id. Ein Hash
|
||||
ist nicht umrechenbar; das ist sein Zweck. Erstes Konto mit
|
||||
`tesm-admin create-admin`.
|
||||
* **Gruppen und Rechte.** Die Rechteschlüssel wurden neu geschnitten.
|
||||
* **LDAP, Webserver, Zertifikat.** Von Hand nachziehen.
|
||||
|
||||
Beim **Lizenzserver** gibt es Schritt 4 und 5 nicht: die Übernahme liest die
|
||||
alte Datenbank unmittelbar, holt Endpunkt und Anbieterdaten aus ihren
|
||||
Einstellungen und **signiert jede Lizenz neu**. Der alte Signaturschlüssel wird
|
||||
dabei übernommen -- sonst müsste jeder Kunde einen Ausstellerwechsel
|
||||
bestätigen; `--new-key` erzwingt das Gegenteil. Der Bericht nennt namentlich,
|
||||
wer eine neue Lizenzdatei braucht, denn die Bindung an eine Maschine gilt nur
|
||||
für die alte Datei.
|
||||
|
||||
Nur die Anwendung, ohne Daten: `--no-data`. Nur zeigen, was geschähe:
|
||||
`--dry-run`.
|
||||
|
||||
---
|
||||
|
||||
## 4. HTTPS
|
||||
|
||||
Der Zielzustand ist immer: **Port 443 mit TLS, Port 80 leitet um, ausser dem
|
||||
ACME-Pfad.**
|
||||
|
||||
Drei Wege dorthin:
|
||||
|
||||
**a) Bei der Installation** -- siehe oben, `--https`.
|
||||
|
||||
**b) Nachträglich über die Kommandozeile:**
|
||||
|
||||
```bash
|
||||
sudo -u tesm tesm-admin web-setup --https --self-signed --apply
|
||||
sudo -u tesm tesm-admin web-setup --https --domain tesm.firma.de \
|
||||
--acme --acme-email it@firma.de --hsts --apply
|
||||
```
|
||||
|
||||
`--print-config` zeigt die Datei, ohne etwas zu schreiben.
|
||||
|
||||
**c) In der Oberfläche** unter *Einstellungen -> Webserver und TLS*.
|
||||
|
||||
Danach muss `COOKIE_SECURE` in `/etc/tesm/<name>.env` auf `1` stehen (alle drei
|
||||
Wege erledigen das selbst) und der Dienst neu gestartet werden.
|
||||
|
||||
### Die Site-Datei wird immer neu erzeugt
|
||||
|
||||
Sie ist **kein** Ort für Handarbeit. Jede Übernahme schreibt sie vollständig
|
||||
aus der Vorlage neu. Was früher von Hand hineingeschrieben wurde -- Domain,
|
||||
Ports, Zertifikatspfade, HSTS, Upload-Grenze -- ist heute Einstellung in der
|
||||
Anwendung.
|
||||
|
||||
Der Grund steht im Vorgängerprojekt: dort wurde die Datei nur angelegt, wenn
|
||||
sie fehlte. Ein einmal falscher `alias`-Pfad überlebte dadurch jahrelang jedes
|
||||
Update, und die Oberfläche kam die ganze Zeit ohne Design.
|
||||
|
||||
### Selbstsigniert oder Let's Encrypt?
|
||||
|
||||
* **Öffentlich erreichbar, echter DNS-Name:** Let's Encrypt. Nur dann ist HSTS
|
||||
sinnvoll.
|
||||
* **Geschlossenes Netz:** selbstsigniert. Browser zeigen eine Warnung, die
|
||||
Verbindung ist trotzdem verschlüsselt und das Sitzungscookie darf `Secure`
|
||||
tragen. **HSTS hier nicht einschalten** -- der Browser merkt sich die Vorgabe
|
||||
monatelang und die Warnung lässt sich dann nicht mehr wegklicken.
|
||||
|
||||
Die Erneuerung von Let's Encrypt läuft über `certbot certonly --webroot`
|
||||
(bewusst nicht über das nginx-Plugin, das die erzeugte Site umschreiben
|
||||
würde). Der ACME-Pfad bleibt auch bei aktiver Umleitung erreichbar.
|
||||
|
||||
---
|
||||
|
||||
## 4a. Anmeldebanner (MOTD)
|
||||
|
||||
`install.sh` legt `/etc/update-motd.d/50-<name>` an. Bei jeder SSH-Anmeldung
|
||||
steht dann der Zustand der Installation im Login-Banner:
|
||||
|
||||
```
|
||||
TESM · TimEShepManager
|
||||
UEBERWACHEN · BOOTEN · ANBINDEN (DHCP) · BETRIEBSSYSTEM GEBEN (PXE)
|
||||
|
||||
3 Clients online · 0 nicht erreichbar · DHCP: 2 Leases aktiv
|
||||
```
|
||||
|
||||
Das Skript liegt unter `/usr/local/lib/tesm/tesm-motd` und benutzt **nur die
|
||||
Standardbibliothek**: kein venv, kein Import der Anwendung, die Datenbank wird
|
||||
nur lesend geöffnet. Ein Lauf dauert Millisekunden. Der Vorgänger importierte
|
||||
dafür die ganze Flask-Anwendung -- bei jeder Anmeldung, und nach einem Wechsel
|
||||
des Quellverzeichnisses zeigte er stillschweigend die Zahlen einer Datenbank,
|
||||
die niemand mehr füllte.
|
||||
|
||||
Von Hand prüfen:
|
||||
|
||||
```bash
|
||||
sudo /usr/local/lib/tesm/tesm-motd --app tesm --root /srv/tesm-opus
|
||||
```
|
||||
|
||||
Der Aufruf im Banner ist mit `timeout 3` und `|| true` abgesichert: ein
|
||||
hängender oder fehlschlagender Bannerlauf darf eine Anmeldung nie blockieren.
|
||||
Ein Banner der Vorgängerfassung (`50-tesm`, das `motd_banner.py` aufruft) wird
|
||||
entfernt -- nur dieses, nichts anderes in dem Verzeichnis.
|
||||
|
||||
---
|
||||
|
||||
## 4b. Installation umbenennen
|
||||
|
||||
Aus einer Instanz die Hauptinstallation machen (oder umgekehrt):
|
||||
|
||||
```bash
|
||||
sudo bash deploy/rename.sh --app tesm --from tesm-opus --to tesm
|
||||
sudo bash deploy/rename.sh --app tesm-license --from tesm-license-opus --to tesm-license
|
||||
```
|
||||
|
||||
Der Name steckt an mehr Stellen als im Verzeichnisnamen, und drei davon sind
|
||||
leicht zu übersehen:
|
||||
|
||||
* **`SITE_KEY`** in der Umgebungsdatei -- daraus entsteht der Name des
|
||||
Sitzungscookies. Bleibt er alt, heisst das Cookie weiter wie die alte
|
||||
Instanz.
|
||||
* **`web_cert_path`, `web_key_path`, `web_acme_webroot`** in der Datenbank. Sie
|
||||
enthalten den Namen; ein Zertifikat unter dem alten Pfad findet nginx nach
|
||||
dem Umzug nicht mehr.
|
||||
* **`server_name`** der nginx-Site. `install.sh` schreibt die Datei aus seiner
|
||||
Vorlage neu, sobald sich die `alias`-Pfade ändern -- und die ändern sich hier
|
||||
zwangsläufig. Ohne den Namen aus der Datenbank stünden danach zwei Sites mit
|
||||
`server_name _` auf Port 80, und die erste gewinnt.
|
||||
|
||||
Ausserdem wird das **venv verworfen und neu gebaut**: seine Konsolenskripte
|
||||
tragen den absoluten Pfad im Shebang, und nach dem Umzug zeigt
|
||||
`venv/bin/gunicorn` auf einen Interpreter, den es nicht mehr gibt. systemd
|
||||
meldet dann `No such file or directory` -- für eine Datei, die sichtbar da ist.
|
||||
|
||||
Belegt der Zielname noch eine Installation der Fassung 1.x, wandert **alles**,
|
||||
was ihn belegt, in ein Archiv `/srv/<ziel>-sonnet5`: das Verzeichnis, die
|
||||
Protokolle unter `/var/log/<ziel>`, der ACME-Pfad, die logrotate-Datei, und die
|
||||
systemd-Units samt nginx-Site unter `systemd-alt/`. Gelöscht wird nichts.
|
||||
|
||||
Ports und HTTPS bleiben, wie sie waren; ein vorhandenes Zertifikat zieht mit
|
||||
und wird weiterverwendet. **Alle angemeldeten Benutzer werden abgemeldet** --
|
||||
das Sitzungscookie heisst danach anders.
|
||||
|
||||
Danach ist auch das Aktualisieren einfacher, weil der Instanzname wegfällt:
|
||||
|
||||
```bash
|
||||
sudo bash /srv/tesm/deploy/update.sh tesm
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Dienste
|
||||
|
||||
```bash
|
||||
systemctl status tesm.service # Weboberfläche (gunicorn)
|
||||
systemctl status tesm-monitor.service # Überwachungsschleife (nur TESM)
|
||||
systemctl status tesm-license.service
|
||||
journalctl -u tesm -n 100 -f
|
||||
```
|
||||
|
||||
Die Units laufen unprivilegiert (`User=tesm`) mit
|
||||
`NoNewPrivileges`, `ProtectSystem=strict`, `PrivateTmp` und einer engen
|
||||
`RestrictAddressFamilies`-Liste. Schreibrechte gibt es nur auf `/srv/<name>/data`
|
||||
und `/var/log/<name>`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Root-Rechte
|
||||
|
||||
Die Anwendung hat keine. Alles, was Root braucht, geht durch **einen** Pfad:
|
||||
|
||||
```
|
||||
/usr/local/lib/tesm/tesm-helper <verb> [argumente]
|
||||
```
|
||||
|
||||
Der Helfer gehört `root`, ist per sudoers ausschliesslich für genau diesen
|
||||
Pfad mit `NOPASSWD` freigegeben und prüft jedes Argument gegen eine
|
||||
Positivliste. Er reicht nie etwas an eine Shell weiter (kein `eval`, kein
|
||||
`sh -c`).
|
||||
|
||||
Verben ansehen:
|
||||
|
||||
```bash
|
||||
sudo /usr/local/lib/tesm/tesm-helper
|
||||
grep -n ')$' /usr/local/lib/tesm/tesm-helper
|
||||
```
|
||||
|
||||
Regel für Änderungen: **Jedes neue Verb braucht eine Argumentprüfung.**
|
||||
`tests/test_repo_hygiene.py` erzwingt, dass jedes der Anwendung bekannte Verb
|
||||
im Helfer vorkommt und dass jede Pfadprüfung `..` abweist.
|
||||
|
||||
---
|
||||
|
||||
## 7. Sicherung und Wiederherstellung
|
||||
|
||||
In der Oberfläche unter *Sicherung* -- verschlüsselte Ausfuhr (Argon2id +
|
||||
AES-256-GCM) mit einem selbst gewählten Kennwort. Der Import zeigt zuerst eine
|
||||
Vorschau, bevor irgendetwas geschrieben wird.
|
||||
|
||||
Auf dem Host reicht:
|
||||
|
||||
```bash
|
||||
sudo systemctl stop tesm
|
||||
sudo tar -czf /root/tesm-data-$(date +%F).tar.gz -C /srv/tesm data
|
||||
sudo systemctl start tesm
|
||||
```
|
||||
|
||||
Unverzichtbar:
|
||||
|
||||
* `data/app.db` -- alle Daten
|
||||
* `data/secret.key` -- ohne sie sind alle Sitzungen ungültig
|
||||
* `data/data.keys` -- ohne sie sind **alle gespeicherten Geheimnisse verloren**
|
||||
* `data/license.json` und `data/license_key.json`
|
||||
* Lizenzserver zusätzlich: `data/master_signing_key.json`
|
||||
|
||||
---
|
||||
|
||||
## 8. Wenn etwas nicht geht
|
||||
|
||||
**Die Anwendung antwortet nicht.**
|
||||
```bash
|
||||
journalctl -u tesm -n 200 --no-pager
|
||||
curl -s http://127.0.0.1:5000/gesundheit
|
||||
```
|
||||
Antwortet gunicorn, aber der Browser nicht, liegt es an nginx.
|
||||
|
||||
**nginx lässt sich nicht neu laden.**
|
||||
```bash
|
||||
sudo nginx -t
|
||||
```
|
||||
Die Übernahme aus der Anwendung nimmt eine abgelehnte Konfiguration selbst
|
||||
zurück. Eine von Hand bearbeitete Datei tut das nicht -- in dem Fall hilft
|
||||
`install.sh`, das eine unbrauchbare Site durch die Vorlage ersetzt.
|
||||
|
||||
**Anmeldung schlägt ohne Fehlermeldung fehl.**
|
||||
Fast immer `COOKIE_SECURE=1` ohne HTTPS. Der Browser bekommt dann ein Cookie,
|
||||
das er über HTTP nie zurücksendet.
|
||||
```bash
|
||||
grep COOKIE_SECURE /etc/tesm/tesm.env
|
||||
```
|
||||
|
||||
**"Die Anfrage kam von einer fremden Herkunft (Origin/Referer)."**
|
||||
Der Reverse Proxy reicht den Host falsch durch. In der Site-Datei muss
|
||||
`proxy_set_header Host $http_host;` stehen -- **nicht** `$host`, das verwirft
|
||||
den Port.
|
||||
|
||||
**Statische Dateien fehlen, die Oberfläche ist unformatiert.**
|
||||
```bash
|
||||
grep alias /etc/nginx/sites-available/tesm
|
||||
ls /srv/tesm/static /srv/tesm/static-core
|
||||
```
|
||||
Beide `alias`-Pfade müssen in das Installationsverzeichnis zeigen.
|
||||
|
||||
**Zwei Instanzen stören sich.**
|
||||
`SITE_KEY` in beiden `/etc/tesm/*.env` vergleichen -- er muss sich
|
||||
unterscheiden. Sonst teilen sich beide nginx-Site und Sitzungscookie.
|
||||
|
||||
**Die Wartung meldet "Der SSH-Host-Schlüssel ist unbekannt".**
|
||||
Kein Fehler, sondern die Absicht: ein Hintergrundauftrag darf keinen fremden
|
||||
Schlüssel akzeptieren. Die Freigabe erfolgt von Hand, in zwei Schritten, auf
|
||||
der Detailseite des Geräts (*Clients -> Gerät -> SSH-Host-Schlüssel*):
|
||||
erst **Auslesen**, dann den Fingerprint mit einer unabhängigen Quelle
|
||||
vergleichen, dann **Freigeben**. Die Wartungsübersicht zeigt in einer eigenen
|
||||
Spalte, für welche Geräte das noch offen ist. Für Switche liegt dieselbe
|
||||
Freigabe auf der Switch-Detailseite.
|
||||
|
||||
Der Schlüssel wird nach **IP und Port** gespeichert, nicht nach Name -- TESM
|
||||
verbindet sich immer über die hinterlegte IP-Adresse. Ändert sich die IP
|
||||
eines Geräts, ist der Schlüssel erneut freizugeben.
|
||||
|
||||
**Die Prüfkette des Änderungsprotokolls ist gebrochen.**
|
||||
Unter *Diagnose* sichtbar. Das Protokoll ist verkettet gehasht; ein Bruch heisst,
|
||||
dass jemand direkt in die Datenbank geschrieben hat. Der Eintrag, ab dem es
|
||||
nicht mehr stimmt, wird genannt.
|
||||
|
||||
---
|
||||
|
||||
## 9. Zurück auf eine ältere Version
|
||||
|
||||
```bash
|
||||
sudo systemctl stop tesm
|
||||
sudo mv /srv/tesm /srv/tesm-kaputt
|
||||
sudo cp -a /srv/tesm-backup-<zeitstempel> /srv/tesm
|
||||
sudo systemctl start tesm
|
||||
```
|
||||
|
||||
Schema-Migrationen laufen nur vorwärts. Ein Rücksprung über eine
|
||||
Schema-Änderung hinweg braucht deshalb auch die Datenbank aus der Sicherung --
|
||||
genau das liegt im Backup-Verzeichnis.
|
||||
@@ -0,0 +1,183 @@
|
||||
# Sicherheit
|
||||
|
||||
Was geschützt wird, womit, und wo bewusst Grenzen gezogen sind.
|
||||
|
||||
---
|
||||
|
||||
## 1. Anmeldung
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Speicherung | Argon2id (argon2-cffi) |
|
||||
| Altbestand | bcrypt wird noch geprüft und bei der nächsten erfolgreichen Anmeldung stillschweigend auf Argon2id umgestellt |
|
||||
| Zweiter Faktor | TOTP, optional erzwingbar für Administratoren |
|
||||
| Sperre je Konto | nach `LOGIN_MAX_ATTEMPTS` Versuchen |
|
||||
| Sperre je Adresse | zusätzlich `LOGIN_MAX_ATTEMPTS_PER_IP` -- sonst lässt sich die Kontosperre umgehen, indem man viele Konten je einmal probiert |
|
||||
| Verzeichnisdienst | LDAP nur über TLS, Zertifikat wird geprüft. Ohne gültiges Zertifikat keine Anmeldung -- kein Schalter, der das abschaltet |
|
||||
|
||||
Nach einer Anmeldung wird die Sitzungskennung neu vergeben (gegen Session
|
||||
Fixation) und das CSRF-Token gewechselt.
|
||||
|
||||
### Erneute Bestätigung ("sudo-Modus")
|
||||
|
||||
Alles, was Geheimnisse offenlegt oder Daten aus dem System herausträgt,
|
||||
verlangt eine frische Passworteingabe: Klartextanzeige von Zugangsdaten,
|
||||
Ausfuhr, Herunterladen eines Lizenzbundles, Schlüsselrotation,
|
||||
2FA-Änderungen, Deaktivierung einer Lizenz.
|
||||
|
||||
Ein übernommenes Sitzungscookie allein reicht dafür nicht.
|
||||
|
||||
---
|
||||
|
||||
## 2. Sitzungen
|
||||
|
||||
Serverseitig in SQLite, nicht im Cookie. Das Cookie trägt nur eine
|
||||
Zufallskennung.
|
||||
|
||||
* `HttpOnly`, `SameSite=Lax`, `Secure` sobald HTTPS eingerichtet ist
|
||||
* Leerlaufzeit **und** absolute Höchstdauer
|
||||
* Der Cookiename enthält den `SITE_KEY` -- zwei Installationen auf einem Host
|
||||
können sich die Sitzung nicht gegenseitig überschreiben (Cookies
|
||||
unterscheiden keine Ports)
|
||||
* Aktive Sitzungen sind einsehbar und einzeln beendbar
|
||||
|
||||
---
|
||||
|
||||
## 3. CSRF
|
||||
|
||||
Drei voneinander unabhängige Schichten:
|
||||
|
||||
1. `SameSite=Lax` am Sitzungscookie.
|
||||
2. Ein Token je Sitzung, in jedem Formular, in konstanter Zeit verglichen.
|
||||
3. Origin-/Referer-Prüfung gegen den Host der Anfrage.
|
||||
|
||||
Die dritte Schicht akzeptiert den Host mit und ohne Port sowie die
|
||||
Standardports -- **nicht** aber beliebige Ports: ein anderer Dienst auf
|
||||
demselben Rechner ist ein anderer Ursprung.
|
||||
|
||||
Der Vorgänger hatte gar keinen CSRF-Schutz. Eine präparierte Seite konnte im
|
||||
Namen eines angemeldeten Administrators Geräte löschen oder Benutzer anlegen.
|
||||
|
||||
Ausnahmen (`@csrf.exempt`) gibt es nur für Maschinenschnittstellen, die sich
|
||||
**nicht** über Cookies autorisieren. Eine cookie-autorisierte Route ohne
|
||||
CSRF-Schutz wäre eine Lücke.
|
||||
|
||||
---
|
||||
|
||||
## 4. Header
|
||||
|
||||
Bei jeder Antwort:
|
||||
|
||||
* `Content-Security-Policy` mit einem Nonce je Anfrage. **Kein**
|
||||
`unsafe-inline` für Skripte -- deshalb gibt es im ganzen Projekt kein
|
||||
Inline-Script ohne Nonce, und JavaScript hängt über
|
||||
`data-behavior`-Attribute am DOM. `style-src` erlaubt `unsafe-inline`, weil
|
||||
einzelne berechnete Breiten (Fortschrittsbalken) als Style-Attribut gesetzt
|
||||
werden; Style-Attribute führen keinen Code aus.
|
||||
* `X-Content-Type-Options: nosniff`
|
||||
* `X-Frame-Options: DENY`, `frame-ancestors 'none'`
|
||||
* `Referrer-Policy: same-origin`
|
||||
* `Permissions-Policy` -- Kamera, Mikrofon, Ort, Zahlung, USB abgeschaltet
|
||||
* `Cross-Origin-Opener-Policy`, `Cross-Origin-Resource-Policy`
|
||||
* `Strict-Transport-Security` nur, wenn TLS tatsächlich terminiert wird
|
||||
|
||||
> HSTS gehört **nicht** zu einem selbstsignierten Zertifikat. Der Browser
|
||||
> merkt sich die Vorgabe monatelang; die Warnung lässt sich danach nicht mehr
|
||||
> wegklicken. `install.sh` setzt HSTS deshalb nur zusammen mit Let's Encrypt.
|
||||
|
||||
---
|
||||
|
||||
## 5. Geheimnisse im Ruhezustand
|
||||
|
||||
Ein Schlüsselspeicher mit AES-256-GCM (`data/data.keys`, Modus 0600):
|
||||
|
||||
* Versionierte Schlüssel, rotierbar. Chiffrate tragen ihre Schlüsselkennung
|
||||
(`v1.<id>.<nonce>.<ct>`) und bleiben nach einer Rotation lesbar.
|
||||
* Der Verwendungszweck geht als AAD in die Verschlüsselung ein. Ein Chiffrat
|
||||
aus einem Kontext lässt sich in einem anderen nicht entschlüsseln.
|
||||
* Beim Start wird geprüft, ob die Rechte auf Schlüssel- und Lizenzdateien
|
||||
noch stimmen; Abweichungen erscheinen unter *Diagnose*.
|
||||
|
||||
Passwörter für Mounts gehen über `stdin` an den privilegierten Helfer, der
|
||||
sie in eine 0600-Datei schreibt -- nie als Kommandozeilenargument (Prozessliste)
|
||||
und nie über die Umgebung (`/proc/<pid>/environ`).
|
||||
|
||||
---
|
||||
|
||||
## 6. Rechtetrennung auf dem Host
|
||||
|
||||
Der Webprozess läuft als `tesm` bzw. `tesm-license`. Root-Aktionen gehen durch
|
||||
genau einen allowlisted Helfer (`/usr/local/lib/tesm/tesm-helper`), der per
|
||||
sudoers ausschliesslich für diesen Pfad freigegeben ist.
|
||||
|
||||
Regeln, die im Repository durch Tests erzwungen werden:
|
||||
|
||||
* Jedes Verb, das die Anwendung kennt, existiert im Helfer.
|
||||
* Kein `eval`, kein `bash -c`, kein `sh -c`.
|
||||
* Jede Pfadprüfung weist `..` ab -- sonst lässt sich eine Positivliste über
|
||||
den Basisnamen unterlaufen (`/etc/nginx/sites-available/../../../root/x` hat
|
||||
den zulässigen Basisnamen `x`).
|
||||
* Der Instanzname ist auf `tesm[-<instanz>]` begrenzt; er landet in Pfaden.
|
||||
* Keine sudoers-Regel ausser der einen. `NOPASSWD: ALL` gibt es nicht.
|
||||
|
||||
Die systemd-Units haben `NoNewPrivileges=yes`, `ProtectSystem=strict`,
|
||||
`PrivateTmp=yes` und eine enge `RestrictAddressFamilies`-Liste.
|
||||
|
||||
Der Vorgänger liess die komplette Flask-Anwendung als root laufen. Eine
|
||||
einzige Lücke in irgendeinem Pfad hätte den Host bedeutet.
|
||||
|
||||
---
|
||||
|
||||
## 7. Änderungsprotokoll
|
||||
|
||||
Verkettet gehasht: jeder Eintrag enthält den Hash seines Vorgängers. Ein
|
||||
nachträgliches Ändern oder Löschen fällt auf und wird unter *Diagnose*
|
||||
angezeigt, mitsamt dem Eintrag, ab dem die Kette bricht.
|
||||
|
||||
Protokolliert wird, wer was wann von welcher Adresse getan hat -- ausdrücklich
|
||||
auch Lesezugriffe auf Geheimnisse (`credential.secret_revealed`).
|
||||
|
||||
Ältere Tage wandern in JSONL-Archive und lassen sich als ZIP herunterladen.
|
||||
|
||||
---
|
||||
|
||||
## 8. Lizenzprotokoll
|
||||
|
||||
Siehe [ARCHITEKTUR.md](ARCHITEKTUR.md), Abschnitt Lizenzierung. Kurz:
|
||||
getrennte Signaturkontexte, kanonisches JSON, Nonce gegen Wiedereinspielung,
|
||||
Frischefenster, `seq` gegen Rückstufung, Antwort an die Anfrage gebunden,
|
||||
Aussteller-Pinning.
|
||||
|
||||
Das Auslieferungsbundle enthält den privaten Clientschlüssel und ist damit
|
||||
selbst ein Geheimnis: eigenes Recht (`licenses.export`), frische
|
||||
Passwortbestätigung, `Cache-Control: no-store`, Eintrag im Zustell- und im
|
||||
Änderungsprotokoll.
|
||||
|
||||
---
|
||||
|
||||
## 9. Ein- und Ausfuhr
|
||||
|
||||
Verschlüsselte Umschläge (Argon2id-Ableitung + AES-256-GCM) mit einem selbst
|
||||
gewählten Kennwort. Der Import zeigt eine Vorschau, bevor etwas geschrieben
|
||||
wird; die Vorschau liegt in der Datenbank, nicht im Prozessspeicher -- deshalb
|
||||
braucht der Dienst kein `--workers 1` mehr.
|
||||
|
||||
---
|
||||
|
||||
## 10. Bewusst nicht getan
|
||||
|
||||
* **Kein Schalter, der die LDAP-Zertifikatsprüfung abschaltet.** Wer ihn
|
||||
einmal setzt, setzt ihn dauerhaft.
|
||||
* **Kein CSRF-Freibrief für cookie-autorisierte Routen.**
|
||||
* **Kein `unsafe-inline` für Skripte**, auch nicht "vorübergehend".
|
||||
* **Keine Rechteausweitung**, auch nicht für Administratoren untereinander.
|
||||
* **Kein Hostname im Lizenz-Fingerabdruck** -- Umbenennen darf die Bindung
|
||||
nicht brechen.
|
||||
* **Keine von Hand gepflegte nginx-Site.** Sie wird immer neu erzeugt.
|
||||
|
||||
---
|
||||
|
||||
## 11. Wenn Sie eine Schwachstelle finden
|
||||
|
||||
Bitte nicht öffentlich melden. Wenden Sie sich an die im Anbieterprofil des
|
||||
Lizenzservers hinterlegte Adresse.
|
||||
@@ -1,18 +0,0 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
location / {
|
||||
client_max_body_size 4m;
|
||||
proxy_pass http://127.0.0.1:5000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /static/ {
|
||||
alias /srv/tesm-license/static/;
|
||||
expires 7d;
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
[Unit]
|
||||
Description=TESM Lizenzserver (Master)
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
WorkingDirectory=/srv/tesm-license
|
||||
# Analog zu TESM_WEB_PROCESS bei TESM selbst -- markiert diesen Prozess als
|
||||
# den echten Web-Dienst (startet den Lizenz-Heartbeat- und den Auditlog-
|
||||
# Archivierungs-Thread genau einmal). Anders als bei TESM importiert hier
|
||||
# kein Hilfsskript app.py periodisch als Modul (kein Geräte-Polling auf
|
||||
# dem Lizenzserver) -- das Flag bleibt trotzdem gesetzt, für den Fall
|
||||
# künftiger Hilfsskripte und zur Konsistenz mit dem TESM-Muster.
|
||||
Environment=TESM_LICENSE_WEB_PROCESS=1
|
||||
# Kein WebSocket-Terminal wie bei TESM -- gthread/--timeout 0 daher nicht
|
||||
# nötig, ein paar Threads reichen für die parallele Aktivierungs-/
|
||||
# Heartbeat-API mehrerer Kunden.
|
||||
# "--bind 127.0.0.1:5000" wie bei TESM: nur über den nginx-Reverse-Proxy
|
||||
# von außen erreichbar.
|
||||
ExecStart=/srv/tesm-license/venv/bin/gunicorn --workers 1 --threads 8 \
|
||||
--bind 127.0.0.1:5000 app:app
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
-155
@@ -1,155 +0,0 @@
|
||||
#!/bin/bash
|
||||
# ============================================================================
|
||||
# TESM-Lizenzserver — Installer/Updater
|
||||
# Richtet System-Pakete und die Flask-App (systemd + nginx) unter
|
||||
# /srv/tesm-license ein. Ursprünglich als Teil des TESM-Hauptrepos gebaut
|
||||
# (Fork von dessen install.sh) und später in dieses eigenständige Repo
|
||||
# ausgelagert -- Backup + Health-Check + automatischer Rückroll bei einem
|
||||
# fehlgeschlagenen Update funktionieren identisch zu TESMs eigenem
|
||||
# Installer, hier nur die Kurzfassung je Schritt.
|
||||
#
|
||||
# Auszuführen als root, nachdem dieses Repo/Release-Paket z.B. nach
|
||||
# /root/tesm-license entpackt wurde:
|
||||
# sudo ./install.sh
|
||||
# ============================================================================
|
||||
set -e
|
||||
|
||||
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
print_status() { echo -e "${GREEN}✔${NC} ${1} completed."; }
|
||||
step() { echo -e "${RED}→${NC} ${1}..." | tee -a /var/log/tesm-license-install.log; }
|
||||
|
||||
install_if_changed() {
|
||||
local src="$1" dst="$2" mode="${3:-644}"
|
||||
if [ -f "$dst" ] && cmp -s "$src" "$dst"; then
|
||||
return 1
|
||||
fi
|
||||
sudo install -m "$mode" "$src" "$dst"
|
||||
return 0
|
||||
}
|
||||
|
||||
clear 2>/dev/null || true
|
||||
|
||||
# ---- Bestehende Installation erkennen ----
|
||||
EXISTING_INSTALL=0
|
||||
[ -f /srv/tesm-license/sqlite.db ] && EXISTING_INSTALL=1
|
||||
|
||||
NEEDS_MIGRATION_GUARD=0
|
||||
BACKUP_DIR=""
|
||||
|
||||
if [ "$EXISTING_INSTALL" -eq 1 ]; then
|
||||
OLD_SCHEMA_VERSION=""
|
||||
[ -f /srv/tesm-license/SCHEMA_VERSION ] && OLD_SCHEMA_VERSION="$(tr -d '[:space:]' < /srv/tesm-license/SCHEMA_VERSION)"
|
||||
NEW_SCHEMA_VERSION=""
|
||||
[ -f "$REPO_DIR/srv/tesm-license/SCHEMA_VERSION" ] && NEW_SCHEMA_VERSION="$(tr -d '[:space:]' < "$REPO_DIR/srv/tesm-license/SCHEMA_VERSION")"
|
||||
|
||||
step "Stopping tesm-license.service for update"
|
||||
systemctl stop tesm-license.service 2>/dev/null || true
|
||||
print_status "Service stopped"
|
||||
|
||||
if [ -n "$OLD_SCHEMA_VERSION" ] && [ -n "$NEW_SCHEMA_VERSION" ] && [ "$OLD_SCHEMA_VERSION" == "$NEW_SCHEMA_VERSION" ]; then
|
||||
echo -e "${GREEN}Bestehende Installation erkannt, Datenbank-Schema unverändert (Version ${OLD_SCHEMA_VERSION}):${NC} In-Place-Update."
|
||||
else
|
||||
echo -e "${YELLOW}Bestehende Installation erkannt, Schema-Version geändert oder unbekannt:${NC} Update mit automatischer Migration."
|
||||
fi
|
||||
NEEDS_MIGRATION_GUARD=1
|
||||
BACKUP_DIR="/srv/tesm-license-backup-pre-update-$(date +%Y%m%d-%H%M%S)"
|
||||
step "Backing up current installation to $BACKUP_DIR before update"
|
||||
cp -a /srv/tesm-license "$BACKUP_DIR"
|
||||
print_status "Backup created"
|
||||
fi
|
||||
|
||||
# ---- Pakete ----
|
||||
step "Installing system packages"
|
||||
sudo apt-get update >>/var/log/tesm-license-install.log 2>&1 && print_status "apt update"
|
||||
sudo apt-get install -y python3 python3-venv python3-pip nginx sqlite3 rsync logrotate certbot >>/var/log/tesm-license-install.log 2>&1 && print_status "Packages installed"
|
||||
|
||||
# ---- Log-Verzeichnis ----
|
||||
sudo mkdir -p /var/log/tesm-license
|
||||
sudo chmod 755 /var/log/tesm-license
|
||||
|
||||
# ---- App-Verzeichnis ----
|
||||
step "Deploying application to /srv/tesm-license"
|
||||
sudo mkdir -p /srv/tesm-license
|
||||
sudo rsync -a --delete --exclude 'venv' --exclude 'sqlite.db' --exclude 'fernet.key' --exclude 'secret.key' \
|
||||
--exclude 'license.json' --exclude 'master_signing_key.json' \
|
||||
"$REPO_DIR/srv/tesm-license/" /srv/tesm-license/ >>/var/log/tesm-license-install.log 2>&1
|
||||
print_status "Application files copied"
|
||||
|
||||
# ---- Python venv ----
|
||||
step "Setting up Python virtual environment"
|
||||
cd /srv/tesm-license
|
||||
sudo python3 -m venv venv
|
||||
sudo ./venv/bin/python3 -m pip install --upgrade pip >>/var/log/tesm-license-install.log 2>&1
|
||||
sudo ./venv/bin/python3 -m pip install -r requirements.txt >>/var/log/tesm-license-install.log 2>&1
|
||||
print_status "Virtual environment ready"
|
||||
|
||||
# ---- Datenbank ----
|
||||
if [ ! -f /srv/tesm-license/sqlite.db ]; then
|
||||
step "Initializing database"
|
||||
sudo ./venv/bin/python3 create_db.py >>/var/log/tesm-license-install.log 2>&1
|
||||
print_status "Database initialized"
|
||||
echo -e "${RED}→${NC} Kein Admin-Benutzer vorhanden. Bitte danach ausführen:"
|
||||
echo " sudo /srv/tesm-license/venv/bin/python3 /srv/tesm-license/create_admin.py"
|
||||
echo -e "${RED}→${NC} Und die eigene Bootstrap-Lizenz erzeugen:"
|
||||
echo " sudo /srv/tesm-license/venv/bin/python3 /srv/tesm-license/create_master_license.py"
|
||||
fi
|
||||
|
||||
# ---- systemd ----
|
||||
step "Copying systemd unit"
|
||||
UNITS_CHANGED=0
|
||||
install_if_changed "$REPO_DIR/etc/systemd/system/tesm-license.service" /etc/systemd/system/tesm-license.service 644 && UNITS_CHANGED=1
|
||||
print_status "Unit copied"
|
||||
|
||||
# ---- nginx ----
|
||||
step "Configuring nginx reverse proxy"
|
||||
if [ ! -f /etc/nginx/sites-available/tesm-license ]; then
|
||||
sudo cp "$REPO_DIR/etc/nginx/sites-available/tesm-license" /etc/nginx/sites-available/tesm-license
|
||||
fi
|
||||
sudo ln -sf /etc/nginx/sites-available/tesm-license /etc/nginx/sites-enabled/tesm-license
|
||||
sudo rm -f /etc/nginx/sites-enabled/default
|
||||
sudo nginx -t >>/var/log/tesm-license-install.log 2>&1 && sudo systemctl reload nginx
|
||||
print_status "nginx configured"
|
||||
|
||||
# ---- Dienst aktivieren ----
|
||||
step "Enabling service"
|
||||
if [ "$UNITS_CHANGED" -eq 1 ]; then
|
||||
sudo systemctl daemon-reload
|
||||
fi
|
||||
sudo systemctl enable --now tesm-license.service
|
||||
print_status "Service enabled and started"
|
||||
|
||||
# ---- Sicherheitsnetz prüfen ----
|
||||
if [ "$NEEDS_MIGRATION_GUARD" -eq 1 ]; then
|
||||
step "Verifying migrated installation"
|
||||
HEALTHY=0
|
||||
for i in $(seq 1 15); do
|
||||
sleep 2
|
||||
if curl -fsS -o /dev/null http://127.0.0.1:5000/login 2>/dev/null; then
|
||||
HEALTHY=1
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [ "$HEALTHY" -eq 1 ]; then
|
||||
print_status "Migration verified healthy"
|
||||
rm -rf "$BACKUP_DIR"
|
||||
echo -e "${GREEN}✔${NC} Update erfolgreich -- Backup wieder entfernt."
|
||||
else
|
||||
echo -e "${RED}✖ Update fehlgeschlagen -- Dienst antwortet nicht gesund. Rolle zurück...${NC}"
|
||||
sudo systemctl stop tesm-license.service 2>/dev/null || true
|
||||
FAILED_DIR="/srv/tesm-license-failed-update-$(date +%Y%m%d-%H%M%S)"
|
||||
sudo mv /srv/tesm-license "$FAILED_DIR"
|
||||
sudo cp -a "$BACKUP_DIR" /srv/tesm-license
|
||||
sudo systemctl start tesm-license.service
|
||||
echo -e "${YELLOW}Zurückgerollt auf den Stand vor dem Update.${NC}"
|
||||
echo "Backup bleibt erhalten unter: ${BACKUP_DIR}"
|
||||
echo "Die fehlgeschlagene Installation liegt zur Fehlersuche unter: ${FAILED_DIR}"
|
||||
echo "Bitte /var/log/tesm-license/app.log dort prüfen, bevor erneut aktualisiert wird."
|
||||
fi
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}✔${NC} Installation abgeschlossen. Web-App erreichbar auf Port 80 (nginx) bzw. 5000 (Flask direkt)."
|
||||
@@ -0,0 +1,25 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "tesm-core"
|
||||
version = "2.0.7"
|
||||
description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"Flask>=3.1",
|
||||
"cryptography>=42",
|
||||
"argon2-cffi>=23",
|
||||
"tesm-licensing>=2.0.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
ldap = ["ldap3>=2.9"]
|
||||
legacy-hashes = ["bcrypt>=4"]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
tesm_core = ["templates/**/*.html", "static/**/*"]
|
||||
@@ -0,0 +1,51 @@
|
||||
"""tesm_core -- gemeinsames Anwendungsgeruest von TESM und TESM-Lizenzserver.
|
||||
|
||||
Enthaelt alles, was beide Anwendungen teilen: Konfiguration, Datenbankzugriff,
|
||||
Migrationen, Schluesselverwaltung, Sitzungen, CSRF, Rechtesystem, Navigation,
|
||||
Benutzer-/Gruppen-/Kontoverwaltung, Papierkorb, Diagnose und das
|
||||
Design-System.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
#: Version aus den Metadaten des installierten Pakets -- die einzige Quelle
|
||||
#: ist ``pyproject.toml``. Vorher stand hier eine feste Zahl, und die
|
||||
#: Fusszeile zeigte vier Releases lang 2.0.2.
|
||||
#:
|
||||
#: Im Quellbaum ohne ``pip install`` gibt es keine Metadaten; dann steht dort
|
||||
#: "unbekannt" statt einer erfundenen Nummer. In einem editierbaren Install
|
||||
#: zeigt sie den Stand des letzten ``pip install -e`` -- also die Version, die
|
||||
#: tatsaechlich installiert ist.
|
||||
try: # pragma: no cover - haengt an der Installationsart
|
||||
from importlib.metadata import PackageNotFoundError, version as _paketversion
|
||||
|
||||
__version__ = _paketversion("tesm-core")
|
||||
except (ImportError, PackageNotFoundError): # pragma: no cover
|
||||
__version__ = "unbekannt"
|
||||
|
||||
from .app_factory import create_base_app
|
||||
from .config import CoreConfig, SecurityConfig, load_core_config
|
||||
from .db import Database, NestedConnectionError
|
||||
from .extension import TesmCore, core, db, transaction
|
||||
from .migrations import Migration
|
||||
from .nav import NavItem, NavTree
|
||||
from .settings_store import SettingSpec, SettingsStore
|
||||
|
||||
__all__ = [
|
||||
"CoreConfig",
|
||||
"Database",
|
||||
"Migration",
|
||||
"NavItem",
|
||||
"NavTree",
|
||||
"NestedConnectionError",
|
||||
"SecurityConfig",
|
||||
"SettingSpec",
|
||||
"SettingsStore",
|
||||
"TesmCore",
|
||||
"core",
|
||||
"create_base_app",
|
||||
"db",
|
||||
"load_core_config",
|
||||
"transaction",
|
||||
"__version__",
|
||||
]
|
||||
@@ -0,0 +1,417 @@
|
||||
"""Anwendungsfabrik: verdrahtet alle Bausteine zu einer lauffaehigen Flask-App.
|
||||
|
||||
Jede der beiden Anwendungen ruft ``create_base_app()`` und ergaenzt danach nur
|
||||
noch ihre eigenen Blueprints. Alles, was beide teilen -- Datenbank,
|
||||
Migrationen, Sitzungen, CSRF, Header, Protokollierung, Rechtesystem,
|
||||
Navigation, Benutzer-/Gruppenverwaltung, Kontoseite, Diagnose -- entsteht hier
|
||||
genau einmal.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Sequence
|
||||
|
||||
from flask import Blueprint, Flask, g, render_template, request
|
||||
from werkzeug.exceptions import HTTPException
|
||||
|
||||
from . import audit, logging_setup, ui
|
||||
from .config import CoreConfig
|
||||
from .core_schema import CORE_MIGRATIONS
|
||||
from .db import Database
|
||||
from .extension import TesmCore, close_db, core, db
|
||||
from .keystore import Keystore, load_or_create_secret_key
|
||||
from .licensing_client import LicenseManager
|
||||
from .migrations import Migration
|
||||
from .nav import NavTree
|
||||
from .rbac import service as rbac
|
||||
from .rbac.model import PermissionTree, RolePreset
|
||||
from .security import csrf
|
||||
from .security import sessions as session_store
|
||||
from .security.sessions import SqliteSessionInterface
|
||||
from .settings_store import SettingSpec, SettingsStore
|
||||
from .sysops import SysOps
|
||||
from .auth import directory
|
||||
from .auth import routes as auth_routes
|
||||
from .auth import service as auth_service
|
||||
from .auth.models import ANONYMOUS, current_user
|
||||
from .views import account as account_views
|
||||
from .views import admin as admin_views
|
||||
from .views import diagnostics as diagnostics_views
|
||||
from .views import hostsettings as hostsettings_views
|
||||
from .views import logs as logs_views
|
||||
from .views import trash as trash_views
|
||||
|
||||
#: Rechteinhaber, in dieser Reihenfolge. Steht an einer Stelle, weil die
|
||||
#: Fusszeile in zwei Vorlagen auftaucht (Anwendungsrahmen und Anmeldeseiten)
|
||||
#: und zwei Texte fuer dieselbe Aussage auseinanderlaufen.
|
||||
COPYRIGHT_NOTICE = "Tim Eertmoed · WiS IT-Solutions GmbH"
|
||||
|
||||
CORE_SETTINGS: tuple[SettingSpec, ...] = (
|
||||
SettingSpec("nav_order", [], "json", "Reihenfolge der Navigation", section="intern"),
|
||||
SettingSpec("nav_child_order", {}, "json", "Reihenfolge der Unterpunkte", section="intern"),
|
||||
SettingSpec(
|
||||
"trash_retention_days",
|
||||
30,
|
||||
"int",
|
||||
"Aufbewahrung im Papierkorb (Tage)",
|
||||
hint="Nach dieser Frist werden Eintraege beim naechsten Aufruf endgueltig entfernt.",
|
||||
section="system",
|
||||
minimum=1,
|
||||
maximum=3650,
|
||||
),
|
||||
SettingSpec(
|
||||
"session_idle_minutes",
|
||||
30,
|
||||
"int",
|
||||
"Automatische Abmeldung nach Leerlauf (Minuten)",
|
||||
hint="Jede Anfrage setzt den Zaehler zurueck -- gezaehlt wird nur Untaetigkeit. "
|
||||
"Gilt auch fuer die eingebundenen Dateifreigaben: mit der Sitzung endet die "
|
||||
"Einbindung.",
|
||||
section="system",
|
||||
minimum=5,
|
||||
maximum=1440,
|
||||
),
|
||||
SettingSpec("timezone", "", "str", "Zeitzone des Hosts", section="system"),
|
||||
SettingSpec("hostname", "", "str", "Hostname", section="system"),
|
||||
SettingSpec(
|
||||
"log_rotate_interval",
|
||||
"weekly",
|
||||
"str",
|
||||
"Rotationsintervall der Protokolle",
|
||||
section="system",
|
||||
choices=(("daily", "taeglich"), ("weekly", "woechentlich"), ("monthly", "monatlich")),
|
||||
),
|
||||
SettingSpec(
|
||||
"log_rotate_keep", 4, "int", "Aufbewahrte Rotationen", section="system", minimum=1, maximum=52
|
||||
),
|
||||
# -- Webserver/TLS (Host-Ebene, in beiden Anwendungen gleich) ----------- #
|
||||
# Lizenzserver: eigenes Zertifikat statt abgeschalteter Pruefung.
|
||||
SettingSpec(
|
||||
"license_server_ca",
|
||||
"",
|
||||
"str",
|
||||
"Zertifikat des Lizenzservers (PEM)",
|
||||
section="license",
|
||||
),
|
||||
SettingSpec("web_domain", "", "str", "Domain", section="web"),
|
||||
SettingSpec("web_https_enabled", False, "bool", "HTTPS aktiv", 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_cert_path", "", "str", "Zertifikat (Pfad)", section="web"),
|
||||
SettingSpec("web_key_path", "", "str", "Privater Schluessel (Pfad)", section="web"),
|
||||
SettingSpec("web_acme_email", "", "str", "E-Mail fuer Let's Encrypt", section="web"),
|
||||
SettingSpec("web_acme_webroot", "", "str", "ACME-Webroot", section="web"),
|
||||
# Abweichende Ports braucht nur eine Testinstanz neben der Produktion --
|
||||
# im Regelbetrieb bleibt es bei 80 und 443.
|
||||
SettingSpec(
|
||||
"web_http_port", 80, "int", "HTTP-Port", section="web", minimum=1, maximum=65535
|
||||
),
|
||||
SettingSpec(
|
||||
"web_https_port", 443, "int", "HTTPS-Port", section="web", minimum=1, maximum=65535
|
||||
),
|
||||
SettingSpec(
|
||||
"web_max_body_mb", 128, "int", "Maximale Anfragegroesse (MB)", section="web",
|
||||
minimum=1, maximum=8192,
|
||||
),
|
||||
*directory.SETTINGS,
|
||||
)
|
||||
|
||||
|
||||
def create_base_app(
|
||||
*,
|
||||
config: CoreConfig,
|
||||
permissions: PermissionTree,
|
||||
nav: NavTree,
|
||||
role_presets: Sequence[RolePreset],
|
||||
app_migrations: dict[str, Sequence[Migration]] | None = None,
|
||||
settings_specs: Sequence[SettingSpec] = (),
|
||||
import_name: str = "tesm_core",
|
||||
app_short: str = "TESM",
|
||||
app_edition: str = "",
|
||||
#: Zeile unter der Wortmarke auf den Anmeldeseiten. Leer = nur die
|
||||
#: Wortmarke, ohne Zeile darunter; die Marke selbst zeigen beide
|
||||
#: Anwendungen.
|
||||
auth_tagline: str = "",
|
||||
app_version: str = "2.0.7",
|
||||
built_in_trusted_keys: tuple[str, ...] = (),
|
||||
enable_license: bool = True,
|
||||
template_folder: str | None = None,
|
||||
static_folder: str | None = None,
|
||||
) -> Flask:
|
||||
app = Flask(
|
||||
import_name,
|
||||
template_folder=template_folder or "templates",
|
||||
static_folder=static_folder or "static",
|
||||
static_url_path="/assets",
|
||||
)
|
||||
|
||||
# Templates und Design-System des Kerns liegen im Paket ``tesm_core`` und
|
||||
# werden ueber einen eigenen Blueprint bereitgestellt. Dadurch kann jede
|
||||
# Anwendung zusaetzlich ihre eigenen templates/static mitbringen, ohne dass
|
||||
# sich Pfade in die Quere kommen.
|
||||
core_bp = Blueprint(
|
||||
"tesm_core",
|
||||
"tesm_core",
|
||||
template_folder="templates",
|
||||
static_folder="static/tesm_core",
|
||||
static_url_path="/core-assets",
|
||||
)
|
||||
app.register_blueprint(core_bp)
|
||||
|
||||
config.data_dir.mkdir(parents=True, exist_ok=True)
|
||||
config.log_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
app.config.update(
|
||||
SECRET_KEY=load_or_create_secret_key(config.secret_key_file),
|
||||
# site_key statt app_key: Cookies unterscheiden keine Ports. Zwei
|
||||
# Instanzen derselben Anwendung auf einem Host wuerden sich sonst
|
||||
# gegenseitig die Sitzung ueberschreiben.
|
||||
SESSION_COOKIE_NAME=f"{config.site_key.replace('-', '_')}_session",
|
||||
SESSION_COOKIE_HTTPONLY=True,
|
||||
SESSION_COOKIE_SECURE=config.security.cookie_secure,
|
||||
SESSION_COOKIE_SAMESITE=config.security.cookie_samesite,
|
||||
MAX_CONTENT_LENGTH=config.security.max_content_length,
|
||||
JSON_SORT_KEYS=False,
|
||||
TRAP_HTTP_EXCEPTIONS=False,
|
||||
PREFERRED_URL_SCHEME="https" if config.security.cookie_secure else "http",
|
||||
TEMPLATES_AUTO_RELOAD=config.debug,
|
||||
EXPLAIN_TEMPLATE_LOADING=False,
|
||||
)
|
||||
app.debug = config.debug
|
||||
|
||||
logging_setup.init_app(app, log_dir=config.log_dir, debug=config.debug)
|
||||
|
||||
database = Database(config.db_path)
|
||||
settings = SettingsStore([*CORE_SETTINGS, *settings_specs])
|
||||
keystore = Keystore(config.data_key_file)
|
||||
|
||||
extension = TesmCore(
|
||||
config=config,
|
||||
database=database,
|
||||
keystore=keystore,
|
||||
permissions=permissions,
|
||||
nav=nav,
|
||||
settings=settings,
|
||||
role_presets=tuple(role_presets),
|
||||
migrations={"core": CORE_MIGRATIONS, **(app_migrations or {})},
|
||||
sysops=SysOps(app_key=config.app_key, site_key=config.site_key),
|
||||
)
|
||||
if enable_license:
|
||||
extension.license = LicenseManager(
|
||||
database=database,
|
||||
settings=settings,
|
||||
license_path=config.license_path,
|
||||
key_path=config.license_key_path,
|
||||
app_key=config.app_key,
|
||||
built_in_trusted_keys=built_in_trusted_keys,
|
||||
)
|
||||
app.extensions["tesm"] = extension
|
||||
|
||||
def _leerlauf(conn: Any) -> int:
|
||||
"""Leerlaufgrenze aus den Einstellungen -- ueber die offene Verbindung.
|
||||
|
||||
Bewusst mit der Verbindung, die die Sitzungsverwaltung schon haelt: eine
|
||||
zweite zu oeffnen waere eine verschachtelte Datenbankverbindung, und die
|
||||
gilt hier als Fehler.
|
||||
"""
|
||||
try:
|
||||
wert = int(settings.get(conn, "session_idle_minutes") or 0)
|
||||
except (TypeError, ValueError):
|
||||
wert = 0
|
||||
return wert if wert > 0 else config.security.session_idle_minutes
|
||||
|
||||
app.session_interface = SqliteSessionInterface(
|
||||
database,
|
||||
cookie_name=app.config["SESSION_COOKIE_NAME"],
|
||||
idle_minutes=config.security.session_idle_minutes,
|
||||
absolute_hours=config.security.session_absolute_hours,
|
||||
idle_lookup=_leerlauf,
|
||||
)
|
||||
|
||||
# -- Schema und Startzustand ------------------------------------------ #
|
||||
applied = extension.run_migrations()
|
||||
with database.session() as conn, database.transaction(conn):
|
||||
created = rbac.ensure_presets(conn, permissions, extension.role_presets)
|
||||
if applied or created:
|
||||
audit.log(
|
||||
conn,
|
||||
actor="system",
|
||||
action="system.startup_migration",
|
||||
detail="; ".join([*applied, *(f"Gruppe {name} angelegt" for name in created)]),
|
||||
severity="notice",
|
||||
)
|
||||
session_store.prune(conn)
|
||||
if extension.license is not None:
|
||||
with database.session() as conn:
|
||||
extension.license.reload(conn)
|
||||
|
||||
# -- Request-Lebenszyklus --------------------------------------------- #
|
||||
csrf.init_app(app)
|
||||
ui.init_app(app)
|
||||
app.teardown_appcontext(close_db)
|
||||
|
||||
@app.before_request
|
||||
def _prepare_request() -> None:
|
||||
g.csp_nonce = secrets.token_urlsafe(16)
|
||||
auth_service.load_request_user()
|
||||
|
||||
@app.after_request
|
||||
def _finish_request(response): # type: ignore[no-untyped-def]
|
||||
# Ein Aktualisierungsdurchlauf, der mit Sitzungscookie kommt und
|
||||
# unangemeldet endet, hat gerade seine Sitzung verloren. Bei einer
|
||||
# geschuetzten Seite sagt das schon der Status (401); bei einer, die
|
||||
# auch oeffentlich antwortet -- die Statusuebersicht etwa -- kommt eine
|
||||
# 200 mit den oeffentlichen Kacheln zurueck, und der offene Tab merkt
|
||||
# nichts: er zeigt weiter eine Oberflaeche, als sei alles in Ordnung.
|
||||
# Deshalb ein Kopf, den der Browser unabhaengig vom Status auswerten
|
||||
# kann.
|
||||
if session_store.is_automatic(request) and not current_user.is_authenticated:
|
||||
if request.cookies.get(app.config["SESSION_COOKIE_NAME"], ""):
|
||||
response.headers["X-Session-Expired"] = "1"
|
||||
hsts = ""
|
||||
if config.security.hsts_enabled:
|
||||
hsts = f"max-age={config.security.hsts_max_age}; includeSubDomains"
|
||||
return csrf.apply_security_headers(
|
||||
response, nonce=getattr(g, "csp_nonce", ""), hsts=hsts
|
||||
)
|
||||
|
||||
# -- Template-Kontext -------------------------------------------------- #
|
||||
from .rbac.model import ACTION_META
|
||||
|
||||
@app.context_processor
|
||||
def _inject_context() -> dict[str, Any]:
|
||||
user = getattr(g, "user", ANONYMOUS)
|
||||
conn = db()
|
||||
order = settings.get(conn, "nav_order") or nav.default_order()
|
||||
child_order = settings.get(conn, "nav_child_order") or {}
|
||||
pills: list[dict[str, str]] = []
|
||||
if extension.license is not None and user.is_authenticated:
|
||||
pill = extension.license.topbar_pill()
|
||||
if pill:
|
||||
pills.append(pill)
|
||||
pills.extend(extension.registry.get("status_pill_builder", lambda _u: [])(user))
|
||||
|
||||
nav_items = nav.resolve(user, order=order, child_order=child_order)
|
||||
palette = [
|
||||
{**entry, "url": _safe_url(entry["endpoint"])}
|
||||
for entry in nav.searchable(user)
|
||||
if entry["endpoint"]
|
||||
]
|
||||
return {
|
||||
"app_name": config.app_name,
|
||||
"app_short": app_short,
|
||||
"app_edition": app_edition,
|
||||
"auth_tagline": auth_tagline,
|
||||
"app_version": app_version,
|
||||
"current_year": datetime.now(timezone.utc).year,
|
||||
"copyright_notice": COPYRIGHT_NOTICE,
|
||||
"current_user": current_user,
|
||||
"nav_items": nav_items,
|
||||
"palette_entries": palette,
|
||||
"status_pills": pills,
|
||||
"csp_nonce": getattr(g, "csp_nonce", ""),
|
||||
"permission_tree": permissions,
|
||||
"action_meta": ACTION_META,
|
||||
"license_state": extension.license.state if extension.license else None,
|
||||
"license_active": extension.license.active() if extension.license else True,
|
||||
"module_active": (
|
||||
extension.license.module_active if extension.license else (lambda _n: True)
|
||||
),
|
||||
"feature_allowed": (
|
||||
extension.license.feature_allowed if extension.license else (lambda _n: True)
|
||||
),
|
||||
"migration_warning": bool(extension.registry.get("migration_warning")),
|
||||
}
|
||||
|
||||
# -- Fehlerseiten ------------------------------------------------------ #
|
||||
@app.errorhandler(HTTPException)
|
||||
def _http_error(error: HTTPException): # type: ignore[no-untyped-def]
|
||||
if _wants_json():
|
||||
return {
|
||||
"error": error.name,
|
||||
"message": error.description,
|
||||
"request_id": getattr(g, "request_id", ""),
|
||||
}, error.code or 500
|
||||
return (
|
||||
render_template(
|
||||
"tesm_core/error.html",
|
||||
code=error.code or 500,
|
||||
name=error.name,
|
||||
description=error.description,
|
||||
request_id=getattr(g, "request_id", ""),
|
||||
back_url=request.referrer or "/",
|
||||
),
|
||||
error.code or 500,
|
||||
)
|
||||
|
||||
@app.errorhandler(Exception)
|
||||
def _unhandled(error: Exception): # type: ignore[no-untyped-def]
|
||||
app.logger.exception("Unbehandelter Fehler: %s", error)
|
||||
if _wants_json():
|
||||
return {
|
||||
"error": "Interner Fehler",
|
||||
"message": "Der Vorgang konnte nicht abgeschlossen werden.",
|
||||
"request_id": getattr(g, "request_id", ""),
|
||||
}, 500
|
||||
return (
|
||||
render_template(
|
||||
"tesm_core/error.html",
|
||||
code=500,
|
||||
name="Interner Fehler",
|
||||
description=(
|
||||
"Der Vorgang konnte nicht abgeschlossen werden. Der Fehler wurde "
|
||||
"protokolliert."
|
||||
),
|
||||
request_id=getattr(g, "request_id", ""),
|
||||
back_url=request.referrer or "/",
|
||||
),
|
||||
500,
|
||||
)
|
||||
|
||||
# -- Gemeinsame Blueprints -------------------------------------------- #
|
||||
app.register_blueprint(auth_routes.bp)
|
||||
app.register_blueprint(admin_views.bp)
|
||||
app.register_blueprint(account_views.bp)
|
||||
app.register_blueprint(diagnostics_views.bp)
|
||||
app.register_blueprint(trash_views.bp)
|
||||
app.register_blueprint(hostsettings_views.bp)
|
||||
app.register_blueprint(logs_views.bp)
|
||||
with app.app_context():
|
||||
trash_views.register_core_providers()
|
||||
|
||||
app.add_url_rule("/gesundheit", "health", _health, methods=["GET"])
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def _safe_url(endpoint: str) -> str:
|
||||
from flask import url_for
|
||||
|
||||
try:
|
||||
return url_for(endpoint)
|
||||
except Exception: # noqa: BLE001 - Endpunkt existiert in dieser App nicht
|
||||
return ""
|
||||
|
||||
|
||||
def _wants_json() -> bool:
|
||||
if request.path.startswith("/api/"):
|
||||
return True
|
||||
accept = request.accept_mimetypes
|
||||
return bool(accept.accept_json and not accept.accept_html)
|
||||
|
||||
|
||||
def _health() -> tuple[dict[str, Any], int]:
|
||||
"""Gesundheitsendpunkt fuer install.sh und Monitoring.
|
||||
|
||||
Bewusst ohne Anmeldung, aber ohne jede verwertbare Information: nur
|
||||
"laeuft die App und antwortet die Datenbank".
|
||||
"""
|
||||
extension = core()
|
||||
try:
|
||||
with extension.database.session() as conn:
|
||||
conn.execute("SELECT 1").fetchone()
|
||||
except Exception: # noqa: BLE001
|
||||
return {"status": "degraded"}, 503
|
||||
return {"status": "ok", "version": extension.config.app_key}, 200
|
||||
@@ -0,0 +1,283 @@
|
||||
"""Aenderungsprotokoll mit Hash-Kette.
|
||||
|
||||
Ein Audit-Log, das der Administrator selbst manipulieren kann, ist kein
|
||||
Audit-Log. Der Vorgaenger schrieb einfache Zeilen in eine Tabelle -- wer
|
||||
Schreibzugriff auf die SQLite-Datei hatte (auf dem Zielsystem: jeder root, und
|
||||
die App lief als root), konnte Eintraege spurlos entfernen.
|
||||
|
||||
Hier bindet jede Zeile den Hash ihrer Vorgaengerin ein:
|
||||
|
||||
hash_n = sha256(hash_(n-1) || kanonische Felder von Zeile n)
|
||||
|
||||
Eine geloeschte oder geaenderte Zeile bricht die Kette an genau dieser Stelle.
|
||||
``verify_chain()`` findet den Bruch; die Diagnoseseite zeigt ihn an. Das
|
||||
verhindert Manipulation nicht, macht sie aber nachweisbar -- und genau darum
|
||||
geht es bei einem Protokoll.
|
||||
|
||||
Archivierung: ab ``archive_threshold`` Zeilen werden die aeltesten vollstaendigen
|
||||
Kalendertage in Dateien ``audit-JJJJ-MM-TT.jsonl`` ausgelagert, inklusive ihrer
|
||||
Hashes, sodass die Kette ueber die Archivgrenze hinweg pruefbar bleibt.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import sqlite3
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
|
||||
from .db import Database
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
GENESIS = "0" * 64
|
||||
ARCHIVE_THRESHOLD = 20000
|
||||
ARCHIVE_TARGET = 15000
|
||||
|
||||
SEVERITIES = ("info", "notice", "warning", "critical")
|
||||
|
||||
|
||||
def _canonical(row: dict[str, Any]) -> bytes:
|
||||
payload = {
|
||||
"ts": row.get("ts", ""),
|
||||
"actor": row.get("actor", ""),
|
||||
"actor_id": row.get("actor_id"),
|
||||
"action": row.get("action", ""),
|
||||
"target": row.get("target", ""),
|
||||
"detail": row.get("detail", ""),
|
||||
"ip": row.get("ip", ""),
|
||||
"request_id": row.get("request_id", ""),
|
||||
"severity": row.get("severity", "info"),
|
||||
}
|
||||
return json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||
|
||||
|
||||
def compute_hash(prev_hash: str, row: dict[str, Any]) -> str:
|
||||
return hashlib.sha256((prev_hash or GENESIS).encode("ascii") + _canonical(row)).hexdigest()
|
||||
|
||||
|
||||
def log(
|
||||
conn: sqlite3.Connection,
|
||||
*,
|
||||
actor: str,
|
||||
action: str,
|
||||
target: str = "",
|
||||
detail: str = "",
|
||||
actor_id: int | None = None,
|
||||
ip: str = "",
|
||||
request_id: str = "",
|
||||
severity: str = "info",
|
||||
ts: datetime | None = None,
|
||||
) -> int:
|
||||
"""Schreibt einen Protokolleintrag auf der uebergebenen Verbindung.
|
||||
|
||||
Wichtig: **immer** die bestehende Verbindung nutzen. Eine Protokollfunktion,
|
||||
die selbst eine Verbindung oeffnet, blockiert die laufende Transaktion --
|
||||
das war im Vorgaenger eine reale Deadlock-Ursache.
|
||||
"""
|
||||
if severity not in SEVERITIES:
|
||||
severity = "info"
|
||||
row = {
|
||||
"ts": (ts or datetime.now(timezone.utc)).isoformat(timespec="seconds"),
|
||||
"actor": actor or "system",
|
||||
"actor_id": actor_id,
|
||||
"action": action,
|
||||
"target": target,
|
||||
"detail": detail,
|
||||
"ip": ip,
|
||||
"request_id": request_id,
|
||||
"severity": severity,
|
||||
}
|
||||
prev_hash = (
|
||||
Database.value(conn, "SELECT hash FROM audit_log ORDER BY id DESC LIMIT 1") or GENESIS
|
||||
)
|
||||
row_hash = compute_hash(str(prev_hash), row)
|
||||
cursor = conn.execute(
|
||||
"INSERT INTO audit_log (ts, actor, actor_id, action, target, detail, ip, request_id, "
|
||||
"severity, prev_hash, hash) VALUES (?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(
|
||||
row["ts"],
|
||||
row["actor"],
|
||||
row["actor_id"],
|
||||
row["action"],
|
||||
row["target"],
|
||||
row["detail"],
|
||||
row["ip"],
|
||||
row["request_id"],
|
||||
row["severity"],
|
||||
prev_hash,
|
||||
row_hash,
|
||||
),
|
||||
)
|
||||
return int(cursor.lastrowid or 0)
|
||||
|
||||
|
||||
def verify_chain(conn: sqlite3.Connection, *, limit: int | None = None) -> dict[str, Any]:
|
||||
"""Prueft die Hash-Kette. Rueckgabe: ``{"ok", "checked", "broken_at", "reason"}``."""
|
||||
sql = "SELECT * FROM audit_log ORDER BY id"
|
||||
params: tuple[Any, ...] = ()
|
||||
if limit:
|
||||
sql = "SELECT * FROM (SELECT * FROM audit_log ORDER BY id DESC LIMIT ?) ORDER BY id"
|
||||
params = (limit,)
|
||||
|
||||
checked = 0
|
||||
expected_prev: str | None = None
|
||||
for row in conn.execute(sql, params):
|
||||
data = {key: row[key] for key in row.keys()}
|
||||
if expected_prev is not None and data["prev_hash"] != expected_prev:
|
||||
return {
|
||||
"ok": False,
|
||||
"checked": checked,
|
||||
"broken_at": data["id"],
|
||||
"reason": "Vorgaenger-Hash passt nicht -- eine Zeile wurde entfernt oder eingefuegt.",
|
||||
}
|
||||
if compute_hash(str(data["prev_hash"]), data) != data["hash"]:
|
||||
return {
|
||||
"ok": False,
|
||||
"checked": checked,
|
||||
"broken_at": data["id"],
|
||||
"reason": "Zeileninhalt passt nicht zum gespeicherten Hash -- Eintrag geaendert.",
|
||||
}
|
||||
expected_prev = str(data["hash"])
|
||||
checked += 1
|
||||
return {"ok": True, "checked": checked, "broken_at": None, "reason": ""}
|
||||
|
||||
|
||||
def search(
|
||||
conn: sqlite3.Connection,
|
||||
*,
|
||||
query: str = "",
|
||||
action: str = "",
|
||||
actor: str = "",
|
||||
severity: str = "",
|
||||
limit: int = 200,
|
||||
offset: int = 0,
|
||||
) -> list[dict[str, Any]]:
|
||||
clauses: list[str] = []
|
||||
params: list[Any] = []
|
||||
if query:
|
||||
clauses.append("(target LIKE ? OR detail LIKE ? OR action LIKE ? OR actor LIKE ?)")
|
||||
params.extend([f"%{query}%"] * 4)
|
||||
if action:
|
||||
clauses.append("action = ?")
|
||||
params.append(action)
|
||||
if actor:
|
||||
clauses.append("actor = ?")
|
||||
params.append(actor)
|
||||
if severity:
|
||||
clauses.append("severity = ?")
|
||||
params.append(severity)
|
||||
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
|
||||
params.extend([limit, offset])
|
||||
return Database.all(
|
||||
conn,
|
||||
f"SELECT * FROM audit_log {where} ORDER BY id DESC LIMIT ? OFFSET ?",
|
||||
tuple(params),
|
||||
)
|
||||
|
||||
|
||||
def count(conn: sqlite3.Connection) -> int:
|
||||
return int(Database.value(conn, "SELECT COUNT(*) FROM audit_log", default=0) or 0)
|
||||
|
||||
|
||||
def distinct_actions(conn: sqlite3.Connection) -> list[str]:
|
||||
return [
|
||||
str(row["action"])
|
||||
for row in Database.all(conn, "SELECT DISTINCT action FROM audit_log ORDER BY action")
|
||||
]
|
||||
|
||||
|
||||
def archive_old_rows(
|
||||
conn: sqlite3.Connection,
|
||||
archive_dir: Path,
|
||||
*,
|
||||
threshold: int = ARCHIVE_THRESHOLD,
|
||||
target: int = ARCHIVE_TARGET,
|
||||
) -> list[Path]:
|
||||
"""Laagert ganze Kalendertage aus, bis die Tabelle unter ``target`` liegt.
|
||||
|
||||
Nur *vollstaendige* Tage werden ausgelagert -- der laufende Tag bleibt immer
|
||||
in der Datenbank, damit die Auditseite nie halbe Tage zeigt.
|
||||
"""
|
||||
total = count(conn)
|
||||
if total <= threshold:
|
||||
return []
|
||||
|
||||
archive_dir.mkdir(parents=True, exist_ok=True)
|
||||
today = datetime.now(timezone.utc).date().isoformat()
|
||||
written: list[Path] = []
|
||||
|
||||
while total > target:
|
||||
# Nach Zeitstempel, nicht nach id: Eintraege koennen mit einem
|
||||
# nachgereichten ts geschrieben worden sein (Import, Migration).
|
||||
oldest_day = Database.value(conn, "SELECT MIN(substr(ts,1,10)) FROM audit_log")
|
||||
if not oldest_day or str(oldest_day) >= today:
|
||||
break
|
||||
rows = Database.all(
|
||||
conn,
|
||||
"SELECT * FROM audit_log WHERE substr(ts,1,10)=? ORDER BY id",
|
||||
(str(oldest_day),),
|
||||
)
|
||||
if not rows:
|
||||
break
|
||||
path = archive_dir / f"audit-{oldest_day}.jsonl"
|
||||
with path.open("a", encoding="utf-8", newline="\n") as stream:
|
||||
for row in rows:
|
||||
stream.write(json.dumps(row, ensure_ascii=False, sort_keys=True) + "\n")
|
||||
conn.execute("DELETE FROM audit_log WHERE substr(ts,1,10)=?", (str(oldest_day),))
|
||||
written.append(path)
|
||||
total -= len(rows)
|
||||
logger.info("Auditlog: %s Zeilen von %s nach %s ausgelagert", len(rows), oldest_day, path)
|
||||
|
||||
return written
|
||||
|
||||
|
||||
def archived_days(archive_dir: Path) -> list[dict[str, Any]]:
|
||||
"""Liste der Archivdateien fuer die Verlaufsseite."""
|
||||
if not archive_dir.exists():
|
||||
return []
|
||||
entries: list[dict[str, Any]] = []
|
||||
for path in sorted(archive_dir.glob("audit-*.jsonl"), reverse=True):
|
||||
try:
|
||||
stat = path.stat()
|
||||
except OSError:
|
||||
continue
|
||||
entries.append(
|
||||
{
|
||||
"day": path.stem.removeprefix("audit-"),
|
||||
"path": path,
|
||||
"size": stat.st_size,
|
||||
"modified": datetime.fromtimestamp(stat.st_mtime, timezone.utc).isoformat(
|
||||
timespec="seconds"
|
||||
),
|
||||
}
|
||||
)
|
||||
return entries
|
||||
|
||||
|
||||
def read_archived_day(archive_dir: Path, day: str) -> list[dict[str, Any]]:
|
||||
path = archive_dir / f"audit-{day}.jsonl"
|
||||
if not path.is_file():
|
||||
return []
|
||||
rows: list[dict[str, Any]] = []
|
||||
with path.open("r", encoding="utf-8") as stream:
|
||||
for line in stream:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
rows.append(json.loads(line))
|
||||
except ValueError:
|
||||
continue
|
||||
return rows
|
||||
|
||||
|
||||
def summarize_by_day(rows: Iterable[dict[str, Any]]) -> list[tuple[str, int]]:
|
||||
buckets: dict[str, int] = defaultdict(int)
|
||||
for row in rows:
|
||||
buckets[str(row.get("ts", ""))[:10]] += 1
|
||||
return sorted(buckets.items(), reverse=True)
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Authentisierung: Benutzerobjekt, Anmeldedienst, Verzeichnisanbindung."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .models import (
|
||||
ANONYMOUS,
|
||||
AnonymousUser,
|
||||
Group,
|
||||
User,
|
||||
admin_required,
|
||||
current_user,
|
||||
login_required,
|
||||
permission_required,
|
||||
reauth_required,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ANONYMOUS",
|
||||
"AnonymousUser",
|
||||
"Group",
|
||||
"User",
|
||||
"admin_required",
|
||||
"current_user",
|
||||
"login_required",
|
||||
"permission_required",
|
||||
"reauth_required",
|
||||
]
|
||||
@@ -0,0 +1,581 @@
|
||||
"""Anmeldung gegen Active Directory / LDAP (Same Sign-On).
|
||||
|
||||
"Same Sign-On", nicht "Single Sign-On": der Benutzer tippt sein
|
||||
Verzeichnispasswort weiterhin auf der normalen Anmeldeseite ein; es wird bei
|
||||
jeder Anmeldung live gegen das Verzeichnis geprueft, nie hier gespeichert.
|
||||
|
||||
Sicherheitsaenderungen gegenueber dem Vorgaenger:
|
||||
|
||||
* **Transportverschluesselung ist Pflicht** (LDAPS oder StartTLS). Der
|
||||
Vorgaenger erlaubte unverschluesseltes LDAP auf Port 389 ohne Warnung -- das
|
||||
Passwort ging im Klartext ueber das Netz. Wer es dennoch braucht, muss
|
||||
``ldap_allow_insecure`` ausdruecklich setzen; das wird bei jeder Anmeldung
|
||||
protokolliert.
|
||||
* **Serverzertifikat wird geprueft.** Der Vorgaenger validierte es nicht --
|
||||
LDAPS ohne Validierung schuetzt nur gegen passives Mitlesen, nicht gegen
|
||||
einen Man-in-the-Middle.
|
||||
* Das Bind-Passwort des Dienstkontos liegt AES-GCM-verschluesselt in
|
||||
``service_accounts`` (an den Zweck gebunden), nicht Fernet-verschluesselt
|
||||
ohne Kontext.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import socket
|
||||
import sqlite3
|
||||
import ssl
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Literal
|
||||
|
||||
from .. import audit
|
||||
from ..db import Database
|
||||
from ..extension import core
|
||||
from ..settings_store import SettingSpec
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SERVICE_PURPOSE = "ldap_bind"
|
||||
AAD_LDAP_BIND = "service_accounts.ldap_bind"
|
||||
|
||||
#: userAccountControl-Bit fuer "Konto deaktiviert".
|
||||
ACCOUNTDISABLE = 0x0002
|
||||
#: OID fuer rekursive Gruppenaufloesung in AD.
|
||||
MATCHING_RULE_IN_CHAIN = "1.2.840.113556.1.4.1941"
|
||||
|
||||
Status = Literal["ok", "invalid", "unavailable", "disabled"]
|
||||
|
||||
SETTINGS: tuple[SettingSpec, ...] = (
|
||||
SettingSpec("ldap_enabled", False, "bool", "Verzeichnisanmeldung aktiv", section="ldap"),
|
||||
SettingSpec("ldap_host", "", "str", "Server (Hostname)", section="ldap"),
|
||||
SettingSpec("ldap_port", 636, "int", "Port", section="ldap", minimum=1, maximum=65535),
|
||||
SettingSpec(
|
||||
"ldap_security",
|
||||
"ldaps",
|
||||
"str",
|
||||
"Transport",
|
||||
section="ldap",
|
||||
choices=(("ldaps", "LDAPS (Port 636)"), ("starttls", "StartTLS (Port 389)"), ("none", "Unverschluesselt")),
|
||||
),
|
||||
SettingSpec("ldap_verify_cert", True, "bool", "Serverzertifikat pruefen", section="ldap"),
|
||||
SettingSpec("ldap_ca_file", "", "str", "CA-Zertifikat (Pfad)", section="ldap"),
|
||||
SettingSpec(
|
||||
"ldap_allow_insecure",
|
||||
False,
|
||||
"bool",
|
||||
"Unverschluesselte Verbindung ausdruecklich erlauben",
|
||||
hint="Nur fuer Testumgebungen. Das Passwort geht dabei im Klartext ueber das Netz.",
|
||||
section="ldap",
|
||||
),
|
||||
SettingSpec("ldap_base_dn", "", "str", "Basis-DN (leer = automatisch)", section="ldap"),
|
||||
SettingSpec("ldap_user_attribute", "sAMAccountName", "str", "Anmeldeattribut", section="ldap"),
|
||||
SettingSpec("ldap_email_attribute", "userPrincipalName", "str", "E-Mail-Attribut", section="ldap"),
|
||||
SettingSpec("ldap_group_sync", True, "bool", "Gruppen aus dem Verzeichnis uebernehmen", section="ldap"),
|
||||
SettingSpec("ldap_timeout", 8, "int", "Zeitlimit (Sekunden)", section="ldap", minimum=1, maximum=60),
|
||||
)
|
||||
|
||||
_USERNAME_RE = re.compile(r"^[A-Za-z0-9._\-@ ]{1,128}$")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DirectoryProfile:
|
||||
username: str
|
||||
dn: str
|
||||
first_name: str = ""
|
||||
last_name: str = ""
|
||||
email: str = ""
|
||||
group_dns: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DirectoryResult:
|
||||
status: Status
|
||||
profile: DirectoryProfile | None = None
|
||||
message: str = ""
|
||||
warnings: tuple[str, ...] = field(default=())
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def is_enabled(conn: sqlite3.Connection) -> bool:
|
||||
settings = core().settings
|
||||
if not settings.get(conn, "ldap_enabled"):
|
||||
return False
|
||||
return bool(settings.get(conn, "ldap_host"))
|
||||
|
||||
|
||||
def bind_credentials(conn: sqlite3.Connection) -> tuple[str, str]:
|
||||
"""Benutzer und Passwort des Bind-Dienstkontos (entschluesselt)."""
|
||||
row = Database.one(
|
||||
conn,
|
||||
"SELECT username, password_encrypted FROM service_accounts WHERE purpose=?",
|
||||
(SERVICE_PURPOSE,),
|
||||
)
|
||||
if row is None:
|
||||
return "", ""
|
||||
encrypted = str(row.get("password_encrypted") or "")
|
||||
if not encrypted:
|
||||
return str(row.get("username") or ""), ""
|
||||
try:
|
||||
secret = core().keystore.decrypt(encrypted, aad=AAD_LDAP_BIND)
|
||||
except Exception: # noqa: BLE001 - Schluessel gewechselt/Datei kaputt
|
||||
logger.error("Bind-Passwort des Verzeichnisdienstes konnte nicht entschluesselt werden")
|
||||
return str(row.get("username") or ""), ""
|
||||
return str(row.get("username") or ""), secret
|
||||
|
||||
|
||||
def store_bind_credentials(conn: sqlite3.Connection, username: str, password: str | None) -> None:
|
||||
"""Speichert das Dienstkonto. ``password=None`` laesst das bestehende unveraendert."""
|
||||
existing = Database.one(
|
||||
conn, "SELECT password_encrypted FROM service_accounts WHERE purpose=?", (SERVICE_PURPOSE,)
|
||||
)
|
||||
encrypted = str(existing.get("password_encrypted") or "") if existing else ""
|
||||
if password:
|
||||
encrypted = core().keystore.encrypt(password, aad=AAD_LDAP_BIND)
|
||||
conn.execute(
|
||||
"INSERT INTO service_accounts (purpose, username, password_encrypted, updated_at) "
|
||||
"VALUES (?,?,?,?) ON CONFLICT(purpose) DO UPDATE SET username=excluded.username, "
|
||||
"password_encrypted=excluded.password_encrypted, updated_at=excluded.updated_at",
|
||||
(SERVICE_PURPOSE, username.strip(), encrypted, _now()),
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Verbindung
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _tls_object(config: dict[str, Any]):
|
||||
from ldap3 import Tls
|
||||
|
||||
verify = bool(config["ldap_verify_cert"])
|
||||
ca_file = str(config["ldap_ca_file"] or "") or None
|
||||
return Tls(
|
||||
validate=ssl.CERT_REQUIRED if verify else ssl.CERT_NONE,
|
||||
version=ssl.PROTOCOL_TLS_CLIENT,
|
||||
ca_certs_file=ca_file,
|
||||
)
|
||||
|
||||
|
||||
def _server(config: dict[str, Any]):
|
||||
from ldap3 import Server
|
||||
|
||||
security = str(config["ldap_security"])
|
||||
return Server(
|
||||
str(config["ldap_host"]),
|
||||
port=int(config["ldap_port"]),
|
||||
use_ssl=security == "ldaps",
|
||||
tls=_tls_object(config) if security in {"ldaps", "starttls"} else None,
|
||||
get_info="ALL",
|
||||
connect_timeout=int(config["ldap_timeout"]),
|
||||
)
|
||||
|
||||
|
||||
def _connect(config: dict[str, Any], user: str, password: str):
|
||||
"""Baut eine Verbindung auf und bindet. Wirft bei Netz-/TLS-Problemen."""
|
||||
from ldap3 import Connection
|
||||
|
||||
security = str(config["ldap_security"])
|
||||
if security == "none" and not config["ldap_allow_insecure"]:
|
||||
raise PermissionError(
|
||||
"Unverschluesselte Verzeichnisverbindungen sind deaktiviert. "
|
||||
"Nutzen Sie LDAPS oder StartTLS."
|
||||
)
|
||||
connection = Connection(
|
||||
_server(config),
|
||||
user=user or None,
|
||||
password=password or None,
|
||||
auto_bind=False,
|
||||
raise_exceptions=False,
|
||||
receive_timeout=int(config["ldap_timeout"]),
|
||||
)
|
||||
if security == "starttls":
|
||||
if not connection.open():
|
||||
raise ConnectionError("Verbindung zum Verzeichnisdienst fehlgeschlagen")
|
||||
if not connection.start_tls():
|
||||
raise ConnectionError(
|
||||
f"StartTLS fehlgeschlagen: {connection.result.get('description', 'unbekannt')}"
|
||||
)
|
||||
if not connection.bind():
|
||||
return connection, False
|
||||
return connection, True
|
||||
|
||||
|
||||
def _read_config(conn: sqlite3.Connection) -> dict[str, Any]:
|
||||
settings = core().settings
|
||||
return settings.get_many(conn, [spec.key for spec in SETTINGS])
|
||||
|
||||
|
||||
def _base_dn(connection, config: dict[str, Any]) -> str:
|
||||
configured = str(config["ldap_base_dn"] or "").strip()
|
||||
if configured:
|
||||
return configured
|
||||
info = getattr(connection.server, "info", None)
|
||||
naming = getattr(info, "other", {}).get("defaultNamingContext") if info else None
|
||||
if isinstance(naming, list) and naming:
|
||||
return str(naming[0])
|
||||
if isinstance(naming, str):
|
||||
return naming
|
||||
contexts = getattr(info, "naming_contexts", None) if info else None
|
||||
if contexts:
|
||||
return str(contexts[0])
|
||||
return ""
|
||||
|
||||
|
||||
def _escape(value: str) -> str:
|
||||
"""RFC-4515-Escaping fuer Filterwerte -- verhindert LDAP-Injection."""
|
||||
replacements = {"\\": r"\5c", "*": r"\2a", "(": r"\28", ")": r"\29", "\x00": r"\00", "/": r"\2f"}
|
||||
return "".join(replacements.get(char, char) for char in value)
|
||||
|
||||
|
||||
def authenticate(conn: sqlite3.Connection, username: str, password: str) -> DirectoryResult:
|
||||
"""Suchen-und-Binden gegen das Verzeichnis."""
|
||||
try:
|
||||
import ldap3 # noqa: F401
|
||||
except ImportError:
|
||||
return DirectoryResult("unavailable", message="Das Paket ldap3 ist nicht installiert.")
|
||||
|
||||
if not _USERNAME_RE.match(username or ""):
|
||||
return DirectoryResult("invalid")
|
||||
|
||||
config = _read_config(conn)
|
||||
if not config.get("ldap_enabled") or not config.get("ldap_host"):
|
||||
return DirectoryResult("disabled")
|
||||
|
||||
bind_user, bind_password = bind_credentials(conn)
|
||||
warnings: list[str] = []
|
||||
if str(config["ldap_security"]) == "none":
|
||||
warnings.append("Unverschluesselte Verzeichnisverbindung in Benutzung")
|
||||
if not config["ldap_verify_cert"]:
|
||||
warnings.append("Serverzertifikat des Verzeichnisdienstes wird nicht geprueft")
|
||||
|
||||
try:
|
||||
service_conn, bound = _connect(config, bind_user, bind_password)
|
||||
except (PermissionError, ConnectionError) as exc:
|
||||
logger.error("Verzeichnisdienst: %s", exc)
|
||||
return DirectoryResult("unavailable", message=str(exc), warnings=tuple(warnings))
|
||||
except (socket.error, ssl.SSLError, OSError) as exc:
|
||||
logger.error("Verzeichnisdienst nicht erreichbar: %s", exc)
|
||||
return DirectoryResult("unavailable", message=str(exc), warnings=tuple(warnings))
|
||||
|
||||
if not bound:
|
||||
detail = service_conn.result.get("description", "unbekannt") if service_conn.result else ""
|
||||
logger.error("Bind des Dienstkontos fehlgeschlagen: %s", detail)
|
||||
return DirectoryResult(
|
||||
"unavailable",
|
||||
message=f"Anmeldung des Dienstkontos fehlgeschlagen ({detail}).",
|
||||
warnings=tuple(warnings),
|
||||
)
|
||||
|
||||
try:
|
||||
base = _base_dn(service_conn, config)
|
||||
if not base:
|
||||
return DirectoryResult(
|
||||
"unavailable", message="Basis-DN konnte nicht ermittelt werden.", warnings=tuple(warnings)
|
||||
)
|
||||
|
||||
attribute = str(config["ldap_user_attribute"] or "sAMAccountName")
|
||||
email_attribute = str(config["ldap_email_attribute"] or "userPrincipalName")
|
||||
wanted = ["distinguishedName", "givenName", "sn", "userAccountControl", email_attribute, attribute]
|
||||
|
||||
service_conn.search(
|
||||
search_base=base,
|
||||
search_filter=f"(&(objectClass=user)({attribute}={_escape(username)}))",
|
||||
attributes=list(dict.fromkeys(wanted)),
|
||||
size_limit=2,
|
||||
)
|
||||
entries = list(service_conn.entries)
|
||||
if len(entries) != 1:
|
||||
return DirectoryResult("invalid", warnings=tuple(warnings))
|
||||
|
||||
entry = entries[0]
|
||||
dn = str(entry.entry_dn)
|
||||
control = _attr_int(entry, "userAccountControl")
|
||||
if control is not None and control & ACCOUNTDISABLE:
|
||||
return DirectoryResult("invalid", message="Konto im Verzeichnis deaktiviert.", warnings=tuple(warnings))
|
||||
|
||||
# Zweite, unabhaengige Verbindung mit den Benutzerdaten -- die eigentliche
|
||||
# Passwortpruefung. Niemals ueber die Dienstkonto-Verbindung rebinden.
|
||||
user_conn, user_bound = _connect(config, dn, password)
|
||||
try:
|
||||
if not user_bound:
|
||||
return DirectoryResult("invalid", warnings=tuple(warnings))
|
||||
finally:
|
||||
try:
|
||||
user_conn.unbind()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
group_dns: tuple[str, ...] = ()
|
||||
if config["ldap_group_sync"]:
|
||||
group_dns = _resolve_groups(service_conn, base, dn)
|
||||
|
||||
profile = DirectoryProfile(
|
||||
username=_attr(entry, attribute) or username,
|
||||
dn=dn,
|
||||
first_name=_attr(entry, "givenName"),
|
||||
last_name=_attr(entry, "sn"),
|
||||
email=_attr(entry, email_attribute),
|
||||
group_dns=group_dns,
|
||||
)
|
||||
return DirectoryResult("ok", profile=profile, warnings=tuple(warnings))
|
||||
finally:
|
||||
try:
|
||||
service_conn.unbind()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
|
||||
def _attr(entry: Any, name: str) -> str:
|
||||
try:
|
||||
value = entry[name].value
|
||||
except Exception: # noqa: BLE001
|
||||
return ""
|
||||
if isinstance(value, (list, tuple)):
|
||||
return str(value[0]) if value else ""
|
||||
return "" if value is None else str(value)
|
||||
|
||||
|
||||
def _attr_int(entry: Any, name: str) -> int | None:
|
||||
raw = _attr(entry, name)
|
||||
try:
|
||||
return int(raw)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_groups(connection: Any, base: str, user_dn: str) -> tuple[str, ...]:
|
||||
"""Rekursive Gruppenaufloesung ueber LDAP_MATCHING_RULE_IN_CHAIN."""
|
||||
try:
|
||||
connection.search(
|
||||
search_base=base,
|
||||
search_filter=f"(member:{MATCHING_RULE_IN_CHAIN}:={_escape(user_dn)})",
|
||||
attributes=["distinguishedName", "cn"],
|
||||
size_limit=500,
|
||||
)
|
||||
return tuple(str(entry.entry_dn) for entry in connection.entries)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("Gruppenaufloesung fehlgeschlagen: %s", exc)
|
||||
return ()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Schattenkonto
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def sync_account(conn: sqlite3.Connection, profile: DirectoryProfile) -> dict[str, Any]:
|
||||
"""Legt das lokale Schattenkonto an bzw. aktualisiert es und mappt Gruppen."""
|
||||
from ..rbac import service as rbac
|
||||
|
||||
row = rbac.find_user_by_username(conn, profile.username)
|
||||
with core().database.transaction(conn):
|
||||
if row is None:
|
||||
cursor = conn.execute(
|
||||
"INSERT INTO users (username, password_hash, is_admin, auth_source, first_name, "
|
||||
"last_name, email, created_at, updated_at) VALUES (?,?,0,'ldap',?,?,?,?,?)",
|
||||
(
|
||||
profile.username,
|
||||
"", # Kein lokales Passwort -- Anmeldung laeuft immer ueber das Verzeichnis.
|
||||
profile.first_name,
|
||||
profile.last_name,
|
||||
profile.email,
|
||||
_now(),
|
||||
_now(),
|
||||
),
|
||||
)
|
||||
user_id = int(cursor.lastrowid or 0)
|
||||
audit.log(
|
||||
conn,
|
||||
actor=profile.username,
|
||||
actor_id=user_id,
|
||||
action="auth.directory_account_created",
|
||||
target=profile.username,
|
||||
detail=profile.dn,
|
||||
severity="notice",
|
||||
)
|
||||
else:
|
||||
user_id = int(row["id"])
|
||||
conn.execute(
|
||||
"UPDATE users SET first_name=?, last_name=?, email=?, auth_source='ldap', "
|
||||
"updated_at=? WHERE id=?",
|
||||
(profile.first_name, profile.last_name, profile.email, _now(), user_id),
|
||||
)
|
||||
|
||||
_apply_group_mappings(conn, user_id, profile.group_dns)
|
||||
|
||||
result = rbac.get_user(conn, user_id)
|
||||
assert result is not None
|
||||
return result
|
||||
|
||||
|
||||
def _apply_group_mappings(
|
||||
conn: sqlite3.Connection, user_id: int, group_dns: tuple[str, ...]
|
||||
) -> None:
|
||||
"""Setzt die Gruppen additiv aus den Verzeichnisgruppen.
|
||||
|
||||
Bewusst *ersetzend*: wer im Verzeichnis aus einer Gruppe entfernt wurde,
|
||||
verliert das Recht bei der naechsten Anmeldung. Manuell in der Anwendung
|
||||
zugewiesene Gruppen bleiben erhalten, wenn sie kein Verzeichnis-Mapping
|
||||
haben -- sonst waere jede lokale Zuweisung nach dem naechsten Login weg.
|
||||
"""
|
||||
if not group_dns:
|
||||
return
|
||||
mappings = Database.all(
|
||||
conn, "SELECT ad_group_dn, app_group_id FROM ldap_group_mappings"
|
||||
)
|
||||
if not mappings:
|
||||
return
|
||||
mapped_group_ids = {int(row["app_group_id"]) for row in mappings}
|
||||
lowered = {dn.lower() for dn in group_dns}
|
||||
wanted = {
|
||||
int(row["app_group_id"])
|
||||
for row in mappings
|
||||
if str(row["ad_group_dn"]).lower() in lowered
|
||||
}
|
||||
|
||||
current = {int(row["id"]) for row in _user_group_ids(conn, user_id)}
|
||||
# Alles, was ueber ein Mapping kommt, wird neu gesetzt; alles andere bleibt.
|
||||
keep = {gid for gid in current if gid not in mapped_group_ids}
|
||||
final = keep | wanted
|
||||
|
||||
if final == current:
|
||||
return
|
||||
conn.execute("DELETE FROM user_groups WHERE user_id=?", (user_id,))
|
||||
conn.executemany(
|
||||
"INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?,?)",
|
||||
[(user_id, gid) for gid in sorted(final)],
|
||||
)
|
||||
|
||||
|
||||
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,))
|
||||
|
||||
|
||||
def search_directory_users(
|
||||
conn: sqlite3.Connection, query: str, *, limit: int = 20
|
||||
) -> list[dict[str, str]]:
|
||||
"""Live-Suche fuer die Vorab-Zuweisung von Verzeichniskonten."""
|
||||
if len(query.strip()) < 2:
|
||||
return []
|
||||
try:
|
||||
import ldap3 # noqa: F401
|
||||
except ImportError:
|
||||
return []
|
||||
|
||||
config = _read_config(conn)
|
||||
if not config.get("ldap_enabled") or not config.get("ldap_host"):
|
||||
return []
|
||||
bind_user, bind_password = bind_credentials(conn)
|
||||
try:
|
||||
connection, bound = _connect(config, bind_user, bind_password)
|
||||
except Exception: # noqa: BLE001
|
||||
return []
|
||||
if not bound:
|
||||
return []
|
||||
try:
|
||||
base = _base_dn(connection, config)
|
||||
attribute = str(config["ldap_user_attribute"] or "sAMAccountName")
|
||||
email_attribute = str(config["ldap_email_attribute"] or "userPrincipalName")
|
||||
escaped = _escape(query.strip())
|
||||
connection.search(
|
||||
search_base=base,
|
||||
search_filter=(
|
||||
f"(&(objectClass=user)(|({attribute}={escaped}*)"
|
||||
f"(givenName={escaped}*)(sn={escaped}*)(displayName={escaped}*)))"
|
||||
),
|
||||
attributes=[attribute, "givenName", "sn", email_attribute],
|
||||
size_limit=limit,
|
||||
)
|
||||
return [
|
||||
{
|
||||
"username": _attr(entry, attribute),
|
||||
"first_name": _attr(entry, "givenName"),
|
||||
"last_name": _attr(entry, "sn"),
|
||||
"email": _attr(entry, email_attribute),
|
||||
"dn": str(entry.entry_dn),
|
||||
}
|
||||
for entry in connection.entries
|
||||
if _attr(entry, attribute)
|
||||
]
|
||||
except Exception: # noqa: BLE001
|
||||
return []
|
||||
finally:
|
||||
try:
|
||||
connection.unbind()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
|
||||
def search_directory_groups(conn: sqlite3.Connection, query: str, *, limit: int = 20) -> list[dict[str, str]]:
|
||||
"""Live-Suche nach Verzeichnisgruppen fuer die Mapping-Oberflaeche."""
|
||||
if len(query.strip()) < 2:
|
||||
return []
|
||||
try:
|
||||
import ldap3 # noqa: F401
|
||||
except ImportError:
|
||||
return []
|
||||
config = _read_config(conn)
|
||||
if not config.get("ldap_enabled") or not config.get("ldap_host"):
|
||||
return []
|
||||
bind_user, bind_password = bind_credentials(conn)
|
||||
try:
|
||||
connection, bound = _connect(config, bind_user, bind_password)
|
||||
except Exception: # noqa: BLE001
|
||||
return []
|
||||
if not bound:
|
||||
return []
|
||||
try:
|
||||
base = _base_dn(connection, config)
|
||||
escaped = _escape(query.strip())
|
||||
connection.search(
|
||||
search_base=base,
|
||||
search_filter=f"(&(objectClass=group)(cn={escaped}*))",
|
||||
attributes=["cn", "distinguishedName"],
|
||||
size_limit=limit,
|
||||
)
|
||||
return [
|
||||
{"name": _attr(entry, "cn"), "dn": str(entry.entry_dn)} for entry in connection.entries
|
||||
]
|
||||
except Exception: # noqa: BLE001
|
||||
return []
|
||||
finally:
|
||||
try:
|
||||
connection.unbind()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
|
||||
def test_connection(conn: sqlite3.Connection) -> dict[str, Any]:
|
||||
"""Verbindungstest fuer die Einstellungsseite -- ohne Anmeldung eines Benutzers."""
|
||||
try:
|
||||
import ldap3 # noqa: F401
|
||||
except ImportError:
|
||||
return {"ok": False, "message": "Das Paket ldap3 ist nicht installiert."}
|
||||
config = _read_config(conn)
|
||||
if not config.get("ldap_host"):
|
||||
return {"ok": False, "message": "Es ist kein Server konfiguriert."}
|
||||
bind_user, bind_password = bind_credentials(conn)
|
||||
try:
|
||||
connection, bound = _connect(config, bind_user, bind_password)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {"ok": False, "message": str(exc)}
|
||||
if not bound:
|
||||
detail = connection.result.get("description", "unbekannt") if connection.result else ""
|
||||
return {"ok": False, "message": f"Anmeldung des Dienstkontos fehlgeschlagen ({detail})."}
|
||||
try:
|
||||
base = _base_dn(connection, config)
|
||||
return {
|
||||
"ok": True,
|
||||
"message": f"Verbindung erfolgreich. Basis-DN: {base or 'nicht ermittelbar'}",
|
||||
"base_dn": base,
|
||||
}
|
||||
finally:
|
||||
try:
|
||||
connection.unbind()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
@@ -0,0 +1,311 @@
|
||||
"""Benutzerobjekt, Rechtepruefung und Zugriffsdekoratoren."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, Iterable, Sequence
|
||||
|
||||
from flask import abort, flash, g, redirect, request, session, url_for
|
||||
from werkzeug.local import LocalProxy
|
||||
|
||||
from ..rbac.model import Action, PermissionTree
|
||||
from ..security.sessions import is_automatic
|
||||
|
||||
AUTH_SOURCES = ("local", "ldap")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Group:
|
||||
id: int
|
||||
name: str
|
||||
is_system: bool = False
|
||||
is_default: bool = False
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class User:
|
||||
"""Angemeldeter Benutzer. Unveraenderlich fuer die Dauer eines Requests."""
|
||||
|
||||
id: int
|
||||
username: str
|
||||
is_admin: bool = False
|
||||
is_locked: bool = False
|
||||
auth_source: str = "local"
|
||||
first_name: str = ""
|
||||
last_name: str = ""
|
||||
email: str = ""
|
||||
avatar_filename: str = ""
|
||||
totp_enabled: bool = False
|
||||
must_change_password: bool = False
|
||||
groups: tuple[Group, ...] = ()
|
||||
granted: frozenset[str] = frozenset()
|
||||
_tree: PermissionTree | None = field(default=None, repr=False, compare=False)
|
||||
_effective: frozenset[str] | None = field(default=None, repr=False, compare=False)
|
||||
|
||||
# -- Anzeige ------------------------------------------------------- #
|
||||
|
||||
@property
|
||||
def is_authenticated(self) -> bool:
|
||||
return True
|
||||
|
||||
@property
|
||||
def display_name(self) -> str:
|
||||
full = f"{self.first_name} {self.last_name}".strip()
|
||||
return full or self.username
|
||||
|
||||
@property
|
||||
def initials(self) -> str:
|
||||
parts = [p for p in (self.first_name, self.last_name) if p]
|
||||
if parts:
|
||||
return "".join(p[0] for p in parts[:2]).upper()
|
||||
return (self.username[:2] or "?").upper()
|
||||
|
||||
@property
|
||||
def role_label(self) -> str:
|
||||
if self.is_admin:
|
||||
return "Administrator"
|
||||
if self.groups:
|
||||
return ", ".join(group.name for group in self.groups)
|
||||
return "Ohne Gruppe"
|
||||
|
||||
@property
|
||||
def is_directory_account(self) -> bool:
|
||||
return self.auth_source == "ldap"
|
||||
|
||||
# -- Rechte -------------------------------------------------------- #
|
||||
|
||||
@property
|
||||
def effective_permissions(self) -> frozenset[str]:
|
||||
"""Rechte nach Anwendung der Bereichs-Kill-Switches."""
|
||||
if self._effective is None:
|
||||
tree = self._tree
|
||||
self._effective = (
|
||||
frozenset(tree.effective(self.granted)) if tree is not None else frozenset()
|
||||
)
|
||||
return self._effective
|
||||
|
||||
def has(self, *permissions: str) -> bool:
|
||||
"""True, wenn **alle** angegebenen Rechte greifen. Admins duerfen immer alles."""
|
||||
if self.is_admin:
|
||||
return True
|
||||
if not permissions:
|
||||
return True
|
||||
return all(permission in self.effective_permissions for permission in permissions)
|
||||
|
||||
def has_any(self, permissions: Iterable[str]) -> bool:
|
||||
if self.is_admin:
|
||||
return True
|
||||
effective = self.effective_permissions
|
||||
return any(permission in effective for permission in permissions)
|
||||
|
||||
def can(self, resource: str, action: Action | str) -> bool:
|
||||
value = action.value if isinstance(action, Action) else str(action)
|
||||
return self.has(f"{resource}.{value}")
|
||||
|
||||
def can_any(self, resource: str, *actions: Action | str) -> bool:
|
||||
return self.has_any(
|
||||
f"{resource}.{a.value if isinstance(a, Action) else a}" for a in actions
|
||||
)
|
||||
|
||||
def as_audit_actor(self) -> str:
|
||||
return self.username
|
||||
|
||||
|
||||
class AnonymousUser:
|
||||
"""Nicht angemeldet. Hat keine Rechte -- auch nicht ``view``."""
|
||||
|
||||
id = 0
|
||||
username = ""
|
||||
is_admin = False
|
||||
is_locked = False
|
||||
auth_source = "anonymous"
|
||||
first_name = ""
|
||||
last_name = ""
|
||||
email = ""
|
||||
avatar_filename = ""
|
||||
totp_enabled = False
|
||||
must_change_password = False
|
||||
groups: tuple[Group, ...] = ()
|
||||
granted: frozenset[str] = frozenset()
|
||||
|
||||
@property
|
||||
def is_authenticated(self) -> bool:
|
||||
return False
|
||||
|
||||
@property
|
||||
def display_name(self) -> str:
|
||||
return "Gast"
|
||||
|
||||
@property
|
||||
def initials(self) -> str:
|
||||
return "?"
|
||||
|
||||
@property
|
||||
def role_label(self) -> str:
|
||||
return "Nicht angemeldet"
|
||||
|
||||
@property
|
||||
def is_directory_account(self) -> bool:
|
||||
return False
|
||||
|
||||
@property
|
||||
def effective_permissions(self) -> frozenset[str]:
|
||||
return frozenset()
|
||||
|
||||
def has(self, *permissions: str) -> bool:
|
||||
return False
|
||||
|
||||
def has_any(self, permissions: Iterable[str]) -> bool:
|
||||
return False
|
||||
|
||||
def can(self, resource: str, action: Action | str) -> bool:
|
||||
return False
|
||||
|
||||
def can_any(self, resource: str, *actions: Action | str) -> bool:
|
||||
return False
|
||||
|
||||
def as_audit_actor(self) -> str:
|
||||
return "anonym"
|
||||
|
||||
|
||||
ANONYMOUS = AnonymousUser()
|
||||
|
||||
|
||||
def _lookup_user() -> User | AnonymousUser:
|
||||
return getattr(g, "user", ANONYMOUS)
|
||||
|
||||
|
||||
#: Aktueller Benutzer -- in Templates und Views gleich benutzbar.
|
||||
current_user: Any = LocalProxy(_lookup_user)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Dekoratoren
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _deny(message: str) -> Any:
|
||||
"""Nicht angemeldet -> Login; angemeldet aber ohne Recht -> 403."""
|
||||
if not current_user.is_authenticated:
|
||||
if request.method == "GET" and not request.headers.get("X-Requested-With"):
|
||||
session["_after_login"] = request.full_path if request.query_string else request.path
|
||||
# Eine automatische Aktualisierung fragt einen Ausschnitt ab und
|
||||
# erwartet HTML. Eine Umleitung auf die Anmeldeseite wuerde sie
|
||||
# dankbar entgegennehmen und eine ganze Seite in eine Kachel setzen --
|
||||
# der Tab bliebe stumm stehen, obwohl die Sitzung abgelaufen ist. 401
|
||||
# sagt es deutlich; der Browser laedt daraufhin die Seite neu und der
|
||||
# Mensch sieht die Anmeldemaske.
|
||||
if is_automatic(request):
|
||||
abort(401, description="Sitzung abgelaufen.")
|
||||
if request.accept_mimetypes.accept_json and not request.accept_mimetypes.accept_html:
|
||||
abort(401, description="Anmeldung erforderlich.")
|
||||
# "Bitte melden Sie sich an." stand auf einer Seite, die aus einem
|
||||
# Anmeldefeld, einem Passwortfeld und einem Knopf "Anmelden" besteht --
|
||||
# eine Meldung, die nichts hinzufuegt. Wer dagegen gerade noch
|
||||
# angemeldet war, soll erfahren, warum er es nicht mehr ist.
|
||||
if getattr(g, "session_expired", False):
|
||||
flash("Ihre Sitzung ist abgelaufen. Bitte melden Sie sich erneut an.", "info")
|
||||
return redirect(url_for("auth.login"))
|
||||
abort(403, description=message)
|
||||
|
||||
|
||||
def login_required(view: Callable[..., Any]) -> Callable[..., Any]:
|
||||
@functools.wraps(view)
|
||||
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
if not current_user.is_authenticated:
|
||||
return _deny("Anmeldung erforderlich.")
|
||||
return view(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def permission_required(*permissions: str, mode: str = "all") -> Callable[..., Any]:
|
||||
"""Erzwingt Rechte serverseitig.
|
||||
|
||||
``mode="any"`` fuer Seiten, die mehrere unabhaengige Rechte akzeptieren --
|
||||
wichtig, weil im Vorgaenger eine Seite mit reinem Export-Recht nicht
|
||||
erreichbar war, obwohl das Recht vergeben war.
|
||||
"""
|
||||
|
||||
def decorator(view: Callable[..., Any]) -> Callable[..., Any]:
|
||||
@functools.wraps(view)
|
||||
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
ok = (
|
||||
current_user.has_any(permissions)
|
||||
if mode == "any"
|
||||
else current_user.has(*permissions)
|
||||
)
|
||||
if not ok:
|
||||
return _deny("Fuer diese Seite fehlt Ihnen die Berechtigung.")
|
||||
return view(*args, **kwargs)
|
||||
|
||||
wrapper._required_permissions = permissions # type: ignore[attr-defined]
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def admin_required(view: Callable[..., Any]) -> Callable[..., Any]:
|
||||
"""Nur echte Administratoren -- nicht ueber Gruppenrechte delegierbar.
|
||||
|
||||
Fuer Grenzen, die einen lokalen Admin *nicht* ueberleben muessen, aber auch
|
||||
nicht an eine Gruppe vergeben werden sollen (Rechtevergabe selbst,
|
||||
Navigationsreihenfolge, Schluesselrotation).
|
||||
"""
|
||||
|
||||
@functools.wraps(view)
|
||||
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
if not current_user.is_authenticated or not current_user.is_admin:
|
||||
return _deny("Diese Aktion ist Administratoren vorbehalten.")
|
||||
return view(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def reauth_required(view: Callable[..., Any]) -> Callable[..., Any]:
|
||||
"""Verlangt eine frische Passworteingabe ("sudo-Modus").
|
||||
|
||||
Vorgeschaltet bei allem, was Geheimnisse offenlegt oder Daten aus dem System
|
||||
heraustraegt: Klartextpasswoerter, Export, Schluesselrotation, 2FA-Aenderung.
|
||||
Ein uebernommenes Sitzungscookie allein reicht dafuer nicht mehr aus.
|
||||
"""
|
||||
|
||||
@functools.wraps(view)
|
||||
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
from .service import reauth_is_fresh # zyklischen Import vermeiden
|
||||
|
||||
if not current_user.is_authenticated:
|
||||
return _deny("Anmeldung erforderlich.")
|
||||
if not reauth_is_fresh():
|
||||
session["_after_reauth"] = request.full_path if request.query_string else request.path
|
||||
flash("Bitte bestaetigen Sie zur Sicherheit Ihr Passwort.", "info")
|
||||
return redirect(url_for("auth.reauth"))
|
||||
return view(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def build_user(
|
||||
row: dict[str, Any],
|
||||
*,
|
||||
groups: Sequence[Group],
|
||||
granted: Iterable[str],
|
||||
tree: PermissionTree,
|
||||
) -> User:
|
||||
return User(
|
||||
id=int(row["id"]),
|
||||
username=str(row["username"]),
|
||||
is_admin=bool(row.get("is_admin")),
|
||||
is_locked=bool(row.get("is_locked")),
|
||||
auth_source=str(row.get("auth_source") or "local"),
|
||||
first_name=str(row.get("first_name") or ""),
|
||||
last_name=str(row.get("last_name") or ""),
|
||||
email=str(row.get("email") or ""),
|
||||
avatar_filename=str(row.get("avatar_filename") or ""),
|
||||
totp_enabled=bool(row.get("totp_secret")),
|
||||
must_change_password=bool(row.get("must_change_password")),
|
||||
groups=tuple(groups),
|
||||
granted=frozenset(granted),
|
||||
_tree=tree,
|
||||
)
|
||||
@@ -0,0 +1,135 @@
|
||||
"""Anmelde-Routen: Login, zweiter Faktor, Abmeldung, erneute Bestaetigung."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from flask import Blueprint, flash, redirect, render_template, request, session, url_for
|
||||
|
||||
from ..extension import core, db
|
||||
from . import directory, service
|
||||
from .models import current_user, login_required
|
||||
|
||||
bp = Blueprint("auth", __name__)
|
||||
|
||||
|
||||
def _safe_next(candidate: str | None, fallback_endpoint: str = "index") -> str:
|
||||
"""Verhindert Open Redirects: nur pfad-relative Ziele derselben Anwendung."""
|
||||
default = url_for(fallback_endpoint)
|
||||
if not candidate:
|
||||
return default
|
||||
parsed = urlparse(candidate)
|
||||
if parsed.scheme or parsed.netloc or not candidate.startswith("/"):
|
||||
return default
|
||||
return candidate
|
||||
|
||||
|
||||
@bp.route("/login", methods=["GET", "POST"])
|
||||
def login():
|
||||
if current_user.is_authenticated:
|
||||
return redirect(_safe_next(session.pop("_after_login", None)))
|
||||
|
||||
conn = db()
|
||||
username = ""
|
||||
if request.method == "POST":
|
||||
username = (request.form.get("username") or "").strip()
|
||||
password = request.form.get("password") or ""
|
||||
outcome = service.authenticate(
|
||||
conn, username, password, ip=service.client_ip()
|
||||
)
|
||||
|
||||
if outcome.status == "needs_totp" and outcome.user_row:
|
||||
service.start_totp_challenge(outcome.user_row)
|
||||
return redirect(url_for("auth.second_factor"))
|
||||
|
||||
if outcome.ok and outcome.user_row:
|
||||
user = service.complete_login(
|
||||
conn,
|
||||
outcome.user_row,
|
||||
ip=service.client_ip(),
|
||||
secret=password,
|
||||
group_dns=outcome.group_dns,
|
||||
)
|
||||
flash(f"Willkommen, {user.display_name}.", "success")
|
||||
if user.must_change_password:
|
||||
flash("Bitte legen Sie zuerst ein neues Passwort fest.", "warning")
|
||||
return redirect(url_for("account.security"))
|
||||
return redirect(_safe_next(session.pop("_after_login", None)))
|
||||
|
||||
flash(outcome.message or service.GENERIC_FAILURE, "danger")
|
||||
|
||||
return render_template(
|
||||
"tesm_core/login.html",
|
||||
username=username,
|
||||
directory_enabled=directory.is_enabled(conn),
|
||||
public_dashboard_url=core().registry.get("public_dashboard_url", ""),
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/login/zweiter-faktor", methods=["GET", "POST"])
|
||||
def second_factor():
|
||||
conn = db()
|
||||
user_row = service.pending_totp_user(conn)
|
||||
if user_row is None:
|
||||
flash("Die Anmeldung ist abgelaufen. Bitte erneut versuchen.", "info")
|
||||
return redirect(url_for("auth.login"))
|
||||
|
||||
if request.method == "POST":
|
||||
outcome = service.verify_second_factor(
|
||||
conn, user_row, request.form.get("code") or "", ip=service.client_ip()
|
||||
)
|
||||
if outcome.ok:
|
||||
# Kein ``secret``: das Passwort liegt seit dem ersten Schritt nicht
|
||||
# mehr vor, und es fuer den zweiten Schritt in der Sitzung
|
||||
# zwischenzulagern waere ein schlechter Tausch. Freigaben, die die
|
||||
# Anmeldung des Benutzers weiterreichen, binden sich bei aktivem
|
||||
# zweitem Faktor deshalb nicht ein -- Freigaben mit hinterlegtem
|
||||
# Konto schon.
|
||||
user = service.complete_login(conn, user_row, ip=service.client_ip())
|
||||
flash(f"Willkommen, {user.display_name}.", "success")
|
||||
return redirect(_safe_next(session.pop("_after_login", None)))
|
||||
flash(outcome.message, "danger")
|
||||
|
||||
return render_template("tesm_core/second_factor.html")
|
||||
|
||||
|
||||
@bp.post("/logout")
|
||||
@login_required
|
||||
def logout():
|
||||
service.logout(db())
|
||||
flash("Sie wurden abgemeldet.", "info")
|
||||
return redirect(url_for("auth.login"))
|
||||
|
||||
|
||||
@bp.get("/logout")
|
||||
def logout_get():
|
||||
"""Abmeldung ueber einen Link -- fuer den Abbruch des 2FA-Schritts.
|
||||
|
||||
Aendert nur die eigene Sitzung und ist damit kein CSRF-relevanter Vorgang
|
||||
mit Datenwirkung; die eigentliche Abmeldung im angemeldeten Bereich laeuft
|
||||
trotzdem ueber POST mit Token.
|
||||
"""
|
||||
if current_user.is_authenticated:
|
||||
service.logout(db())
|
||||
else:
|
||||
session.clear()
|
||||
return redirect(url_for("auth.login"))
|
||||
|
||||
|
||||
@bp.route("/bestaetigen", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def reauth():
|
||||
target = _safe_next(session.get("_after_reauth"))
|
||||
if request.method == "POST":
|
||||
password = request.form.get("password") or ""
|
||||
if service.confirm_password(db(), current_user, password):
|
||||
service.mark_reauth()
|
||||
session.pop("_after_reauth", None)
|
||||
return redirect(target)
|
||||
flash("Das Passwort ist nicht korrekt.", "danger")
|
||||
|
||||
return render_template(
|
||||
"tesm_core/reauth.html",
|
||||
cancel_url=url_for("index"),
|
||||
reauth_minutes=core().config.security.reauth_valid_minutes,
|
||||
)
|
||||
@@ -0,0 +1,499 @@
|
||||
"""Anmeldung, Sitzungsaufbau, erneute Passwortbestaetigung.
|
||||
|
||||
Ablauf einer Anmeldung (bewusst in dieser Reihenfolge):
|
||||
|
||||
1. Ratenbegrenzung pro IP **und** pro Benutzername pruefen -- bevor irgendein
|
||||
Hash berechnet wird.
|
||||
2. Lokales Konto suchen. Existiert es mit ``auth_source='local'``, wird das
|
||||
Passwort gegen den Argon2-Hash geprueft; existiert es nicht oder als
|
||||
Verzeichniskonto, greift (falls konfiguriert) die LDAP-Pruefung.
|
||||
3. Bei Erfolg: gesperrt? -> abweisen. 2FA aktiv? -> Zwischenschritt.
|
||||
4. Sitzung rotieren (neue ID), Rechte laden, Erfolg protokollieren.
|
||||
|
||||
Bei jedem Fehlschlag wird dieselbe, nichts verratende Meldung ausgegeben und
|
||||
dieselbe Rechenzeit verbraucht (``passwords.dummy_verify``) -- die Antwort
|
||||
erlaubt keinen Rueckschluss darauf, ob ein Konto existiert.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sqlite3
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Literal
|
||||
|
||||
from flask import current_app, g, request, session
|
||||
|
||||
from .. import audit
|
||||
from ..db import Database
|
||||
from ..extension import core, db
|
||||
from ..rbac import service as rbac
|
||||
from ..security import passwords, ratelimit
|
||||
from ..security import sessions as session_store
|
||||
from .models import ANONYMOUS, Group, User, build_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SESSION_USER_KEY = "user_id"
|
||||
SESSION_PENDING_KEY = "_pending_user_id"
|
||||
SESSION_REAUTH_KEY = "_reauth_at"
|
||||
|
||||
BUCKET_LOGIN_USER = "login:user"
|
||||
BUCKET_LOGIN_IP = "login:ip"
|
||||
BUCKET_TOTP = "totp:user"
|
||||
|
||||
GENERIC_FAILURE = "Benutzername oder Passwort ist falsch."
|
||||
|
||||
Status = Literal["ok", "invalid", "locked", "rate_limited", "needs_totp", "directory_unavailable"]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AuthOutcome:
|
||||
status: Status
|
||||
message: str = ""
|
||||
user_row: dict[str, Any] | None = None
|
||||
retry_after: int = 0
|
||||
#: Verzeichnisgruppen der laufenden Anmeldung. Sie stehen nur hier zur
|
||||
#: Verfuegung -- gespeichert werden sie bewusst nicht, denn beim naechsten
|
||||
#: Mal koennen es andere sein.
|
||||
group_dns: tuple[str, ...] = ()
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
return self.status == "ok"
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _iso(value: datetime) -> str:
|
||||
return value.isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def client_ip() -> str:
|
||||
"""Client-IP unter Beruecksichtigung vertrauenswuerdiger Proxy-Hops."""
|
||||
hops = core().config.security.trusted_proxy_hops
|
||||
if hops > 0:
|
||||
forwarded = request.headers.get("X-Forwarded-For", "")
|
||||
if forwarded:
|
||||
parts = [part.strip() for part in forwarded.split(",") if part.strip()]
|
||||
if parts:
|
||||
# Der letzte vertrauenswuerdige Hop steht von rechts gezaehlt.
|
||||
index = max(0, len(parts) - hops)
|
||||
return parts[index]
|
||||
return request.remote_addr or ""
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Benutzer laden
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def load_user(conn: sqlite3.Connection, user_id: int) -> User | None:
|
||||
row = Database.one(
|
||||
conn, "SELECT * FROM users WHERE id=? AND deleted_at IS NULL", (user_id,)
|
||||
)
|
||||
if row is None or row.get("is_locked"):
|
||||
return None
|
||||
groups = [
|
||||
Group(
|
||||
id=int(entry["id"]),
|
||||
name=str(entry["name"]),
|
||||
is_system=bool(entry["is_system"]),
|
||||
is_default=bool(entry["is_default"]),
|
||||
)
|
||||
for entry in rbac.user_groups(conn, user_id)
|
||||
]
|
||||
return build_user(
|
||||
row,
|
||||
groups=groups,
|
||||
granted=rbac.granted_permissions(conn, user_id),
|
||||
tree=core().permissions,
|
||||
)
|
||||
|
||||
|
||||
def load_request_user() -> None:
|
||||
"""``before_request``-Hook: setzt ``g.user``."""
|
||||
g.user = ANONYMOUS
|
||||
user_id = session.get(SESSION_USER_KEY)
|
||||
if not user_id:
|
||||
return
|
||||
user = load_user(db(), int(user_id))
|
||||
if user is None:
|
||||
# Konto geloescht, gesperrt oder Rechte entzogen -> Sitzung beenden.
|
||||
session.clear()
|
||||
return
|
||||
g.user = user
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Anmeldung
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def authenticate(
|
||||
conn: sqlite3.Connection, username: str, password: str, *, ip: str
|
||||
) -> AuthOutcome:
|
||||
security = core().config.security
|
||||
name = (username or "").strip()
|
||||
if not name or not password:
|
||||
return AuthOutcome("invalid", GENERIC_FAILURE)
|
||||
|
||||
ip_verdict = ratelimit.check(
|
||||
conn,
|
||||
BUCKET_LOGIN_IP,
|
||||
ip or "unbekannt",
|
||||
limit=security.login_max_attempts_per_ip,
|
||||
window_minutes=security.login_lockout_minutes,
|
||||
)
|
||||
if ip_verdict.blocked:
|
||||
return AuthOutcome(
|
||||
"rate_limited",
|
||||
"Zu viele Anmeldeversuche von dieser Adresse. Bitte spaeter erneut versuchen.",
|
||||
retry_after=ip_verdict.retry_after_seconds,
|
||||
)
|
||||
|
||||
user_verdict = ratelimit.check(
|
||||
conn,
|
||||
BUCKET_LOGIN_USER,
|
||||
name.lower(),
|
||||
limit=security.login_max_attempts,
|
||||
window_minutes=security.login_lockout_minutes,
|
||||
)
|
||||
if user_verdict.blocked:
|
||||
return AuthOutcome(
|
||||
"rate_limited",
|
||||
"Dieses Konto ist nach mehreren Fehlversuchen voruebergehend gesperrt.",
|
||||
retry_after=user_verdict.retry_after_seconds,
|
||||
)
|
||||
|
||||
row = rbac.find_user_by_username(conn, name)
|
||||
outcome = _verify_credentials(conn, row, name, password)
|
||||
|
||||
if not outcome.ok:
|
||||
with core().database.transaction(conn):
|
||||
ratelimit.record(conn, BUCKET_LOGIN_IP, ip or "unbekannt")
|
||||
ratelimit.record(conn, BUCKET_LOGIN_USER, name.lower())
|
||||
audit.log(
|
||||
conn,
|
||||
actor=name,
|
||||
action="auth.login_failed",
|
||||
target=name,
|
||||
detail=outcome.status,
|
||||
ip=ip,
|
||||
severity="warning",
|
||||
request_id=getattr(g, "request_id", ""),
|
||||
)
|
||||
return outcome
|
||||
|
||||
user_row = outcome.user_row or {}
|
||||
if bool(user_row.get("is_locked")):
|
||||
with core().database.transaction(conn):
|
||||
audit.log(
|
||||
conn,
|
||||
actor=str(user_row.get("username") or name),
|
||||
action="auth.login_locked",
|
||||
target=str(user_row.get("username") or name),
|
||||
ip=ip,
|
||||
severity="warning",
|
||||
)
|
||||
return AuthOutcome("locked", "Dieses Konto ist gesperrt. Bitte wenden Sie sich an die IT.")
|
||||
|
||||
with core().database.transaction(conn):
|
||||
ratelimit.clear(conn, BUCKET_LOGIN_USER, name.lower())
|
||||
|
||||
if str(user_row.get("totp_secret") or ""):
|
||||
return AuthOutcome("needs_totp", "", user_row=user_row, group_dns=outcome.group_dns)
|
||||
|
||||
if core().config.security.require_totp_for_admins and bool(user_row.get("is_admin")):
|
||||
# Kein harter Block: der Admin kommt herein, muss aber sofort 2FA
|
||||
# einrichten (die Kontoseite fuehrt ihn dorthin).
|
||||
logger.warning(
|
||||
"Administrator %s hat keine 2FA, obwohl sie verlangt wird", user_row.get("username")
|
||||
)
|
||||
|
||||
# ``outcome`` mitfuehren, nicht neu bauen: die Verzeichnisgruppen der
|
||||
# laufenden Anmeldung stehen nur dort. Wer hier ein frisches AuthOutcome
|
||||
# erzeugt, verliert sie lautlos -- und mit ihnen jede Freigabe, die daran
|
||||
# haengt.
|
||||
return AuthOutcome("ok", user_row=user_row, group_dns=outcome.group_dns)
|
||||
|
||||
|
||||
def _verify_credentials(
|
||||
conn: sqlite3.Connection, row: dict[str, Any] | None, name: str, password: str
|
||||
) -> AuthOutcome:
|
||||
from . import directory # lokaler Import: ldap3 ist optional
|
||||
|
||||
directory_enabled = directory.is_enabled(conn)
|
||||
|
||||
if row is not None and str(row.get("auth_source")) == "local":
|
||||
if not passwords.verify_password(str(row.get("password_hash") or ""), password):
|
||||
return AuthOutcome("invalid", GENERIC_FAILURE)
|
||||
if passwords.needs_rehash(str(row.get("password_hash") or "")):
|
||||
with core().database.transaction(conn):
|
||||
conn.execute(
|
||||
"UPDATE users SET password_hash=?, updated_at=? WHERE id=?",
|
||||
(passwords.hash_password(password), _iso(_now()), row["id"]),
|
||||
)
|
||||
return AuthOutcome("ok", user_row=row)
|
||||
|
||||
if not directory_enabled:
|
||||
passwords.dummy_verify()
|
||||
return AuthOutcome("invalid", GENERIC_FAILURE)
|
||||
|
||||
result = directory.authenticate(conn, name, password)
|
||||
if result.status == "unavailable":
|
||||
return AuthOutcome(
|
||||
"directory_unavailable",
|
||||
"Der Verzeichnisdienst ist derzeit nicht erreichbar. "
|
||||
"Bitte versuchen Sie es spaeter erneut.",
|
||||
)
|
||||
if result.status != "ok" or result.profile is None:
|
||||
return AuthOutcome("invalid", GENERIC_FAILURE)
|
||||
|
||||
user_row = directory.sync_account(conn, result.profile)
|
||||
return AuthOutcome("ok", user_row=user_row, group_dns=result.profile.group_dns)
|
||||
|
||||
|
||||
def complete_login(
|
||||
conn: sqlite3.Connection,
|
||||
user_row: dict[str, Any],
|
||||
*,
|
||||
ip: str,
|
||||
secret: str = "",
|
||||
group_dns: tuple[str, ...] = (),
|
||||
) -> User:
|
||||
"""Baut die Sitzung auf. Rotiert die Sitzungs-ID (Session-Fixation).
|
||||
|
||||
``secret`` ist das Passwort dieser Anmeldung. Es wird **nicht** gespeichert
|
||||
und nicht in die Sitzung geschrieben; es wird ausschliesslich an die
|
||||
angemeldeten Haken weitergereicht und ist danach weg. Gebraucht wird es
|
||||
genau von einem: der Einbindung von Dateifreigaben, die sich mit der
|
||||
Anmeldung des Benutzers am Fileserver anmeldet.
|
||||
"""
|
||||
from ..security import csrf
|
||||
|
||||
with core().database.transaction(conn):
|
||||
session_store.rotate(conn, session, reason="login") # type: ignore[arg-type]
|
||||
conn.execute(
|
||||
"UPDATE users SET last_login_at=? WHERE id=?", (_iso(_now()), user_row["id"])
|
||||
)
|
||||
audit.log(
|
||||
conn,
|
||||
actor=str(user_row["username"]),
|
||||
actor_id=int(user_row["id"]),
|
||||
action="auth.login",
|
||||
target=str(user_row["username"]),
|
||||
detail=f"Quelle: {user_row.get('auth_source') or 'local'}",
|
||||
ip=ip,
|
||||
request_id=getattr(g, "request_id", ""),
|
||||
)
|
||||
|
||||
session.clear()
|
||||
session[SESSION_USER_KEY] = int(user_row["id"])
|
||||
session[SESSION_REAUTH_KEY] = _iso(_now())
|
||||
session["_ip"] = ip
|
||||
session["_ua"] = (request.user_agent.string or "")[:200]
|
||||
csrf.rotate_token()
|
||||
|
||||
user = load_user(conn, int(user_row["id"]))
|
||||
if user is None: # pragma: no cover - direkt nach Pruefung praktisch unmoeglich
|
||||
raise RuntimeError("Benutzer konnte nach der Anmeldung nicht geladen werden")
|
||||
g.user = user
|
||||
_run_login_hooks(conn, user, secret=secret, group_dns=group_dns)
|
||||
return user
|
||||
|
||||
|
||||
def _run_login_hooks(
|
||||
conn: sqlite3.Connection, user: User, *, secret: str, group_dns: tuple[str, ...]
|
||||
) -> None:
|
||||
"""Ruft die angemeldeten Haken auf -- ohne die Anmeldung zu gefaehrden.
|
||||
|
||||
Ein Haken, der scheitert, darf niemanden aussperren: was hier passiert, ist
|
||||
Zusatzarbeit (Freigaben einbinden), nicht die Anmeldung selbst. Deshalb
|
||||
wird jeder Fehler protokolliert und geschluckt.
|
||||
"""
|
||||
for hook in core().login_hooks:
|
||||
try:
|
||||
hook(conn, user, secret, group_dns)
|
||||
except Exception: # pragma: no cover - haengt am Fileserver
|
||||
current_app.logger.exception(
|
||||
"Anmeldehaken %r fehlgeschlagen", getattr(hook, "__name__", hook)
|
||||
)
|
||||
|
||||
|
||||
def logout(conn: sqlite3.Connection) -> None:
|
||||
user = getattr(g, "user", ANONYMOUS)
|
||||
if user.is_authenticated:
|
||||
# Vor dem Protokolleintrag, solange die Sitzung noch steht: die Haken
|
||||
# raeumen auf, was zu ihr gehoert (z. B. eingebundene Freigaben).
|
||||
for hook in core().logout_hooks:
|
||||
try:
|
||||
hook(conn, user)
|
||||
except Exception: # pragma: no cover - haengt am Fileserver
|
||||
current_app.logger.exception(
|
||||
"Abmeldehaken %r fehlgeschlagen", getattr(hook, "__name__", hook)
|
||||
)
|
||||
with core().database.transaction(conn):
|
||||
audit.log(
|
||||
conn,
|
||||
actor=user.username,
|
||||
actor_id=user.id,
|
||||
action="auth.logout",
|
||||
target=user.username,
|
||||
ip=client_ip(),
|
||||
)
|
||||
session.destroy() if hasattr(session, "destroy") else session.clear() # type: ignore[attr-defined]
|
||||
g.user = ANONYMOUS
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Zweiter Faktor
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def start_totp_challenge(user_row: dict[str, Any]) -> None:
|
||||
session[SESSION_PENDING_KEY] = int(user_row["id"])
|
||||
session["_pending_since"] = _iso(_now())
|
||||
|
||||
|
||||
def pending_totp_user(conn: sqlite3.Connection) -> dict[str, Any] | None:
|
||||
user_id = session.get(SESSION_PENDING_KEY)
|
||||
if not user_id:
|
||||
return None
|
||||
started = session.get("_pending_since")
|
||||
if started:
|
||||
try:
|
||||
if _now() - datetime.fromisoformat(str(started)) > timedelta(minutes=5):
|
||||
session.pop(SESSION_PENDING_KEY, None)
|
||||
return None
|
||||
except ValueError:
|
||||
pass
|
||||
return Database.one(
|
||||
conn, "SELECT * FROM users WHERE id=? AND deleted_at IS NULL", (int(user_id),)
|
||||
)
|
||||
|
||||
|
||||
def verify_second_factor(
|
||||
conn: sqlite3.Connection, user_row: dict[str, Any], code: str, *, ip: str
|
||||
) -> AuthOutcome:
|
||||
from ..security import totp
|
||||
|
||||
security = core().config.security
|
||||
subject = str(user_row["id"])
|
||||
verdict = ratelimit.check(
|
||||
conn, BUCKET_TOTP, subject, limit=8, window_minutes=security.login_lockout_minutes
|
||||
)
|
||||
if verdict.blocked:
|
||||
return AuthOutcome(
|
||||
"rate_limited",
|
||||
"Zu viele Fehlversuche. Bitte warten Sie einen Moment.",
|
||||
retry_after=verdict.retry_after_seconds,
|
||||
)
|
||||
|
||||
cleaned = (code or "").strip()
|
||||
if totp.verify_code(str(user_row.get("totp_secret") or ""), cleaned):
|
||||
with core().database.transaction(conn):
|
||||
ratelimit.clear(conn, BUCKET_TOTP, subject)
|
||||
return AuthOutcome("ok", user_row=user_row)
|
||||
|
||||
if _consume_recovery_code(conn, int(user_row["id"]), cleaned, ip=ip):
|
||||
return AuthOutcome("ok", user_row=user_row)
|
||||
|
||||
with core().database.transaction(conn):
|
||||
ratelimit.record(conn, BUCKET_TOTP, subject)
|
||||
audit.log(
|
||||
conn,
|
||||
actor=str(user_row["username"]),
|
||||
actor_id=int(user_row["id"]),
|
||||
action="auth.totp_failed",
|
||||
target=str(user_row["username"]),
|
||||
ip=ip,
|
||||
severity="warning",
|
||||
)
|
||||
return AuthOutcome("invalid", "Der Code ist nicht korrekt.")
|
||||
|
||||
|
||||
def _consume_recovery_code(
|
||||
conn: sqlite3.Connection, user_id: int, code: str, *, ip: str
|
||||
) -> bool:
|
||||
candidates = Database.all(
|
||||
conn,
|
||||
"SELECT id, code_hash FROM user_recovery_codes WHERE user_id=? AND used_at IS NULL",
|
||||
(user_id,),
|
||||
)
|
||||
normalized = code.strip().lower()
|
||||
for entry in candidates:
|
||||
if passwords.verify_password(str(entry["code_hash"]), normalized):
|
||||
with core().database.transaction(conn):
|
||||
conn.execute(
|
||||
"UPDATE user_recovery_codes SET used_at=? WHERE id=?",
|
||||
(_iso(_now()), entry["id"]),
|
||||
)
|
||||
remaining = int(
|
||||
Database.value(
|
||||
conn,
|
||||
"SELECT COUNT(*) FROM user_recovery_codes "
|
||||
"WHERE user_id=? AND used_at IS NULL",
|
||||
(user_id,),
|
||||
default=0,
|
||||
)
|
||||
or 0
|
||||
)
|
||||
audit.log(
|
||||
conn,
|
||||
actor=str(
|
||||
Database.value(conn, "SELECT username FROM users WHERE id=?", (user_id,))
|
||||
or ""
|
||||
),
|
||||
actor_id=user_id,
|
||||
action="auth.recovery_code_used",
|
||||
detail=f"Verbleibende Codes: {remaining}",
|
||||
ip=ip,
|
||||
severity="notice",
|
||||
)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Erneute Bestaetigung ("sudo-Modus")
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def reauth_is_fresh() -> bool:
|
||||
stamp = session.get(SESSION_REAUTH_KEY)
|
||||
if not stamp:
|
||||
return False
|
||||
try:
|
||||
moment = datetime.fromisoformat(str(stamp))
|
||||
except ValueError:
|
||||
return False
|
||||
if moment.tzinfo is None:
|
||||
moment = moment.replace(tzinfo=timezone.utc)
|
||||
window = timedelta(minutes=core().config.security.reauth_valid_minutes)
|
||||
return _now() - moment <= window
|
||||
|
||||
|
||||
def mark_reauth() -> None:
|
||||
session[SESSION_REAUTH_KEY] = _iso(_now())
|
||||
|
||||
|
||||
def confirm_password(conn: sqlite3.Connection, user: User, password: str) -> bool:
|
||||
"""Prueft das Passwort des angemeldeten Benutzers erneut.
|
||||
|
||||
Fuer Verzeichniskonten laeuft die Pruefung gegen das Verzeichnis -- sonst
|
||||
koennten LDAP-Nutzer den sudo-Modus nie erreichen.
|
||||
"""
|
||||
from . import directory
|
||||
|
||||
row = rbac.get_user(conn, user.id)
|
||||
if row is None:
|
||||
return False
|
||||
if str(row.get("auth_source")) == "local":
|
||||
return passwords.verify_password(str(row.get("password_hash") or ""), password)
|
||||
if not directory.is_enabled(conn):
|
||||
return False
|
||||
return directory.authenticate(conn, user.username, password).status == "ok"
|
||||
@@ -0,0 +1,230 @@
|
||||
"""Der Befehl ``web-setup`` -- HTTPS einrichten, ohne nginx von Hand anzufassen.
|
||||
|
||||
Beide Anwendungen registrieren ihn; die Logik steht darum genau einmal hier.
|
||||
|
||||
Warum es diesen Befehl ueberhaupt gibt: die nginx-Site wird ausschliesslich aus
|
||||
der Vorlage in :mod:`tesm_core.system.webserver` erzeugt -- das ist die
|
||||
Konsequenz aus dem alias-Fehler des Vorgaengers, der jahrelang ueberlebte, weil
|
||||
irgendwann jemand die Datei von Hand angelegt hatte. Ein Installationsskript,
|
||||
das sich seine eigene TLS-Konfiguration zusammensetzt, waere derselbe Fehler in
|
||||
neu. Stattdessen setzt dieser Befehl die Einstellungen und laesst die Anwendung
|
||||
schreiben -- genau wie die Oberflaeche es tut.
|
||||
|
||||
Aufruf aus ``install.sh``:
|
||||
|
||||
tesm-admin web-setup --https --self-signed --hsts --apply
|
||||
tesm-admin web-setup --https --domain tesm.example.org --acme-email a@b.c --apply
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
from .system import webserver
|
||||
|
||||
|
||||
def _config_from_settings(extension: Any, conn: Any) -> webserver.WebConfig:
|
||||
values = extension.settings.get_many(
|
||||
conn,
|
||||
[
|
||||
"web_domain",
|
||||
"web_https_enabled",
|
||||
"web_hsts_enabled",
|
||||
"web_redirect_http",
|
||||
"web_cert_path",
|
||||
"web_key_path",
|
||||
"web_acme_webroot",
|
||||
"web_max_body_mb",
|
||||
"web_http_port",
|
||||
"web_https_port",
|
||||
],
|
||||
)
|
||||
return webserver.WebConfig(
|
||||
app_key=extension.config.site_key,
|
||||
upstream=str(extension.registry.get("upstream", "127.0.0.1:5000")),
|
||||
static_root=str(
|
||||
extension.registry.get("static_root", extension.config.base_dir / "static")
|
||||
),
|
||||
domain=str(values["web_domain"] or ""),
|
||||
https_enabled=bool(values["web_https_enabled"]),
|
||||
hsts_enabled=bool(values["web_hsts_enabled"]),
|
||||
redirect_http=bool(values["web_redirect_http"]),
|
||||
cert_path=str(values["web_cert_path"] or ""),
|
||||
key_path=str(values["web_key_path"] or ""),
|
||||
acme_webroot=str(
|
||||
values["web_acme_webroot"] or f"/var/www/{extension.config.site_key}-acme"
|
||||
),
|
||||
websocket_path=str(extension.registry.get("websocket_path", "")),
|
||||
max_body_mb=int(values["web_max_body_mb"] or 128),
|
||||
http_port=int(values["web_http_port"] or 80),
|
||||
https_port=int(values["web_https_port"] or 443),
|
||||
)
|
||||
|
||||
|
||||
def run_web_setup(app: Any, extension: Any, args: argparse.Namespace) -> int:
|
||||
"""Setzt die Webserver-Einstellungen und schreibt optional die Site-Datei."""
|
||||
sysops = extension.sysops
|
||||
with app.app_context(), extension.database.session() as conn:
|
||||
values: dict[str, Any] = {}
|
||||
|
||||
if args.domain is not None:
|
||||
try:
|
||||
values["web_domain"] = webserver.validate_domain(args.domain)
|
||||
except ValueError as exc:
|
||||
print(str(exc), file=sys.stderr)
|
||||
return 2
|
||||
if args.acme_email:
|
||||
values["web_acme_email"] = args.acme_email.strip()
|
||||
if args.max_body_mb:
|
||||
values["web_max_body_mb"] = int(args.max_body_mb)
|
||||
for flag, key in (("http_port", "web_http_port"), ("https_port", "web_https_port")):
|
||||
port = getattr(args, flag, None)
|
||||
if port:
|
||||
if not 1 <= int(port) <= 65535:
|
||||
print(f"Ungueltiger Port: {port}", file=sys.stderr)
|
||||
return 2
|
||||
values[key] = int(port)
|
||||
|
||||
with extension.database.transaction(conn):
|
||||
if values:
|
||||
extension.settings.set_many(conn, values, actor="install")
|
||||
|
||||
# -- Zertifikat --------------------------------------------------- #
|
||||
config = _config_from_settings(extension, conn)
|
||||
cert: dict[str, Any] = {"ok": True, "message": "Zertifikat unveraendert."}
|
||||
|
||||
if args.acme:
|
||||
if sysops is None or not sysops.available:
|
||||
print("Der Systemhelfer ist nicht verfuegbar.", file=sys.stderr)
|
||||
return 2
|
||||
cert = webserver.request_certificate(
|
||||
sysops,
|
||||
domain=config.domain,
|
||||
email=str(extension.settings.get(conn, "web_acme_email") or ""),
|
||||
webroot=config.acme_webroot,
|
||||
)
|
||||
if not cert["ok"] and args.self_signed:
|
||||
# Kein oeffentliches Zertifikat bekommen -- lieber
|
||||
# selbstsigniertes HTTPS als gar keins. Der Aufrufer erfaehrt
|
||||
# beides.
|
||||
print(f"Let's Encrypt fehlgeschlagen: {cert.get('detail', cert['message'])}")
|
||||
print("Weiche auf ein selbstsigniertes Zertifikat aus.")
|
||||
args.acme = False
|
||||
elif not cert["ok"]:
|
||||
print(cert.get("detail") or cert["message"], file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if args.self_signed and not args.acme:
|
||||
if sysops is None or not sysops.available:
|
||||
print("Der Systemhelfer ist nicht verfuegbar.", file=sys.stderr)
|
||||
return 2
|
||||
suggested = webserver.local_names()
|
||||
cert = webserver.create_self_signed(
|
||||
sysops,
|
||||
common_name=config.domain or (suggested[0] if suggested else "localhost"),
|
||||
alt_names=[*suggested, "localhost"],
|
||||
)
|
||||
if not cert["ok"]:
|
||||
print(cert.get("detail") or cert["message"], file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if cert.get("cert_path"):
|
||||
with extension.database.transaction(conn):
|
||||
extension.settings.set_many(
|
||||
conn,
|
||||
{"web_cert_path": cert["cert_path"], "web_key_path": cert["key_path"]},
|
||||
actor="install",
|
||||
)
|
||||
print(cert["message"])
|
||||
|
||||
# -- HTTPS-Schalter ------------------------------------------------ #
|
||||
config = _config_from_settings(extension, conn)
|
||||
if args.https:
|
||||
if not (config.cert_path and config.key_path):
|
||||
print(
|
||||
"HTTPS verlangt Zertifikat und Schluessel -- bitte --self-signed "
|
||||
"oder --acme angeben.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
with extension.database.transaction(conn):
|
||||
extension.settings.set_many(
|
||||
conn,
|
||||
{
|
||||
"web_https_enabled": True,
|
||||
"web_hsts_enabled": bool(args.hsts),
|
||||
"web_redirect_http": not args.no_redirect,
|
||||
},
|
||||
actor="install",
|
||||
)
|
||||
|
||||
config = _config_from_settings(extension, conn)
|
||||
rendered = webserver.render(config)
|
||||
|
||||
if args.print_config:
|
||||
print(rendered)
|
||||
return 0
|
||||
|
||||
if not args.apply:
|
||||
print("Einstellungen gespeichert. Mit --apply wird nginx neu geschrieben.")
|
||||
return 0
|
||||
|
||||
if sysops is None or not sysops.available:
|
||||
print("Der Systemhelfer ist nicht verfuegbar.", file=sys.stderr)
|
||||
return 2
|
||||
result = webserver.apply_config(sysops, rendered)
|
||||
print(result["message"])
|
||||
if not result["ok"]:
|
||||
print(result.get("detail", ""), file=sys.stderr)
|
||||
return 1
|
||||
if config.https_enabled:
|
||||
detail = f"HTTPS auf {config.https_port}, {config.http_port} leitet um"
|
||||
else:
|
||||
detail = f"HTTP auf {config.http_port}"
|
||||
print(f"nginx: {config.server_name} -> {config.upstream} -- {detail}")
|
||||
return 0
|
||||
|
||||
|
||||
def add_web_setup(sub: Any, runner: Any) -> None:
|
||||
"""Haengt ``web-setup`` an einen ``argparse``-Subparser."""
|
||||
parser = sub.add_parser(
|
||||
"web-setup",
|
||||
help="nginx und TLS einrichten (Port 80/443)",
|
||||
description=(
|
||||
"Setzt die Webserver-Einstellungen und erzeugt daraus die nginx-Site. "
|
||||
"Die Datei wird immer vollstaendig neu geschrieben -- eine von Hand "
|
||||
"bearbeitete Site-Datei gibt es in diesem Projekt bewusst nicht."
|
||||
),
|
||||
)
|
||||
parser.add_argument("--https", action="store_true", help="HTTPS auf Port 443 aktivieren")
|
||||
parser.add_argument(
|
||||
"--self-signed",
|
||||
action="store_true",
|
||||
help="Selbstsigniertes Zertifikat erzeugen (geschlossene Netze ohne Domain)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--acme",
|
||||
action="store_true",
|
||||
help="Zertifikat per Let's Encrypt anfordern (Domain muss oeffentlich zeigen)",
|
||||
)
|
||||
parser.add_argument("--domain", help="Oeffentlicher Name dieser Installation")
|
||||
parser.add_argument("--acme-email", help="Kontaktadresse fuer Let's Encrypt")
|
||||
parser.add_argument("--hsts", action="store_true", help="HSTS-Header setzen")
|
||||
parser.add_argument(
|
||||
"--no-redirect",
|
||||
action="store_true",
|
||||
help="Port 80 nicht auf HTTPS umleiten (Sonderfall)",
|
||||
)
|
||||
parser.add_argument("--max-body-mb", type=int, help="Obergrenze fuer Uploads")
|
||||
parser.add_argument("--http-port", type=int, help="Abweichender HTTP-Port (Standard 80)")
|
||||
parser.add_argument("--https-port", type=int, help="Abweichender HTTPS-Port (Standard 443)")
|
||||
parser.add_argument("--apply", action="store_true", help="nginx schreiben, pruefen, neu laden")
|
||||
parser.add_argument(
|
||||
"--print-config", action="store_true", help="Nur die erzeugte Datei ausgeben"
|
||||
)
|
||||
parser.set_defaults(func=runner)
|
||||
|
||||
|
||||
__all__ = ["add_web_setup", "run_web_setup"]
|
||||
@@ -0,0 +1,205 @@
|
||||
"""Konfiguration aus Umgebungsvariablen -- typisiert, an einer Stelle.
|
||||
|
||||
Im Vorgaengerprojekt waren Pfade und Konstanten quer durch eine mehrere
|
||||
tausend Zeilen lange ``app.py`` verstreut. Hier gibt es genau ein Objekt, das
|
||||
beim Start gebaut, validiert und danach nicht mehr veraendert wird.
|
||||
|
||||
Namensraum: jede Anwendung bekommt ein eigenes Env-Praefix (``TESM_`` bzw.
|
||||
``TESM_LICENSE_``), damit beide Dienste auf demselben Host ohne Kollisionen
|
||||
laufen koennen -- inklusive der Pfade, die im Vorgaenger kollidierten
|
||||
(netplan-Override!).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass, field, replace
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _env(prefix: str, name: str, default: str = "") -> str:
|
||||
return os.environ.get(f"{prefix}{name}", default).strip()
|
||||
|
||||
|
||||
def _env_bool(prefix: str, name: str, default: bool) -> bool:
|
||||
raw = _env(prefix, name)
|
||||
if not raw:
|
||||
return default
|
||||
return raw.lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def _env_int(prefix: str, name: str, default: int) -> int:
|
||||
raw = _env(prefix, name)
|
||||
try:
|
||||
return int(raw) if raw else default
|
||||
except ValueError:
|
||||
return default
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SecurityConfig:
|
||||
"""Alles, was das Sicherheitsverhalten steuert. Defaults sind die sichere Wahl."""
|
||||
|
||||
#: Cookie nur ueber HTTPS ausliefern. In Produktion immer True (nginx terminiert TLS).
|
||||
cookie_secure: bool = True
|
||||
cookie_samesite: str = "Lax"
|
||||
#: Leerlauf-Zeitfenster einer Sitzung.
|
||||
session_idle_minutes: int = 30
|
||||
#: Harte Obergrenze unabhaengig von Aktivitaet.
|
||||
session_absolute_hours: int = 12
|
||||
#: Wie lange eine erneute Passwortabfrage ("sudo-Modus") gilt.
|
||||
reauth_valid_minutes: int = 15
|
||||
#: Fehlversuche bis zur temporaeren Sperre eines Kontos.
|
||||
login_max_attempts: int = 8
|
||||
login_lockout_minutes: int = 15
|
||||
#: Fehlversuche pro IP im gleichen Zeitfenster (unabhaengig vom Konto).
|
||||
login_max_attempts_per_ip: int = 30
|
||||
#: Mindestlaenge lokaler Passwoerter.
|
||||
password_min_length: int = 12
|
||||
#: 2FA fuer Administratoren erzwingen.
|
||||
require_totp_for_admins: bool = False
|
||||
#: HSTS-Header setzen (nur sinnvoll, wenn TLS tatsaechlich terminiert wird).
|
||||
hsts_enabled: bool = False
|
||||
hsts_max_age: int = 15552000
|
||||
#: Vertrauenswuerdige Proxy-Hops fuer X-Forwarded-For (0 = Header ignorieren).
|
||||
trusted_proxy_hops: int = 1
|
||||
#: Maximale Groesse eines Uploads in Bytes (None = unbegrenzt).
|
||||
max_content_length: int | None = 64 * 1024 * 1024
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CoreConfig:
|
||||
"""Basiskonfiguration, die beide Anwendungen teilen."""
|
||||
|
||||
app_key: str
|
||||
"""Technischer Kurzname, z. B. ``tesm`` -- Namensraum fuer Pfade und Units."""
|
||||
|
||||
site_key: str
|
||||
"""Name *dieser Installation* auf dem Host, z. B. ``tesm-opus``.
|
||||
|
||||
Weicht vom ``app_key`` ab, sobald neben der Hauptinstallation eine zweite
|
||||
Instanz laeuft. Alles, was ausserhalb von ``/srv/<name>`` liegt und damit
|
||||
zwischen Instanzen kollidieren koennte -- nginx-Site, netplan-Datei,
|
||||
logrotate-Regel, Zertifikatsverzeichnis, Name des Sitzungscookies --, wird
|
||||
ueber diesen Schluessel benannt und **nicht** ueber den ``app_key``.
|
||||
|
||||
Ohne diese Trennung wuerde eine Testinstanz beim Speichern der
|
||||
Webserver-Einstellungen die Site-Datei der Produktion ueberschreiben, und
|
||||
beide Instanzen wuerden sich auf demselben Host dasselbe Sitzungscookie
|
||||
teilen (Cookies unterscheiden keine Ports).
|
||||
"""
|
||||
|
||||
app_name: str
|
||||
"""Anzeigename in der Oberflaeche."""
|
||||
|
||||
env_prefix: str
|
||||
base_dir: Path
|
||||
data_dir: Path
|
||||
log_dir: Path
|
||||
db_path: Path
|
||||
secret_key_file: Path
|
||||
data_key_file: Path
|
||||
license_path: Path
|
||||
license_key_path: Path
|
||||
#: Adresse, auf der der Anwendungsserver lauscht -- exakt das, was nginx
|
||||
#: als ``proxy_pass``-Ziel braucht. Kommt aus derselben Umgebungsdatei wie
|
||||
#: der ``--bind``-Parameter von gunicorn, damit beide nie auseinanderlaufen.
|
||||
#: Vorher stand hier ein fest verdrahtetes ``127.0.0.1:5000``; eine zweite
|
||||
#: Instanz auf Port 5100 erzeugte damit eine nginx-Site, die auf die
|
||||
#: *andere* Installation zeigte.
|
||||
bind: str = "127.0.0.1:5000"
|
||||
debug: bool = False
|
||||
#: Instanz laeuft als echter Webprozess (gesetzt durch die systemd-Unit).
|
||||
web_process: bool = False
|
||||
security: SecurityConfig = field(default_factory=SecurityConfig)
|
||||
#: Zusaetzliche, app-spezifische Werte.
|
||||
extra: dict[str, str] = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def is_production(self) -> bool:
|
||||
return not self.debug
|
||||
|
||||
def path_in_data(self, name: str) -> Path:
|
||||
return self.data_dir / name
|
||||
|
||||
|
||||
def load_core_config(
|
||||
*,
|
||||
app_key: str,
|
||||
app_name: str,
|
||||
env_prefix: str,
|
||||
default_base: Path | None = None,
|
||||
default_bind: str = "127.0.0.1:5000",
|
||||
) -> CoreConfig:
|
||||
"""Liest die Konfiguration aus der Umgebung.
|
||||
|
||||
Ohne gesetzte Env-Vars laeuft alles in einem Unterverzeichnis
|
||||
``instance/<app_key>`` relativ zum aktuellen Arbeitsverzeichnis -- damit ist
|
||||
die Anwendung ohne jede Vorbereitung lokal startbar, ohne je nach
|
||||
``/srv``/``/var/log`` zu greifen.
|
||||
"""
|
||||
base = Path(_env(env_prefix, "BASE_DIR") or (default_base or Path.cwd() / "instance" / app_key))
|
||||
base = base.expanduser().resolve()
|
||||
data_dir = Path(_env(env_prefix, "DATA_DIR") or base / "data").expanduser()
|
||||
log_dir = Path(_env(env_prefix, "LOG_DIR") or base / "logs").expanduser()
|
||||
|
||||
security = SecurityConfig(
|
||||
cookie_secure=_env_bool(env_prefix, "COOKIE_SECURE", True),
|
||||
cookie_samesite=_env(env_prefix, "COOKIE_SAMESITE") or "Lax",
|
||||
session_idle_minutes=_env_int(env_prefix, "SESSION_IDLE_MINUTES", 30),
|
||||
session_absolute_hours=_env_int(env_prefix, "SESSION_ABSOLUTE_HOURS", 12),
|
||||
reauth_valid_minutes=_env_int(env_prefix, "REAUTH_VALID_MINUTES", 15),
|
||||
login_max_attempts=_env_int(env_prefix, "LOGIN_MAX_ATTEMPTS", 8),
|
||||
login_lockout_minutes=_env_int(env_prefix, "LOGIN_LOCKOUT_MINUTES", 15),
|
||||
login_max_attempts_per_ip=_env_int(env_prefix, "LOGIN_MAX_ATTEMPTS_PER_IP", 30),
|
||||
password_min_length=_env_int(env_prefix, "PASSWORD_MIN_LENGTH", 12),
|
||||
require_totp_for_admins=_env_bool(env_prefix, "REQUIRE_TOTP_FOR_ADMINS", False),
|
||||
hsts_enabled=_env_bool(env_prefix, "HSTS_ENABLED", False),
|
||||
hsts_max_age=_env_int(env_prefix, "HSTS_MAX_AGE", 15552000),
|
||||
trusted_proxy_hops=_env_int(env_prefix, "TRUSTED_PROXY_HOPS", 1),
|
||||
max_content_length=_env_int(env_prefix, "MAX_CONTENT_LENGTH", 64 * 1024 * 1024) or None,
|
||||
)
|
||||
|
||||
debug = _env_bool(env_prefix, "DEBUG", False)
|
||||
if debug:
|
||||
# Lokale Entwicklung laeuft ueber http://127.0.0.1 -- ein Secure-Cookie
|
||||
# wuerde dort nie gesetzt und die Anmeldung schlicht nicht funktionieren.
|
||||
# dataclasses.replace, nicht __dict__: SecurityConfig hat slots und
|
||||
# damit gar kein __dict__ -- der alte Ausdruck warf beim Start einen
|
||||
# AttributeError, und zwar genau im Entwicklungsmodus, den das README
|
||||
# empfiehlt.
|
||||
security = replace(
|
||||
security, cookie_secure=_env_bool(env_prefix, "COOKIE_SECURE", False)
|
||||
)
|
||||
|
||||
bind = (_env(env_prefix, "BIND") or default_bind).strip()
|
||||
if not re.fullmatch(r"[0-9A-Za-z_.\[\]:-]{1,80}:\d{1,5}", bind):
|
||||
raise ValueError(f"Ungueltige Bind-Adresse: {bind!r}")
|
||||
|
||||
site_key = (_env(env_prefix, "SITE_KEY") or app_key).strip().lower()
|
||||
if not re.fullmatch(r"[a-z0-9][a-z0-9-]{0,39}", site_key):
|
||||
raise ValueError(f"Ungueltiger SITE_KEY: {site_key!r}")
|
||||
|
||||
return CoreConfig(
|
||||
app_key=app_key,
|
||||
site_key=site_key,
|
||||
bind=bind,
|
||||
app_name=_env(env_prefix, "APP_NAME") or app_name,
|
||||
env_prefix=env_prefix,
|
||||
base_dir=base,
|
||||
data_dir=data_dir,
|
||||
log_dir=log_dir,
|
||||
db_path=Path(_env(env_prefix, "DB_PATH") or data_dir / "app.db").expanduser(),
|
||||
secret_key_file=Path(
|
||||
_env(env_prefix, "SECRET_KEY_FILE") or data_dir / "secret.key"
|
||||
).expanduser(),
|
||||
data_key_file=Path(_env(env_prefix, "DATA_KEY_FILE") or data_dir / "data.keys").expanduser(),
|
||||
license_path=Path(_env(env_prefix, "LICENSE_PATH") or data_dir / "license.json").expanduser(),
|
||||
license_key_path=Path(
|
||||
_env(env_prefix, "LICENSE_KEY_PATH") or data_dir / "license_key.json"
|
||||
).expanduser(),
|
||||
debug=debug,
|
||||
web_process=_env(env_prefix, "WEB_PROCESS") == "1",
|
||||
security=security,
|
||||
)
|
||||
@@ -0,0 +1,193 @@
|
||||
"""Migrationen der Komponente ``core``: Benutzer, Gruppen, Sitzungen, Audit, Settings.
|
||||
|
||||
Diese Tabellen sind in beiden Anwendungen identisch. Neue Aenderungen kommen
|
||||
als **neue** Migration mit neuer Versionsnummer dazu -- bestehende Migrationen
|
||||
werden nie editiert (die Pruefsummenwarnung des Runners weist darauf hin).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .migrations import Migration
|
||||
|
||||
V1_BASELINE = """
|
||||
-- Benutzerkonten. 'local' = eigenes Passwort, 'ldap' = Schattenkonto aus dem
|
||||
-- Verzeichnisdienst (Passwort wird dort geprueft, nie hier gespeichert).
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL COLLATE NOCASE,
|
||||
password_hash TEXT NOT NULL DEFAULT '',
|
||||
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||
is_locked INTEGER NOT NULL DEFAULT 0,
|
||||
auth_source TEXT NOT NULL DEFAULT 'local',
|
||||
first_name TEXT NOT NULL DEFAULT '',
|
||||
last_name TEXT NOT NULL DEFAULT '',
|
||||
email TEXT NOT NULL DEFAULT '',
|
||||
avatar_filename TEXT NOT NULL DEFAULT '',
|
||||
totp_secret TEXT NOT NULL DEFAULT '',
|
||||
totp_confirmed_at TEXT,
|
||||
must_change_password INTEGER NOT NULL DEFAULT 0,
|
||||
password_changed_at TEXT,
|
||||
last_login_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
deleted_at TEXT
|
||||
);
|
||||
-- Eindeutigkeit nur unter den *nicht* geloeschten Konten: ein Name im
|
||||
-- Papierkorb blockiert die Neuanlage nicht dauerhaft, solange die Anwendung
|
||||
-- den Konflikt bewusst behandelt.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_username_live
|
||||
ON users(username) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_users_deleted ON users(deleted_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS groups (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL COLLATE NOCASE,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
is_system INTEGER NOT NULL DEFAULT 0,
|
||||
is_default INTEGER NOT NULL DEFAULT 0,
|
||||
preset_key TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
deleted_at TEXT
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_groups_name_live
|
||||
ON groups(name) WHERE deleted_at IS NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS group_permissions (
|
||||
group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||
permission TEXT NOT NULL,
|
||||
PRIMARY KEY (group_id, permission)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_groups (
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (user_id, group_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_groups_group ON user_groups(group_id);
|
||||
|
||||
-- Einmal-Codes fuer den Verlust des Authenticators. Gespeichert wird nur ein
|
||||
-- Argon2-Hash -- ein Datenbankleck gibt die Codes nicht her.
|
||||
CREATE TABLE IF NOT EXISTS user_recovery_codes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
code_hash TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
used_at TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_recovery_user ON user_recovery_codes(user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at TEXT NOT NULL,
|
||||
last_seen_at TEXT NOT NULL,
|
||||
idle_expires_at TEXT NOT NULL,
|
||||
absolute_expires_at TEXT NOT NULL,
|
||||
reauth_at TEXT,
|
||||
ip TEXT NOT NULL DEFAULT '',
|
||||
user_agent TEXT NOT NULL DEFAULT '',
|
||||
data TEXT NOT NULL DEFAULT '{}',
|
||||
revoked_at TEXT,
|
||||
revoked_reason TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_user ON user_sessions(user_id, revoked_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_idle ON user_sessions(idle_expires_at);
|
||||
|
||||
-- Sliding-Window-Log der Ratenbegrenzung (Anmeldeversuche, Portal-Anfragen, API).
|
||||
CREATE TABLE IF NOT EXISTS rate_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
bucket TEXT NOT NULL,
|
||||
subject TEXT NOT NULL,
|
||||
ts TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_rate_lookup ON rate_events(bucket, subject, ts);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
updated_by TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
-- Aenderungsprotokoll mit Hash-Kette: jede Zeile bindet den Hash ihrer
|
||||
-- Vorgaengerin ein. Eine nachtraeglich geloeschte oder veraenderte Zeile bricht
|
||||
-- die Kette und wird von der Pruefroutine gefunden.
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ts TEXT NOT NULL,
|
||||
actor TEXT NOT NULL,
|
||||
actor_id INTEGER,
|
||||
action TEXT NOT NULL,
|
||||
target TEXT NOT NULL DEFAULT '',
|
||||
detail TEXT NOT NULL DEFAULT '',
|
||||
ip TEXT NOT NULL DEFAULT '',
|
||||
request_id TEXT NOT NULL DEFAULT '',
|
||||
severity TEXT NOT NULL DEFAULT 'info',
|
||||
prev_hash TEXT NOT NULL DEFAULT '',
|
||||
hash TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_ts ON audit_log(ts);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_actor ON audit_log(actor);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_log(action);
|
||||
|
||||
-- Dienstkonten (z. B. LDAP-Bind). Das Passwort liegt AES-GCM-verschluesselt vor,
|
||||
-- gebunden an den Verwendungszweck als AAD.
|
||||
CREATE TABLE IF NOT EXISTS service_accounts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
purpose TEXT NOT NULL UNIQUE,
|
||||
username TEXT NOT NULL DEFAULT '',
|
||||
password_encrypted TEXT NOT NULL DEFAULT '',
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ldap_group_mappings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ad_group_dn TEXT NOT NULL UNIQUE,
|
||||
ad_group_name TEXT NOT NULL,
|
||||
app_group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
"""
|
||||
|
||||
V2_TRUSTED_KEYS = """
|
||||
-- Gepinnte Aussteller-Schluessel der Lizenzpruefung (Trust on First Use bzw.
|
||||
-- im Build mitgeliefert). Der Vorgaenger vertraute allein dem in der
|
||||
-- Lizenzdatei eingebetteten Schluessel.
|
||||
CREATE TABLE IF NOT EXISTS trusted_issuer_keys (
|
||||
key_id TEXT PRIMARY KEY,
|
||||
public_key TEXT NOT NULL,
|
||||
label TEXT NOT NULL DEFAULT '',
|
||||
source TEXT NOT NULL DEFAULT 'tofu',
|
||||
added_at TEXT NOT NULL,
|
||||
added_by TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
"""
|
||||
|
||||
V3_JOBS = """
|
||||
-- Persistente Auftragsverfolgung fuer Hintergrundlaeufe (Wartung, Import,
|
||||
-- Berichte). Im Vorgaenger lebte dieser Zustand ausschliesslich im
|
||||
-- Prozessspeicher und war nach einem Dienst-Neustart weg -- inklusive der
|
||||
-- Information, ob ein Lauf ueberhaupt stattgefunden hat.
|
||||
CREATE TABLE IF NOT EXISTS jobs (
|
||||
id TEXT PRIMARY KEY,
|
||||
kind TEXT NOT NULL,
|
||||
subject TEXT NOT NULL DEFAULT '',
|
||||
state TEXT NOT NULL DEFAULT 'pending',
|
||||
progress INTEGER NOT NULL DEFAULT 0,
|
||||
message TEXT NOT NULL DEFAULT '',
|
||||
output TEXT NOT NULL DEFAULT '',
|
||||
started_by TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
finished_at TEXT
|
||||
);
|
||||
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);
|
||||
"""
|
||||
|
||||
CORE_MIGRATIONS: tuple[Migration, ...] = (
|
||||
Migration(version=1, name="baseline", sql=V1_BASELINE),
|
||||
Migration(version=2, name="trusted-issuer-keys", sql=V2_TRUSTED_KEYS),
|
||||
Migration(version=3, name="jobs", sql=V3_JOBS),
|
||||
)
|
||||
@@ -0,0 +1,213 @@
|
||||
"""Datenbankzugriff auf SQLite -- eine Verbindung pro Request, Schreibsperren strukturell.
|
||||
|
||||
Die schmerzhafteste Fehlerklasse des Vorgaengerprojekts war
|
||||
``sqlite3.OperationalError: database is locked``: eine Hilfsfunktion oeffnete
|
||||
still eine *zweite* Verbindung, waehrend die aeussere Funktion noch eine offene
|
||||
Schreibtransaktion hielt. Das ist hier nicht mehr durch Disziplin, sondern
|
||||
strukturell verhindert:
|
||||
|
||||
* Pro Request existiert genau **eine** Verbindung (in ``flask.g``).
|
||||
* ``Database.transaction()`` zaehlt die Verschachtelungstiefe. Wer waehrend
|
||||
einer offenen Schreibtransaktion ``Database.connect()`` aufruft, bekommt
|
||||
sofort einen ``NestedConnectionError`` mit Klartext-Erklaerung -- nicht erst
|
||||
nach 15 Sekunden Busy-Timeout einen Deadlock.
|
||||
* Verschachtelte ``transaction()``-Aufrufe auf derselben Verbindung sind
|
||||
erlaubt und werden zu einem einzigen Commit zusammengefasst (Savepoints),
|
||||
damit Service-Funktionen sich gefahrlos gegenseitig aufrufen koennen.
|
||||
|
||||
Zusaetzlich: WAL-Modus (paralleles Lesen waehrend eines Schreibers),
|
||||
``foreign_keys=ON`` (im Vorgaenger nie aktiviert -- Fremdschluessel waren
|
||||
Dekoration) und ``busy_timeout=15000``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
import threading
|
||||
from collections.abc import Iterator, Mapping, Sequence
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
class NestedConnectionError(RuntimeError):
|
||||
"""Zweite Verbindung waehrend einer offenen Schreibtransaktion."""
|
||||
|
||||
|
||||
class Row(sqlite3.Row):
|
||||
"""``sqlite3.Row`` mit ``dict``-artigem ``get`` und Mapping-Konvertierung."""
|
||||
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
try:
|
||||
return self[key]
|
||||
except (IndexError, KeyError):
|
||||
return default
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {key: self[key] for key in self.keys()}
|
||||
|
||||
|
||||
def row_to_dict(row: sqlite3.Row | None) -> dict[str, Any] | None:
|
||||
if row is None:
|
||||
return None
|
||||
return {key: row[key] for key in row.keys()}
|
||||
|
||||
|
||||
def rows_to_dicts(rows: Sequence[sqlite3.Row]) -> list[dict[str, Any]]:
|
||||
return [{key: row[key] for key in row.keys()} for row in rows]
|
||||
|
||||
|
||||
class Database:
|
||||
"""Zugriffsobjekt fuer eine SQLite-Datei."""
|
||||
|
||||
def __init__(self, path: Path | str, *, timeout: float = 15.0) -> None:
|
||||
self.path = Path(path)
|
||||
self.timeout = timeout
|
||||
self._state = threading.local()
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Verbindungen
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def _depth(self) -> int:
|
||||
return int(getattr(self._state, "depth", 0))
|
||||
|
||||
def _set_depth(self, value: int) -> None:
|
||||
self._state.depth = value
|
||||
|
||||
def connect(self) -> sqlite3.Connection:
|
||||
"""Oeffnet eine neue Verbindung. Nicht aufrufen, solange eine Transaktion laeuft."""
|
||||
if self._depth() > 0:
|
||||
raise NestedConnectionError(
|
||||
"Es laeuft bereits eine Schreibtransaktion auf dieser Datenbank. "
|
||||
"Reiche die bestehende Verbindung durch, statt eine neue zu oeffnen "
|
||||
"(sonst blockiert SQLite sich selbst)."
|
||||
)
|
||||
return self._raw_connect()
|
||||
|
||||
def _raw_connect(self) -> sqlite3.Connection:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fresh = not self.path.exists()
|
||||
conn = sqlite3.connect(
|
||||
self.path,
|
||||
timeout=self.timeout,
|
||||
isolation_level=None, # Transaktionen steuern wir selbst, explizit.
|
||||
check_same_thread=False,
|
||||
)
|
||||
conn.row_factory = Row
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA synchronous=NORMAL")
|
||||
conn.execute("PRAGMA foreign_keys=ON")
|
||||
conn.execute("PRAGMA busy_timeout=15000")
|
||||
conn.execute("PRAGMA secure_delete=ON")
|
||||
if fresh:
|
||||
# Die Datei enthaelt Passwort-Hashes, verschluesselte Zugangsdaten und
|
||||
# Sitzungen -- sie gehoert niemandem ausser dem Dienstbenutzer.
|
||||
try:
|
||||
os.chmod(self.path, 0o600)
|
||||
except OSError: # pragma: no cover - z. B. Windows/NTFS
|
||||
pass
|
||||
return conn
|
||||
|
||||
@contextmanager
|
||||
def session(self) -> Iterator[sqlite3.Connection]:
|
||||
"""Kurzlebige Verbindung fuer Skripte und Hintergrund-Threads."""
|
||||
conn = self.connect()
|
||||
try:
|
||||
yield conn
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Transaktionen
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
@contextmanager
|
||||
def transaction(self, conn: sqlite3.Connection | None = None) -> Iterator[sqlite3.Connection]:
|
||||
"""Schreibtransaktion mit ``BEGIN IMMEDIATE``.
|
||||
|
||||
``BEGIN IMMEDIATE`` nimmt die Schreibsperre sofort statt erst beim ersten
|
||||
Schreibzugriff -- damit scheitert ein konkurrierender Schreiber sauber am
|
||||
Busy-Timeout, statt mitten in der Transaktion in einen
|
||||
``SQLITE_BUSY_SNAPSHOT`` zu laufen.
|
||||
"""
|
||||
own_connection = conn is None
|
||||
if own_connection:
|
||||
conn = self._raw_connect()
|
||||
assert conn is not None
|
||||
|
||||
depth = self._depth()
|
||||
savepoint = f"sp_{depth}"
|
||||
self._set_depth(depth + 1)
|
||||
try:
|
||||
if depth == 0:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
else:
|
||||
conn.execute(f"SAVEPOINT {savepoint}")
|
||||
yield conn
|
||||
except BaseException:
|
||||
try:
|
||||
if depth == 0:
|
||||
conn.execute("ROLLBACK")
|
||||
else:
|
||||
conn.execute(f"ROLLBACK TO {savepoint}")
|
||||
except sqlite3.Error: # pragma: no cover - Verbindung schon tot
|
||||
pass
|
||||
raise
|
||||
else:
|
||||
if depth == 0:
|
||||
conn.execute("COMMIT")
|
||||
else:
|
||||
conn.execute(f"RELEASE {savepoint}")
|
||||
finally:
|
||||
self._set_depth(depth)
|
||||
if own_connection:
|
||||
conn.close()
|
||||
|
||||
@property
|
||||
def in_transaction(self) -> bool:
|
||||
return self._depth() > 0
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Bequeme Abfragehelfer (immer parametrisiert -- kein String-Basteln)
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
@staticmethod
|
||||
def one(
|
||||
conn: sqlite3.Connection, sql: str, params: Sequence[Any] | Mapping[str, Any] = ()
|
||||
) -> dict[str, Any] | None:
|
||||
return row_to_dict(conn.execute(sql, params).fetchone())
|
||||
|
||||
@staticmethod
|
||||
def all(
|
||||
conn: sqlite3.Connection, sql: str, params: Sequence[Any] | Mapping[str, Any] = ()
|
||||
) -> list[dict[str, Any]]:
|
||||
return rows_to_dicts(conn.execute(sql, params).fetchall())
|
||||
|
||||
@staticmethod
|
||||
def value(
|
||||
conn: sqlite3.Connection,
|
||||
sql: str,
|
||||
params: Sequence[Any] | Mapping[str, Any] = (),
|
||||
default: Any = None,
|
||||
) -> Any:
|
||||
row = conn.execute(sql, params).fetchone()
|
||||
if row is None:
|
||||
return default
|
||||
return row[0]
|
||||
|
||||
@staticmethod
|
||||
def column_exists(conn: sqlite3.Connection, table: str, column: str) -> bool:
|
||||
rows = conn.execute(f"PRAGMA table_info({table})").fetchall()
|
||||
return any(row["name"] == column for row in rows)
|
||||
|
||||
@staticmethod
|
||||
def table_exists(conn: sqlite3.Connection, table: str) -> bool:
|
||||
return (
|
||||
conn.execute(
|
||||
"SELECT 1 FROM sqlite_master WHERE type IN ('table','view') AND name=?",
|
||||
(table,),
|
||||
).fetchone()
|
||||
is not None
|
||||
)
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Die zentrale Erweiterung: haelt alles zusammen, was eine App braucht.
|
||||
|
||||
Statt globaler Modulvariablen (der Vorgaenger hatte ein Dutzend davon, u. a.
|
||||
``_license_state`` und die DB-Pfade) haengt der gesamte Anwendungszustand an
|
||||
einem Objekt in ``app.extensions["tesm"]``. Zugriff ueber ``core()``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Callable, Sequence
|
||||
|
||||
from flask import Flask, current_app, g
|
||||
|
||||
from .config import CoreConfig
|
||||
from .db import Database
|
||||
from .keystore import Keystore
|
||||
from .migrations import Migration, MigrationRunner
|
||||
from .nav import NavTree
|
||||
from .rbac.model import PermissionTree, RolePreset
|
||||
from .security.passwords import PasswordPolicy
|
||||
from .settings_store import SettingsStore
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from .licensing_client import LicenseManager
|
||||
from .sysops import SysOps
|
||||
|
||||
|
||||
@dataclass
|
||||
class TesmCore:
|
||||
"""Anwendungsweiter Zustand. Nach ``init_app`` unveraendert."""
|
||||
|
||||
config: CoreConfig
|
||||
database: Database
|
||||
keystore: Keystore
|
||||
permissions: PermissionTree
|
||||
nav: NavTree
|
||||
settings: SettingsStore
|
||||
role_presets: tuple[RolePreset, ...] = ()
|
||||
migrations: dict[str, Sequence[Migration]] = field(default_factory=dict)
|
||||
license: "LicenseManager | None" = None
|
||||
sysops: "SysOps | None" = None
|
||||
#: App-spezifische Erweiterungen (z. B. Papierkorb-Register, Job-Registry).
|
||||
registry: dict[str, Any] = field(default_factory=dict)
|
||||
#: Wird nach einer erfolgreichen Anmeldung aufgerufen:
|
||||
#: ``(conn, user, secret, group_dns)``. ``secret`` ist das Passwort dieser
|
||||
#: Anmeldung -- es wird nirgends gespeichert und ist nach dem Aufruf weg.
|
||||
#: Ein Haken, der scheitert, sperrt niemanden aus (siehe
|
||||
#: ``auth.service._run_login_hooks``).
|
||||
login_hooks: list[Callable[..., None]] = field(default_factory=list)
|
||||
#: Wird vor dem Verwerfen der Sitzung aufgerufen: ``(conn, user)``. Was die
|
||||
#: Anmeldung aufgebaut hat, wird hier wieder abgebaut -- sonst bliebe es
|
||||
#: bestehen, bis eine Zeitgrenze greift.
|
||||
logout_hooks: list[Callable[..., None]] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def password_policy(self) -> PasswordPolicy:
|
||||
return PasswordPolicy(min_length=self.config.security.password_min_length)
|
||||
|
||||
@property
|
||||
def audit_archive_dir(self) -> Path:
|
||||
return self.config.log_dir / "audit"
|
||||
|
||||
def run_migrations(self) -> list[str]:
|
||||
runner = MigrationRunner(self.database)
|
||||
applied: list[str] = []
|
||||
for component, migrations in self.migrations.items():
|
||||
applied.extend(runner.run(component, migrations))
|
||||
return applied
|
||||
|
||||
def migration_status(self) -> list[dict[str, object]]:
|
||||
return MigrationRunner(self.database).status(list(self.migrations))
|
||||
|
||||
|
||||
def core(app: Flask | None = None) -> TesmCore:
|
||||
target = app or current_app
|
||||
try:
|
||||
return target.extensions["tesm"] # type: ignore[return-value]
|
||||
except KeyError as exc: # pragma: no cover - Programmierfehler
|
||||
raise RuntimeError("tesm_core ist fuer diese Anwendung nicht initialisiert") from exc
|
||||
|
||||
|
||||
def db() -> sqlite3.Connection:
|
||||
"""Die Verbindung dieses Requests. Genau eine pro Request, nie eine zweite.
|
||||
|
||||
Diese Funktion ist der einzige zugelassene Weg, im Request-Kontext an eine
|
||||
Verbindung zu kommen. Wer stattdessen selbst ``Database.connect()`` aufruft,
|
||||
riskiert genau den Deadlock, an dem der Vorgaenger mehrfach hing.
|
||||
"""
|
||||
connection = getattr(g, "_tesm_conn", None)
|
||||
if connection is None:
|
||||
connection = core().database.connect()
|
||||
g._tesm_conn = connection
|
||||
return connection
|
||||
|
||||
|
||||
def close_db(_exception: BaseException | None = None) -> None:
|
||||
connection = getattr(g, "_tesm_conn", None)
|
||||
if connection is not None:
|
||||
g._tesm_conn = None
|
||||
connection.close()
|
||||
|
||||
|
||||
def transaction() -> Any:
|
||||
"""Kurzform fuer ``core().database.transaction(db())``."""
|
||||
return core().database.transaction(db())
|
||||
|
||||
|
||||
def with_transaction(fn: Callable[..., Any]) -> Callable[..., Any]:
|
||||
"""Dekorator: fuehrt eine Funktion innerhalb einer Schreibtransaktion aus."""
|
||||
|
||||
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
with transaction() as conn:
|
||||
return fn(conn, *args, **kwargs)
|
||||
|
||||
wrapper.__name__ = fn.__name__
|
||||
wrapper.__doc__ = fn.__doc__
|
||||
return wrapper
|
||||
@@ -0,0 +1,212 @@
|
||||
"""Schluesselverwaltung und Feldverschluesselung (AES-256-GCM, rotierbar).
|
||||
|
||||
Der Vorgaenger nutzte Fernet mit einem einzigen, nie rotierbaren Schluessel in
|
||||
``fernet.key``. Fernet ist in Ordnung, aber:
|
||||
|
||||
* Ein Schluesselwechsel war nicht vorgesehen -- ein kompromittierter Schluessel
|
||||
haette bedeutet, alle Zugangsdaten von Hand neu einzugeben.
|
||||
* Es gab keine zugeordneten Daten (AAD), also keine kryptographische Bindung
|
||||
eines Chiffrats an den Ort, an dem es steht. Ein Chiffrat aus Tabelle A liess
|
||||
sich in Tabelle B einspielen.
|
||||
|
||||
Hier: ein versionierter Schluesselbund (``data.keys``, Modus 0600) mit
|
||||
AES-256-GCM. Jedes Chiffrat traegt die Schluessel-ID im Klartext-Praefix, damit
|
||||
alte Werte nach einer Rotation weiter lesbar bleiben. ``aad`` bindet ein
|
||||
Chiffrat an seinen Verwendungszweck (z. B. ``credentials.password:42``).
|
||||
|
||||
Format: ``v1.<key_id>.<base64url(nonce)>.<base64url(ciphertext)>``
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import stat
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from cryptography.exceptions import InvalidTag
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
|
||||
_PREFIX = "v1"
|
||||
_NONCE_BYTES = 12
|
||||
_KEY_BYTES = 32
|
||||
|
||||
|
||||
class DecryptionError(Exception):
|
||||
"""Chiffrat gehoert nicht zu diesem Schluesselbund oder wurde manipuliert."""
|
||||
|
||||
|
||||
def _b64e(raw: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
|
||||
|
||||
|
||||
def _b64d(text: str) -> bytes:
|
||||
padded = text + "=" * (-len(text) % 4)
|
||||
return base64.urlsafe_b64decode(padded.encode("ascii"))
|
||||
|
||||
|
||||
def write_private_file(path: Path, content: str) -> None:
|
||||
"""Schreibt eine Datei mit Modus 0600, atomar (erst tmp, dann ersetzen)."""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC
|
||||
handle = os.open(tmp, flags, 0o600)
|
||||
try:
|
||||
with os.fdopen(handle, "w", encoding="utf-8") as stream:
|
||||
stream.write(content)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
except BaseException:
|
||||
tmp.unlink(missing_ok=True)
|
||||
raise
|
||||
os.replace(tmp, path)
|
||||
try:
|
||||
os.chmod(path, 0o600)
|
||||
except OSError: # pragma: no cover - Windows
|
||||
pass
|
||||
|
||||
|
||||
def check_private_file(path: Path) -> list[str]:
|
||||
"""Meldet zu weite Dateirechte -- die Diagnoseseite zeigt das an."""
|
||||
problems: list[str] = []
|
||||
try:
|
||||
mode = path.stat().st_mode
|
||||
except OSError:
|
||||
return problems
|
||||
if mode & (stat.S_IRGRP | stat.S_IROTH | stat.S_IWGRP | stat.S_IWOTH):
|
||||
problems.append(f"{path} ist fuer Gruppe/Andere lesbar oder schreibbar (erwartet 0600)")
|
||||
return problems
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _Key:
|
||||
key_id: str
|
||||
material: bytes
|
||||
created_at: str
|
||||
|
||||
|
||||
class Keystore:
|
||||
"""Rotierbarer Schluesselbund fuer Feldverschluesselung."""
|
||||
|
||||
def __init__(self, path: Path) -> None:
|
||||
self.path = Path(path)
|
||||
self._keys: dict[str, _Key] = {}
|
||||
self._active: str = ""
|
||||
self._load_or_create()
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def _load_or_create(self) -> None:
|
||||
if self.path.exists():
|
||||
raw = json.loads(self.path.read_text(encoding="utf-8"))
|
||||
for entry in raw.get("keys", []):
|
||||
key = _Key(
|
||||
key_id=str(entry["id"]),
|
||||
material=_b64d(str(entry["key"])),
|
||||
created_at=str(entry.get("created_at", "")),
|
||||
)
|
||||
if len(key.material) != _KEY_BYTES:
|
||||
raise ValueError(f"Schluessel {key.key_id} hat die falsche Laenge")
|
||||
self._keys[key.key_id] = key
|
||||
self._active = str(raw.get("active") or "")
|
||||
if self._active not in self._keys:
|
||||
raise ValueError("Aktiver Schluessel fehlt im Schluesselbund")
|
||||
return
|
||||
self._active = "1"
|
||||
self._keys = {
|
||||
"1": _Key(
|
||||
key_id="1",
|
||||
material=secrets.token_bytes(_KEY_BYTES),
|
||||
created_at=datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||
)
|
||||
}
|
||||
self._persist()
|
||||
|
||||
def _persist(self) -> None:
|
||||
payload = {
|
||||
"active": self._active,
|
||||
"keys": [
|
||||
{"id": key.key_id, "key": _b64e(key.material), "created_at": key.created_at}
|
||||
for key in sorted(self._keys.values(), key=lambda k: int(k.key_id))
|
||||
],
|
||||
}
|
||||
write_private_file(self.path, json.dumps(payload, indent=2, sort_keys=True) + "\n")
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
@property
|
||||
def active_key_id(self) -> str:
|
||||
return self._active
|
||||
|
||||
def key_ids(self) -> list[str]:
|
||||
return sorted(self._keys, key=int)
|
||||
|
||||
def rotate(self) -> str:
|
||||
"""Legt einen neuen aktiven Schluessel an. Alte bleiben zum Lesen erhalten."""
|
||||
new_id = str(max(int(k) for k in self._keys) + 1)
|
||||
self._keys[new_id] = _Key(
|
||||
key_id=new_id,
|
||||
material=secrets.token_bytes(_KEY_BYTES),
|
||||
created_at=datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||
)
|
||||
self._active = new_id
|
||||
self._persist()
|
||||
return new_id
|
||||
|
||||
def drop_key(self, key_id: str) -> None:
|
||||
"""Entfernt einen alten Schluessel -- erst nach vollstaendiger Neuverschluesselung."""
|
||||
if key_id == self._active:
|
||||
raise ValueError("Der aktive Schluessel kann nicht entfernt werden")
|
||||
self._keys.pop(key_id, None)
|
||||
self._persist()
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def encrypt(self, plaintext: str, *, aad: str) -> str:
|
||||
key = self._keys[self._active]
|
||||
nonce = secrets.token_bytes(_NONCE_BYTES)
|
||||
ciphertext = AESGCM(key.material).encrypt(
|
||||
nonce, plaintext.encode("utf-8"), aad.encode("utf-8")
|
||||
)
|
||||
return f"{_PREFIX}.{key.key_id}.{_b64e(nonce)}.{_b64e(ciphertext)}"
|
||||
|
||||
def decrypt(self, token: str, *, aad: str) -> str:
|
||||
try:
|
||||
prefix, key_id, nonce_b64, ct_b64 = token.split(".", 3)
|
||||
except ValueError as exc:
|
||||
raise DecryptionError("Chiffrat hat ein unbekanntes Format") from exc
|
||||
if prefix != _PREFIX:
|
||||
raise DecryptionError(f"Unbekannte Chiffrat-Version: {prefix!r}")
|
||||
key = self._keys.get(key_id)
|
||||
if key is None:
|
||||
raise DecryptionError(f"Schluessel {key_id} ist nicht im Schluesselbund")
|
||||
try:
|
||||
plain = AESGCM(key.material).decrypt(
|
||||
_b64d(nonce_b64), _b64d(ct_b64), aad.encode("utf-8")
|
||||
)
|
||||
except (InvalidTag, ValueError) as exc:
|
||||
raise DecryptionError(
|
||||
"Chiffrat konnte nicht entschluesselt werden (falscher Schluessel, "
|
||||
"manipulierte Daten oder falscher Verwendungszweck)"
|
||||
) from exc
|
||||
return plain.decode("utf-8")
|
||||
|
||||
def needs_reencrypt(self, token: str) -> bool:
|
||||
"""True, wenn das Chiffrat noch unter einem alten Schluessel liegt."""
|
||||
parts = token.split(".", 3)
|
||||
return len(parts) == 4 and parts[1] != self._active
|
||||
|
||||
|
||||
def load_or_create_secret_key(path: Path) -> bytes:
|
||||
"""Flask-Secret-Key: 64 Byte, Datei mit Modus 0600, beim ersten Start erzeugt."""
|
||||
if path.exists():
|
||||
raw = path.read_text(encoding="utf-8").strip()
|
||||
if raw:
|
||||
return _b64d(raw)
|
||||
material = secrets.token_bytes(64)
|
||||
write_private_file(path, _b64e(material) + "\n")
|
||||
return material
|
||||
@@ -0,0 +1,814 @@
|
||||
"""Lizenzverwaltung auf Client-Seite (TESM) und fuer die eigene Lizenz des Servers.
|
||||
|
||||
Zustaendig fuer: Lizenzdatei laden und verifizieren, Aussteller pinnen,
|
||||
Aktivierung/Deaktivierung/Heartbeat (online und offline), Modul-Freischaltung
|
||||
und die Anzeige in der Topbar.
|
||||
|
||||
Wesentliche Aenderungen gegenueber dem Vorgaenger:
|
||||
|
||||
* **Vertrauensanker.** Die Signatur wird gegen gepinnte Aussteller-Schluessel
|
||||
geprueft (Tabelle ``trusted_issuer_keys``). Beim ersten Import wird der
|
||||
Schluessel per Trust-on-First-Use uebernommen und dabei protokolliert; jede
|
||||
spaetere Lizenz eines *anderen* Ausstellers wird abgelehnt, bis ein
|
||||
Administrator den neuen Schluessel ausdruecklich freigibt. Der Vorgaenger
|
||||
pruefte ausschliesslich gegen den Schluessel *in derselben Datei* -- damit war
|
||||
jede selbst signierte Datei "gueltig".
|
||||
* **Trennung von Dokument und Client-Schluessel** (siehe ``tesm_licensing``):
|
||||
``license.json`` (0640) und ``license_key.json`` (0600).
|
||||
* **Ein Gate, eine Wahrheit.** Der Vorgaenger hatte eine Docstring, die mehr
|
||||
behauptete als der Code durchsetzte (LDAP/NGINX waren nur im Template
|
||||
gegated). Hier gibt es genau eine Funktion ``require_license()`` und eine
|
||||
Tabelle in ``LICENSED_FEATURES`` -- was dort steht, wird serverseitig
|
||||
durchgesetzt, ohne Ausnahme.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
import tesm_licensing as lic
|
||||
|
||||
from . import audit
|
||||
from .db import Database
|
||||
from .keystore import write_private_file
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SETTING_STATUS = "license_activation_status"
|
||||
SETTING_PENDING = "license_pending_request"
|
||||
SETTING_LAST_HEARTBEAT = "license_last_heartbeat_at"
|
||||
SETTING_HEARTBEAT_ERROR = "license_heartbeat_error"
|
||||
|
||||
#: Ab wann die Restlaufzeit als *Hinweis* erscheint (gruen). Unterhalb von
|
||||
#: ``EXPIRY_WARNING_DAYS`` uebernimmt die Warnung (orange), nach Ablauf Rot.
|
||||
EXPIRY_HINT_DAYS = 90
|
||||
SETTING_EXPORT_GRACE = "license_export_grace"
|
||||
|
||||
STATUS_INACTIVE = "inactive"
|
||||
STATUS_PENDING = "pending"
|
||||
STATUS_ACTIVE = "active"
|
||||
|
||||
API_PATHS = {
|
||||
"activate": "/api/v1/licenses/activate",
|
||||
"deactivate": "/api/v1/licenses/deactivate",
|
||||
"heartbeat": "/api/v1/licenses/heartbeat",
|
||||
}
|
||||
|
||||
#: Funktionen, die eine gueltige, aktivierte Lizenz brauchen. Diese Tabelle ist
|
||||
#: die einzige Wahrheit -- Templates lesen sie, Routen erzwingen sie.
|
||||
LICENSED_FEATURES: dict[str, str] = {
|
||||
"directory": "Verzeichnisanmeldung (LDAP/AD) konfigurieren",
|
||||
"nginx": "Webserver-Einstellungen aendern",
|
||||
"export": "Daten exportieren",
|
||||
"trash_restore": "Papierkorb wiederherstellen und endgueltig loeschen",
|
||||
"monitor_interval": "Pruefintervall der Ueberwachung aendern",
|
||||
"auto_poe_restart": "Automatischer PoE-Neustart bei Ausfall",
|
||||
}
|
||||
|
||||
|
||||
def describe_transport_error(exc: BaseException, endpoint: str) -> str:
|
||||
"""Uebersetzt einen Netzfehler in etwas, mit dem man arbeiten kann.
|
||||
|
||||
"War nicht erreichbar" ist bei einem Zertifikatsfehler schlicht falsch --
|
||||
der Server war sehr wohl erreichbar, nur nicht vertrauenswuerdig. Wer das
|
||||
nicht weiss, sucht am falschen Ende.
|
||||
"""
|
||||
text = str(exc)
|
||||
if "CERTIFICATE_VERIFY_FAILED" in text or "certificate verify failed" in text:
|
||||
detail = "Das Zertifikat konnte nicht geprueft werden"
|
||||
if "self-signed" in text or "self signed" in text:
|
||||
detail = "Der Lizenzserver benutzt ein selbstsigniertes Zertifikat"
|
||||
return (
|
||||
f"{detail} ({endpoint}). Hinterlegen Sie sein Zertifikat unter "
|
||||
"Einstellungen -> Lizenz, oder geben Sie dem Lizenzserver ein Zertifikat "
|
||||
"einer anerkannten Stelle."
|
||||
)
|
||||
if "Name or service not known" in text or "nodename nor servname" in text:
|
||||
return f"Der Name im Endpunkt ({endpoint}) laesst sich nicht aufloesen."
|
||||
if "Connection refused" in text:
|
||||
return f"{endpoint} nimmt keine Verbindung an."
|
||||
if "timed out" in text or "timeout" in text.lower():
|
||||
return f"{endpoint} hat nicht rechtzeitig geantwortet."
|
||||
return f"Der Lizenzserver ({endpoint}) war nicht erreichbar: {text[:160]}"
|
||||
|
||||
|
||||
class LicenseError(Exception):
|
||||
"""Fehler beim Import oder bei der Aktivierung -- Text ist fuer Anwender gedacht."""
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class LicenseState:
|
||||
"""Zwischengespeicherter Lizenzzustand. Wird bei jeder Aenderung neu gebaut."""
|
||||
|
||||
present: bool = False
|
||||
valid: bool = False
|
||||
issuer_pinned: bool = False
|
||||
activated: bool = False
|
||||
document: dict[str, Any] = field(default_factory=dict)
|
||||
status: lic.LicenseStatus | None = None
|
||||
error: str = ""
|
||||
warnings: tuple[str, ...] = ()
|
||||
last_heartbeat_at: str = ""
|
||||
heartbeat_error: str = ""
|
||||
pending_action: str = ""
|
||||
|
||||
@property
|
||||
def role(self) -> str:
|
||||
return self.status.role if self.status else "customer"
|
||||
|
||||
@property
|
||||
def modules(self) -> frozenset[str]:
|
||||
return self.status.modules if self.status else frozenset()
|
||||
|
||||
@property
|
||||
def customer_name(self) -> str:
|
||||
return str((self.status.customer if self.status else {}).get("name") or "")
|
||||
|
||||
@property
|
||||
def vendor(self) -> dict[str, Any]:
|
||||
return dict(self.status.vendor) if self.status else {}
|
||||
|
||||
|
||||
class LicenseManager:
|
||||
"""Haelt den Lizenzzustand und fuehrt die Handshakes durch."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
database: Database,
|
||||
settings: Any,
|
||||
license_path: Path,
|
||||
key_path: Path,
|
||||
app_key: str,
|
||||
built_in_trusted_keys: tuple[str, ...] = (),
|
||||
) -> None:
|
||||
self.database = database
|
||||
self.settings = settings
|
||||
self.license_path = Path(license_path)
|
||||
self.key_path = Path(key_path)
|
||||
self.app_key = app_key
|
||||
self.built_in_trusted_keys = tuple(built_in_trusted_keys)
|
||||
self._state = LicenseState()
|
||||
self._lock = threading.RLock()
|
||||
self._heartbeat_thread: threading.Thread | None = None
|
||||
self._report_builder: Callable[[sqlite3.Connection], dict[str, Any]] | None = None
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Zustand
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
@property
|
||||
def state(self) -> LicenseState:
|
||||
return self._state
|
||||
|
||||
def trusted_keys(self, conn: sqlite3.Connection) -> list[str]:
|
||||
rows = Database.all(conn, "SELECT public_key FROM trusted_issuer_keys")
|
||||
return [*self.built_in_trusted_keys, *(str(row["public_key"]) for row in rows)]
|
||||
|
||||
def reload(self, conn: sqlite3.Connection) -> LicenseState:
|
||||
"""Liest Lizenzdatei und Status neu ein."""
|
||||
with self._lock:
|
||||
state = LicenseState()
|
||||
state.last_heartbeat_at = str(self.settings.get(conn, SETTING_LAST_HEARTBEAT) or "")
|
||||
state.heartbeat_error = str(self.settings.get(conn, SETTING_HEARTBEAT_ERROR) or "")
|
||||
pending = self._pending(conn)
|
||||
state.pending_action = str(pending.get("action") or "") if pending else ""
|
||||
|
||||
if not self.license_path.is_file():
|
||||
state.error = "Es ist keine Lizenzdatei installiert."
|
||||
self._state = state
|
||||
return state
|
||||
|
||||
try:
|
||||
document = json.loads(self.license_path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError) as exc:
|
||||
state.error = f"Die Lizenzdatei ist unlesbar: {exc}"
|
||||
self._state = state
|
||||
return state
|
||||
|
||||
if isinstance(document, dict) and "license" in document:
|
||||
# Jemand hat das Auslieferungs-Bundle direkt hierher kopiert.
|
||||
document, _ = lic.split_bundle(document)
|
||||
|
||||
state.present = True
|
||||
state.document = document
|
||||
trusted = self.trusted_keys(conn)
|
||||
try:
|
||||
lic.verify_license(document, trusted_master_keys=trusted or None)
|
||||
state.valid = True
|
||||
state.issuer_pinned = bool(trusted)
|
||||
except lic.UntrustedIssuer as exc:
|
||||
state.error = str(exc)
|
||||
except lic.LicensingError as exc:
|
||||
state.error = str(exc)
|
||||
|
||||
if state.valid:
|
||||
state.status = lic.license_status(
|
||||
document, issuer_pinned=state.issuer_pinned
|
||||
)
|
||||
state.warnings = state.status.warnings
|
||||
|
||||
state.activated = (
|
||||
str(self.settings.get(conn, SETTING_STATUS) or STATUS_INACTIVE) == STATUS_ACTIVE
|
||||
)
|
||||
self._state = state
|
||||
return state
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Gates
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def active(self) -> bool:
|
||||
"""Grundvoraussetzung fuer alles Lizenzpflichtige."""
|
||||
state = self._state
|
||||
return bool(
|
||||
state.valid
|
||||
and state.activated
|
||||
and state.status is not None
|
||||
and state.status.modules_active
|
||||
)
|
||||
|
||||
def module_active(self, name: str) -> bool:
|
||||
"""Ist ein Vollmodul (dhcp/fileshare/maintenance) freigeschaltet?"""
|
||||
return self.active() and name in self._state.modules
|
||||
|
||||
def feature_allowed(self, feature: str) -> bool:
|
||||
"""Lizenzpflichtige Einzelfunktion.
|
||||
|
||||
Ausnahme ``export``: bleibt erlaubt, wenn der lizenzlose Zustand durch
|
||||
eine vom Kunden selbst ausgeloeste Deaktivierung entstanden ist
|
||||
(Systemwechsel) -- sonst waeren die eigenen Daten eingeschlossen.
|
||||
"""
|
||||
if feature == "export" and not self.active():
|
||||
with self.database.session() as conn:
|
||||
return str(self.settings.get(conn, SETTING_EXPORT_GRACE) or "") == "1"
|
||||
return self.active()
|
||||
|
||||
def require(self, feature: str) -> None:
|
||||
if not self.feature_allowed(feature):
|
||||
raise LicenseError(
|
||||
f"{LICENSED_FEATURES.get(feature, feature)} ist ohne gueltige, "
|
||||
"aktivierte Lizenz nicht moeglich."
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Import
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def import_bundle(
|
||||
self,
|
||||
conn: sqlite3.Connection,
|
||||
raw: bytes | str,
|
||||
*,
|
||||
actor: str,
|
||||
allow_new_issuer: bool = False,
|
||||
) -> LicenseState:
|
||||
"""Nimmt eine hochgeladene Lizenzdatei an.
|
||||
|
||||
``allow_new_issuer`` ist nur fuer den ausdruecklichen Fall gedacht, dass
|
||||
ein Administrator einen Aussteller-Wechsel bestaetigt (z. B. Wechsel des
|
||||
Lizenzservers). Ohne dieses Kennzeichen wird eine Datei mit unbekanntem
|
||||
Aussteller abgelehnt, sobald schon einer gepinnt ist.
|
||||
"""
|
||||
text = raw.decode("utf-8") if isinstance(raw, bytes) else raw
|
||||
try:
|
||||
payload = json.loads(text)
|
||||
except ValueError as exc:
|
||||
raise LicenseError("Die Datei ist kein gueltiges JSON.") from exc
|
||||
|
||||
try:
|
||||
document, client_key = lic.split_bundle(payload)
|
||||
except lic.LicensingError as exc:
|
||||
raise LicenseError(str(exc)) from exc
|
||||
|
||||
try:
|
||||
lic.verify_license(document) # innere Konsistenz
|
||||
except lic.LicensingError as exc:
|
||||
raise LicenseError(f"Die Lizenzdatei ist ungueltig: {exc}") from exc
|
||||
|
||||
master_pubkey = str(document.get("master_pubkey") or "")
|
||||
known = self.trusted_keys(conn)
|
||||
if known and master_pubkey not in set(known) and not allow_new_issuer:
|
||||
raise LicenseError(
|
||||
"Diese Lizenz wurde von einem anderen Lizenzserver ausgestellt "
|
||||
f"(Schluessel {lic.key_id(master_pubkey)}). Ein Administrator muss den "
|
||||
"Wechsel des Ausstellers ausdruecklich bestaetigen."
|
||||
)
|
||||
|
||||
if not client_key and not self.key_path.is_file():
|
||||
raise LicenseError(
|
||||
"Der Datei fehlt der Client-Schluessel. Bitte das vollstaendige "
|
||||
"Lizenz-Bundle des Lizenzservers verwenden."
|
||||
)
|
||||
|
||||
# Reihenfolge: erst Schluessel pinnen, dann Dateien schreiben, dann
|
||||
# Aktivierungsstatus zuruecksetzen -- ein Abbruch dazwischen hinterlaesst
|
||||
# keinen halb gueltigen Zustand.
|
||||
if master_pubkey not in set(known):
|
||||
conn.execute(
|
||||
"INSERT INTO trusted_issuer_keys (key_id, public_key, label, source, added_at, added_by) "
|
||||
"VALUES (?,?,?,?,?,?) ON CONFLICT(key_id) DO NOTHING",
|
||||
(
|
||||
lic.key_id(master_pubkey),
|
||||
master_pubkey,
|
||||
str((document.get("vendor") or {}).get("name") or "Lizenzserver"),
|
||||
"tofu" if not known else "admin-confirmed",
|
||||
datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||
actor,
|
||||
),
|
||||
)
|
||||
audit.log(
|
||||
conn,
|
||||
actor=actor,
|
||||
action="license.issuer_pinned",
|
||||
target=lic.key_id(master_pubkey),
|
||||
detail="Trust on first use" if not known else "Aussteller-Wechsel bestaetigt",
|
||||
severity="notice",
|
||||
)
|
||||
|
||||
self.license_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.license_path.write_text(
|
||||
json.dumps(document, indent=2, sort_keys=True, ensure_ascii=False) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
try:
|
||||
self.license_path.chmod(0o640)
|
||||
except OSError:
|
||||
pass
|
||||
if client_key:
|
||||
write_private_file(
|
||||
self.key_path,
|
||||
json.dumps({"license_id": document.get("license_id"), "client_key": client_key})
|
||||
+ "\n",
|
||||
)
|
||||
|
||||
self.settings.set(conn, SETTING_STATUS, STATUS_INACTIVE, actor=actor)
|
||||
self.settings.set(conn, SETTING_PENDING, "", actor=actor)
|
||||
self.settings.set(conn, SETTING_EXPORT_GRACE, "", actor=actor)
|
||||
audit.log(
|
||||
conn,
|
||||
actor=actor,
|
||||
action="license.imported",
|
||||
target=str(document.get("license_id") or ""),
|
||||
detail=f"Typ {document.get('type')}, Rolle {document.get('role')}",
|
||||
severity="notice",
|
||||
)
|
||||
return self.reload(conn)
|
||||
|
||||
def client_key(self) -> str:
|
||||
if not self.key_path.is_file():
|
||||
return ""
|
||||
try:
|
||||
data = json.loads(self.key_path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return ""
|
||||
return str(data.get("client_key") or "")
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Handshake
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def _pending(self, conn: sqlite3.Connection) -> dict[str, Any]:
|
||||
raw = self.settings.get(conn, SETTING_PENDING) or ""
|
||||
if not raw:
|
||||
return {}
|
||||
try:
|
||||
return json.loads(raw) if isinstance(raw, str) else dict(raw)
|
||||
except ValueError:
|
||||
return {}
|
||||
|
||||
def _endpoint(self, action: str) -> str:
|
||||
base = str(self._state.document.get("master_endpoint") or "").rstrip("/")
|
||||
if not base:
|
||||
return ""
|
||||
return base + API_PATHS[action]
|
||||
|
||||
def build_request(
|
||||
self, action: str, *, hostname: str = "", report: dict[str, Any] | None = None
|
||||
) -> dict[str, Any]:
|
||||
if not self._state.valid:
|
||||
raise LicenseError("Es ist keine gueltige Lizenz installiert.")
|
||||
key = self.client_key()
|
||||
if not key:
|
||||
raise LicenseError(
|
||||
"Der Client-Schluessel dieser Lizenz fehlt. Bitte die Lizenzdatei erneut importieren."
|
||||
)
|
||||
return lic.build_client_request(
|
||||
action, self._state.document, key, hostname=hostname, report=report
|
||||
)
|
||||
|
||||
def handshake(
|
||||
self,
|
||||
conn: sqlite3.Connection,
|
||||
action: str,
|
||||
*,
|
||||
actor: str,
|
||||
hostname: str = "",
|
||||
report: dict[str, Any] | None = None,
|
||||
timeout: int = 12,
|
||||
) -> dict[str, Any]:
|
||||
"""Fuehrt eine Aktion durch. Rueckgabe beschreibt das Ergebnis fuer die UI.
|
||||
|
||||
``{"ok": bool, "transport": "online"|"offline", "code": str, "message": str}``
|
||||
"""
|
||||
request_payload = self.build_request(action, hostname=hostname, report=report)
|
||||
endpoint = self._endpoint(action)
|
||||
|
||||
reason = ""
|
||||
if endpoint:
|
||||
try:
|
||||
response = self._post_json(
|
||||
endpoint,
|
||||
request_payload,
|
||||
timeout=timeout,
|
||||
ca_pem=str(self.settings.get(conn, "license_server_ca") or ""),
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 - jeder Netzfehler fuehrt offline weiter
|
||||
reason = describe_transport_error(exc, endpoint)
|
||||
logger.info("Online-Handshake fehlgeschlagen (%s): %s", action, exc)
|
||||
else:
|
||||
return self._apply_response(
|
||||
conn, action, request_payload, response, actor=actor, transport="online"
|
||||
)
|
||||
else:
|
||||
reason = (
|
||||
"In dieser Lizenz ist kein Lizenzserver hinterlegt -- sie kann sich nur "
|
||||
"ueber den Offline-Weg aktivieren."
|
||||
)
|
||||
|
||||
# Offline-Weg: Anfrage merken -- **mit** dem Code. Zwischen Anfrage und
|
||||
# Antwort liegen in der Praxis eine E-Mail und ein Arbeitstag; der Code
|
||||
# muss ein Neuladen der Seite ueberstehen.
|
||||
code = lic.encode_code(request_payload)
|
||||
self.settings.set(
|
||||
conn,
|
||||
SETTING_PENDING,
|
||||
json.dumps(
|
||||
{
|
||||
"action": action,
|
||||
"nonce": request_payload["nonce"],
|
||||
"fingerprint": request_payload["fingerprint"],
|
||||
"license_id": request_payload["license_id"],
|
||||
"created_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||
"code": code,
|
||||
"reason": reason,
|
||||
}
|
||||
),
|
||||
actor=actor,
|
||||
)
|
||||
if action == "activate":
|
||||
self.settings.set(conn, SETTING_STATUS, STATUS_PENDING, actor=actor)
|
||||
self.reload(conn)
|
||||
return {
|
||||
"ok": False,
|
||||
"transport": "offline",
|
||||
"code": code,
|
||||
"message": (
|
||||
(reason + " " if reason else "")
|
||||
+ "Der Offline-Weg steht offen: uebermitteln Sie den angezeigten Code an "
|
||||
"Ihren Ansprechpartner und tragen Sie die Antwort hier ein."
|
||||
),
|
||||
}
|
||||
|
||||
def submit_offline_response(
|
||||
self, conn: sqlite3.Connection, code: str, *, actor: str
|
||||
) -> dict[str, Any]:
|
||||
"""Nimmt den Antwortcode des Lizenzservers an (Offline-Weg)."""
|
||||
pending = self._pending(conn)
|
||||
if not pending:
|
||||
raise LicenseError("Es liegt keine offene Anfrage vor.")
|
||||
try:
|
||||
response = lic.decode_code(code)
|
||||
except ValueError as exc:
|
||||
raise LicenseError(f"Der Code ist unlesbar: {exc}") from exc
|
||||
return self._apply_response(
|
||||
conn,
|
||||
str(pending.get("action") or ""),
|
||||
{
|
||||
"license_id": str(pending.get("license_id") or ""),
|
||||
"fingerprint": str(pending.get("fingerprint") or ""),
|
||||
"nonce": str(pending.get("nonce") or ""),
|
||||
},
|
||||
response,
|
||||
actor=actor,
|
||||
transport="offline",
|
||||
)
|
||||
|
||||
def _apply_response(
|
||||
self,
|
||||
conn: sqlite3.Connection,
|
||||
action: str,
|
||||
request_payload: dict[str, Any],
|
||||
response: dict[str, Any],
|
||||
*,
|
||||
actor: str,
|
||||
transport: str,
|
||||
) -> dict[str, Any]:
|
||||
master_pubkey = str(self._state.document.get("master_pubkey") or "")
|
||||
current_seq = int(self._state.document.get("seq") or 1)
|
||||
try:
|
||||
lic.verify_master_response(
|
||||
response,
|
||||
master_pubkey,
|
||||
expected_license_id=str(request_payload["license_id"]),
|
||||
expected_fingerprint=str(request_payload["fingerprint"]),
|
||||
expected_action=action,
|
||||
expected_nonce=str(request_payload["nonce"]),
|
||||
current_seq=current_seq,
|
||||
)
|
||||
except lic.LicensingError as exc:
|
||||
raise LicenseError(f"Die Antwort des Lizenzservers ist nicht gueltig: {exc}") from exc
|
||||
|
||||
status = str(response.get("status") or "")
|
||||
detail = str(response.get("detail") or "")
|
||||
update = response.get("license_update")
|
||||
|
||||
if isinstance(update, dict):
|
||||
try:
|
||||
lic.verify_license(update, trusted_master_keys=self.trusted_keys(conn) or None)
|
||||
except lic.LicensingError as exc:
|
||||
logger.warning("Mitgeliefertes Lizenz-Update verworfen: %s", exc)
|
||||
else:
|
||||
self.license_path.write_text(
|
||||
json.dumps(update, indent=2, sort_keys=True, ensure_ascii=False) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
audit.log(
|
||||
conn,
|
||||
actor=actor,
|
||||
action="license.updated_by_master",
|
||||
target=str(update.get("license_id") or ""),
|
||||
detail=f"Revision {update.get('seq')}",
|
||||
severity="notice",
|
||||
)
|
||||
|
||||
if status == "revoked":
|
||||
self.settings.set(conn, SETTING_STATUS, STATUS_INACTIVE, actor=actor)
|
||||
self.settings.set(conn, SETTING_PENDING, "", actor=actor)
|
||||
audit.log(
|
||||
conn,
|
||||
actor=actor,
|
||||
action="license.revoked_by_master",
|
||||
target=str(request_payload["license_id"]),
|
||||
severity="warning",
|
||||
)
|
||||
self.reload(conn)
|
||||
return {
|
||||
"ok": False,
|
||||
"transport": transport,
|
||||
"code": "",
|
||||
"message": "Diese Lizenz wurde vom Lizenzserver widerrufen.",
|
||||
}
|
||||
|
||||
if status != "ok":
|
||||
self.reload(conn)
|
||||
return {
|
||||
"ok": False,
|
||||
"transport": transport,
|
||||
"code": "",
|
||||
"message": detail or f"Der Lizenzserver hat die Anfrage abgelehnt ({status}).",
|
||||
}
|
||||
|
||||
if action == "activate":
|
||||
self.settings.set(conn, SETTING_STATUS, STATUS_ACTIVE, actor=actor)
|
||||
self.settings.set(conn, SETTING_EXPORT_GRACE, "", actor=actor)
|
||||
message = "Die Lizenz ist jetzt aktiv."
|
||||
elif action == "deactivate":
|
||||
self.settings.set(conn, SETTING_STATUS, STATUS_INACTIVE, actor=actor)
|
||||
# Der Kunde hat selbst deaktiviert (Systemwechsel): Export bleibt
|
||||
# erlaubt, damit er seine Daten mitnehmen kann.
|
||||
self.settings.set(conn, SETTING_EXPORT_GRACE, "1", actor=actor)
|
||||
message = "Die Lizenz wurde deaktiviert und kann auf einem anderen System aktiviert werden."
|
||||
else:
|
||||
self.settings.set(
|
||||
conn,
|
||||
SETTING_LAST_HEARTBEAT,
|
||||
datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||
actor=actor,
|
||||
)
|
||||
self.settings.set(conn, SETTING_HEARTBEAT_ERROR, "", actor=actor)
|
||||
message = "Verbindung zum Lizenzserver bestaetigt."
|
||||
|
||||
self.settings.set(conn, SETTING_PENDING, "", actor=actor)
|
||||
# Ein erfolgreicher Heartbeat laeuft alle paar Minuten und ist kein
|
||||
# Ereignis, sondern der Normalzustand. Im Auditlog stand dadurch
|
||||
# praktisch nur noch er, und die Eintraege, um die es dort geht --
|
||||
# Aktivierung, Deaktivierung, Widerruf --, gingen darin unter. Der
|
||||
# Zeitpunkt des letzten Heartbeats steht ohnehin in den Einstellungen
|
||||
# und auf der Lizenzseite. Ein *fehlgeschlagener* Heartbeat wird
|
||||
# weiterhin protokolliert (siehe die Fehlerpfade oben).
|
||||
if action != "heartbeat":
|
||||
audit.log(
|
||||
conn,
|
||||
actor=actor,
|
||||
action=f"license.{action}",
|
||||
target=str(request_payload["license_id"]),
|
||||
detail=f"Transport: {transport}",
|
||||
severity="notice",
|
||||
)
|
||||
self.reload(conn)
|
||||
return {"ok": True, "transport": transport, "code": "", "message": message}
|
||||
|
||||
@staticmethod
|
||||
def _tls_context(ca_pem: str) -> Any:
|
||||
"""Prueft gegen ein hinterlegtes Zertifikat -- oder gegen die Systemanker.
|
||||
|
||||
Nie ohne Pruefung: das Lizenzprotokoll ist zwar selbst signiert und
|
||||
gegen Wiedereinspielung geschuetzt, aber das ist kein Grund, den
|
||||
Transport offen zu lassen.
|
||||
"""
|
||||
import ssl
|
||||
|
||||
context = ssl.create_default_context()
|
||||
pem = (ca_pem or "").strip()
|
||||
if not pem:
|
||||
return context
|
||||
try:
|
||||
# cadata nimmt PEM direkt entgegen -- keine temporaere Datei, die
|
||||
# liegen bleiben oder mitgelesen werden koennte.
|
||||
context.load_verify_locations(cadata=pem)
|
||||
except (ssl.SSLError, ValueError) as exc:
|
||||
raise ValueError(
|
||||
"Das hinterlegte Zertifikat des Lizenzservers ist unlesbar: " + str(exc)
|
||||
) from exc
|
||||
return context
|
||||
|
||||
@staticmethod
|
||||
def _post_json(
|
||||
url: str, payload: dict[str, Any], *, timeout: int, ca_pem: str = ""
|
||||
) -> dict[str, Any]:
|
||||
if not url.startswith(("https://", "http://")):
|
||||
raise ValueError("Ungueltiger Endpunkt")
|
||||
data = json.dumps(payload).encode("utf-8")
|
||||
request = urllib.request.Request( # noqa: S310 - Schema oben geprueft
|
||||
url,
|
||||
data=data,
|
||||
headers={"Content-Type": "application/json", "Accept": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
context = LicenseManager._tls_context(ca_pem) if url.startswith("https://") else None
|
||||
try:
|
||||
with urllib.request.urlopen( # noqa: S310
|
||||
request, timeout=timeout, context=context
|
||||
) as response:
|
||||
body = response.read().decode("utf-8")
|
||||
except urllib.error.HTTPError as exc:
|
||||
body = exc.read().decode("utf-8", errors="replace")
|
||||
try:
|
||||
parsed = json.loads(body)
|
||||
except ValueError:
|
||||
raise
|
||||
if isinstance(parsed, dict) and "signature" in parsed:
|
||||
return parsed # Auch abweisende Antworten sind signiert.
|
||||
raise
|
||||
return json.loads(body)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Heartbeat im Hintergrund
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def set_report_builder(self, builder: Callable[[sqlite3.Connection], dict[str, Any]]) -> None:
|
||||
"""Nur der Lizenzserver setzt das: Bericht an den eigenen Master."""
|
||||
self._report_builder = builder
|
||||
|
||||
def start_heartbeat(self, *, hostname: str = "") -> None:
|
||||
if self._heartbeat_thread is not None:
|
||||
return
|
||||
|
||||
def loop() -> None:
|
||||
# Erster Lauf verzoegert: der Dienst soll erst sauber hochkommen.
|
||||
time.sleep(90)
|
||||
while True:
|
||||
try:
|
||||
self.run_heartbeat(hostname=hostname)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("Heartbeat fehlgeschlagen: %s", exc)
|
||||
time.sleep(lic.HEARTBEAT_INTERVAL_SECONDS)
|
||||
|
||||
thread = threading.Thread(target=loop, name="license-heartbeat", daemon=True)
|
||||
thread.start()
|
||||
self._heartbeat_thread = thread
|
||||
logger.info("Lizenz-Heartbeat gestartet (Intervall %ss)", lic.HEARTBEAT_INTERVAL_SECONDS)
|
||||
|
||||
def run_heartbeat(self, *, hostname: str = "") -> dict[str, Any]:
|
||||
with self.database.session() as conn:
|
||||
self.reload(conn)
|
||||
if not self._state.valid or not self._state.activated:
|
||||
return {"ok": False, "message": "Keine aktive Lizenz."}
|
||||
report = None
|
||||
if self._report_builder is not None and self._state.role == "license_server":
|
||||
report = self._report_builder(conn)
|
||||
try:
|
||||
with self.database.transaction(conn):
|
||||
return self.handshake(
|
||||
conn, "heartbeat", actor="system", hostname=hostname, report=report
|
||||
)
|
||||
except LicenseError as exc:
|
||||
with self.database.transaction(conn):
|
||||
self.settings.set(conn, SETTING_HEARTBEAT_ERROR, str(exc), actor="system")
|
||||
# Der *erfolgreiche* Heartbeat wird nicht protokolliert -- er
|
||||
# ist der Normalzustand und hat das Auditlog zugeschuettet.
|
||||
# Ein Fehlschlag dagegen ist genau das, was dort stehen soll:
|
||||
# er kann bedeuten, dass die Lizenz demnaechst in die
|
||||
# Kulanzzeit laeuft.
|
||||
audit.log(
|
||||
conn,
|
||||
actor="system",
|
||||
action="license.heartbeat_failed",
|
||||
target=self._state.license_id or "",
|
||||
detail=str(exc)[:500],
|
||||
severity="warning",
|
||||
)
|
||||
return {"ok": False, "message": str(exc)}
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Anzeige
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def topbar_pill(self) -> dict[str, str] | None:
|
||||
"""Zustandsanzeige fuer die Topbar. Reihenfolge = Dringlichkeit."""
|
||||
state = self._state
|
||||
if not state.present:
|
||||
return {
|
||||
"label": "Keine Lizenz",
|
||||
"css_class": "status-pill--danger",
|
||||
"dot": "1",
|
||||
"dot_class": "status-pill__dot--pulse",
|
||||
"title": "Es ist keine Lizenzdatei installiert.",
|
||||
}
|
||||
if not state.valid:
|
||||
return {
|
||||
"label": "Lizenz ungueltig",
|
||||
"css_class": "status-pill--danger",
|
||||
"dot": "1",
|
||||
"dot_class": "status-pill__dot--pulse",
|
||||
"title": state.error,
|
||||
}
|
||||
if not state.activated:
|
||||
return {
|
||||
"label": "Lizenz nicht aktiviert",
|
||||
"css_class": "status-pill--warning",
|
||||
"dot": "1",
|
||||
"dot_class": "status-pill__dot--pulse",
|
||||
"title": "Die Lizenz muss noch aktiviert werden.",
|
||||
}
|
||||
status = state.status
|
||||
if status is None:
|
||||
return None
|
||||
if status.expired:
|
||||
days = status.days_since_expiry or 0
|
||||
return {
|
||||
"label": f"Lizenz abgelaufen (seit {days} Tagen)",
|
||||
"css_class": "status-pill--danger",
|
||||
"dot": "1",
|
||||
"dot_class": "status-pill__dot--pulse",
|
||||
"title": (
|
||||
"Die Kulanzfrist laeuft noch, Funktionen bleiben vorerst verfuegbar."
|
||||
if status.grace_active
|
||||
else "Lizenzpflichtige Funktionen sind abgeschaltet."
|
||||
),
|
||||
}
|
||||
if status.expiring_soon:
|
||||
return {
|
||||
"label": f"Lizenz laeuft in {status.days_left} Tagen ab",
|
||||
"css_class": "status-pill--warning",
|
||||
"dot": "1",
|
||||
"dot_class": "status-pill__dot--pulse",
|
||||
"title": "Bitte rechtzeitig verlaengern.",
|
||||
}
|
||||
# Zwischen der Warnschwelle und 90 Tagen: gruen, rein als Hinweis. Wer
|
||||
# eine Verlaengerung im Voraus plant, will das Datum sehen, ohne dass es
|
||||
# nach einem Problem aussieht.
|
||||
if status.days_left is not None and 0 < status.days_left <= EXPIRY_HINT_DAYS:
|
||||
return {
|
||||
"label": f"Lizenz laeuft in {status.days_left} Tagen ab",
|
||||
"css_class": "",
|
||||
"dot": "1",
|
||||
"dot_class": "",
|
||||
"title": "Nur ein Hinweis -- es ist nichts eingeschraenkt.",
|
||||
}
|
||||
if state.last_heartbeat_at:
|
||||
try:
|
||||
last = lic.parse_iso(state.last_heartbeat_at)
|
||||
days = (lic.utcnow() - last).days
|
||||
except ValueError:
|
||||
days = 0
|
||||
if days >= lic.HEARTBEAT_WARNING_DAYS:
|
||||
return {
|
||||
"label": f"Lizenzserver seit {days} Tagen nicht erreichbar",
|
||||
"css_class": "status-pill--warning",
|
||||
"dot": "1",
|
||||
"dot_class": "",
|
||||
"title": "Rein informativ -- es wird nichts abgeschaltet.",
|
||||
}
|
||||
return None
|
||||
@@ -0,0 +1,136 @@
|
||||
"""Protokollierung: strukturierte Zeilen, Vorgangsnummer, Rotation via logrotate.
|
||||
|
||||
Jede Anfrage bekommt eine ``request_id``. Sie steht in jeder Logzeile, in
|
||||
Fehlerseiten und im Audit-Log -- damit ein Anruf "bei mir kam Fehler 500"
|
||||
in Sekunden auffindbar ist. Der Vorgaenger loggte ohne Korrelations-ID.
|
||||
|
||||
Rotation macht ``logrotate`` mit ``copytruncate`` (siehe ``deploy/``), nicht
|
||||
Python: mehrere Prozesse (Web, Monitor) schreiben in dasselbe Verzeichnis, und
|
||||
keiner von ihnen kann ein Reopen-Signal verarbeiten.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import logging.handlers
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Flask, g, has_request_context, request
|
||||
|
||||
ACCESS_LOGGER = "tesm.access"
|
||||
|
||||
|
||||
class RequestFormatter(logging.Formatter):
|
||||
"""Textformat fuer Menschen, mit Vorgangsnummer und Benutzer."""
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
if has_request_context():
|
||||
record.request_id = getattr(g, "request_id", "-")
|
||||
record.actor = getattr(getattr(g, "user", None), "username", "-") or "-"
|
||||
else:
|
||||
record.request_id = getattr(record, "request_id", "-")
|
||||
record.actor = getattr(record, "actor", "-")
|
||||
return super().format(record)
|
||||
|
||||
|
||||
class JsonFormatter(logging.Formatter):
|
||||
"""Maschinenlesbares Format -- fuer die Weiterverarbeitung in einem SIEM."""
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
payload = {
|
||||
"ts": time.strftime("%Y-%m-%dT%H:%M:%S%z", time.localtime(record.created)),
|
||||
"level": record.levelname,
|
||||
"logger": record.name,
|
||||
"message": record.getMessage(),
|
||||
}
|
||||
if has_request_context():
|
||||
payload["request_id"] = getattr(g, "request_id", "")
|
||||
payload["path"] = request.path
|
||||
payload["method"] = request.method
|
||||
payload["actor"] = getattr(getattr(g, "user", None), "username", "") or ""
|
||||
if record.exc_info:
|
||||
payload["exception"] = self.formatException(record.exc_info)
|
||||
for key, value in getattr(record, "extra_fields", {}).items():
|
||||
payload[key] = value
|
||||
return json.dumps(payload, ensure_ascii=False)
|
||||
|
||||
|
||||
def init_app(app: Flask, *, log_dir: Path, debug: bool, json_logs: bool = False) -> None:
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
level = logging.DEBUG if debug else logging.INFO
|
||||
|
||||
root = logging.getLogger()
|
||||
root.setLevel(level)
|
||||
for handler in list(root.handlers):
|
||||
root.removeHandler(handler)
|
||||
|
||||
formatter: logging.Formatter = (
|
||||
JsonFormatter()
|
||||
if json_logs
|
||||
else RequestFormatter(
|
||||
"%(asctime)s %(levelname)-7s [%(request_id)s] %(actor)s %(name)s: %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
)
|
||||
|
||||
stream = logging.StreamHandler(sys.stderr)
|
||||
stream.setFormatter(formatter)
|
||||
root.addHandler(stream)
|
||||
|
||||
try:
|
||||
app_log = log_dir / "app.log"
|
||||
file_handler = logging.FileHandler(app_log, encoding="utf-8", delay=True)
|
||||
file_handler.setFormatter(formatter)
|
||||
file_handler.setLevel(level)
|
||||
root.addHandler(file_handler)
|
||||
if app_log.exists():
|
||||
try:
|
||||
os.chmod(app_log, 0o640)
|
||||
except OSError:
|
||||
pass
|
||||
except OSError as exc: # pragma: no cover - z. B. fehlende Rechte
|
||||
root.warning("Konnte app.log nicht oeffnen: %s", exc)
|
||||
|
||||
# Zugriffsprotokoll separat: eine Zeile pro Anfrage, ohne Stacktraces.
|
||||
access = logging.getLogger(ACCESS_LOGGER)
|
||||
access.propagate = False
|
||||
access.setLevel(logging.INFO)
|
||||
try:
|
||||
access_handler = logging.FileHandler(log_dir / "access.log", encoding="utf-8", delay=True)
|
||||
access_handler.setFormatter(
|
||||
JsonFormatter() if json_logs else logging.Formatter("%(asctime)s %(message)s")
|
||||
)
|
||||
access.addHandler(access_handler)
|
||||
except OSError: # pragma: no cover
|
||||
pass
|
||||
|
||||
logging.getLogger("werkzeug").setLevel(logging.WARNING)
|
||||
|
||||
@app.before_request
|
||||
def _assign_request_id() -> None:
|
||||
incoming = request.headers.get("X-Request-Id", "")
|
||||
g.request_id = incoming[:64] if incoming.isascii() and incoming else uuid.uuid4().hex[:16]
|
||||
g.request_started = time.perf_counter()
|
||||
|
||||
@app.after_request
|
||||
def _log_access(response): # type: ignore[no-untyped-def]
|
||||
if request.path.startswith("/static/"):
|
||||
return response
|
||||
duration = (time.perf_counter() - getattr(g, "request_started", time.perf_counter())) * 1000
|
||||
actor = getattr(getattr(g, "user", None), "username", "") or "-"
|
||||
access.info(
|
||||
"%s %s %s %s %s %.1fms",
|
||||
getattr(g, "request_id", "-"),
|
||||
actor,
|
||||
request.method,
|
||||
request.full_path if request.query_string else request.path,
|
||||
response.status_code,
|
||||
duration,
|
||||
)
|
||||
response.headers.setdefault("X-Request-Id", getattr(g, "request_id", ""))
|
||||
return response
|
||||
@@ -0,0 +1,234 @@
|
||||
"""Versionierte Schema-Migrationen.
|
||||
|
||||
Der Vorgaenger hatte eine ``_ensure_schema()``-Funktion, die bei jedem Start
|
||||
rund 35 idempotente ``ALTER TABLE``-Blocks durchlief, plus Sentinel-Zeilen in
|
||||
der ``settings``-Tabelle als Marker fuer Einmal-Migrationen. Das funktioniert,
|
||||
ist aber nicht nachvollziehbar: welche Migration lief wann, in welcher
|
||||
Reihenfolge, mit welchem Inhalt?
|
||||
|
||||
Hier: eine ``schema_migrations``-Tabelle mit ``(component, version)``, einer
|
||||
Pruefsumme des Migrationsinhalts und Zeitstempel. Jede Migration laeuft genau
|
||||
einmal, in einer eigenen Transaktion, in Versionsreihenfolge.
|
||||
|
||||
``component`` trennt die Namensraeume: ``core`` bringt Benutzer/Gruppen/
|
||||
Sitzungen/Audit mit, jede Anwendung ihre Fachtabellen. Dadurch koennen beide
|
||||
Anwendungen dieselben Core-Migrationen nutzen, ohne dass sich Versionsnummern
|
||||
in die Quere kommen.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import sqlite3
|
||||
from collections.abc import Callable, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from .db import Database
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MigrationFn = Callable[[sqlite3.Connection], None]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Migration:
|
||||
"""Eine Schemaaenderung.
|
||||
|
||||
``sql`` wird als Skript ausgefuehrt, danach ggf. ``fn`` fuer Datenmigrationen,
|
||||
die sich in reinem SQL nicht sinnvoll ausdruecken lassen.
|
||||
|
||||
``allow_integrity_error``: fuer nachtraeglich eingefuehrte Unique-Indizes.
|
||||
Trifft so ein Index auf bereits vorhandene, verletzende Altdaten, darf der
|
||||
Anwendungsstart nicht crashen -- er loggt und laeuft weiter. Genau dieses
|
||||
Muster hat sich im Vorgaengerprojekt bewaehrt.
|
||||
"""
|
||||
|
||||
version: int
|
||||
name: str
|
||||
sql: str = ""
|
||||
fn: MigrationFn | None = None
|
||||
allow_integrity_error: bool = False
|
||||
tags: tuple[str, ...] = field(default=())
|
||||
|
||||
@property
|
||||
def checksum(self) -> str:
|
||||
payload = f"{self.version}:{self.name}:{self.sql}".encode("utf-8")
|
||||
if self.fn is not None:
|
||||
payload += f":fn={self.fn.__name__}".encode("utf-8")
|
||||
return hashlib.sha256(payload).hexdigest()[:16]
|
||||
|
||||
|
||||
def split_statements(script: str) -> list[str]:
|
||||
"""Zerlegt ein SQL-Skript in einzelne Anweisungen.
|
||||
|
||||
Warum nicht ``executescript``: das setzt in Pythons sqlite3-Treiber
|
||||
**implizit ein COMMIT** ab, bevor es laeuft. Innerhalb einer laufenden
|
||||
Transaktion zerreisst das genau die Atomizitaet, die eine Migration
|
||||
braucht. Also: selbst zerlegen und Anweisung fuer Anweisung ausfuehren.
|
||||
|
||||
Der Splitter beruecksichtigt Zeichenketten, Zeilen- und Blockkommentare;
|
||||
``BEGIN ... END``-Bloecke (Trigger) werden zusammengehalten.
|
||||
"""
|
||||
statements: list[str] = []
|
||||
current: list[str] = []
|
||||
index = 0
|
||||
length = len(script)
|
||||
depth = 0 # BEGIN/END-Tiefe fuer Trigger
|
||||
|
||||
while index < length:
|
||||
char = script[index]
|
||||
nxt = script[index + 1] if index + 1 < length else ""
|
||||
|
||||
if char == "-" and nxt == "-":
|
||||
end = script.find("\n", index)
|
||||
index = length if end == -1 else end + 1
|
||||
continue
|
||||
if char == "/" and nxt == "*":
|
||||
end = script.find("*/", index + 2)
|
||||
index = length if end == -1 else end + 2
|
||||
continue
|
||||
if char in "'\"":
|
||||
quote = char
|
||||
current.append(char)
|
||||
index += 1
|
||||
while index < length:
|
||||
current.append(script[index])
|
||||
if script[index] == quote:
|
||||
if index + 1 < length and script[index + 1] == quote:
|
||||
current.append(script[index + 1])
|
||||
index += 2
|
||||
continue
|
||||
index += 1
|
||||
break
|
||||
index += 1
|
||||
continue
|
||||
|
||||
if char == ";" and depth == 0:
|
||||
statement = "".join(current).strip()
|
||||
if statement:
|
||||
statements.append(statement)
|
||||
current = []
|
||||
index += 1
|
||||
continue
|
||||
|
||||
current.append(char)
|
||||
upper_tail = "".join(current[-6:]).upper()
|
||||
if upper_tail.endswith("BEGIN"):
|
||||
depth += 1
|
||||
elif upper_tail.endswith("END") and depth > 0:
|
||||
depth -= 1
|
||||
index += 1
|
||||
|
||||
tail = "".join(current).strip()
|
||||
if tail:
|
||||
statements.append(tail)
|
||||
return statements
|
||||
|
||||
|
||||
def run_script(conn: sqlite3.Connection, script: str) -> None:
|
||||
for statement in split_statements(script):
|
||||
conn.execute(statement)
|
||||
|
||||
|
||||
_BOOTSTRAP = """
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
component TEXT NOT NULL,
|
||||
version INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
checksum TEXT NOT NULL,
|
||||
applied_at TEXT NOT NULL,
|
||||
PRIMARY KEY (component, version)
|
||||
);
|
||||
"""
|
||||
|
||||
|
||||
class MigrationRunner:
|
||||
"""Fuehrt Migrationen mehrerer Komponenten gegen eine Datenbank aus."""
|
||||
|
||||
def __init__(self, database: Database) -> None:
|
||||
self.database = database
|
||||
|
||||
def applied(self, conn: sqlite3.Connection, component: str) -> dict[int, dict[str, str]]:
|
||||
rows = Database.all(
|
||||
conn,
|
||||
"SELECT version, name, checksum, applied_at FROM schema_migrations WHERE component=?",
|
||||
(component,),
|
||||
)
|
||||
return {int(row["version"]): row for row in rows}
|
||||
|
||||
def run(self, component: str, migrations: Sequence[Migration]) -> list[str]:
|
||||
"""Wendet fehlende Migrationen an. Rueckgabe: Namen der ausgefuehrten."""
|
||||
ordered = sorted(migrations, key=lambda m: m.version)
|
||||
versions = [m.version for m in ordered]
|
||||
if len(set(versions)) != len(versions):
|
||||
duplicates = sorted({v for v in versions if versions.count(v) > 1})
|
||||
raise ValueError(f"Doppelte Migrationsversionen in {component}: {duplicates}")
|
||||
|
||||
executed: list[str] = []
|
||||
with self.database.session() as conn:
|
||||
run_script(conn, _BOOTSTRAP)
|
||||
known = self.applied(conn, component)
|
||||
|
||||
for migration in ordered:
|
||||
record = known.get(migration.version)
|
||||
if record is not None:
|
||||
if record["checksum"] != migration.checksum:
|
||||
logger.warning(
|
||||
"Migration %s/%s (%s) wurde nachtraeglich geaendert "
|
||||
"(Pruefsumme %s != %s). Bereits angewendete Migrationen werden "
|
||||
"nicht erneut ausgefuehrt -- neue Aenderungen brauchen eine neue "
|
||||
"Versionsnummer.",
|
||||
component,
|
||||
migration.version,
|
||||
migration.name,
|
||||
record["checksum"],
|
||||
migration.checksum,
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
with self.database.transaction(conn):
|
||||
if migration.sql.strip():
|
||||
run_script(conn, migration.sql)
|
||||
if migration.fn is not None:
|
||||
migration.fn(conn)
|
||||
conn.execute(
|
||||
"INSERT INTO schema_migrations "
|
||||
"(component, version, name, checksum, applied_at) VALUES (?,?,?,?,?)",
|
||||
(
|
||||
component,
|
||||
migration.version,
|
||||
migration.name,
|
||||
migration.checksum,
|
||||
datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||
),
|
||||
)
|
||||
except sqlite3.IntegrityError:
|
||||
if not migration.allow_integrity_error:
|
||||
raise
|
||||
logger.error(
|
||||
"Migration %s/%s (%s) konnte wegen bestehender Altdaten nicht "
|
||||
"angewendet werden. Der Start laeuft weiter; die Daten muessen "
|
||||
"manuell bereinigt werden.",
|
||||
component,
|
||||
migration.version,
|
||||
migration.name,
|
||||
)
|
||||
continue
|
||||
executed.append(f"{component}/{migration.version} {migration.name}")
|
||||
logger.info("Migration angewendet: %s/%s %s", component, migration.version, migration.name)
|
||||
return executed
|
||||
|
||||
def status(self, components: Sequence[str]) -> list[dict[str, object]]:
|
||||
"""Uebersicht fuer die Diagnoseseite."""
|
||||
with self.database.session() as conn:
|
||||
run_script(conn, _BOOTSTRAP)
|
||||
return Database.all(
|
||||
conn,
|
||||
"SELECT component, version, name, checksum, applied_at FROM schema_migrations "
|
||||
f"WHERE component IN ({','.join('?' * len(components))}) "
|
||||
"ORDER BY component, version",
|
||||
tuple(components),
|
||||
)
|
||||
@@ -0,0 +1,160 @@
|
||||
"""Navigation: Struktur, Sichtbarkeit, Reihenfolge.
|
||||
|
||||
Uebernommen vom Vorgaenger (das Muster war gut), aber mit zwei Aenderungen:
|
||||
|
||||
* Sichtbarkeit haengt an einer Liste von Rechten **plus** optional einer
|
||||
Bedingung. Im Vorgaenger war das eine ``if/elif``-Kette in
|
||||
``_nav_key_visible()`` -- jede neue Seite bedeutete einen Eingriff in diese
|
||||
Funktion. Hier deklariert jeder Eintrag selbst, was er braucht.
|
||||
* "Sichtbar, wenn *irgendeines* der Rechte greift" ist der Default. Im
|
||||
Vorgaenger war eine Seite mit reinem Export-Recht unerreichbar, weil die
|
||||
Navigation nur auf ``.view`` prueft.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, Iterable, Sequence
|
||||
|
||||
VisibilityFn = Callable[[Any], bool]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class NavItem:
|
||||
key: str
|
||||
label: str
|
||||
icon: str
|
||||
endpoint: str = ""
|
||||
#: Sichtbar, wenn eines dieser Rechte greift (leer = immer sichtbar).
|
||||
permissions: tuple[str, ...] = ()
|
||||
#: Zusaetzliche Bedingung, z. B. Modul-Lizenz oder vorhandene Freigaben.
|
||||
condition: VisibilityFn | None = field(default=None, compare=False)
|
||||
children: tuple["NavItem", ...] = ()
|
||||
badge: Callable[[], str] | None = field(default=None, compare=False)
|
||||
hint: str = ""
|
||||
|
||||
@property
|
||||
def is_group(self) -> bool:
|
||||
return bool(self.children)
|
||||
|
||||
|
||||
class NavTree:
|
||||
"""Navigationsbaum mit laufzeitkonfigurierbarer Reihenfolge."""
|
||||
|
||||
def __init__(self, items: Sequence[NavItem]) -> None:
|
||||
self.items: tuple[NavItem, ...] = tuple(items)
|
||||
self._by_key: dict[str, NavItem] = {}
|
||||
for item in self.items:
|
||||
self._by_key[item.key] = item
|
||||
for child in item.children:
|
||||
self._by_key[child.key] = child
|
||||
|
||||
def item(self, key: str) -> NavItem | None:
|
||||
return self._by_key.get(key)
|
||||
|
||||
def default_order(self) -> list[str]:
|
||||
return [item.key for item in self.items]
|
||||
|
||||
def default_child_order(self, group_key: str) -> list[str]:
|
||||
item = self._by_key.get(group_key)
|
||||
return [child.key for child in item.children] if item else []
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def visible(self, item: NavItem, user: Any) -> bool:
|
||||
if item.permissions and not user.has_any(item.permissions):
|
||||
return False
|
||||
if item.condition is not None and not item.condition(user):
|
||||
return False
|
||||
return True
|
||||
|
||||
def resolve(
|
||||
self,
|
||||
user: Any,
|
||||
*,
|
||||
order: Sequence[str] | None = None,
|
||||
child_order: dict[str, Sequence[str]] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Liefert die gefilterte, sortierte Navigation fuer das Template."""
|
||||
child_order = child_order or {}
|
||||
result: list[dict[str, Any]] = []
|
||||
|
||||
for item in self._ordered(self.items, order):
|
||||
if item.is_group:
|
||||
children = [
|
||||
child
|
||||
for child in self._ordered(item.children, child_order.get(item.key))
|
||||
if self.visible(child, user)
|
||||
]
|
||||
if not children:
|
||||
continue
|
||||
result.append(
|
||||
{
|
||||
"key": item.key,
|
||||
"label": item.label,
|
||||
"icon": item.icon,
|
||||
"endpoint": "",
|
||||
"hint": item.hint,
|
||||
"children": [self._as_dict(child) for child in children],
|
||||
}
|
||||
)
|
||||
continue
|
||||
if not self.visible(item, user):
|
||||
continue
|
||||
result.append(self._as_dict(item))
|
||||
return result
|
||||
|
||||
def _as_dict(self, item: NavItem) -> dict[str, Any]:
|
||||
return {
|
||||
"key": item.key,
|
||||
"label": item.label,
|
||||
"icon": item.icon,
|
||||
"endpoint": item.endpoint,
|
||||
"hint": item.hint,
|
||||
"badge": item.badge() if item.badge else "",
|
||||
"children": [],
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _ordered(
|
||||
items: Sequence[NavItem], order: Sequence[str] | None
|
||||
) -> list[NavItem]:
|
||||
if not order:
|
||||
return list(items)
|
||||
index = {key: position for position, key in enumerate(order)}
|
||||
# Unbekannte Schluessel (neu dazugekommene Seiten) landen hinten, statt
|
||||
# zu verschwinden -- der Vorgaenger verlor sie stillschweigend.
|
||||
return sorted(items, key=lambda item: (index.get(item.key, len(index)), item.label))
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def sanitize_order(self, keys: Iterable[str]) -> list[str]:
|
||||
known = self.default_order()
|
||||
chosen = [key for key in keys if key in known]
|
||||
return chosen + [key for key in known if key not in chosen]
|
||||
|
||||
def sanitize_child_order(self, group_key: str, keys: Iterable[str]) -> list[str]:
|
||||
known = self.default_child_order(group_key)
|
||||
chosen = [key for key in keys if key in known]
|
||||
return chosen + [key for key in known if key not in chosen]
|
||||
|
||||
def searchable(self, user: Any) -> list[dict[str, str]]:
|
||||
"""Flache Liste aller sichtbaren Seiten -- Datenbasis der Befehlspalette."""
|
||||
entries: list[dict[str, str]] = []
|
||||
for item in self.items:
|
||||
if item.is_group:
|
||||
for child in item.children:
|
||||
if self.visible(child, user):
|
||||
entries.append(
|
||||
{
|
||||
"label": child.label,
|
||||
"group": item.label,
|
||||
"endpoint": child.endpoint,
|
||||
"icon": child.icon,
|
||||
}
|
||||
)
|
||||
elif self.visible(item, user):
|
||||
entries.append(
|
||||
{"label": item.label, "group": "", "endpoint": item.endpoint, "icon": item.icon}
|
||||
)
|
||||
return entries
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Rechtesystem: Modell (Bereiche/Ressourcen/Aktionen) und Verwaltungsdienste."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .model import (
|
||||
ACTION_META,
|
||||
IMPLIED_BY,
|
||||
SENSITIVE_ACTIONS,
|
||||
Action,
|
||||
Area,
|
||||
PermissionTree,
|
||||
Resource,
|
||||
RolePreset,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ACTION_META",
|
||||
"Action",
|
||||
"Area",
|
||||
"IMPLIED_BY",
|
||||
"PermissionTree",
|
||||
"Resource",
|
||||
"RolePreset",
|
||||
"SENSITIVE_ACTIONS",
|
||||
]
|
||||
@@ -0,0 +1,161 @@
|
||||
"""Ressourcen, die beide Anwendungen teilen.
|
||||
|
||||
``tesm_core`` bringt Benutzer-, Gruppen-, Papierkorb-, Protokoll- und
|
||||
Diagnoseseiten selbst mit. Damit deren Rechtepruefungen greifen, muessen die
|
||||
zugehoerigen Ressourcenschluessel im Rechtebaum der Anwendung existieren --
|
||||
darum stehen sie hier und werden von beiden Apps eingebunden statt zweimal
|
||||
getippt.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .model import Action, Area, Resource
|
||||
|
||||
A = Action
|
||||
|
||||
USERS = Resource(
|
||||
key="users",
|
||||
label="Benutzer",
|
||||
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE),
|
||||
hint="Konten anlegen, sperren, Gruppen zuordnen",
|
||||
action_hints={
|
||||
A.EDIT: "Stammdaten, Sperre und Gruppenzuordnung aendern.",
|
||||
A.DELETE: "Konto in den Papierkorb legen (Verzeichniskonten sofort entfernen).",
|
||||
},
|
||||
)
|
||||
|
||||
GROUPS = Resource(
|
||||
key="groups",
|
||||
label="Gruppen und Rechte",
|
||||
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE),
|
||||
hint="Rechtematrix pflegen",
|
||||
action_hints={
|
||||
A.EDIT: "Rechte vergeben -- nur Rechte, die man selbst besitzt.",
|
||||
},
|
||||
)
|
||||
|
||||
SETTINGS_SYSTEM = Resource(
|
||||
key="settings_system",
|
||||
label="Systemeinstellungen",
|
||||
actions=(A.VIEW, A.EDIT, A.EXECUTE),
|
||||
hint="Hostname, Zeitzone, Protokollrotation, Navigation",
|
||||
action_hints={A.EXECUTE: "Systemaktionen ausloesen (Dienste, Netzwerk uebernehmen)."},
|
||||
)
|
||||
|
||||
SETTINGS_LICENSE = Resource(
|
||||
key="settings_license",
|
||||
label="Lizenz",
|
||||
actions=(A.VIEW, A.EDIT),
|
||||
hint="Lizenzdatei einspielen, aktivieren, deaktivieren",
|
||||
)
|
||||
|
||||
SETTINGS_DIRECTORY = Resource(
|
||||
key="settings_directory",
|
||||
label="Verzeichnisdienst (LDAP/AD)",
|
||||
actions=(A.VIEW, A.EDIT, A.EXECUTE, A.SECRETS),
|
||||
hint="Anbindung und Gruppen-Zuordnung",
|
||||
action_hints={
|
||||
A.EXECUTE: "Verbindungstest ausfuehren.",
|
||||
A.SECRETS: "Bind-Passwort des Dienstkontos setzen.",
|
||||
},
|
||||
)
|
||||
|
||||
SETTINGS_WEB = Resource(
|
||||
key="settings_web",
|
||||
label="Webserver und TLS",
|
||||
actions=(A.VIEW, A.EDIT, A.EXECUTE),
|
||||
hint="Domain, Zertifikat, HSTS",
|
||||
action_hints={A.EXECUTE: "Konfiguration uebernehmen, Zertifikat anfordern."},
|
||||
)
|
||||
|
||||
SETTINGS_NETWORK = Resource(
|
||||
key="settings_network",
|
||||
label="Netzwerkeinstellungen",
|
||||
actions=(A.VIEW, A.EDIT),
|
||||
hint="IP-Adresse, Gateway, DNS des Hosts",
|
||||
)
|
||||
|
||||
BACKUP = Resource(
|
||||
key="backup",
|
||||
label="Sicherung und Uebertragung",
|
||||
actions=(A.VIEW, A.EXECUTE, A.EXPORT, A.SECRETS),
|
||||
hint="Verschluesselter Export und Import",
|
||||
action_hints={
|
||||
A.EXECUTE: "Eine Sicherung einspielen.",
|
||||
A.EXPORT: "Sicherung erzeugen und herunterladen.",
|
||||
A.SECRETS: "Zugangsdaten in die Sicherung einschliessen.",
|
||||
},
|
||||
)
|
||||
|
||||
TRASH = Resource(
|
||||
key="trash",
|
||||
label="Papierkorb",
|
||||
actions=(A.VIEW, A.EDIT, A.DELETE),
|
||||
hint="Geloeschtes wiederherstellen oder endgueltig entfernen",
|
||||
action_hints={
|
||||
A.EDIT: "Eintraege wiederherstellen.",
|
||||
A.DELETE: "Eintraege endgueltig entfernen.",
|
||||
},
|
||||
)
|
||||
|
||||
DIAGNOSTICS = Resource(
|
||||
key="diagnostics",
|
||||
label="Diagnose",
|
||||
actions=(A.VIEW,),
|
||||
hint="Schemastand, Protokollintegritaet, Schluesselrechte",
|
||||
)
|
||||
|
||||
LOGS_LIVE = Resource(
|
||||
key="logs_live",
|
||||
label="Laufendes Protokoll",
|
||||
actions=(A.VIEW,),
|
||||
)
|
||||
|
||||
LOGS_HISTORY = Resource(
|
||||
key="logs_history",
|
||||
label="Protokollverlauf",
|
||||
actions=(A.VIEW, A.DELETE, A.EXPORT),
|
||||
hint="Rotierte und archivierte Protokolle",
|
||||
action_hints={A.DELETE: "Archivierte Tage vom Server entfernen."},
|
||||
)
|
||||
|
||||
LOGS_AUDIT = Resource(
|
||||
key="logs_audit",
|
||||
label="Aenderungsprotokoll",
|
||||
actions=(A.VIEW, A.EXPORT),
|
||||
hint="Wer hat wann was geaendert",
|
||||
)
|
||||
|
||||
|
||||
def administration_area(*extra: Resource) -> Area:
|
||||
"""Bereich "Verwaltung" -- Basis fuer beide Anwendungen."""
|
||||
return Area(
|
||||
key="administration",
|
||||
label="Verwaltung",
|
||||
icon="sliders",
|
||||
hint="Benutzer, Rechte, Systemeinstellungen. Ohne diesen Zugang bleibt der "
|
||||
"gesamte Verwaltungsbereich verborgen.",
|
||||
resources=(
|
||||
USERS,
|
||||
GROUPS,
|
||||
SETTINGS_SYSTEM,
|
||||
SETTINGS_LICENSE,
|
||||
SETTINGS_DIRECTORY,
|
||||
SETTINGS_WEB,
|
||||
SETTINGS_NETWORK,
|
||||
BACKUP,
|
||||
TRASH,
|
||||
DIAGNOSTICS,
|
||||
*extra,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def observability_area(*extra: Resource) -> Area:
|
||||
"""Bereich "Protokolle"."""
|
||||
return Area(
|
||||
key="observability",
|
||||
label="Protokolle",
|
||||
icon="terminal",
|
||||
resources=(LOGS_LIVE, LOGS_HISTORY, LOGS_AUDIT, *extra),
|
||||
)
|
||||
@@ -0,0 +1,246 @@
|
||||
"""Rechtemodell: Bereiche, Ressourcen, Aktionen.
|
||||
|
||||
Das Vorgaengermodell kannte fuenf Spalten (R/W/E/D/X), wobei ``E`` ("Aendern")
|
||||
das Loeschen **mit abdeckte** und ``D``/``X`` je nach Zeile etwas voellig anderes
|
||||
bedeuteten (Dashboard-Neustart bzw. Export). Das war kompakt, aber:
|
||||
|
||||
* Wer jemandem erlauben wollte, Geraete zu pflegen, musste ihm zwangslaeufig
|
||||
auch das Loeschen erlauben. Das ist genau die Trennung, die man in einer
|
||||
Verwaltungsanwendung am haeufigsten braucht.
|
||||
* Dieselbe Spalte bedeutete in verschiedenen Zeilen Verschiedenes -- eine
|
||||
Rechtematrix, die man erklaeren muss, statt sie zu lesen.
|
||||
|
||||
Neues Modell: ein festes, ueberall gleich bedeutendes Aktionsvokabular. Welche
|
||||
Aktionen eine Ressource ueberhaupt kennt, entscheidet die Ressource; die Matrix
|
||||
rendert nur die Spalten, die im jeweiligen Bereich wirklich vorkommen.
|
||||
|
||||
Beibehalten wird der bewaehrte **Kill-Switch**: jeder Bereich hat ein
|
||||
``<bereich>.view``-Recht. Ohne dieses greift kein einziges Recht innerhalb des
|
||||
Bereichs -- unabhaengig davon, was einzeln angehakt ist. Das erlaubt es, einer
|
||||
Gruppe einen ganzen Bereich mit einem Klick zu entziehen, ohne die fein
|
||||
eingestellten Einzelrechte zu verlieren.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Iterable, Mapping, Sequence
|
||||
|
||||
|
||||
class Action(str, Enum):
|
||||
"""Aktionsvokabular. Bedeutung ist ueberall dieselbe -- keine Ausnahmen."""
|
||||
|
||||
VIEW = "view"
|
||||
CREATE = "create"
|
||||
EDIT = "edit"
|
||||
DELETE = "delete"
|
||||
EXECUTE = "execute"
|
||||
EXPORT = "export"
|
||||
SECRETS = "secrets"
|
||||
|
||||
|
||||
#: Kuerzel und Erklaerung je Aktion -- Kopfzeile der Rechtematrix.
|
||||
ACTION_META: dict[Action, tuple[str, str, str]] = {
|
||||
Action.VIEW: ("R", "Ansehen", "Datensaetze und Seiten dieses Bereichs lesen."),
|
||||
Action.CREATE: ("C", "Anlegen", "Neue Datensaetze erstellen."),
|
||||
Action.EDIT: ("U", "Bearbeiten", "Bestehende Datensaetze aendern -- ohne Loeschen."),
|
||||
Action.DELETE: ("D", "Loeschen", "Datensaetze loeschen bzw. endgueltig entfernen."),
|
||||
Action.EXECUTE: ("X", "Ausfuehren", "Aktionen ausloesen (Neustart, Dienste, Jobs, Import)."),
|
||||
Action.EXPORT: ("E", "Exportieren", "Daten aus dem System heraustragen (Datei-Download)."),
|
||||
Action.SECRETS: (
|
||||
"S",
|
||||
"Geheimnisse",
|
||||
"Passwoerter und Schluessel im Klartext sehen oder mitnehmen.",
|
||||
),
|
||||
}
|
||||
|
||||
#: Aktionen, die zusaetzlich eine frische Passworteingabe verlangen (sudo-Modus).
|
||||
SENSITIVE_ACTIONS: frozenset[Action] = frozenset({Action.SECRETS, Action.EXPORT})
|
||||
|
||||
#: ``view`` ist Voraussetzung fuer alles andere in derselben Zeile.
|
||||
IMPLIED_BY: dict[Action, tuple[Action, ...]] = {
|
||||
action: (Action.VIEW,) for action in Action if action is not Action.VIEW
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Resource:
|
||||
"""Eine Zeile der Rechtematrix."""
|
||||
|
||||
key: str
|
||||
label: str
|
||||
actions: tuple[Action, ...]
|
||||
hint: str = ""
|
||||
#: Abweichende Erklaerung einzelner Aktionen (z. B. was "Ausfuehren" hier meint).
|
||||
action_hints: Mapping[Action, str] = field(default_factory=dict)
|
||||
|
||||
def permission(self, action: Action) -> str:
|
||||
return f"{self.key}.{action.value}"
|
||||
|
||||
def has(self, action: Action) -> bool:
|
||||
return action in self.actions
|
||||
|
||||
def permissions(self) -> tuple[str, ...]:
|
||||
return tuple(self.permission(action) for action in self.actions)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Area:
|
||||
"""Ein Bereich (Gruppe von Ressourcen) mit eigenem Kill-Switch."""
|
||||
|
||||
key: str
|
||||
label: str
|
||||
icon: str
|
||||
resources: tuple[Resource, ...]
|
||||
hint: str = ""
|
||||
|
||||
@property
|
||||
def view_key(self) -> str:
|
||||
return f"{self.key}.view"
|
||||
|
||||
def columns(self) -> tuple[Action, ...]:
|
||||
"""Nur die Aktionen, die in diesem Bereich tatsaechlich vorkommen."""
|
||||
present = {action for resource in self.resources for action in resource.actions}
|
||||
return tuple(action for action in Action if action in present)
|
||||
|
||||
def resource(self, key: str) -> Resource | None:
|
||||
for resource in self.resources:
|
||||
if resource.key == key:
|
||||
return resource
|
||||
return None
|
||||
|
||||
|
||||
class PermissionTree:
|
||||
"""Aus Bereichen abgeleitete Nachschlagestrukturen.
|
||||
|
||||
Wird beim Import einmal gebaut und danach nur gelesen.
|
||||
"""
|
||||
|
||||
def __init__(self, areas: Sequence[Area]) -> None:
|
||||
self.areas: tuple[Area, ...] = tuple(areas)
|
||||
self._by_key: dict[str, Area] = {}
|
||||
self._parent: dict[str, str] = {}
|
||||
self._all: list[str] = []
|
||||
self._resources: dict[str, tuple[Area, Resource]] = {}
|
||||
|
||||
seen_keys: set[str] = set()
|
||||
for area in self.areas:
|
||||
if area.key in seen_keys:
|
||||
raise ValueError(f"Doppelter Bereichsschluessel: {area.key}")
|
||||
seen_keys.add(area.key)
|
||||
self._by_key[area.key] = area
|
||||
self._all.append(area.view_key)
|
||||
for resource in area.resources:
|
||||
if resource.key in seen_keys:
|
||||
raise ValueError(f"Doppelter Ressourcenschluessel: {resource.key}")
|
||||
seen_keys.add(resource.key)
|
||||
if Action.VIEW not in resource.actions:
|
||||
raise ValueError(
|
||||
f"Ressource {resource.key} muss die Aktion 'view' anbieten -- "
|
||||
"sie ist Voraussetzung fuer jede andere Aktion."
|
||||
)
|
||||
self._resources[resource.key] = (area, resource)
|
||||
for permission in resource.permissions():
|
||||
self._all.append(permission)
|
||||
self._parent[permission] = area.view_key
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def all_keys(self) -> tuple[str, ...]:
|
||||
return tuple(self._all)
|
||||
|
||||
def area(self, key: str) -> Area | None:
|
||||
return self._by_key.get(key)
|
||||
|
||||
def parent_view_key(self, permission: str) -> str | None:
|
||||
return self._parent.get(permission)
|
||||
|
||||
def resource(self, key: str) -> tuple[Area, Resource] | None:
|
||||
return self._resources.get(key)
|
||||
|
||||
def label_for(self, permission: str) -> str:
|
||||
"""Menschenlesbare Beschreibung eines Rechteschluessels -- fuer Audit-Logs."""
|
||||
if permission.endswith(".view") and permission[:-5] in self._by_key:
|
||||
return f"Bereich {self._by_key[permission[:-5]].label}: Zugang"
|
||||
resource_key, _, action_value = permission.rpartition(".")
|
||||
entry = self._resources.get(resource_key)
|
||||
if entry is None:
|
||||
return permission
|
||||
_, resource = entry
|
||||
try:
|
||||
action = Action(action_value)
|
||||
except ValueError:
|
||||
return permission
|
||||
return f"{resource.label}: {ACTION_META[action][1]}"
|
||||
|
||||
def sanitize(self, permissions: Iterable[str]) -> list[str]:
|
||||
"""Wirft unbekannte Rechte weg und ergaenzt implizierte.
|
||||
|
||||
Regeln:
|
||||
* Ein Recht, das nicht im Baum steht, wird verworfen (kein stilles
|
||||
Weiterschleppen von Altrechten nach einem Refactoring).
|
||||
* Jede Aktion impliziert ``view`` derselben Zeile.
|
||||
* Existiert irgendein Recht eines Bereichs, wird der Kill-Switch des
|
||||
Bereichs mitgesetzt -- sonst waere die Auswahl wirkungslos und der
|
||||
Bearbeiter wuerde sich wundern.
|
||||
"""
|
||||
known = set(self._all)
|
||||
result: set[str] = {p for p in permissions if p in known}
|
||||
for permission in list(result):
|
||||
resource_key, _, action_value = permission.rpartition(".")
|
||||
if resource_key in self._resources:
|
||||
try:
|
||||
action = Action(action_value)
|
||||
except ValueError:
|
||||
continue
|
||||
for implied in IMPLIED_BY.get(action, ()):
|
||||
candidate = f"{resource_key}.{implied.value}"
|
||||
if candidate in known:
|
||||
result.add(candidate)
|
||||
for area in self.areas:
|
||||
if any(p in result for resource in area.resources for p in resource.permissions()):
|
||||
result.add(area.view_key)
|
||||
return sorted(result)
|
||||
|
||||
def effective(self, permissions: Iterable[str]) -> set[str]:
|
||||
"""Rechte, die nach Anwendung der Kill-Switches tatsaechlich greifen."""
|
||||
granted = set(permissions)
|
||||
active = {key for key in granted if key.endswith(".view") and key[:-5] in self._by_key}
|
||||
return {
|
||||
key
|
||||
for key in granted
|
||||
if (parent := self._parent.get(key)) is None or parent in active
|
||||
}
|
||||
|
||||
def expand_preset(self, preset: "RolePreset") -> list[str]:
|
||||
return self.sanitize(preset.permissions(self))
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RolePreset:
|
||||
"""Benannte Rechtevorlage -- wird beim ersten Start als Gruppe angelegt.
|
||||
|
||||
Der Vorgaenger kannte nur "Admin" und eine Standardgruppe "Benutzer"; jede
|
||||
weitere Rolle musste jeder Kunde von Hand zusammenklicken. Vorlagen machen
|
||||
aus der haeufigsten Aufgabe einen Startpunkt.
|
||||
"""
|
||||
|
||||
key: str
|
||||
name: str
|
||||
description: str
|
||||
#: ``(ressource, aktionen)``-Paare, ``"*"`` fuer alle Aktionen der Ressource.
|
||||
grants: tuple[tuple[str, tuple[Action, ...] | str], ...]
|
||||
is_default: bool = False
|
||||
"""Standardgruppe: wird jedem neuen Nicht-Admin automatisch zugeordnet."""
|
||||
|
||||
def permissions(self, tree: PermissionTree) -> list[str]:
|
||||
result: list[str] = []
|
||||
for resource_key, actions in self.grants:
|
||||
entry = tree.resource(resource_key)
|
||||
if entry is None:
|
||||
continue
|
||||
_, resource = entry
|
||||
wanted = resource.actions if actions == "*" else tuple(a for a in actions if resource.has(a)) # type: ignore[arg-type]
|
||||
result.extend(resource.permission(action) for action in wanted)
|
||||
return result
|
||||
@@ -0,0 +1,717 @@
|
||||
"""Benutzer- und Gruppenverwaltung inklusive Eskalationsschutz.
|
||||
|
||||
Die wichtigste inhaltliche Korrektur gegenueber dem Vorgaenger steckt in
|
||||
``guard_no_amplification``:
|
||||
|
||||
Im Vorgaenger konnte jeder, der das Recht ``groups.edit`` besass, einer Gruppe
|
||||
*beliebige* Rechte zuweisen -- also auch Rechte, die er selbst nicht hatte --
|
||||
und sich anschliessend selbst in diese Gruppe setzen. Das Rechtesystem war
|
||||
damit fuer jeden Gruppenverwalter effektiv offen. Hier gilt: wer selbst kein
|
||||
Recht X hat, kann Recht X auch nicht vergeben. Entziehen ist immer erlaubt.
|
||||
Echte Administratoren sind davon ausgenommen (sie haben ohnehin alles).
|
||||
|
||||
Weitere fest verdrahtete Grenzen, die kein Gruppenrecht aushebeln kann:
|
||||
|
||||
* Das Admin-Kennzeichen setzt oder entfernt nur ein echter Administrator.
|
||||
* Ein bestehendes Administratorkonto bearbeitet, sperrt oder loescht nur ein
|
||||
echter Administrator.
|
||||
* Es muss immer mindestens ein entsperrtes Administratorkonto uebrig bleiben.
|
||||
* Niemand kann sich selbst das Admin-Kennzeichen entziehen oder sich selbst
|
||||
sperren (haeufigster Weg, sich komplett auszusperren).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Iterable, Sequence
|
||||
|
||||
from ..db import Database
|
||||
from ..security import passwords
|
||||
from .model import Action, PermissionTree, RolePreset
|
||||
|
||||
|
||||
class PermissionDenied(Exception):
|
||||
"""Verstoss gegen eine feste Sicherheitsgrenze -- wird als Klartext angezeigt."""
|
||||
|
||||
|
||||
class ValidationError(Exception):
|
||||
"""Eingabefehler, der dem Benutzer erklaert wird."""
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Gruppen
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class GroupRecord:
|
||||
id: int
|
||||
name: str
|
||||
description: str
|
||||
is_system: bool
|
||||
is_default: bool
|
||||
preset_key: str
|
||||
permissions: tuple[str, ...]
|
||||
member_count: int
|
||||
deleted_at: str | None = None
|
||||
|
||||
|
||||
def list_groups(
|
||||
conn: sqlite3.Connection, *, include_deleted: bool = False
|
||||
) -> list[GroupRecord]:
|
||||
where = "" if include_deleted else "WHERE g.deleted_at IS NULL"
|
||||
rows = Database.all(
|
||||
conn,
|
||||
f"""
|
||||
SELECT g.*,
|
||||
(SELECT COUNT(*) FROM user_groups ug
|
||||
JOIN users u ON u.id = ug.user_id AND u.deleted_at IS NULL
|
||||
WHERE ug.group_id = g.id) AS member_count
|
||||
FROM groups g {where}
|
||||
ORDER BY g.is_system DESC, g.name COLLATE NOCASE
|
||||
""",
|
||||
)
|
||||
perms = _permissions_by_group(conn)
|
||||
return [
|
||||
GroupRecord(
|
||||
id=int(row["id"]),
|
||||
name=str(row["name"]),
|
||||
description=str(row["description"] or ""),
|
||||
is_system=bool(row["is_system"]),
|
||||
is_default=bool(row["is_default"]),
|
||||
preset_key=str(row["preset_key"] or ""),
|
||||
permissions=tuple(perms.get(int(row["id"]), ())),
|
||||
member_count=int(row["member_count"] or 0),
|
||||
deleted_at=row["deleted_at"],
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
def _permissions_by_group(conn: sqlite3.Connection) -> dict[int, list[str]]:
|
||||
result: dict[int, list[str]] = {}
|
||||
for row in Database.all(conn, "SELECT group_id, permission FROM group_permissions"):
|
||||
result.setdefault(int(row["group_id"]), []).append(str(row["permission"]))
|
||||
for values in result.values():
|
||||
values.sort()
|
||||
return result
|
||||
|
||||
|
||||
def get_group(conn: sqlite3.Connection, group_id: int) -> GroupRecord | None:
|
||||
for group in list_groups(conn, include_deleted=True):
|
||||
if group.id == group_id:
|
||||
return group
|
||||
return None
|
||||
|
||||
|
||||
def group_permissions(conn: sqlite3.Connection, group_id: int) -> list[str]:
|
||||
return [
|
||||
str(row["permission"])
|
||||
for row in Database.all(
|
||||
conn,
|
||||
"SELECT permission FROM group_permissions WHERE group_id=? ORDER BY permission",
|
||||
(group_id,),
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def guard_no_amplification(
|
||||
actor: Any, tree: PermissionTree, added: Iterable[str], *, what: str = "Recht"
|
||||
) -> None:
|
||||
"""Verhindert, dass jemand Rechte vergibt, die er selbst nicht besitzt."""
|
||||
if getattr(actor, "is_admin", False):
|
||||
return
|
||||
missing = sorted(p for p in added if not actor.has(p))
|
||||
if missing:
|
||||
labels = ", ".join(tree.label_for(p) for p in missing[:6])
|
||||
more = "" if len(missing) <= 6 else f" (und {len(missing) - 6} weitere)"
|
||||
raise PermissionDenied(
|
||||
f"Sie koennen kein {what} vergeben, das Sie selbst nicht besitzen: {labels}{more}."
|
||||
)
|
||||
|
||||
|
||||
def create_group(
|
||||
conn: sqlite3.Connection,
|
||||
*,
|
||||
name: str,
|
||||
description: str,
|
||||
permissions: Sequence[str],
|
||||
tree: PermissionTree,
|
||||
actor: Any,
|
||||
preset_key: str = "",
|
||||
is_system: bool = False,
|
||||
is_default: bool = False,
|
||||
) -> int:
|
||||
clean_name = (name or "").strip()
|
||||
if not clean_name:
|
||||
raise ValidationError("Der Gruppenname darf nicht leer sein.")
|
||||
if len(clean_name) > 64:
|
||||
raise ValidationError("Der Gruppenname ist zu lang (maximal 64 Zeichen).")
|
||||
existing = Database.one(
|
||||
conn, "SELECT id, deleted_at FROM groups WHERE name=? COLLATE NOCASE", (clean_name,)
|
||||
)
|
||||
if existing is not None:
|
||||
if existing["deleted_at"]:
|
||||
raise ValidationError(
|
||||
f"Eine Gruppe namens {clean_name!r} liegt im Papierkorb. "
|
||||
"Stellen Sie sie wieder her oder loeschen Sie sie endgueltig."
|
||||
)
|
||||
raise ValidationError(f"Eine Gruppe namens {clean_name!r} existiert bereits.")
|
||||
|
||||
wanted = tree.sanitize(permissions)
|
||||
guard_no_amplification(actor, tree, wanted)
|
||||
|
||||
cursor = conn.execute(
|
||||
"INSERT INTO groups (name, description, is_system, is_default, preset_key, "
|
||||
"created_at, updated_at) VALUES (?,?,?,?,?,?,?)",
|
||||
(clean_name, (description or "").strip(), int(is_system), int(is_default), preset_key, _now(), _now()),
|
||||
)
|
||||
group_id = int(cursor.lastrowid or 0)
|
||||
_write_permissions(conn, group_id, wanted)
|
||||
return group_id
|
||||
|
||||
|
||||
def _write_permissions(conn: sqlite3.Connection, group_id: int, permissions: Sequence[str]) -> None:
|
||||
conn.execute("DELETE FROM group_permissions WHERE group_id=?", (group_id,))
|
||||
conn.executemany(
|
||||
"INSERT INTO group_permissions (group_id, permission) VALUES (?,?)",
|
||||
[(group_id, permission) for permission in permissions],
|
||||
)
|
||||
|
||||
|
||||
def update_group(
|
||||
conn: sqlite3.Connection,
|
||||
group_id: int,
|
||||
*,
|
||||
name: str | None = None,
|
||||
description: str | None = None,
|
||||
permissions: Sequence[str] | None = None,
|
||||
tree: PermissionTree,
|
||||
actor: Any,
|
||||
unlock_system: bool = False,
|
||||
) -> list[str]:
|
||||
"""Aendert eine Gruppe. Rueckgabe: Liste der Aenderungen fuer das Audit-Log."""
|
||||
group = get_group(conn, group_id)
|
||||
if group is None or group.deleted_at:
|
||||
raise ValidationError("Diese Gruppe existiert nicht (mehr).")
|
||||
|
||||
changes: list[str] = []
|
||||
|
||||
if name is not None and name.strip() != group.name:
|
||||
if group.is_system:
|
||||
raise PermissionDenied("Systemgruppen koennen nicht umbenannt werden.")
|
||||
clean = name.strip()
|
||||
if not clean:
|
||||
raise ValidationError("Der Gruppenname darf nicht leer sein.")
|
||||
clash = Database.one(
|
||||
conn,
|
||||
"SELECT id FROM groups WHERE name=? COLLATE NOCASE AND id<>? AND deleted_at IS NULL",
|
||||
(clean, group_id),
|
||||
)
|
||||
if clash is not None:
|
||||
raise ValidationError(f"Eine Gruppe namens {clean!r} existiert bereits.")
|
||||
conn.execute("UPDATE groups SET name=?, updated_at=? WHERE id=?", (clean, _now(), group_id))
|
||||
changes.append(f"Name: {group.name} -> {clean}")
|
||||
|
||||
if description is not None and description.strip() != group.description:
|
||||
conn.execute(
|
||||
"UPDATE groups SET description=?, updated_at=? WHERE id=?",
|
||||
(description.strip(), _now(), group_id),
|
||||
)
|
||||
changes.append("Beschreibung geaendert")
|
||||
|
||||
if permissions is not None:
|
||||
if group.is_system and not unlock_system:
|
||||
raise PermissionDenied(
|
||||
"Die Rechte dieser Systemgruppe sind gesperrt. "
|
||||
"Sie muessen sie zuerst ausdruecklich freischalten."
|
||||
)
|
||||
wanted = tree.sanitize(permissions)
|
||||
added = sorted(set(wanted) - set(group.permissions))
|
||||
removed = sorted(set(group.permissions) - set(wanted))
|
||||
guard_no_amplification(actor, tree, added)
|
||||
if added or removed:
|
||||
_write_permissions(conn, group_id, wanted)
|
||||
conn.execute("UPDATE groups SET updated_at=? WHERE id=?", (_now(), group_id))
|
||||
if added:
|
||||
changes.append("Rechte hinzugefuegt: " + ", ".join(tree.label_for(p) for p in added))
|
||||
if removed:
|
||||
changes.append("Rechte entfernt: " + ", ".join(tree.label_for(p) for p in removed))
|
||||
|
||||
return changes
|
||||
|
||||
|
||||
def soft_delete_group(conn: sqlite3.Connection, group_id: int) -> None:
|
||||
group = get_group(conn, group_id)
|
||||
if group is None:
|
||||
raise ValidationError("Diese Gruppe existiert nicht.")
|
||||
if group.is_system:
|
||||
raise PermissionDenied("Systemgruppen koennen nicht geloescht werden.")
|
||||
conn.execute("UPDATE groups SET deleted_at=? WHERE id=?", (_now(), group_id))
|
||||
|
||||
|
||||
def restore_group(conn: sqlite3.Connection, group_id: int) -> None:
|
||||
group = get_group(conn, group_id)
|
||||
if group is None:
|
||||
raise ValidationError("Diese Gruppe existiert nicht.")
|
||||
clash = Database.one(
|
||||
conn,
|
||||
"SELECT id FROM groups WHERE name=? COLLATE NOCASE AND id<>? AND deleted_at IS NULL",
|
||||
(group.name, group_id),
|
||||
)
|
||||
if clash is not None:
|
||||
raise ValidationError(
|
||||
f"Es existiert bereits eine aktive Gruppe namens {group.name!r}. "
|
||||
"Benennen Sie diese zuerst um."
|
||||
)
|
||||
conn.execute("UPDATE groups SET deleted_at=NULL WHERE id=?", (group_id,))
|
||||
|
||||
|
||||
def purge_group(conn: sqlite3.Connection, group_id: int) -> None:
|
||||
# Mitgliedschaften und Rechte haengen per ON DELETE CASCADE dran.
|
||||
conn.execute("DELETE FROM groups WHERE id=?", (group_id,))
|
||||
|
||||
|
||||
def ensure_presets(
|
||||
conn: sqlite3.Connection, tree: PermissionTree, presets: Sequence[RolePreset]
|
||||
) -> list[str]:
|
||||
"""Legt fehlende Vorlagen-Gruppen an.
|
||||
|
||||
Wichtig -- Unterschied zum Vorgaenger: eine bereits existierende Gruppe wird
|
||||
**nicht** bei jedem Start auf den Vorlagen-Rechtesatz zurueckgesetzt. Der
|
||||
Vorgaenger tat das fuer die Standardgruppe "Benutzer", wodurch jede
|
||||
Anpassung durch den Kunden beim naechsten Neustart verschwand. Wer die
|
||||
Vorlage neu anwenden will, tut das ausdruecklich in der Oberflaeche.
|
||||
"""
|
||||
created: list[str] = []
|
||||
for preset in presets:
|
||||
row = Database.one(
|
||||
conn, "SELECT id FROM groups WHERE name=? COLLATE NOCASE", (preset.name,)
|
||||
)
|
||||
if row is not None:
|
||||
continue
|
||||
permissions = tree.expand_preset(preset)
|
||||
cursor = conn.execute(
|
||||
"INSERT INTO groups (name, description, is_system, is_default, preset_key, "
|
||||
"created_at, updated_at) VALUES (?,?,?,?,?,?,?)",
|
||||
(
|
||||
preset.name,
|
||||
preset.description,
|
||||
1 if preset.is_default else 0,
|
||||
1 if preset.is_default else 0,
|
||||
preset.key,
|
||||
_now(),
|
||||
_now(),
|
||||
),
|
||||
)
|
||||
_write_permissions(conn, int(cursor.lastrowid or 0), permissions)
|
||||
created.append(preset.name)
|
||||
return created
|
||||
|
||||
|
||||
def default_group_id(conn: sqlite3.Connection) -> int | None:
|
||||
value = Database.value(
|
||||
conn, "SELECT id FROM groups WHERE is_default=1 AND deleted_at IS NULL ORDER BY id LIMIT 1"
|
||||
)
|
||||
return int(value) if value is not None else None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Benutzer
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def granted_permissions(conn: sqlite3.Connection, user_id: int) -> list[str]:
|
||||
"""Vereinigung der Rechte aller (nicht geloeschten) Gruppen des Benutzers."""
|
||||
return [
|
||||
str(row["permission"])
|
||||
for row in Database.all(
|
||||
conn,
|
||||
"""
|
||||
SELECT DISTINCT gp.permission
|
||||
FROM user_groups ug
|
||||
JOIN groups g ON g.id = ug.group_id AND g.deleted_at IS NULL
|
||||
JOIN group_permissions gp ON gp.group_id = g.id
|
||||
WHERE ug.user_id = ?
|
||||
""",
|
||||
(user_id,),
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def user_groups(conn: sqlite3.Connection, user_id: int) -> list[dict[str, Any]]:
|
||||
return Database.all(
|
||||
conn,
|
||||
"SELECT g.id, g.name, g.is_system, g.is_default FROM user_groups ug "
|
||||
"JOIN groups g ON g.id = ug.group_id AND g.deleted_at IS NULL "
|
||||
"WHERE ug.user_id=? ORDER BY g.name COLLATE NOCASE",
|
||||
(user_id,),
|
||||
)
|
||||
|
||||
|
||||
def list_users(
|
||||
conn: sqlite3.Connection, *, include_deleted: bool = False
|
||||
) -> list[dict[str, Any]]:
|
||||
where = "" if include_deleted else "WHERE u.deleted_at IS NULL"
|
||||
users = Database.all(
|
||||
conn,
|
||||
f"""
|
||||
SELECT u.id, u.username, u.is_admin, u.is_locked, u.auth_source, u.first_name,
|
||||
u.last_name, u.email, u.avatar_filename, u.last_login_at, u.created_at,
|
||||
u.deleted_at, u.must_change_password,
|
||||
CASE WHEN u.totp_secret <> '' THEN 1 ELSE 0 END AS totp_enabled
|
||||
FROM users u {where}
|
||||
ORDER BY u.username COLLATE NOCASE
|
||||
""",
|
||||
)
|
||||
memberships: dict[int, list[str]] = {}
|
||||
for row in Database.all(
|
||||
conn,
|
||||
"SELECT ug.user_id, g.name FROM user_groups ug "
|
||||
"JOIN groups g ON g.id = ug.group_id AND g.deleted_at IS NULL",
|
||||
):
|
||||
memberships.setdefault(int(row["user_id"]), []).append(str(row["name"]))
|
||||
for user in users:
|
||||
user["group_names"] = sorted(memberships.get(int(user["id"]), []))
|
||||
return users
|
||||
|
||||
|
||||
def get_user(conn: sqlite3.Connection, user_id: int) -> dict[str, Any] | None:
|
||||
return Database.one(conn, "SELECT * FROM users WHERE id=?", (user_id,))
|
||||
|
||||
|
||||
def find_user_by_username(
|
||||
conn: sqlite3.Connection, username: str, *, include_deleted: bool = False
|
||||
) -> dict[str, Any] | None:
|
||||
suffix = "" if include_deleted else " AND deleted_at IS NULL"
|
||||
return Database.one(
|
||||
conn,
|
||||
f"SELECT * FROM users WHERE username=? COLLATE NOCASE{suffix}",
|
||||
(username,),
|
||||
)
|
||||
|
||||
|
||||
def count_active_admins(conn: sqlite3.Connection, *, exclude_id: int | None = None) -> int:
|
||||
sql = "SELECT COUNT(*) FROM users WHERE is_admin=1 AND is_locked=0 AND deleted_at IS NULL"
|
||||
params: tuple[Any, ...] = ()
|
||||
if exclude_id is not None:
|
||||
sql += " AND id<>?"
|
||||
params = (exclude_id,)
|
||||
return int(Database.value(conn, sql, params, default=0) or 0)
|
||||
|
||||
|
||||
def guard_target_account(actor: Any, target: dict[str, Any]) -> None:
|
||||
"""Ein Administratorkonto darf nur ein echter Administrator anfassen."""
|
||||
if bool(target.get("is_admin")) and not getattr(actor, "is_admin", False):
|
||||
raise PermissionDenied(
|
||||
"Administratorkonten koennen nur von Administratoren bearbeitet werden."
|
||||
)
|
||||
|
||||
|
||||
def guard_last_admin(conn: sqlite3.Connection, user_id: int, *, action: str) -> None:
|
||||
row = get_user(conn, user_id)
|
||||
if row is None or not row.get("is_admin"):
|
||||
return
|
||||
if count_active_admins(conn, exclude_id=user_id) == 0:
|
||||
raise PermissionDenied(
|
||||
f"{action} nicht moeglich: es muss mindestens ein entsperrtes "
|
||||
"Administratorkonto uebrig bleiben."
|
||||
)
|
||||
|
||||
|
||||
def _assignable_groups(
|
||||
conn: sqlite3.Connection, actor: Any, tree: PermissionTree, group_ids: Sequence[int]
|
||||
) -> list[int]:
|
||||
"""Prueft, ob der Bearbeiter diese Gruppen zuweisen darf (kein Rechtezuwachs)."""
|
||||
if getattr(actor, "is_admin", False):
|
||||
return [int(gid) for gid in group_ids]
|
||||
result: list[int] = []
|
||||
for gid in group_ids:
|
||||
permissions = group_permissions(conn, int(gid))
|
||||
guard_no_amplification(actor, tree, permissions, what="Gruppenrecht")
|
||||
result.append(int(gid))
|
||||
return result
|
||||
|
||||
|
||||
def set_user_groups(
|
||||
conn: sqlite3.Connection,
|
||||
user_id: int,
|
||||
group_ids: Sequence[int],
|
||||
*,
|
||||
actor: Any,
|
||||
tree: PermissionTree,
|
||||
) -> None:
|
||||
allowed = _assignable_groups(conn, actor, tree, group_ids)
|
||||
conn.execute("DELETE FROM user_groups WHERE user_id=?", (user_id,))
|
||||
conn.executemany(
|
||||
"INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?,?)",
|
||||
[(user_id, gid) for gid in allowed],
|
||||
)
|
||||
|
||||
|
||||
def create_user(
|
||||
conn: sqlite3.Connection,
|
||||
*,
|
||||
username: str,
|
||||
password: str,
|
||||
is_admin: bool,
|
||||
first_name: str = "",
|
||||
last_name: str = "",
|
||||
email: str = "",
|
||||
group_ids: Sequence[int] = (),
|
||||
actor: Any,
|
||||
tree: PermissionTree,
|
||||
policy: passwords.PasswordPolicy,
|
||||
auth_source: str = "local",
|
||||
must_change_password: bool = False,
|
||||
) -> int:
|
||||
clean = (username or "").strip()
|
||||
if not clean:
|
||||
raise ValidationError("Der Benutzername darf nicht leer sein.")
|
||||
if len(clean) > 64 or any(ch.isspace() for ch in clean):
|
||||
raise ValidationError("Der Benutzername darf keine Leerzeichen enthalten (max. 64 Zeichen).")
|
||||
|
||||
existing = find_user_by_username(conn, clean, include_deleted=True)
|
||||
if existing is not None:
|
||||
if existing["deleted_at"]:
|
||||
raise ValidationError(
|
||||
f"Der Benutzername {clean!r} liegt im Papierkorb. Stellen Sie das Konto "
|
||||
"wieder her oder loeschen Sie es endgueltig."
|
||||
)
|
||||
raise ValidationError(f"Der Benutzername {clean!r} ist bereits vergeben.")
|
||||
|
||||
if is_admin and not getattr(actor, "is_admin", False):
|
||||
raise PermissionDenied("Nur Administratoren koennen weitere Administratoren anlegen.")
|
||||
|
||||
if auth_source == "local":
|
||||
problems = policy.check(password, username=clean)
|
||||
if problems:
|
||||
raise ValidationError(" ".join(problems))
|
||||
password_hash = passwords.hash_password(password)
|
||||
else:
|
||||
password_hash = "" # Verzeichniskonto: Passwort wird nie hier geprueft.
|
||||
|
||||
cursor = conn.execute(
|
||||
"INSERT INTO users (username, password_hash, is_admin, auth_source, first_name, "
|
||||
"last_name, email, must_change_password, password_changed_at, created_at, updated_at) "
|
||||
"VALUES (?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(
|
||||
clean,
|
||||
password_hash,
|
||||
int(is_admin),
|
||||
auth_source,
|
||||
first_name.strip(),
|
||||
last_name.strip(),
|
||||
email.strip(),
|
||||
int(must_change_password),
|
||||
_now() if auth_source == "local" else None,
|
||||
_now(),
|
||||
_now(),
|
||||
),
|
||||
)
|
||||
user_id = int(cursor.lastrowid or 0)
|
||||
|
||||
targets = list(group_ids)
|
||||
if not is_admin and not targets:
|
||||
fallback = default_group_id(conn)
|
||||
if fallback is not None:
|
||||
targets = [fallback]
|
||||
set_user_groups(conn, user_id, targets, actor=actor, tree=tree)
|
||||
return user_id
|
||||
|
||||
|
||||
def update_user(
|
||||
conn: sqlite3.Connection,
|
||||
user_id: int,
|
||||
*,
|
||||
actor: Any,
|
||||
tree: PermissionTree,
|
||||
first_name: str | None = None,
|
||||
last_name: str | None = None,
|
||||
email: str | None = None,
|
||||
is_admin: bool | None = None,
|
||||
is_locked: bool | None = None,
|
||||
group_ids: Sequence[int] | None = None,
|
||||
) -> list[str]:
|
||||
target = get_user(conn, user_id)
|
||||
if target is None or target["deleted_at"]:
|
||||
raise ValidationError("Dieses Konto existiert nicht (mehr).")
|
||||
guard_target_account(actor, target)
|
||||
|
||||
changes: list[str] = []
|
||||
directory = str(target.get("auth_source")) == "ldap"
|
||||
|
||||
if directory and any(value is not None for value in (first_name, last_name, email)):
|
||||
# Beim naechsten Login wuerden diese Felder ohnehin aus dem Verzeichnis
|
||||
# ueberschrieben -- die Bearbeitung waere eine Illusion.
|
||||
raise ValidationError(
|
||||
"Name und E-Mail eines Verzeichniskontos werden bei jeder Anmeldung "
|
||||
"aus dem Verzeichnis uebernommen und koennen hier nicht geaendert werden."
|
||||
)
|
||||
|
||||
fields: dict[str, Any] = {}
|
||||
for column, value in (
|
||||
("first_name", first_name),
|
||||
("last_name", last_name),
|
||||
("email", email),
|
||||
):
|
||||
if value is not None and value.strip() != str(target.get(column) or ""):
|
||||
fields[column] = value.strip()
|
||||
changes.append(f"{column} geaendert")
|
||||
|
||||
if is_admin is not None and bool(is_admin) != bool(target["is_admin"]):
|
||||
if not getattr(actor, "is_admin", False):
|
||||
raise PermissionDenied("Das Admin-Kennzeichen kann nur ein Administrator aendern.")
|
||||
if int(target["id"]) == int(getattr(actor, "id", 0)):
|
||||
raise PermissionDenied("Sie koennen Ihr eigenes Admin-Kennzeichen nicht aendern.")
|
||||
if not is_admin:
|
||||
guard_last_admin(conn, user_id, action="Herabstufen")
|
||||
fields["is_admin"] = int(bool(is_admin))
|
||||
changes.append("Administrator: " + ("ja" if is_admin else "nein"))
|
||||
|
||||
if is_locked is not None and bool(is_locked) != bool(target["is_locked"]):
|
||||
if int(target["id"]) == int(getattr(actor, "id", 0)):
|
||||
raise PermissionDenied("Sie koennen Ihr eigenes Konto nicht sperren.")
|
||||
if is_locked:
|
||||
guard_last_admin(conn, user_id, action="Sperren")
|
||||
fields["is_locked"] = int(bool(is_locked))
|
||||
changes.append("Gesperrt: " + ("ja" if is_locked else "nein"))
|
||||
|
||||
if fields:
|
||||
assignments = ", ".join(f"{column}=?" for column in fields)
|
||||
conn.execute(
|
||||
f"UPDATE users SET {assignments}, updated_at=? WHERE id=?",
|
||||
(*fields.values(), _now(), user_id),
|
||||
)
|
||||
|
||||
if group_ids is not None:
|
||||
before = {int(row["id"]) for row in user_groups(conn, user_id)}
|
||||
after = {int(gid) for gid in group_ids}
|
||||
if before != after:
|
||||
set_user_groups(conn, user_id, sorted(after), actor=actor, tree=tree)
|
||||
changes.append("Gruppenzuordnung geaendert")
|
||||
|
||||
return changes
|
||||
|
||||
|
||||
def set_password(
|
||||
conn: sqlite3.Connection,
|
||||
user_id: int,
|
||||
new_password: str,
|
||||
*,
|
||||
policy: passwords.PasswordPolicy,
|
||||
must_change: bool = False,
|
||||
) -> None:
|
||||
row = get_user(conn, user_id)
|
||||
if row is None:
|
||||
raise ValidationError("Dieses Konto existiert nicht.")
|
||||
if str(row.get("auth_source")) == "ldap":
|
||||
raise ValidationError(
|
||||
"Verzeichniskonten haben hier kein Passwort -- es wird im Verzeichnisdienst gepflegt."
|
||||
)
|
||||
problems = policy.check(new_password, username=str(row["username"]))
|
||||
if problems:
|
||||
raise ValidationError(" ".join(problems))
|
||||
conn.execute(
|
||||
"UPDATE users SET password_hash=?, must_change_password=?, password_changed_at=?, "
|
||||
"updated_at=? WHERE id=?",
|
||||
(passwords.hash_password(new_password), int(must_change), _now(), _now(), user_id),
|
||||
)
|
||||
|
||||
|
||||
def soft_delete_user(conn: sqlite3.Connection, user_id: int, *, actor: Any) -> str:
|
||||
target = get_user(conn, user_id)
|
||||
if target is None or target["deleted_at"]:
|
||||
raise ValidationError("Dieses Konto existiert nicht (mehr).")
|
||||
guard_target_account(actor, target)
|
||||
if int(target["id"]) == int(getattr(actor, "id", 0)):
|
||||
raise PermissionDenied("Sie koennen Ihr eigenes Konto nicht loeschen.")
|
||||
guard_last_admin(conn, user_id, action="Loeschen")
|
||||
|
||||
if str(target.get("auth_source")) == "ldap":
|
||||
# Ein Verzeichniskonto ist nur ein Schattenkonto: es legt sich bei der
|
||||
# naechsten gueltigen Anmeldung von selbst wieder an. Ein Papierkorb
|
||||
# waere hier bedeutungslos.
|
||||
conn.execute("DELETE FROM users WHERE id=?", (user_id,))
|
||||
return "endgueltig"
|
||||
|
||||
conn.execute("UPDATE users SET deleted_at=?, updated_at=? WHERE id=?", (_now(), _now(), user_id))
|
||||
conn.execute(
|
||||
"UPDATE user_sessions SET revoked_at=?, revoked_reason='user-deleted' "
|
||||
"WHERE user_id=? AND revoked_at IS NULL",
|
||||
(_now(), user_id),
|
||||
)
|
||||
return "papierkorb"
|
||||
|
||||
|
||||
def restore_user(conn: sqlite3.Connection, user_id: int) -> None:
|
||||
row = get_user(conn, user_id)
|
||||
if row is None:
|
||||
raise ValidationError("Dieses Konto existiert nicht.")
|
||||
clash = Database.one(
|
||||
conn,
|
||||
"SELECT id FROM users WHERE username=? COLLATE NOCASE AND id<>? AND deleted_at IS NULL",
|
||||
(row["username"], user_id),
|
||||
)
|
||||
if clash is not None:
|
||||
raise ValidationError(
|
||||
f"Es existiert bereits ein aktives Konto namens {row['username']!r}."
|
||||
)
|
||||
conn.execute("UPDATE users SET deleted_at=NULL, updated_at=? WHERE id=?", (_now(), user_id))
|
||||
|
||||
|
||||
def purge_user(conn: sqlite3.Connection, user_id: int) -> None:
|
||||
conn.execute("DELETE FROM users WHERE id=?", (user_id,))
|
||||
|
||||
|
||||
def matrix_state(
|
||||
tree: PermissionTree, permissions: Iterable[str]
|
||||
) -> dict[str, dict[str, bool]]:
|
||||
"""Hilfsstruktur fuer das Rendering der Rechtematrix."""
|
||||
granted = set(permissions)
|
||||
state: dict[str, dict[str, bool]] = {}
|
||||
for area in tree.areas:
|
||||
state[area.key] = {"view": area.view_key in granted}
|
||||
for resource in area.resources:
|
||||
state[resource.key] = {
|
||||
action.value: resource.permission(action) in granted for action in resource.actions
|
||||
}
|
||||
return state
|
||||
|
||||
|
||||
def summarize_permissions(tree: PermissionTree, permissions: Iterable[str]) -> list[str]:
|
||||
"""Kurzfassung fuer Listenansichten: ``Bereich: Ressource (RCUD)``."""
|
||||
granted = set(permissions)
|
||||
lines: list[str] = []
|
||||
for area in tree.areas:
|
||||
if area.view_key not in granted:
|
||||
continue
|
||||
parts: list[str] = []
|
||||
for resource in area.resources:
|
||||
letters = "".join(
|
||||
ACTION_LETTERS[action]
|
||||
for action in resource.actions
|
||||
if resource.permission(action) in granted
|
||||
)
|
||||
if letters:
|
||||
parts.append(f"{resource.label} ({letters})")
|
||||
if parts:
|
||||
lines.append(f"{area.label}: " + ", ".join(parts))
|
||||
return lines
|
||||
|
||||
|
||||
ACTION_LETTERS: dict[Action, str] = {
|
||||
Action.VIEW: "R",
|
||||
Action.CREATE: "C",
|
||||
Action.EDIT: "U",
|
||||
Action.DELETE: "D",
|
||||
Action.EXECUTE: "X",
|
||||
Action.EXPORT: "E",
|
||||
Action.SECRETS: "S",
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
"""Sicherheitsbausteine: Passwoerter, Sitzungen, CSRF, Ratenbegrenzung, TOTP."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from . import csrf, passwords, ratelimit, sessions, totp
|
||||
|
||||
__all__ = ["csrf", "passwords", "ratelimit", "sessions", "totp"]
|
||||
@@ -0,0 +1,180 @@
|
||||
"""CSRF-Schutz und Header-Haertung.
|
||||
|
||||
Der Vorgaenger hatte **keinen** CSRF-Schutz: jede Aenderung lief ueber ein
|
||||
gewoehnliches POST-Formular ohne Token. Eine praeparierte Seite konnte damit im
|
||||
Namen eines eingeloggten Admins Geraete loeschen oder Benutzer anlegen.
|
||||
|
||||
Hier drei Schichten:
|
||||
|
||||
1. ``SameSite=Lax`` am Sitzungscookie (blockt Cross-Site-POSTs bereits im Browser).
|
||||
2. Ein Token pro Sitzung, das in jedem Formular mitgeschickt und in konstanter
|
||||
Zeit verglichen wird.
|
||||
3. Origin-/Referer-Pruefung als Rueckfallebene fuer Browser ohne SameSite.
|
||||
|
||||
Alle drei sind unabhaengig voneinander wirksam.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
import secrets
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from flask import Flask, Response, abort, current_app, request, session
|
||||
|
||||
SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS", "TRACE"})
|
||||
FIELD_NAME = "csrf_token"
|
||||
HEADER_NAME = "X-CSRF-Token"
|
||||
_SESSION_KEY = "_csrf"
|
||||
|
||||
|
||||
class CsrfError(Exception):
|
||||
"""Wird als HTTP 403 mit erklaerender Seite ausgeliefert."""
|
||||
|
||||
|
||||
def token() -> str:
|
||||
"""Aktuelles Token der Sitzung, bei Bedarf neu erzeugt."""
|
||||
value = session.get(_SESSION_KEY)
|
||||
if not value:
|
||||
value = secrets.token_urlsafe(32)
|
||||
session[_SESSION_KEY] = value
|
||||
return str(value)
|
||||
|
||||
|
||||
def rotate_token() -> str:
|
||||
"""Neues Token -- nach Anmeldung/Abmeldung und Rechtewechsel."""
|
||||
session.pop(_SESSION_KEY, None)
|
||||
return token()
|
||||
|
||||
|
||||
def _submitted_token() -> str:
|
||||
for source in (request.form, request.args):
|
||||
value = source.get(FIELD_NAME)
|
||||
if value:
|
||||
return value
|
||||
header = request.headers.get(HEADER_NAME)
|
||||
if header:
|
||||
return header
|
||||
if request.is_json:
|
||||
try:
|
||||
payload = request.get_json(silent=True) or {}
|
||||
except Exception: # pragma: no cover - defensiv
|
||||
payload = {}
|
||||
if isinstance(payload, dict) and payload.get(FIELD_NAME):
|
||||
return str(payload[FIELD_NAME])
|
||||
return ""
|
||||
|
||||
|
||||
def _origin_ok() -> bool:
|
||||
"""Prueft Origin bzw. Referer gegen den Host der Anfrage."""
|
||||
origin = request.headers.get("Origin") or ""
|
||||
referer = request.headers.get("Referer") or ""
|
||||
candidate = origin or referer
|
||||
if not candidate:
|
||||
# Kein Origin/Referer: manche Clients (curl, alte Proxies) senden keinen.
|
||||
# Das Token allein muss dann reichen.
|
||||
return True
|
||||
parsed = urlparse(candidate)
|
||||
if not parsed.netloc:
|
||||
return False
|
||||
|
||||
# Hinter einem Reverse Proxy kann der Host je nach Konfiguration mit oder
|
||||
# ohne Port ankommen. Beide Formen gelten als derselbe Ursprung; ein
|
||||
# *anderer* Host tut das nie.
|
||||
expected: set[str] = {request.host}
|
||||
forwarded_host = (request.headers.get("X-Forwarded-Host") or "").split(",")[0].strip()
|
||||
if forwarded_host:
|
||||
expected.add(forwarded_host)
|
||||
forwarded_port = (request.headers.get("X-Forwarded-Port") or "").strip()
|
||||
if forwarded_port:
|
||||
expected.update(f"{host.split(':')[0]}:{forwarded_port}" for host in set(expected))
|
||||
# Zusaetzlich die portlose Schreibweise -- aber bewusst **keine** beliebigen
|
||||
# Ports: ein anderer Dienst auf demselben Host waere ein anderer Ursprung.
|
||||
expected.update(host.split(":")[0] for host in set(expected))
|
||||
# Und die Standardports, falls der Proxy sie weglaesst.
|
||||
scheme_port = "443" if request.headers.get("X-Forwarded-Proto") == "https" else "80"
|
||||
expected.update(f"{host.split(':')[0]}:{scheme_port}" for host in set(expected))
|
||||
|
||||
return parsed.netloc in expected
|
||||
|
||||
|
||||
def protect() -> None:
|
||||
"""``before_request``-Hook."""
|
||||
if request.method in SAFE_METHODS:
|
||||
return
|
||||
view = current_app.view_functions.get(request.endpoint or "")
|
||||
if getattr(view, "_csrf_exempt", False):
|
||||
return
|
||||
if not _origin_ok():
|
||||
abort(403, description="Die Anfrage kam von einer fremden Herkunft (Origin/Referer).")
|
||||
expected = session.get(_SESSION_KEY) or ""
|
||||
provided = _submitted_token()
|
||||
if not expected or not provided:
|
||||
abort(403, description="Das Sicherheitstoken des Formulars fehlt. Bitte Seite neu laden.")
|
||||
if not hmac.compare_digest(str(expected), str(provided)):
|
||||
abort(403, description="Das Sicherheitstoken des Formulars ist ungueltig. Bitte Seite neu laden.")
|
||||
|
||||
|
||||
def exempt(view):
|
||||
"""Dekorator fuer Endpunkte mit eigener Authentisierung (Maschinen-APIs).
|
||||
|
||||
Bewusst sparsam einsetzen: nur fuer Routen, die *nicht* ueber Cookies
|
||||
autorisiert werden. Eine cookie-autorisierte Route ohne CSRF-Schutz ist eine
|
||||
Luecke.
|
||||
"""
|
||||
view._csrf_exempt = True # type: ignore[attr-defined]
|
||||
return view
|
||||
|
||||
|
||||
def init_app(app: Flask) -> None:
|
||||
app.before_request(protect)
|
||||
app.jinja_env.globals["csrf_token"] = token
|
||||
app.jinja_env.globals["csrf_field_name"] = FIELD_NAME
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Sicherheits-Header
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def content_security_policy(nonce: str) -> str:
|
||||
"""Strikte CSP.
|
||||
|
||||
Kein ``unsafe-inline`` fuer Skripte -- jedes Inline-Script traegt den Nonce
|
||||
dieses Requests. Styles brauchen ``unsafe-inline``, weil Jinja an einzelnen
|
||||
Stellen berechnete Breiten (Fortschrittsbalken) als ``style``-Attribut
|
||||
setzt; Style-Attribute sind kein Skriptausfuehrungsvektor.
|
||||
"""
|
||||
return "; ".join(
|
||||
[
|
||||
"default-src 'self'",
|
||||
f"script-src 'self' 'nonce-{nonce}'",
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"img-src 'self' data:",
|
||||
"font-src 'self'",
|
||||
"connect-src 'self'",
|
||||
"form-action 'self'",
|
||||
"frame-ancestors 'none'",
|
||||
"base-uri 'none'",
|
||||
"object-src 'none'",
|
||||
"worker-src 'self'",
|
||||
"manifest-src 'self'",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def apply_security_headers(response: Response, *, nonce: str, hsts: str = "") -> Response:
|
||||
response.headers.setdefault("Content-Security-Policy", content_security_policy(nonce))
|
||||
response.headers.setdefault("X-Content-Type-Options", "nosniff")
|
||||
response.headers.setdefault("X-Frame-Options", "DENY")
|
||||
response.headers.setdefault("Referrer-Policy", "same-origin")
|
||||
response.headers.setdefault(
|
||||
"Permissions-Policy",
|
||||
"camera=(), microphone=(), geolocation=(), payment=(), usb=(), interest-cohort=()",
|
||||
)
|
||||
response.headers.setdefault("Cross-Origin-Opener-Policy", "same-origin")
|
||||
response.headers.setdefault("Cross-Origin-Resource-Policy", "same-origin")
|
||||
response.headers.setdefault("X-Permitted-Cross-Domain-Policies", "none")
|
||||
if hsts:
|
||||
response.headers.setdefault("Strict-Transport-Security", hsts)
|
||||
return response
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user