Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eaca6f7990 | ||
|
|
4eb1c0c2d8 | ||
|
|
c0a6dd057c | ||
|
|
8c2cd10ee4 | ||
|
|
2c3ca4acc1 | ||
|
|
f16ba7d875 | ||
|
|
7bfe328e4b | ||
|
|
5b9373bb24 | ||
|
|
f77c8b221c | ||
|
|
985deb7a24 | ||
|
|
258e90bf4f | ||
|
|
337a52cc38 | ||
|
|
160675e368 | ||
|
|
3bbddd7d29 | ||
|
|
7407f4bc00 | ||
|
|
635722089e | ||
|
|
6dff1ed137 | ||
|
|
b4529a0e31 | ||
|
|
313eb4bbf9 | ||
|
|
51ae39dd53 | ||
|
|
a8bd5763fb | ||
|
|
d6ba98782e | ||
|
|
47ab97a0ac | ||
|
|
fbbf8eb87b | ||
|
|
9a760274f8 | ||
|
|
cf16809ee2 | ||
|
|
d8368fc200 | ||
|
|
e343aee026 | ||
|
|
4065b3586b | ||
|
|
5bb0809009 |
No files matched your search
@@ -22,3 +22,9 @@ dist/
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
.mypy_cache/
|
||||
|
||||
# Release-Pakete entstehen aus einem Tag und haengen am Release -- sie gehoeren
|
||||
# nicht ins Repository. Einmal ist genau das passiert: der Tarball lag im
|
||||
# Arbeitsbaum, wanderte in den Commit, und das naechste Paket enthielt das
|
||||
# vorige. Doppelte Groesse, verschachtelter Inhalt.
|
||||
*.tar.gz
|
||||
@@ -1,12 +1,12 @@
|
||||
# TESM
|
||||
|
||||
Verwaltung von PoE-Geraeten und Aruba-Switchen: Inventar, Zustandsueberwachung,
|
||||
Portsteuerung, Neustart (PoE, SSH, RPC), DHCP ueber Kea, Dateifreigaben,
|
||||
Wartung ueber SSH, Protokolle, Papierkorb, verschluesselte Ein- und Ausfuhr und
|
||||
Verwaltung von PoE-Geräten und Aruba-Switchen: Inventar, Zustandsüberwachung,
|
||||
Portsteuerung, Neustart (PoE, SSH, RPC), DHCP über Kea, Dateifreigaben,
|
||||
Wartung über SSH, Protokolle, Papierkorb, verschlüsselte Ein- und Ausfuhr und
|
||||
ein SSH-Terminal im Browser.
|
||||
|
||||
**Dieses Repository enthaelt genau das, was zum Installieren und Betreiben von
|
||||
TESM gebraucht wird** -- nichts darueber hinaus:
|
||||
**Dieses Repository enthält genau das, was zum Installieren und Betreiben von
|
||||
TESM gebraucht wird** -- nichts darüber hinaus:
|
||||
|
||||
```
|
||||
apps/tesm die Anwendung
|
||||
@@ -16,14 +16,15 @@ deploy/ install.sh, update.sh, release.sh, systemd, privileg
|
||||
docs/ Betrieb und Sicherheit
|
||||
```
|
||||
|
||||
Die Serverhaelfte des Lizenzprotokolls (`tesm-licensing-server`: ausstellen,
|
||||
Die Serverhälfte des Lizenzprotokolls (`tesm-licensing-server`: ausstellen,
|
||||
erneuern, Antworten signieren) liegt **nicht** hier. TESM verifiziert Lizenzen
|
||||
und stellt Anfragen; den Code zum Ausstellen soll ein Client nicht einmal
|
||||
mitbringen.
|
||||
|
||||
| Wo sonst noch | |
|
||||
|---|---|
|
||||
| [alientim/tesm-license](https://gitea.int.eertmoed.net/alientim/tesm-license) | der Lizenzserver |
|
||||
| [alientim/tesm-license](https://gitea.int.eertmoed.net/alientim/tesm-license) | der Lizenzserver (Sub-Instanzen) |
|
||||
| [alientim/tesm-license-master](https://gitea.int.eertmoed.net/alientim/tesm-license-master) | Lizenzserver, Master-Ausgabe (privat) |
|
||||
| [alientim/TESM-DEV](https://gitea.int.eertmoed.net/alientim/TESM-DEV) | Entwicklung: beide Anwendungen, Tests, Architekturdoku |
|
||||
|
||||
## Zweige
|
||||
@@ -31,21 +32,29 @@ mitbringen.
|
||||
| Zweig | Inhalt |
|
||||
|---|---|
|
||||
| `main` | dieser Neubau (Version 2.0.0) |
|
||||
| `SONNET5` | der Stand davor, unveraendert aufbewahrt |
|
||||
| `SONNET5` | der Stand davor, unverändert aufbewahrt |
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
sudo bash deploy/install.sh --app tesm --https
|
||||
sudo bash install.sh --app tesm --https
|
||||
sudo -u tesm tesm-admin create-admin
|
||||
```
|
||||
|
||||
Danach `https://<host>/`. Ohne `--domain` entsteht ein selbstsigniertes
|
||||
Zertifikat; der Browser warnt einmal, die Verbindung ist verschluesselt.
|
||||
`install.sh` liegt oben im Paket und genügt auch **allein**: ohne Paket
|
||||
drumherum lädt es sich das aktuelle Release selbst.
|
||||
|
||||
Mit oeffentlichem Namen und Let's Encrypt stattdessen:
|
||||
```bash
|
||||
curl -fsSLO https://gitea.int.eertmoed.net/alientim/tesm/raw/branch/main/install.sh
|
||||
sudo bash install.sh --app tesm --https
|
||||
```
|
||||
|
||||
Danach `https://<host>/`. Ohne `--domain` entsteht ein selbstsigniertes
|
||||
Zertifikat; der Browser warnt einmal, die Verbindung ist verschlüsselt.
|
||||
|
||||
Mit öffentlichem Namen und Let's Encrypt stattdessen:
|
||||
|
||||
```bash
|
||||
sudo bash deploy/install.sh --app tesm --https --domain tesm.firma.de --acme-email it@firma.de
|
||||
@@ -54,16 +63,16 @@ sudo bash deploy/install.sh --app tesm --https --domain tesm.firma.de --acme-ema
|
||||
Zielzustand: **Port 443 mit TLS, Port 80 leitet dorthin um** -- ausser dem
|
||||
ACME-Pfad, der erreichbar bleiben muss, sonst kann Let's Encrypt nie erneuern.
|
||||
|
||||
Sitzt ein eigener Reverse Proxy davor, der TLS beendet, laeuft TESM ohne
|
||||
Sitzt ein eigener Reverse Proxy davor, der TLS beendet, läuft TESM ohne
|
||||
`--https` schlicht auf Port 80.
|
||||
|
||||
Eine zweite Installation zum Testen stoert die laufende nicht:
|
||||
Eine zweite Installation zum Testen stört die laufende nicht:
|
||||
|
||||
```bash
|
||||
sudo bash deploy/install.sh --app tesm --instance test --port 5100 --http-port 8080
|
||||
```
|
||||
|
||||
Jede Instanz bekommt eigene Datenbank, eigene Schluessel, eigene nginx-Site und
|
||||
Jede Instanz bekommt eigene Datenbank, eigene Schlüssel, eigene nginx-Site und
|
||||
ein eigenes Sitzungscookie.
|
||||
|
||||
### Aktualisieren
|
||||
@@ -72,9 +81,9 @@ ein eigenes Sitzungscookie.
|
||||
sudo bash deploy/update.sh --app tesm
|
||||
```
|
||||
|
||||
Das laedt ein Release, entpackt es und ruft dessen `install.sh` auf. Vor jedem
|
||||
Update entsteht ein vollstaendiges Backup -- unabhaengig davon, ob sich das
|
||||
Schema geaendert hat -- und danach laeuft ein Health-Check mit automatischem
|
||||
Das lädt ein Release, entpackt es und ruft dessen `install.sh` auf. Vor jedem
|
||||
Update entsteht ein vollständiges Backup -- unabhängig davon, ob sich das
|
||||
Schema geändert hat -- und danach läuft ein Health-Check mit automatischem
|
||||
Rollback.
|
||||
|
||||
---
|
||||
@@ -83,15 +92,15 @@ Rollback.
|
||||
|
||||
```bash
|
||||
sudo -u tesm tesm-admin status # Zustand der Installation
|
||||
sudo -u tesm tesm-admin create-admin # Konto anlegen oder zuruecksetzen
|
||||
sudo -u tesm tesm-admin check-now # Ueberwachungsdurchlauf sofort
|
||||
sudo -u tesm tesm-admin housekeeping # Sitzungen und Protokolle aufraeumen
|
||||
sudo -u tesm tesm-admin create-admin # Konto anlegen oder zurücksetzen
|
||||
sudo -u tesm tesm-admin check-now # Überwachungsdurchlauf sofort
|
||||
sudo -u tesm tesm-admin housekeeping # Sitzungen und Protokolle aufräumen
|
||||
sudo -u tesm tesm-admin web-setup --https # nginx und TLS einrichten
|
||||
```
|
||||
|
||||
### Umzug aus dem alten TESM
|
||||
|
||||
Ein Export des Vorgaengers laesst sich uebersetzen und dann ueber den normalen
|
||||
Ein Export des Vorgängers lässt sich übersetzen und dann über den normalen
|
||||
Import einlesen -- Clients, Switche, Zugangsdaten und die DHCP-Konfiguration:
|
||||
|
||||
```bash
|
||||
@@ -100,11 +109,11 @@ sudo -u tesm tesm-admin convert-legacy tesm_export_20260813_195455.json --dry-ru
|
||||
|
||||
`--dry-run` zeigt nur den Bericht. Ohne die Option entsteht eine Datei im
|
||||
neuen Format, die unter *Verwaltung -> Sicherung* eingelesen wird. Der Bericht
|
||||
nennt ausdruecklich, was **nicht** uebersetzt werden kann (lokale Konten,
|
||||
nennt ausdrücklich, was **nicht** übersetzt werden kann (lokale Konten,
|
||||
Rechte, Verzeichnisdienst, Webserver) -- das ist von Hand nachzuziehen.
|
||||
|
||||
Die Passphrase gehoert an die verdeckte Eingabeaufforderung. `--passphrase`
|
||||
existiert nur fuer Automatisierung: es schreibt sie in Shell-Historie und
|
||||
Die Passphrase gehört an die verdeckte Eingabeaufforderung. `--passphrase`
|
||||
existiert nur für Automatisierung: es schreibt sie in Shell-Historie und
|
||||
Prozessliste.
|
||||
|
||||
---
|
||||
@@ -135,20 +144,36 @@ Entwicklungsrepository, ebenso die Tests.
|
||||
|
||||
## Was diese Fassung anders macht
|
||||
|
||||
Ein Neubau. Die Fehler des Vorgaengers sind bekannt; an den Stellen, an denen
|
||||
sie entstanden sind, steht jetzt ein Kommentar, der erklaert, warum es hier
|
||||
anders geloest ist. Die wichtigsten:
|
||||
Ein Neubau. Die Fehler des Vorgängers sind bekannt; an den Stellen, an denen
|
||||
sie entstanden sind, steht jetzt ein Kommentar, der erklärt, warum es hier
|
||||
anders gelöst ist. Die wichtigsten:
|
||||
|
||||
* **Kein root fuer die Anwendung.** Ein einziger allowlisted Helfer mit
|
||||
Argumentpruefung statt eines Webprozesses als `root`.
|
||||
* **CSRF-Schutz ueberhaupt.** Der Vorgaenger hatte keinen.
|
||||
* **Rechte werden serverseitig geprueft**, nicht nur im Template ausgeblendet.
|
||||
* **Kein root für die Anwendung.** Ein einziger allowlisted Helfer mit
|
||||
Argumentprüfung statt eines Webprozesses als `root`.
|
||||
* **CSRF-Schutz überhaupt.** Der Vorgänger hatte keinen.
|
||||
* **Rechte werden serverseitig geprüft**, nicht nur im Template ausgeblendet.
|
||||
* **Die nginx-Site wird bei jedem Lauf inhaltlich verglichen und erneuert.** Ein
|
||||
falscher `alias`-Pfad ueberlebte im Vorgaenger jahrelang jedes Update.
|
||||
* **Jede erzeugte Konfiguration wird vor dem Uebernehmen geprueft** -- nginx wie
|
||||
Kea. Eine fehlerhafte Datei faellt beim Schreiben auf, nicht erst beim
|
||||
naechsten Start des Dienstes.
|
||||
falscher `alias`-Pfad überlebte im Vorgänger jahrelang jedes Update.
|
||||
* **Jede erzeugte Konfiguration wird vor dem Übernehmen geprüft** -- nginx wie
|
||||
Kea. Eine fehlerhafte Datei fällt beim Schreiben auf, nicht erst beim
|
||||
nächsten Start des Dienstes.
|
||||
* **Kein Hostname im Lizenz-Fingerabdruck.** Umbenennen brach die Bindung.
|
||||
* **Zwei Installationen stoeren sich nicht** -- eigener `SITE_KEY` fuer
|
||||
* **Zwei Installationen stören sich nicht** -- eigener `SITE_KEY` für
|
||||
nginx-Site, netplan, logrotate, Zertifikate und Sitzungscookie.
|
||||
* **Verschachtelte Datenbankverbindungen sind ein Fehler**, kein Deadlock.
|
||||
|
||||
---
|
||||
|
||||
## Von einer Installation der Fassung 1.x
|
||||
|
||||
Eine einzelne Datei genügt -- sie lädt das Release selbst:
|
||||
|
||||
```bash
|
||||
curl -fsSLO https://gitea.int.eertmoed.net/alientim/tesm/raw/branch/main/deploy/upgrade.sh
|
||||
sudo bash upgrade.sh --app tesm
|
||||
```
|
||||
|
||||
Sichert die alte Installation, schaltet sie ab, installiert diese Fassung und
|
||||
übernimmt die Daten. Einzelheiten und was **nicht** mitkommt:
|
||||
[docs/BETRIEB.md](docs/BETRIEB.md).
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "tesm"
|
||||
version = "2.0.0"
|
||||
version = "2.3.2"
|
||||
description = "TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten und Aruba-Switchen"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -23,7 +23,20 @@ from .permissions import PERMISSIONS, ROLE_PRESETS
|
||||
from .schema import TESM_MIGRATIONS
|
||||
from .settings_spec import TESM_SETTINGS
|
||||
|
||||
__version__ = "2.0.0"
|
||||
#: Version aus den Metadaten des installierten Pakets -- die einzige Quelle
|
||||
#: ist ``pyproject.toml``. Vorher stand hier eine feste Zahl, und die
|
||||
#: Fusszeile zeigte vier Releases lang 2.0.2.
|
||||
#:
|
||||
#: Im Quellbaum ohne ``pip install`` gibt es keine Metadaten; dann steht dort
|
||||
#: "unbekannt" statt einer erfundenen Nummer. In einem editierbaren Install
|
||||
#: zeigt sie den Stand des letzten ``pip install -e`` -- also die Version, die
|
||||
#: tatsaechlich installiert ist.
|
||||
try: # pragma: no cover - haengt an der Installationsart
|
||||
from importlib.metadata import PackageNotFoundError, version as _paketversion
|
||||
|
||||
__version__ = _paketversion("tesm")
|
||||
except (ImportError, PackageNotFoundError): # pragma: no cover
|
||||
__version__ = "unbekannt"
|
||||
|
||||
APP_KEY = "tesm"
|
||||
APP_NAME = "TESM"
|
||||
@@ -32,10 +45,12 @@ APP_EDITION = "Geraeteverwaltung"
|
||||
#: Funktionszeile unter der Wortmarke auf der Anmeldeseite. Uebernommen vom
|
||||
#: Vorgaenger, wo sie Teil des Hintergrundmotivs war -- sie sagt in einer Zeile,
|
||||
#: was die Anwendung tut, und das gehoert auf die erste Seite.
|
||||
AUTH_TAGLINE = (
|
||||
"UEBERWACHEN · BOOTEN · ANBINDEN (DHCP) · "
|
||||
"BETRIEBSSYSTEM GEBEN (PXE)"
|
||||
)
|
||||
#: Zeile unter der Wortmarke auf den Anmeldeseiten. Kurz gehalten: sie ist
|
||||
#: eine Marke, keine Funktionsliste. "(PXE)" stand ausserdem fuer etwas, das
|
||||
#: es noch nicht gibt -- das Anmeldebanner hat es aus demselben Grund nie
|
||||
#: gezeigt. Wortgleich in ``deploy/tesm-motd``; ein Hygienetest haelt beides
|
||||
#: zusammen.
|
||||
AUTH_TAGLINE = "ÜBERWACHEN · BOOTEN · ANBINDEN"
|
||||
ENV_PREFIX = "TESM_"
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -181,7 +196,31 @@ def _status_pills(user: Any) -> list[dict[str, str]]:
|
||||
extension = core()
|
||||
pills: list[dict[str, str]] = []
|
||||
|
||||
counts = monitor.dashboard_counts(db())
|
||||
verbindung = db()
|
||||
|
||||
# Timer bis zum naechsten Durchlauf, mit Knopf fuer "sofort pruefen". Der
|
||||
# Knopf gehoert in die Pille, weil er genau die Angabe daneben zuruecksetzt.
|
||||
if user.has_any(("devices.view", "monitoring.view")):
|
||||
intervall = int(extension.settings.get(verbindung, "monitor_interval_seconds") or 300)
|
||||
faellig = monitor.next_check_at(verbindung, intervall)
|
||||
if faellig is not None:
|
||||
eintrag = {
|
||||
"label": "Naechste Pruefung in",
|
||||
"countdown_to": faellig.isoformat(),
|
||||
"countdown_done": "jetzt",
|
||||
"css_class": "",
|
||||
"dot": "1",
|
||||
"dot_class": "",
|
||||
"title": f"Der Ueberwachungsdienst prueft alle {intervall} Sekunden.",
|
||||
}
|
||||
if user.has("settings_system.execute"):
|
||||
from flask import url_for
|
||||
|
||||
eintrag["action"] = url_for("settings.run_check_now")
|
||||
eintrag["action_title"] = "Alle Clients sofort pruefen"
|
||||
pills.append(eintrag)
|
||||
|
||||
counts = monitor.dashboard_counts(verbindung)
|
||||
if counts["offline"]:
|
||||
pills.append(
|
||||
{
|
||||
@@ -275,18 +314,74 @@ def _startup_tasks(app: Flask) -> None:
|
||||
)
|
||||
return
|
||||
|
||||
from .services import maintenance
|
||||
from .services import fileshare, maintenance
|
||||
|
||||
with extension.database.session() as conn, extension.database.transaction(conn):
|
||||
orphaned = maintenance.mark_orphaned_jobs(conn)
|
||||
# Einbindungen leben im Mount-Namensraum dieses Prozesses und sind mit
|
||||
# dem vorherigen verschwunden. Die Zeilen dazu muessen weg, sonst zeigt
|
||||
# die Oberflaeche Freigaben, die es nicht gibt.
|
||||
vergessen = fileshare.forget_all_mounts(conn)
|
||||
if orphaned:
|
||||
logger.warning("%s unterbrochene Wartungsauftraege als unklar markiert", orphaned)
|
||||
if vergessen:
|
||||
logger.info(
|
||||
"%s Freigabe-Einbindungen eines vorherigen Prozesses vergessen "
|
||||
"-- sie bestehen mit dessen Namensraum nicht mehr",
|
||||
vergessen,
|
||||
)
|
||||
|
||||
if extension.license is not None:
|
||||
import socket
|
||||
|
||||
extension.license.start_heartbeat(hostname=socket.gethostname())
|
||||
|
||||
_start_fileshare_sweep(app)
|
||||
|
||||
|
||||
#: Wie oft nach abgelaufenen Einbindungen gesehen wird.
|
||||
FILESHARE_SWEEP_SECONDS = 300
|
||||
|
||||
|
||||
def _start_fileshare_sweep(app: Flask) -> None:
|
||||
"""Loest abgelaufene Freigabe-Einbindungen -- im Webprozess.
|
||||
|
||||
Muss hier laufen und nicht im Ueberwachungsdienst: eingebunden wird im
|
||||
Mount-Namensraum dieses Dienstes, und ein anderer Dienst sieht diese
|
||||
Einbindungen nicht einmal. Ein ``umount`` von dort waere ein Aufruf ins
|
||||
Leere -- die Datenbankzeile waere als geloest markiert, die Freigabe aber
|
||||
weiter eingebunden.
|
||||
"""
|
||||
import threading
|
||||
import time
|
||||
|
||||
from .services import fileshare
|
||||
|
||||
def einmal() -> None:
|
||||
with app.app_context():
|
||||
geloest = fileshare.sweep_expired(core(app).database)
|
||||
if geloest:
|
||||
logger.info("%s abgelaufene Freigabe-Einbindungen geloest", geloest)
|
||||
|
||||
def schleife() -> None:
|
||||
# Zwei Netze: das innere haelt den Faden am Leben, wenn ein einzelner
|
||||
# Durchlauf scheitert; das aeussere sorgt dafuer, dass sein Ende
|
||||
# ueberhaupt auffaellt. Vorher fehlte der Import von "time" -- der
|
||||
# NameError kam beim ersten sleep, also vor jedem try, und der Faden
|
||||
# war weg, ohne eine Zeile zu hinterlassen.
|
||||
try:
|
||||
while True:
|
||||
time.sleep(FILESHARE_SWEEP_SECONDS)
|
||||
try:
|
||||
einmal()
|
||||
except Exception: # noqa: BLE001 - der Webprozess darf nie sterben
|
||||
logger.exception("Aufraeumen der Dateifreigaben fehlgeschlagen")
|
||||
except BaseException: # noqa: BLE001 - auch ein Abbruch gehoert protokolliert
|
||||
logger.exception("Der Aufraeumfaden der Dateifreigaben ist beendet")
|
||||
raise
|
||||
|
||||
threading.Thread(target=schleife, name="fileshare-sweep", daemon=True).start()
|
||||
|
||||
|
||||
# Fuer ``gunicorn tesm:app``
|
||||
def _lazy_app() -> Flask: # pragma: no cover - nur vom WSGI-Server benutzt
|
||||
|
||||
@@ -15,6 +15,7 @@ from __future__ import annotations
|
||||
from typing import Any
|
||||
|
||||
from flask import Blueprint, abort, render_template, request
|
||||
from tesm_core.security.sessions import fragment
|
||||
from tesm_core.extension import core, db
|
||||
|
||||
from ..services import monitor
|
||||
@@ -138,6 +139,7 @@ def index_view():
|
||||
|
||||
|
||||
@bp.get("/uebersicht/kacheln")
|
||||
@fragment
|
||||
def tiles():
|
||||
"""Teilbereich fuer die automatische Aktualisierung -- liefert nur HTML-Fragment."""
|
||||
from tesm_core.auth.models import current_user
|
||||
|
||||
@@ -298,6 +298,12 @@ def _restart_as_job(extension: Any, conn: Any, device: Any, method: Any): # typ
|
||||
severity="notice",
|
||||
)
|
||||
|
||||
# Der Auftrag laeuft im Hintergrund und meldet sein Ergebnis erst, wenn das
|
||||
# Geraet zurueck ist. Der Zustand muss aber sofort umschlagen -- sonst sieht
|
||||
# die Uebersicht aus wie eine, in der nichts passiert ist.
|
||||
with transaction():
|
||||
monitor.mark_restarting(conn, int(device["id"]))
|
||||
|
||||
maintenance_service.start_background(
|
||||
maintenance_service.run_reboot,
|
||||
database=extension.database,
|
||||
@@ -307,6 +313,10 @@ def _restart_as_job(extension: Any, conn: Any, device: Any, method: Any): # typ
|
||||
password=password,
|
||||
wait_seconds=int(extension.settings.get(conn, "maintenance_reboot_wait") or 300),
|
||||
actor=current_user.username,
|
||||
# Der Weg ist oben schon entschieden (restart.resolve). Ihn hier
|
||||
# mitzugeben ist der Unterschied zwischen "Windows-Geraet ueber RPC" und
|
||||
# "Windows-Geraet ueber SSH auf Port 22, bis das Zeitlimit greift".
|
||||
method=method.key,
|
||||
)
|
||||
flash(
|
||||
f"{method.label} fuer {device['name']} gestartet. Der Verlauf steht unter Wartung.",
|
||||
|
||||
@@ -18,6 +18,7 @@ from flask import (
|
||||
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 import uploads
|
||||
from tesm_core.extension import core, db, transaction
|
||||
|
||||
from ..services import fileshare as service
|
||||
@@ -75,7 +76,12 @@ def browse():
|
||||
token=token,
|
||||
listing=listing,
|
||||
error=error,
|
||||
max_upload_mb=int(core().settings.get(conn, "fileshare_upload_max_mb") or 512),
|
||||
# Dieselbe Zahl, die auch die Anfrage begrenzt und in nginx steht --
|
||||
# vorher versprach diese Seite 512 MB, waehrend bei 64 MB abgebrochen
|
||||
# wurde.
|
||||
max_upload_mb=uploads.limit_mb(conn),
|
||||
# Vorschlagsliste fuer "Ausgefuellt speichern": wohin darf es gehen?
|
||||
verzeichnisse=(service.all_directories(mount) if listing else []),
|
||||
)
|
||||
|
||||
|
||||
@@ -95,7 +101,12 @@ def download(token: str):
|
||||
path,
|
||||
as_attachment=True,
|
||||
download_name=path.name,
|
||||
mimetype=service.download_mime(path.name),
|
||||
# Bewusst der neutrale Typ und nicht der erkannte: iOS Safari
|
||||
# entscheidet nach dem MIME-Typ und zeigt eine "application/pdf" im
|
||||
# Browser an, auch wenn sie als Anhang ausgeliefert wird. Wer hier
|
||||
# klickt, will die Datei aber ablegen -- angesehen wird sie in der
|
||||
# Vorschau. Fuer alle anderen Browser aendert sich damit nichts.
|
||||
mimetype="application/octet-stream",
|
||||
)
|
||||
|
||||
|
||||
@@ -119,6 +130,21 @@ def preview(token: str):
|
||||
headers={
|
||||
"Content-Disposition": f'inline; filename="{path.name}"',
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
# Die Vorschau laeuft in einem Rahmen auf der Freigabeseite. Die
|
||||
# anwendungsweite CSP verbietet mit "frame-ancestors 'none'" jedes
|
||||
# Einrahmen -- auch das durch die eigene Seite. Beides wird hier
|
||||
# deshalb ausdruecklich auf "gleicher Ursprung" gesetzt; die Header
|
||||
# der Anwendung werden per setdefault gesetzt und treten damit
|
||||
# zurueck.
|
||||
#
|
||||
# Bewusst nur die Rahmen-Direktiven und nicht die ganze strenge CSP:
|
||||
# was hier ausgeliefert wird, ist eine Datei mit engem, erlaubtem
|
||||
# MIME-Typ (kein HTML, kein SVG) und "nosniff" -- Skript kann darin
|
||||
# nicht ausgefuehrt werden. Eine vollstaendige CSP auf einer
|
||||
# PDF-Antwort riskiert dagegen, die eingebaute Anzeige des Browsers
|
||||
# zu blockieren.
|
||||
"Content-Security-Policy": "frame-ancestors 'self'",
|
||||
"X-Frame-Options": "SAMEORIGIN",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -133,7 +159,7 @@ def upload(token: str):
|
||||
flash("Keine Datei ausgewaehlt.", "danger")
|
||||
return redirect(url_for("fileshare.browse", freigabe=token, pfad=relative))
|
||||
|
||||
limit = int(core().settings.get(db(), "fileshare_upload_max_mb") or 512) * 1024 * 1024
|
||||
limit = uploads.limit_bytes(db())
|
||||
stored = 0
|
||||
try:
|
||||
target_dir = service.resolve_path(mount, relative)
|
||||
@@ -157,6 +183,71 @@ def upload(token: str):
|
||||
return redirect(url_for("fileshare.browse", freigabe=token, pfad=relative))
|
||||
|
||||
|
||||
@bp.post("/<token>/formular")
|
||||
@permission_required("fileshare.create")
|
||||
def save_form(token: str):
|
||||
"""Nimmt ein in der Vorschau ausgefuelltes PDF und legt es daneben ab.
|
||||
|
||||
Als **neue** Datei: auf einer Freigabe liegt in der Regel die leere
|
||||
Vorlage, und die soll leer bleiben. Wer das Original ersetzen will, kann
|
||||
die alte Datei danach loeschen -- das ist eine bewusste Handlung.
|
||||
"""
|
||||
mount = _mount_or_404(token)
|
||||
relative = request.form.get("pfad") or ""
|
||||
upload_file = request.files.get("datei")
|
||||
if upload_file is None or not upload_file.filename:
|
||||
return {"error": "Es kam keine Datei an."}, 400
|
||||
|
||||
grenze = uploads.limit_bytes(db())
|
||||
daten = upload_file.stream.read(grenze + 1)
|
||||
if len(daten) > grenze:
|
||||
return {"error": f"Zu gross (Grenze {grenze // 1024 // 1024} MB)."}, 413
|
||||
if not daten.startswith(b"%PDF-"):
|
||||
# Diese Route nimmt nur, was pdf.js hier ablegt. Ein anderer Inhalt
|
||||
# waere ein Umweg, um beliebige Dateien in die Freigabe zu bekommen.
|
||||
return {"error": "Das ist keine PDF-Datei."}, 415
|
||||
|
||||
# Wohin und unter welchem Namen -- beides darf der Benutzer bestimmen.
|
||||
ordner = (request.form.get("ordner") or "").strip().strip("/")
|
||||
wunschname = (request.form.get("name") or "").strip()
|
||||
ersetzen = request.form.get("ersetzen") == "1"
|
||||
|
||||
try:
|
||||
original = service.resolve_path(mount, relative)
|
||||
# Mit Namen entscheidet das Ordnerfeld -- leer heisst Wurzelverzeichnis,
|
||||
# so wie es im Feld steht. Ohne Namen (Aufruf ohne Oberflaeche) bleibt
|
||||
# es beim Ordner des Originals.
|
||||
if wunschname:
|
||||
zielordner = service.resolve_path(mount, ordner)
|
||||
else:
|
||||
zielordner = service.resolve_path(mount, ordner) if ordner else original.parent
|
||||
if not zielordner.is_dir():
|
||||
return {"error": f"Den Ordner '{ordner}' gibt es in dieser Freigabe nicht."}, 400
|
||||
if wunschname:
|
||||
# Eingegeben wird der Name ohne Endung; die kommt von hier. Wer
|
||||
# sie mittippt, bekommt sie nicht doppelt -- und wer eine falsche
|
||||
# tippt, bekommt trotzdem eine Datei, die sich oeffnen laesst.
|
||||
name = service.safe_filename(wunschname)
|
||||
if not name.lower().endswith(".pdf"):
|
||||
name += ".pdf"
|
||||
ziel = zielordner / name
|
||||
if ziel.exists() and not ersetzen:
|
||||
return {
|
||||
"error": f"'{name}' gibt es dort schon. Anderen Namen waehlen oder "
|
||||
"'Vorhandene ersetzen' ankreuzen."
|
||||
}, 409
|
||||
else:
|
||||
# Ohne Angabe wie bisher: neben das Original, ohne etwas zu ueberschreiben.
|
||||
ziel = service.free_name(original.parent, original.stem, "-ausgefuellt", ".pdf")
|
||||
ziel.write_bytes(daten)
|
||||
except service.FileshareError as exc:
|
||||
return {"error": str(exc)}, 400
|
||||
|
||||
wohin = ziel.relative_to(mount.path.resolve()).as_posix()
|
||||
_audit("fileshare.form_saved", f"{mount.label}:{relative}", wohin, severity="notice")
|
||||
return {"message": f"Gespeichert als {wohin}.", "pfad": wohin}
|
||||
|
||||
|
||||
@bp.post("/<token>/ordner")
|
||||
@permission_required("fileshare.create")
|
||||
def make_directory(token: str):
|
||||
@@ -255,6 +346,26 @@ def mapping_add():
|
||||
return redirect(url_for("fileshare.mappings"))
|
||||
|
||||
|
||||
@bp.post("/zuordnungen/<int:mapping_id>")
|
||||
@permission_required("fileshare.edit")
|
||||
def mapping_update(mapping_id: int):
|
||||
try:
|
||||
with transaction() as conn:
|
||||
service.update_mapping(
|
||||
conn,
|
||||
mapping_id,
|
||||
ad_group_dn=request.form.get("ad_group_dn", ""),
|
||||
ad_group_name=request.form.get("ad_group_name", ""),
|
||||
label=request.form.get("label", ""),
|
||||
unc=request.form.get("unc", ""),
|
||||
)
|
||||
_audit("fileshare.mapping_updated", request.form.get("label", ""), severity="notice")
|
||||
flash("Zuordnung geaendert.", "success")
|
||||
except service.FileshareError as exc:
|
||||
flash(str(exc), "danger")
|
||||
return redirect(url_for("fileshare.mappings"))
|
||||
|
||||
|
||||
@bp.post("/zuordnungen/<int:mapping_id>/loeschen")
|
||||
@permission_required("fileshare.edit")
|
||||
def mapping_delete(mapping_id: int):
|
||||
|
||||
@@ -16,13 +16,14 @@ geratene Kategorie schickt ein ``reboot`` an das falsche Geraet).
|
||||
from __future__ import annotations
|
||||
|
||||
from flask import Blueprint, abort, flash, jsonify, redirect, render_template, request, url_for
|
||||
from tesm_core.security.sessions import fragment
|
||||
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.db import Database
|
||||
from tesm_core.extension import core, db, transaction
|
||||
|
||||
from ..services import inventory, maintenance as service, ssh, windowsops
|
||||
from ..services import inventory, maintenance as service, ssh, windowsops, monitor
|
||||
|
||||
bp = Blueprint("maintenance", __name__, url_prefix="/wartung")
|
||||
|
||||
@@ -176,6 +177,7 @@ def job_detail(job_id: str):
|
||||
|
||||
@bp.get("/auftrag/<job_id>/ausgabe")
|
||||
@permission_required("maintenance.view", "maintenance.execute", mode="any")
|
||||
@fragment
|
||||
def job_output(job_id: str):
|
||||
conn = db()
|
||||
job = service.get_job(conn, job_id)
|
||||
@@ -246,6 +248,9 @@ def _start(kind: str) -> None:
|
||||
timeout=int(extension.settings.get(conn, "maintenance_command_timeout") or 900),
|
||||
)
|
||||
else:
|
||||
# Sofort sichtbar, nicht erst wenn das Geraet zurueck ist.
|
||||
with transaction() as verbindung:
|
||||
monitor.mark_restarting(verbindung, int(device["id"]))
|
||||
service.start_background(
|
||||
service.run_reboot,
|
||||
database=extension.database,
|
||||
@@ -255,6 +260,15 @@ def _start(kind: str) -> None:
|
||||
password=password,
|
||||
wait_seconds=int(extension.settings.get(conn, "maintenance_reboot_wait") or 300),
|
||||
actor=current_user.username,
|
||||
# Hier steht in ``category`` die Kategorie der *Zugangsdaten*
|
||||
# (siehe _query_devices) -- daraus folgt der Weg. Uebergeben
|
||||
# statt in run_reboot erraten: dort bedeutet derselbe
|
||||
# Schluessel je Herkunft etwas anderes.
|
||||
method=(
|
||||
service.METHOD_RPC
|
||||
if str(device["category"]) == inventory.NON_SSH_CATEGORY
|
||||
else service.METHOD_SSH
|
||||
),
|
||||
)
|
||||
started += 1
|
||||
|
||||
|
||||
@@ -54,7 +54,6 @@ def overview():
|
||||
"tesm/switches.html",
|
||||
switches=inventory.list_switches(conn),
|
||||
credentials=inventory.list_credentials(conn),
|
||||
host_keys=Database.all(conn, "SELECT * FROM switch_host_keys ORDER BY host"),
|
||||
terminal_enabled=bool(core().settings.get(conn, "terminal_enabled")),
|
||||
)
|
||||
|
||||
|
||||
@@ -243,6 +243,131 @@ def cmd_convert_legacy(args: argparse.Namespace) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
def cmd_legacy_export(args: argparse.Namespace) -> int:
|
||||
"""Liest eine alte Installation und schreibt deren Daten als Exportdatei.
|
||||
|
||||
Der Vorgaenger kann nur ueber die Weboberflaeche exportieren: anmelden,
|
||||
Kategorien anhaken, Datei herunterladen. Fuer ein Upgrade ist das der
|
||||
falsche Weg -- es soll ohne Browser laufen und auch dann noch, wenn die
|
||||
alte Anwendung nicht mehr startet. Gelesen wird ausschliesslich; die alte
|
||||
Installation bleibt unberuehrt.
|
||||
|
||||
Herauskommt eine Datei im **alten** Format. Damit ist sie genau das, was
|
||||
der Vorgaenger selbst exportiert haette -- und taugt deshalb auch als
|
||||
Rueckweg.
|
||||
"""
|
||||
from getpass import getpass
|
||||
from pathlib import Path
|
||||
|
||||
from .services import legacy_transfer as legacy
|
||||
|
||||
quelle = Path(args.source)
|
||||
ziel = Path(args.out)
|
||||
if ziel.exists() and not args.force:
|
||||
print(f"{ziel} existiert bereits. Mit --force ueberschreiben.")
|
||||
return 2
|
||||
|
||||
passphrase = args.passphrase or getpass("Passphrase fuer die Exportdatei: ")
|
||||
try:
|
||||
nutzlast, bericht = legacy.read_installation(quelle)
|
||||
inhalt = legacy.seal_legacy(nutzlast, passphrase)
|
||||
except legacy.LegacyTransferError as exc:
|
||||
print(f"Fehlgeschlagen: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print(bericht.as_text("Gelesen"))
|
||||
if args.dry_run:
|
||||
print()
|
||||
print("--dry-run: keine Datei geschrieben.")
|
||||
return 0
|
||||
try:
|
||||
ziel.write_text(inhalt, encoding="utf-8", newline="\n")
|
||||
except OSError as exc:
|
||||
print(f"Schreiben fehlgeschlagen: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
print()
|
||||
print(f"Geschrieben: {ziel}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_legacy_import(args: argparse.Namespace) -> int:
|
||||
"""Liest eine Datei im neuen Format ein -- derselbe Weg wie in der Oberflaeche.
|
||||
|
||||
Gebraucht wird das fuer das Upgrade: dort laeuft niemand durch einen
|
||||
Browser. Geschrieben wird ueber ``transfer.apply_import`` -- dieselbe
|
||||
Funktion, die die Seite "Sicherung" benutzt. Ein zweiter Schreibpfad fuer
|
||||
Fremddaten waere die Stelle, die niemand pflegt.
|
||||
"""
|
||||
from getpass import getpass
|
||||
from pathlib import Path
|
||||
|
||||
from .services import transfer
|
||||
|
||||
quelle = Path(args.datei)
|
||||
if not quelle.is_file():
|
||||
print(f"Datei nicht gefunden: {quelle}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
app, extension = _app_and_core()
|
||||
passphrase = args.passphrase or getpass("Passphrase der Datei: ")
|
||||
try:
|
||||
nutzlast, kopf = transfer.unseal(quelle.read_bytes(), passphrase)
|
||||
except transfer.TransferError as exc:
|
||||
print(f"Fehlgeschlagen: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
vorhanden = [eintrag["key"] for eintrag in transfer.summarize(nutzlast)]
|
||||
gewaehlt = (
|
||||
[key for key in args.categories.split(",") if key.strip()]
|
||||
if args.categories
|
||||
else vorhanden
|
||||
)
|
||||
unbekannt = sorted(set(gewaehlt) - set(transfer.CATEGORY_BY_KEY))
|
||||
if unbekannt:
|
||||
print(f"Unbekannte Kategorie(n): {', '.join(unbekannt)}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
print(f"Datei vom {kopf.get('created_at') or 'unbekannt'}:")
|
||||
for eintrag in transfer.summarize(nutzlast):
|
||||
marke = "x" if eintrag["key"] in gewaehlt else " "
|
||||
print(f" [{marke}] {eintrag['label']}: {eintrag['count']}")
|
||||
if args.dry_run:
|
||||
print()
|
||||
print("--dry-run: nichts geschrieben.")
|
||||
return 0
|
||||
|
||||
with app.app_context(), extension.database.session() as conn:
|
||||
with extension.database.transaction(conn):
|
||||
bericht = transfer.apply_import(
|
||||
conn,
|
||||
extension.keystore,
|
||||
nutzlast,
|
||||
categories=gewaehlt,
|
||||
set_setting=lambda key, value: extension.settings.set(
|
||||
conn, key, value, actor=args.actor
|
||||
),
|
||||
)
|
||||
audit.log(
|
||||
conn,
|
||||
actor=args.actor,
|
||||
action="backup.imported",
|
||||
detail="; ".join(
|
||||
f"{key}: {zahlen['created']} neu, {zahlen['updated']} aktualisiert, "
|
||||
f"{zahlen['skipped']} uebersprungen"
|
||||
for key, zahlen in bericht.items()
|
||||
)
|
||||
+ " (Kommandozeile)",
|
||||
severity="warning",
|
||||
)
|
||||
print()
|
||||
for key, zahlen in sorted(bericht.items()):
|
||||
print(
|
||||
f" {key}: {zahlen['created']} neu, {zahlen['updated']} aktualisiert, "
|
||||
f"{zahlen['skipped']} uebersprungen"
|
||||
)
|
||||
return 0
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="tesm-admin", description="Verwaltungswerkzeuge fuer TESM"
|
||||
@@ -288,6 +413,46 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
legacy.add_argument("--force", action="store_true", help="Zieldatei ueberschreiben")
|
||||
legacy.set_defaults(func=cmd_convert_legacy)
|
||||
|
||||
|
||||
export_alt = sub.add_parser(
|
||||
"legacy-export",
|
||||
help="Daten einer alten Installation als Exportdatei sichern",
|
||||
description=(
|
||||
"Liest die Datenbank einer Installation der Vorgaengerfassung und schreibt "
|
||||
"Clients, Switche, Zugangsdaten und DHCP in eine Datei im alten Format. "
|
||||
"Die alte Installation wird nur gelesen. Ohne Browser und auch dann noch "
|
||||
"moeglich, wenn die alte Anwendung nicht mehr startet."
|
||||
),
|
||||
)
|
||||
export_alt.add_argument(
|
||||
"--source", default="/srv/tesm", help="Verzeichnis der alten Installation"
|
||||
)
|
||||
export_alt.add_argument("--out", required=True, help="Zieldatei")
|
||||
export_alt.add_argument("--passphrase", help="Passphrase der Zieldatei (sonst wird gefragt)")
|
||||
export_alt.add_argument("--dry-run", action="store_true", help="Nur den Bericht zeigen")
|
||||
export_alt.add_argument("--force", action="store_true", help="Zieldatei ueberschreiben")
|
||||
export_alt.set_defaults(func=cmd_legacy_export)
|
||||
|
||||
import_neu = sub.add_parser(
|
||||
"legacy-import",
|
||||
help="Datei im Format dieser Anwendung einlesen",
|
||||
description=(
|
||||
"Liest eine Sicherungs- oder uebersetzte Datei ein -- derselbe Weg wie unter "
|
||||
"Verwaltung -> Sicherung, nur ohne Browser. Ohne --categories werden alle "
|
||||
"Kategorien uebernommen, die in der Datei stehen."
|
||||
),
|
||||
)
|
||||
import_neu.add_argument("datei", help="Datei im Format dieser Anwendung")
|
||||
import_neu.add_argument("--passphrase", help="Passphrase der Datei (sonst wird gefragt)")
|
||||
import_neu.add_argument(
|
||||
"--categories", help="Kommaliste, z.B. devices,switches,credentials,dhcp"
|
||||
)
|
||||
import_neu.add_argument(
|
||||
"--actor", default="upgrade", help="Name, der im Auditlog erscheint"
|
||||
)
|
||||
import_neu.add_argument("--dry-run", action="store_true", help="Nur zeigen, was drin steht")
|
||||
import_neu.set_defaults(func=cmd_legacy_import)
|
||||
|
||||
add_web_setup(sub, cmd_web_setup)
|
||||
|
||||
return parser
|
||||
|
||||
@@ -140,7 +140,7 @@ NAVIGATION = NavTree(
|
||||
),
|
||||
NavItem(
|
||||
key="logs_audit",
|
||||
label="Aenderungen",
|
||||
label="Audit",
|
||||
icon="history",
|
||||
endpoint="logs.audit_view",
|
||||
permissions=("logs_audit.view", "logs_audit.export"),
|
||||
|
||||
@@ -27,6 +27,7 @@ import os
|
||||
import re
|
||||
import secrets
|
||||
import shutil
|
||||
import socket
|
||||
import sqlite3
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
@@ -56,6 +57,10 @@ PREVIEW_MIME: dict[str, str] = {
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
".bmp": "image/bmp",
|
||||
# Das Format, in dem ein iPad fotografiert. Safari zeigt es an, andere
|
||||
# Browser nicht -- die Vorschau sagt es dann und bietet den Download an.
|
||||
".heic": "image/heic",
|
||||
".heif": "image/heif",
|
||||
".txt": "text/plain; charset=utf-8",
|
||||
".csv": "text/plain; charset=utf-8",
|
||||
".log": "text/plain; charset=utf-8",
|
||||
@@ -67,6 +72,21 @@ PREVIEW_MIME: dict[str, str] = {
|
||||
#: im Ursprung der Anwendung ausgefuehrt. Download bleibt moeglich.
|
||||
|
||||
|
||||
def preview_kind(name: str) -> str:
|
||||
"""Welche Anzeige passt: ``bild``, ``pdf`` oder ``text``?
|
||||
|
||||
Die Unterscheidung gehoert hierher und nicht in die Vorlage: die
|
||||
Zuordnung Endung -> MIME-Typ steht schon hier, und eine zweite Liste in
|
||||
Jinja liefe frueher oder spaeter auseinander.
|
||||
"""
|
||||
mime = PREVIEW_MIME.get(Path(name).suffix.lower(), "")
|
||||
if mime.startswith("image/"):
|
||||
return "bild"
|
||||
if mime == "application/pdf":
|
||||
return "pdf"
|
||||
return "text"
|
||||
|
||||
|
||||
class FileshareError(Exception):
|
||||
"""Fehler mit Klartextmeldung fuer die Oberflaeche."""
|
||||
|
||||
@@ -114,6 +134,73 @@ def validate_unc(value: str) -> str:
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _resolvable(host: str) -> bool:
|
||||
"""Loest dieser Name auf diesem Server auf?"""
|
||||
try:
|
||||
socket.getaddrinfo(host, None)
|
||||
except (socket.gaierror, UnicodeError, OSError):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def search_suffixes(conn: sqlite3.Connection) -> list[str]:
|
||||
"""Domaenen, mit denen ein kurzer Hostname vervollstaendigt werden kann.
|
||||
|
||||
Ein Windows-Client loest "S2025" ueber seine Suchdomaene auf. Dieser Server
|
||||
hat oft keine (``search .`` in resolv.conf), und dann scheitert die
|
||||
Einbindung mit "could not resolve address for S2025" -- obwohl der Name im
|
||||
Netz voellig richtig ist. Die AD-Domaene ist aber bekannt, also wird sie
|
||||
hier zum Vervollstaendigen benutzt.
|
||||
"""
|
||||
kandidaten: list[str] = []
|
||||
einstellungen = core().settings
|
||||
basis = str(einstellungen.get(conn, "ldap_base_dn") or "").strip()
|
||||
if basis:
|
||||
# DC=ad,DC=eertmoed,DC=net -> ad.eertmoed.net
|
||||
teile = [
|
||||
stueck.split("=", 1)[1]
|
||||
for stueck in basis.split(",")
|
||||
if stueck.strip().lower().startswith("dc=")
|
||||
]
|
||||
if teile:
|
||||
kandidaten.append(".".join(teile))
|
||||
rechner = str(einstellungen.get(conn, "ldap_host") or "").strip()
|
||||
if rechner and "." in rechner:
|
||||
kandidaten.append(rechner)
|
||||
# Auch die Elterndomaene: ist ldap_host ein DC-Name, liegt die Domaene
|
||||
# eine Ebene darueber.
|
||||
kandidaten.append(rechner.split(".", 1)[1])
|
||||
# Reihenfolge erhalten, Doppelte entfernen.
|
||||
gesehen: set[str] = set()
|
||||
return [k for k in kandidaten if k and not (k in gesehen or gesehen.add(k))]
|
||||
|
||||
|
||||
def resolve_unc_host(unc: str, suffixes: Iterable[str]) -> tuple[str, str]:
|
||||
"""Ersetzt den Hostnamen einer UNC durch einen aufloesbaren.
|
||||
|
||||
Rueckgabe: ``(unc, hinweis)``. Der Hinweis ist leer, wenn nichts geaendert
|
||||
wurde; laesst sich der Name gar nicht aufloesen, bleibt die UNC unveraendert
|
||||
und der Hinweis nennt das Problem.
|
||||
"""
|
||||
teile = unc.lstrip(chr(92)).split(chr(92), 1)
|
||||
if not teile or not teile[0]:
|
||||
return unc, ""
|
||||
host = teile[0]
|
||||
rest = teile[1] if len(teile) > 1 else ""
|
||||
if _resolvable(host):
|
||||
return unc, ""
|
||||
for suffix in suffixes:
|
||||
kandidat = f"{host}.{suffix.strip('.')}"
|
||||
if _resolvable(kandidat):
|
||||
neu = chr(92) * 2 + kandidat + (chr(92) + rest if rest else "")
|
||||
return neu, f"Name {host} ueber {kandidat} aufgeloest"
|
||||
return unc, (
|
||||
f"Der Name {host} ist auf diesem Server nicht aufloesbar. Bitte den "
|
||||
"vollqualifizierten Namen oder die IP-Adresse eintragen -- oder unter "
|
||||
"Netzwerk eine Suchdomaene setzen."
|
||||
)
|
||||
|
||||
|
||||
def list_mappings(conn: sqlite3.Connection) -> list[dict[str, Any]]:
|
||||
return Database.all(
|
||||
conn, "SELECT * FROM fileshare_mappings ORDER BY label COLLATE NOCASE"
|
||||
@@ -139,6 +226,52 @@ def add_mapping(
|
||||
return int(cursor.lastrowid or 0)
|
||||
|
||||
|
||||
def update_mapping(
|
||||
conn: sqlite3.Connection,
|
||||
mapping_id: int,
|
||||
*,
|
||||
ad_group_dn: str,
|
||||
ad_group_name: str,
|
||||
label: str,
|
||||
unc: str,
|
||||
) -> None:
|
||||
"""Aendert eine bestehende Zuordnung.
|
||||
|
||||
Dieselben Pruefungen wie beim Anlegen -- eine Zuordnung, die man nur neu
|
||||
anlegen statt korrigieren kann, verleitet dazu, den Tippfehler stehen zu
|
||||
lassen und daneben einen zweiten Eintrag zu machen.
|
||||
"""
|
||||
clean_unc = validate_unc(unc)
|
||||
clean_label = (label or "").strip()
|
||||
clean_dn = (ad_group_dn or "").strip()
|
||||
if not clean_label:
|
||||
raise FileshareError("Der Anzeigename darf nicht leer sein.")
|
||||
if not clean_dn:
|
||||
raise FileshareError("Es ist keine Verzeichnisgruppe ausgewaehlt.")
|
||||
|
||||
vorhanden = Database.one(
|
||||
conn, "SELECT id FROM fileshare_mappings WHERE id=?", (mapping_id,)
|
||||
)
|
||||
if vorhanden is None:
|
||||
raise FileshareError("Diese Zuordnung existiert nicht mehr.")
|
||||
|
||||
# Die Eindeutigkeit (ad_group_dn, unc) gilt weiterhin -- nur nicht gegen
|
||||
# sich selbst.
|
||||
kollision = Database.one(
|
||||
conn,
|
||||
"SELECT id FROM fileshare_mappings WHERE ad_group_dn=? AND unc=? AND id<>?",
|
||||
(clean_dn, clean_unc, mapping_id),
|
||||
)
|
||||
if kollision is not None:
|
||||
raise FileshareError("Diese Zuordnung existiert bereits.")
|
||||
|
||||
conn.execute(
|
||||
"UPDATE fileshare_mappings SET ad_group_dn=?, ad_group_name=?, label=?, unc=? "
|
||||
"WHERE id=?",
|
||||
(clean_dn, (ad_group_name or "").strip(), clean_label, clean_unc, mapping_id),
|
||||
)
|
||||
|
||||
|
||||
def delete_mapping(conn: sqlite3.Connection, mapping_id: int) -> None:
|
||||
conn.execute("DELETE FROM fileshare_mappings WHERE id=?", (mapping_id,))
|
||||
|
||||
@@ -259,12 +392,18 @@ def mount_for_login(
|
||||
root = Path(str(extension.settings.get(conn, "fileshare_mount_root") or "/mnt/tesm-shares"))
|
||||
messages: list[str] = []
|
||||
|
||||
suffixes = search_suffixes(conn)
|
||||
for share in shares:
|
||||
token = secrets.token_urlsafe(16)
|
||||
target = root / token
|
||||
result = sysops.mount_cifs(
|
||||
str(share["unc"]), str(target), username, password, domain=domain
|
||||
)
|
||||
# Kurze Hostnamen vervollstaendigen, bevor mount daran scheitert.
|
||||
unc, hinweis = resolve_unc_host(str(share["unc"]), suffixes)
|
||||
if unc == str(share["unc"]) and hinweis:
|
||||
messages.append(f"{share['label']}: {hinweis}")
|
||||
continue
|
||||
if hinweis:
|
||||
logger.info("%s: %s", share["label"], hinweis)
|
||||
result = sysops.mount_cifs(unc, str(target), username, password, domain=domain)
|
||||
if not result.ok:
|
||||
messages.append(f"{share['label']}: Einbindung fehlgeschlagen ({result.message})")
|
||||
continue
|
||||
@@ -298,6 +437,24 @@ def release_mount(conn: sqlite3.Connection, mount_id: int) -> None:
|
||||
)
|
||||
|
||||
|
||||
def forget_all_mounts(conn: sqlite3.Connection) -> int:
|
||||
"""Vergisst alle Einbindungen -- beim Start des Webprozesses.
|
||||
|
||||
Eingebunden wird im Mount-Namensraum dieses Dienstes. Mit dem Dienst
|
||||
verschwindet der Namensraum und mit ihm jede Einbindung; die Zeilen in der
|
||||
Datenbank ueberleben aber. Ohne dieses Aufraeumen zeigte die Oberflaeche
|
||||
nach jedem Neustart Freigaben an, die es nicht mehr gibt -- und ein Klick
|
||||
darauf fuehrte in ein leeres Verzeichnis.
|
||||
|
||||
Bewusst **kein** ``umount``: es gibt nichts zu loesen. Der Aufruf ginge in
|
||||
einen Namensraum, der nicht mehr existiert.
|
||||
"""
|
||||
cursor = conn.execute(
|
||||
"UPDATE fileshare_mounts SET released_at=? WHERE released_at IS NULL", (_iso(_now()),)
|
||||
)
|
||||
return int(cursor.rowcount or 0)
|
||||
|
||||
|
||||
def release_all_for_user(conn: sqlite3.Connection, user_id: int) -> int:
|
||||
count = 0
|
||||
for mount in active_mounts(conn, user_id):
|
||||
@@ -315,10 +472,26 @@ def sweep_expired(database: Any) -> int:
|
||||
"""
|
||||
released = 0
|
||||
with database.session() as conn:
|
||||
# Eine Einbindung lebt genau so lange wie die Sitzung, aus der sie
|
||||
# entstanden ist. Vorher hing sie an einer eigenen Frist von zwoelf
|
||||
# Stunden: nach dem Abmelden -- oder nach der automatischen Abmeldung
|
||||
# bei Leerlauf -- blieb die Freigabe mit den Zugangsdaten des Benutzers
|
||||
# weiter eingebunden. Die eigene Frist bleibt als Obergrenze bestehen;
|
||||
# eine Sitzung kann durch Aktivitaet beliebig lange leben, eine
|
||||
# Einbindung soll trotzdem irgendwann neu entstehen muessen.
|
||||
rows = Database.all(
|
||||
conn,
|
||||
"SELECT id FROM fileshare_mounts WHERE released_at IS NULL AND expires_at <= ?",
|
||||
(_iso(_now()),),
|
||||
"""
|
||||
SELECT m.id FROM fileshare_mounts m
|
||||
LEFT JOIN user_sessions s ON s.id = m.session_id
|
||||
WHERE m.released_at IS NULL
|
||||
AND (m.expires_at <= ?
|
||||
OR s.id IS NULL
|
||||
OR s.revoked_at IS NOT NULL
|
||||
OR s.idle_expires_at <= ?
|
||||
OR s.absolute_expires_at <= ?)
|
||||
""",
|
||||
(_iso(_now()), _iso(_now()), _iso(_now())),
|
||||
)
|
||||
if not rows:
|
||||
return 0
|
||||
@@ -374,6 +547,11 @@ def list_directory(mount: Mount, relative: str = "") -> dict[str, Any]:
|
||||
directories.append(record)
|
||||
else:
|
||||
record["previewable"] = entry.suffix.lower() in PREVIEW_MIME
|
||||
record["preview_kind"] = preview_kind(entry.name)
|
||||
# Getrennt, damit die Oberflaeche nur den Namen zum Bearbeiten
|
||||
# gibt und die Endung danebenstellt.
|
||||
record["stem"] = entry.stem
|
||||
record["suffix"] = entry.suffix
|
||||
files.append(record)
|
||||
except OSError as exc:
|
||||
raise FileshareError(f"Verzeichnis nicht lesbar ({exc}).") from exc
|
||||
@@ -414,6 +592,53 @@ def safe_filename(name: str) -> str:
|
||||
return cleaned
|
||||
|
||||
|
||||
def all_directories(mount: Mount, *, tiefe: int = 4, grenze: int = 400) -> list[str]:
|
||||
"""Die Ordner der Freigabe, relativ und alphabetisch -- fuer eine Auswahlliste.
|
||||
|
||||
Begrenzt in Tiefe und Anzahl: eine Netzwerkfreigabe kann zehntausende
|
||||
Verzeichnisse haben, und eine Liste, die den Browser lahmlegt, hilft
|
||||
niemandem. Wer tiefer speichern will, tippt den Pfad -- das Feld nimmt
|
||||
beides.
|
||||
"""
|
||||
wurzel = mount.path.resolve()
|
||||
gefunden: list[str] = []
|
||||
|
||||
def hinein(verzeichnis: Path, ebene: int) -> None:
|
||||
if ebene > tiefe or len(gefunden) >= grenze:
|
||||
return
|
||||
try:
|
||||
eintraege = sorted(verzeichnis.iterdir(), key=lambda e: e.name.lower())
|
||||
except OSError:
|
||||
return
|
||||
for eintrag in eintraege:
|
||||
if len(gefunden) >= grenze:
|
||||
return
|
||||
if not eintrag.is_dir() or eintrag.name.startswith("."):
|
||||
continue
|
||||
gefunden.append(eintrag.relative_to(wurzel).as_posix())
|
||||
hinein(eintrag, ebene + 1)
|
||||
|
||||
hinein(wurzel, 1)
|
||||
return gefunden
|
||||
|
||||
|
||||
def free_name(parent: Path, stem: str, zusatz: str, suffix: str) -> Path:
|
||||
"""Ein freier Name neben dem Original: ``vorlage-ausgefuellt.pdf``.
|
||||
|
||||
Nichts ueberschreiben: auf einer Freigabe liegt meist die leere Vorlage,
|
||||
und zwei Leute fuellen sie nacheinander aus. Ab dem zweiten Mal wird
|
||||
durchnummeriert.
|
||||
"""
|
||||
kandidat = parent / f"{stem}{zusatz}{suffix}"
|
||||
zaehler = 2
|
||||
while kandidat.exists():
|
||||
kandidat = parent / f"{stem}{zusatz}-{zaehler}{suffix}"
|
||||
zaehler += 1
|
||||
if zaehler > 200:
|
||||
raise FileshareError("Zu viele Dateien dieses Namens -- bitte aufraeumen.")
|
||||
return kandidat
|
||||
|
||||
|
||||
def delete_entry(mount: Mount, relative: str) -> str:
|
||||
path = resolve_path(mount, relative)
|
||||
if path == mount.path.resolve():
|
||||
@@ -435,8 +660,26 @@ def make_directory(mount: Mount, relative: str, name: str) -> Path:
|
||||
|
||||
|
||||
def rename_entry(mount: Mount, relative: str, new_name: str) -> Path:
|
||||
"""Benennt um und behaelt die Endung der Datei.
|
||||
|
||||
Eingegeben wird nur der Name ohne Endung. Eine Datei, die danach auf
|
||||
``.pdf.txt`` oder auf gar nichts endet, waere auf einer Windows-Freigabe
|
||||
praktisch kaputt -- sie oeffnet sich nicht mehr mit dem richtigen
|
||||
Programm. Verzeichnisse haben keine Endung; dort bleibt der Name, wie er
|
||||
eingegeben wurde.
|
||||
|
||||
Wer die Endung trotzdem mittippt, bekommt sie nicht doppelt.
|
||||
"""
|
||||
path = resolve_path(mount, relative)
|
||||
target = path.parent / safe_filename(new_name)
|
||||
endung = path.suffix if path.is_file() else ""
|
||||
gewuenscht = (new_name or "").strip()
|
||||
if endung and gewuenscht.lower().endswith(endung.lower()):
|
||||
gewuenscht = gewuenscht[: -len(endung)]
|
||||
if not gewuenscht:
|
||||
raise FileshareError("Der Name darf nicht leer sein.")
|
||||
target = path.parent / safe_filename(gewuenscht + endung)
|
||||
if target == path:
|
||||
return path
|
||||
if target.exists():
|
||||
raise FileshareError("Ein Eintrag mit diesem Namen existiert bereits.")
|
||||
path.rename(target)
|
||||
|
||||
@@ -27,6 +27,7 @@ from __future__ import annotations
|
||||
import base64
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
#: Runden der alten Schluesselableitung. Steht so im Vorgaenger und darf sich
|
||||
@@ -84,8 +85,13 @@ class Report:
|
||||
if text not in self.notes:
|
||||
self.notes.append(text)
|
||||
|
||||
def as_text(self) -> str:
|
||||
zeilen = ["Uebersetzt:"]
|
||||
def as_text(self, titel: str = "Uebersetzt") -> str:
|
||||
"""Bericht als Text. Der Titel gehoert dem Aufrufer.
|
||||
|
||||
Beim Lesen einer alten Installation ist noch nichts uebersetzt --
|
||||
"Uebersetzt: devices 4" waere an dieser Stelle einfach falsch.
|
||||
"""
|
||||
zeilen = [f"{titel}:"]
|
||||
zeilen += [f" {key}: {value}" for key, value in sorted(self.counts.items())] or [
|
||||
" nichts"
|
||||
]
|
||||
@@ -415,3 +421,266 @@ def convert(legacy: dict[str, Any]) -> tuple[dict[str, Any], Report]:
|
||||
"Beim Export im alten TESM diese Kategorien mitwaehlen."
|
||||
)
|
||||
return payload, report
|
||||
|
||||
|
||||
def _eine(conn: Any, sql: str, args: tuple[Any, ...] = ()) -> Any:
|
||||
"""Eine Zeile oder ``None`` -- die alte Datenbank kennt kein Database."""
|
||||
return conn.execute(sql, args).fetchone()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Alte Installation lesen
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
#: Dateinamen der Vorgaengerfassung, relativ zu ihrem Verzeichnis.
|
||||
LEGACY_DB_NAME = "sqlite.db"
|
||||
LEGACY_FERNET_NAME = "fernet.key"
|
||||
|
||||
#: Genau die Abfragen des alten Exports. Sie stehen hier als Text und nicht als
|
||||
#: Nachbau: was der Vorgaenger exportiert hat, ist das, was uebersetzbar ist.
|
||||
_ABFRAGEN: dict[str, str] = {
|
||||
"devices": """
|
||||
SELECT devices.mac, devices.ip, devices.port, devices.name,
|
||||
devices.switch_hostname, devices.is_active, devices.ssh_port,
|
||||
credentials.name AS credential_name
|
||||
FROM devices LEFT JOIN credentials ON credentials.id = devices.credential_id
|
||||
WHERE devices.deleted_at IS NULL
|
||||
""",
|
||||
"switches": """
|
||||
SELECT switches.hostname, switches.ip, switches.ssh_port,
|
||||
credentials.name AS credential_name
|
||||
FROM switches LEFT JOIN credentials ON credentials.id = switches.credential_id
|
||||
WHERE switches.deleted_at IS NULL
|
||||
""",
|
||||
}
|
||||
|
||||
|
||||
def _oeffne_alt(pfad: Path) -> Any:
|
||||
"""Oeffnet die alte Datenbank **nur lesend**.
|
||||
|
||||
Der alte Dienst laeuft womoeglich noch. Ein Schreibzugriff waere hier in
|
||||
jedem Fall falsch, und ``mode=ro`` macht daraus einen Fehler statt einer
|
||||
Moeglichkeit.
|
||||
"""
|
||||
import sqlite3
|
||||
|
||||
if not pfad.is_file():
|
||||
raise LegacyTransferError(f"Keine Datenbank gefunden: {pfad}")
|
||||
conn = sqlite3.connect(f"file:{pfad.as_posix()}?mode=ro", uri=True)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
def _tabellen(conn: Any) -> set[str]:
|
||||
return {
|
||||
str(row[0])
|
||||
for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()
|
||||
}
|
||||
|
||||
|
||||
def _entschluessler(root: Path, report: Report) -> Any:
|
||||
"""Gibt eine Funktion zurueck, die ein altes Passwort entschluesselt.
|
||||
|
||||
Fehlt der Schluessel, bleibt der Datensatz erhalten und das Passwort leer.
|
||||
Ein Zugangsdatensatz ohne Passwort ist nachtragbar; ein abgebrochenes
|
||||
Upgrade ist es nicht.
|
||||
"""
|
||||
schluesseldatei = root / LEGACY_FERNET_NAME
|
||||
if not schluesseldatei.is_file():
|
||||
report.note(
|
||||
f"{LEGACY_FERNET_NAME} nicht gefunden -- die Passwoerter der Zugangsdaten "
|
||||
"bleiben leer und sind nachzutragen."
|
||||
)
|
||||
return lambda _token: ""
|
||||
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
|
||||
try:
|
||||
fernet = Fernet(schluesseldatei.read_bytes().strip())
|
||||
except (ValueError, TypeError):
|
||||
report.note(
|
||||
f"{LEGACY_FERNET_NAME} ist unlesbar -- die Passwoerter der Zugangsdaten "
|
||||
"bleiben leer und sind nachzutragen."
|
||||
)
|
||||
return lambda _token: ""
|
||||
|
||||
gescheitert: list[int] = []
|
||||
|
||||
def entschluesseln(token: Any) -> str:
|
||||
if not token:
|
||||
return ""
|
||||
try:
|
||||
return fernet.decrypt(str(token).encode("utf-8")).decode("utf-8")
|
||||
except (InvalidToken, ValueError):
|
||||
gescheitert.append(1)
|
||||
if len(gescheitert) == 1:
|
||||
report.note(
|
||||
"Mindestens ein Passwort liess sich mit dem gefundenen Schluessel "
|
||||
"nicht entschluesseln -- es bleibt leer."
|
||||
)
|
||||
return ""
|
||||
|
||||
return entschluesseln
|
||||
|
||||
|
||||
def _alt_dhcp(conn: Any, vorhanden: set[str]) -> dict[str, Any] | None:
|
||||
"""Baut den DHCP-Abschnitt so, wie der alte Export ihn geschrieben hat."""
|
||||
if "dhcp_subnets" not in vorhanden:
|
||||
return None
|
||||
|
||||
einstellungen: dict[str, Any] = {}
|
||||
if "settings" in vorhanden:
|
||||
# Nur die Schluessel holen, die der Uebersetzer kennt -- der alte
|
||||
# Einstellungsbestand ist gross und heisst groesstenteils anders.
|
||||
for key in DHCP_SETTING_KEYS:
|
||||
row = _eine(conn, "SELECT value FROM settings WHERE key=?", (key,))
|
||||
if row and str(row["value"] or ""):
|
||||
einstellungen[key] = row["value"]
|
||||
|
||||
subnets = [
|
||||
dict(row)
|
||||
for row in conn.execute(
|
||||
"SELECT interface, range_start, range_end, gateway, dns, sort_order, enabled "
|
||||
"FROM dhcp_subnets"
|
||||
).fetchall()
|
||||
]
|
||||
reservations = (
|
||||
[
|
||||
dict(row)
|
||||
for row in conn.execute(
|
||||
"SELECT mac, ip, name FROM dhcp_manual_reservations"
|
||||
).fetchall()
|
||||
]
|
||||
if "dhcp_manual_reservations" in vorhanden
|
||||
else []
|
||||
)
|
||||
|
||||
options: list[dict[str, Any]] = []
|
||||
if "dhcp_option_defs" in vorhanden:
|
||||
for definition in conn.execute(
|
||||
"SELECT id, code, name, type, description, is_standard FROM dhcp_option_defs"
|
||||
).fetchall():
|
||||
werte = (
|
||||
conn.execute(
|
||||
"SELECT device_mac, value FROM dhcp_option_values WHERE option_def_id=?",
|
||||
(definition["id"],),
|
||||
).fetchall()
|
||||
if "dhcp_option_values" in vorhanden
|
||||
else []
|
||||
)
|
||||
if not werte and definition["is_standard"]:
|
||||
continue
|
||||
options.append(
|
||||
{
|
||||
"code": definition["code"],
|
||||
"name": definition["name"],
|
||||
"type": definition["type"],
|
||||
"description": definition["description"],
|
||||
"is_standard": definition["is_standard"],
|
||||
"values": [
|
||||
{"device_mac": w["device_mac"], "value": w["value"]} for w in werte
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
if not (einstellungen or subnets or reservations or options):
|
||||
return None
|
||||
return {
|
||||
"settings": einstellungen,
|
||||
"subnets": subnets,
|
||||
"reservations": reservations,
|
||||
"options": options,
|
||||
}
|
||||
|
||||
|
||||
def read_installation(root: Path) -> tuple[dict[str, Any], Report]:
|
||||
"""Liest eine alte Installation und gibt deren Nutzlast zurueck.
|
||||
|
||||
Ergebnis ist dieselbe Struktur, die ``open_legacy`` aus einer Exportdatei
|
||||
liefert -- der Uebersetzer dahinter bleibt also derselbe. Das ist der
|
||||
Unterschied zum Weg ueber die Oberflaeche: fuer ein Upgrade darf niemand
|
||||
sich anmelden, eine Kategorie anhaken und eine Datei herunterladen muessen.
|
||||
|
||||
Gelesen wird ausschliesslich; die alte Installation bleibt unberuehrt.
|
||||
"""
|
||||
root = Path(root)
|
||||
report = Report()
|
||||
conn = _oeffne_alt(root / LEGACY_DB_NAME)
|
||||
try:
|
||||
vorhanden = _tabellen(conn)
|
||||
payload: dict[str, Any] = {}
|
||||
|
||||
for abschnitt, sql in _ABFRAGEN.items():
|
||||
tabelle = abschnitt
|
||||
if tabelle not in vorhanden:
|
||||
report.note(f"Tabelle {tabelle!r} fehlt in der alten Datenbank.")
|
||||
continue
|
||||
payload[abschnitt] = [dict(row) for row in conn.execute(sql).fetchall()]
|
||||
|
||||
if "credentials" in vorhanden:
|
||||
entschluesseln = _entschluessler(root, report)
|
||||
payload["credentials"] = [
|
||||
{
|
||||
"name": row["name"],
|
||||
"username": row["username"],
|
||||
"password": entschluesseln(row["password"]),
|
||||
"category": row["category"],
|
||||
}
|
||||
for row in conn.execute(
|
||||
"SELECT name, username, password, category FROM credentials "
|
||||
"WHERE deleted_at IS NULL"
|
||||
).fetchall()
|
||||
]
|
||||
else:
|
||||
report.note("Tabelle 'credentials' fehlt in der alten Datenbank.")
|
||||
|
||||
dhcp = _alt_dhcp(conn, vorhanden)
|
||||
if dhcp:
|
||||
payload["dhcp"] = dhcp
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
payload = {key: value for key, value in payload.items() if value}
|
||||
if not payload:
|
||||
raise LegacyTransferError(
|
||||
f"In {root} stehen keine uebernehmbaren Daten (Clients, Switche, "
|
||||
"Zugangsdaten, DHCP)."
|
||||
)
|
||||
for abschnitt, inhalt in payload.items():
|
||||
report.took(abschnitt, len(inhalt) if isinstance(inhalt, list) else 1)
|
||||
return payload, report
|
||||
|
||||
|
||||
def seal_legacy(payload: dict[str, Any], passphrase: str) -> str:
|
||||
"""Schreibt eine Nutzlast im **alten** Umschlag.
|
||||
|
||||
Damit ist die Datei genau das, was der Vorgaenger selbst exportiert haette
|
||||
-- lesbar fuer ``open_legacy`` und fuer den Import des alten TESM. Das ist
|
||||
beim Upgrade der Unterschied zwischen einem Zwischenschritt und einem
|
||||
Rueckweg: geht danach etwas schief, laesst sich diese Datei in die alte
|
||||
Installation zurueckspielen.
|
||||
"""
|
||||
import secrets
|
||||
from datetime import datetime
|
||||
|
||||
from cryptography.fernet import Fernet
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
||||
|
||||
if len(passphrase) < 8:
|
||||
raise LegacyTransferError("Die Passphrase braucht mindestens 8 Zeichen.")
|
||||
|
||||
salt = secrets.token_bytes(16)
|
||||
kdf = PBKDF2HMAC(
|
||||
algorithm=hashes.SHA256(), length=32, salt=salt, iterations=LEGACY_KDF_ITERATIONS
|
||||
)
|
||||
key = base64.urlsafe_b64encode(kdf.derive(passphrase.encode("utf-8")))
|
||||
token = Fernet(key).encrypt(json.dumps(payload).encode("utf-8"))
|
||||
umschlag = {
|
||||
"app": LEGACY_APP_TAG,
|
||||
"version": 2,
|
||||
"exported_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"salt": base64.urlsafe_b64encode(salt).decode("utf-8"),
|
||||
"payload": token.decode("utf-8"),
|
||||
}
|
||||
return json.dumps(umschlag, indent=2, ensure_ascii=False) + "\n"
|
||||
@@ -500,6 +500,7 @@ def run_reboot(
|
||||
password: str,
|
||||
wait_seconds: int,
|
||||
actor: str = "system",
|
||||
method: str = METHOD_SSH,
|
||||
) -> None:
|
||||
"""Loest einen Neustart aus und wartet auf die Rueckkehr.
|
||||
|
||||
@@ -518,7 +519,15 @@ def run_reboot(
|
||||
return int((time.monotonic() - started) * 1000)
|
||||
|
||||
host = str(device["ip"])
|
||||
is_windows = str(device.get("category") or "") == WINDOWS_CATEGORY
|
||||
# Der Weg wird **uebergeben**, nicht aus dem Geraet abgeleitet. Vorher stand
|
||||
# hier ``device["category"]`` -- und dieser Schluessel bedeutet je Herkunft
|
||||
# etwas anderes: die Wartungsabfrage legt die Kategorie der *Zugangsdaten*
|
||||
# darunter, ``inventory.get_device`` die des *Geraets*. Ueber die
|
||||
# Statusuebersicht kam damit ein Windows-Geraet mit leerer Geraetekategorie
|
||||
# an, wurde fuer Linux gehalten und ueber SSH auf Port 22 angesprochen --
|
||||
# bis zum Zeitlimit. Die Entscheidung faellt jetzt einmal beim Aufrufer
|
||||
# (``restart.resolve``) und wird hier nur noch befolgt.
|
||||
is_windows = method == METHOD_RPC
|
||||
# Woran erkennt man, dass das Geraet zurueck ist? Am selben Dienst, ueber
|
||||
# den der Neustart lief: SSH bei Linux, SMB/RPC bei Windows.
|
||||
probe_port = WINDOWS_PROBE_PORT if is_windows else int(device.get("ssh_port") or 22)
|
||||
@@ -566,6 +575,7 @@ def run_reboot(
|
||||
actor=actor,
|
||||
duration_ms=elapsed(),
|
||||
detail=str(exc),
|
||||
method=method,
|
||||
)
|
||||
return
|
||||
|
||||
@@ -607,6 +617,7 @@ def run_reboot(
|
||||
actor=actor,
|
||||
duration_ms=elapsed(),
|
||||
detail=problem,
|
||||
method=method,
|
||||
)
|
||||
return
|
||||
|
||||
@@ -646,6 +657,7 @@ def run_reboot(
|
||||
actor=actor,
|
||||
duration_ms=elapsed(),
|
||||
detail="Neustart ueber SSH, Geraet ist zurueckgekehrt.",
|
||||
method=method,
|
||||
)
|
||||
return
|
||||
time.sleep(REBOOT_POLL_SECONDS)
|
||||
@@ -669,6 +681,7 @@ def run_reboot(
|
||||
f"Neustart ausgeloest, aber {host}:{ssh_port} hat innerhalb von "
|
||||
f"{wait_seconds}s nicht wieder geantwortet."
|
||||
),
|
||||
method=method,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ import threading
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
@@ -106,7 +106,13 @@ def ping(host: str, *, timeout: int = PING_TIMEOUT_SECONDS) -> PingResult:
|
||||
command = [binary, "-c", "1", "-W", str(timeout), host]
|
||||
try:
|
||||
completed = subprocess.run( # noqa: S603 - feste Argumentliste, keine Shell
|
||||
command, capture_output=True, text=True, timeout=timeout + 3, check=False
|
||||
command,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=timeout + 3,
|
||||
check=False,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
return PingResult(False, detail="Zeitlimit ueberschritten")
|
||||
@@ -313,9 +319,10 @@ def record_restart(
|
||||
(device_id, _now(), trigger, actor, action, result, duration_ms, detail[:8000], method),
|
||||
)
|
||||
if result == "ok":
|
||||
# Der Zustand wird hier gesetzt, nicht in den einzelnen Neustartwegen:
|
||||
# PoE, SSH und RPC laufen alle durch diese Funktion. Eine zweite Stelle
|
||||
# waere eine, die beim naechsten Verfahren vergessen wird.
|
||||
# Hier faellt nur noch der Zaehler und der Zeitstempel an. Den Zustand
|
||||
# setzt ``mark_restarting`` schon beim Ausloesen -- dieses Ergebnis
|
||||
# kommt bei SSH und RPC erst, wenn das Geraet zurueck ist, und bis
|
||||
# dahin stand die Kachel weiter auf "online".
|
||||
conn.execute(
|
||||
"UPDATE device_status SET last_restart_at=?, restart_count=restart_count+1, "
|
||||
"state=?, last_change_at=?, fail_count=0 WHERE device_id=?",
|
||||
@@ -323,6 +330,33 @@ def record_restart(
|
||||
)
|
||||
|
||||
|
||||
def mark_restarting(conn: sqlite3.Connection, device_id: int) -> None:
|
||||
"""Setzt den Zustand auf "startet neu" -- beim **Ausloesen**, nicht am Ende.
|
||||
|
||||
Ein Neustart ueber SSH oder RPC laeuft als Auftrag: Kommando schicken, dann
|
||||
auf die Rueckkehr warten. Das Ergebnis steht erst Minuten spaeter fest. Wer
|
||||
den Knopf gedrueckt hat, will die Wirkung aber sofort sehen -- sonst sieht
|
||||
die Uebersicht aus wie eine, in der nichts passiert ist.
|
||||
|
||||
Bewusst ohne ``restart_count``: gezaehlt wird das Ereignis, nicht die
|
||||
Absicht. Und ohne ``last_restart_at``: das Kulanzfenster laeuft ab dem
|
||||
Zeitpunkt, an dem der Neustart tatsaechlich ausgeloest wurde
|
||||
(``record_restart``).
|
||||
"""
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO device_status (device_id, state, fail_count, last_check_at, last_change_at)
|
||||
VALUES (?,?,0,?,?)
|
||||
ON CONFLICT(device_id) DO UPDATE SET
|
||||
state=excluded.state,
|
||||
fail_count=0,
|
||||
last_change_at=CASE WHEN device_status.state <> excluded.state
|
||||
THEN excluded.last_change_at ELSE device_status.last_change_at END
|
||||
""",
|
||||
(device_id, STATE_RESTARTING, _now(), _now()),
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# PoE-Neustart
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -363,6 +397,13 @@ def restart_device_poe(
|
||||
switch_ip = str(device.get("switch_ip") or "")
|
||||
credential_id = device.get("switch_credential_id")
|
||||
|
||||
# Sofort sichtbar machen, dass etwas passiert -- der Portzyklus dauert
|
||||
# Sekunden, das Warten auf die Rueckkehr laenger. Gilt fuer den manuellen
|
||||
# wie fuer den automatischen Neustart, denn beide laufen hier durch.
|
||||
if port and switch_ip:
|
||||
with write():
|
||||
mark_restarting(conn, int(device["id"]))
|
||||
|
||||
if not port or not switch_ip:
|
||||
message = (
|
||||
"Kein Switch oder kein Port zugeordnet -- ein PoE-Neustart ist nicht moeglich."
|
||||
@@ -573,10 +614,34 @@ def _in_cooldown(last_restart_at: Any) -> bool:
|
||||
return (datetime.now(timezone.utc) - moment).total_seconds() < RESTART_COOLDOWN_SECONDS
|
||||
|
||||
|
||||
def dashboard_counts(conn: sqlite3.Connection) -> dict[str, int]:
|
||||
row = Database.one(
|
||||
conn,
|
||||
def next_check_at(conn: sqlite3.Connection, interval_seconds: int) -> datetime | None:
|
||||
"""Wann der naechste Ueberwachungsdurchlauf faellig ist.
|
||||
|
||||
Anker ist die letzte tatsaechliche Pruefung, nicht der Prozessstart: nach
|
||||
einem Neustart des Dienstes soll der Zaehler dort weiterlaufen, wo der
|
||||
Durchlauf steht, und nicht bei voller Laenge beginnen.
|
||||
"""
|
||||
zeile = Database.one(
|
||||
conn, "SELECT MAX(last_check_at) AS letzte FROM device_status", ()
|
||||
)
|
||||
letzte = (zeile or {}).get("letzte")
|
||||
if not letzte:
|
||||
return None
|
||||
try:
|
||||
stamp = datetime.fromisoformat(str(letzte))
|
||||
except ValueError:
|
||||
return None
|
||||
if stamp.tzinfo is None:
|
||||
stamp = stamp.replace(tzinfo=timezone.utc)
|
||||
return stamp + timedelta(seconds=max(1, int(interval_seconds)))
|
||||
|
||||
|
||||
#: Zaehlung fuer die Statusuebersicht. Steht als Konstante, weil das
|
||||
#: Anmeldebanner (``deploy/tesm-motd``) dieselben Zahlen zeigt und ohne
|
||||
#: Anwendung laeuft -- es kann diese Datei nicht importieren, also vergleicht
|
||||
#: ein Hygienetest die beiden Zeichenketten. Zwei Wahrheiten ueber "online"
|
||||
#: waeren schlimmer als eine doppelte Zeichenkette.
|
||||
DASHBOARD_COUNT_SQL = """
|
||||
SELECT
|
||||
COUNT(*) AS total,
|
||||
SUM(CASE WHEN d.is_active=0 THEN 1 ELSE 0 END) AS disabled,
|
||||
@@ -586,8 +651,11 @@ def dashboard_counts(conn: sqlite3.Connection) -> dict[str, int]:
|
||||
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 dashboard_counts(conn: sqlite3.Connection) -> dict[str, int]:
|
||||
row = Database.one(conn, DASHBOARD_COUNT_SQL)
|
||||
row = row or {}
|
||||
return {
|
||||
key: int(row.get(key) or 0)
|
||||
|
||||
@@ -45,7 +45,18 @@ class Method:
|
||||
return self.key == METHOD_POE
|
||||
|
||||
|
||||
#: Derselbe Weg wie PoE, nur anders benannt: bei einem Geraet, das nicht
|
||||
#: antwortet, ist "Neustart" das falsche Wort. Der Schluessel bleibt ``poe``,
|
||||
#: damit Protokoll und Auswertung unveraendert bleiben.
|
||||
POWER_ON = "poe-einschalten"
|
||||
|
||||
DEFINITIONS: dict[str, Method] = {
|
||||
POWER_ON: Method(
|
||||
key=METHOD_POE,
|
||||
label="Einschalten (PoE)",
|
||||
hint="Schaltet den Switch-Port aus und wieder ein. Bei einem Geraet, das nicht "
|
||||
"antwortet, ist das der einzige Weg, der noch etwas bewirkt.",
|
||||
),
|
||||
METHOD_POE: Method(
|
||||
key=METHOD_POE,
|
||||
label="PoE-Port",
|
||||
@@ -82,9 +93,17 @@ def available(device: Any, *, maintenance_active: bool = True) -> list[Method]:
|
||||
return None
|
||||
|
||||
methods: list[Method] = []
|
||||
if field("switch_id") and str(field("port") or "").strip():
|
||||
hat_poe = bool(field("switch_id")) and bool(str(field("port") or "").strip())
|
||||
if hat_poe:
|
||||
methods.append(DEFINITIONS[METHOD_POE])
|
||||
|
||||
# Ein Geraet, das nicht antwortet, laesst sich nicht ueber SSH oder RPC neu
|
||||
# starten -- dafuer muesste es erreichbar sein. Angeboten wird dann nur noch
|
||||
# der PoE-Weg, und der heisst dort "Einschalten": er schaltet den Port
|
||||
# wieder ein, statt ein laufendes System neu zu starten.
|
||||
if str(field("state") or "") == "offline":
|
||||
return [DEFINITIONS[POWER_ON]] if hat_poe else []
|
||||
|
||||
category = str(field("credential_category") or "")
|
||||
has_login = bool(field("credential_id"))
|
||||
if has_login and maintenance_active:
|
||||
|
||||
@@ -52,7 +52,8 @@ HINTS: tuple[tuple[str, str], ...] = (
|
||||
(
|
||||
"NT_STATUS_LOGON_FAILURE",
|
||||
"Benutzername oder Passwort wurden vom Windows-Geraet abgewiesen. Bei einem "
|
||||
"Domaenenkonto die Form DOMAENE\\\\benutzer verwenden.",
|
||||
"Domaenenkonto die Form DOMAENE\\benutzer verwenden -- mit *einem* "
|
||||
"Backslash.",
|
||||
),
|
||||
(
|
||||
"NT_STATUS_ACCESS_DENIED",
|
||||
@@ -73,6 +74,24 @@ HINTS: tuple[tuple[str, str], ...] = (
|
||||
"NT_STATUS_HOST_UNREACHABLE",
|
||||
"Das Geraet ist nicht erreichbar.",
|
||||
),
|
||||
# Diese Meldung liest sich wie ein technisches Detail, hat aber fast immer
|
||||
# eine einzige Ursache: das Konto ist auf dem Zielgeraet kein Administrator.
|
||||
# Nachgemessen an einem echten Fall -- die Anmeldung gelang, aber schon
|
||||
# "net rpc info" und der Dienstmanager antworteten mit ACCESS_DENIED, also
|
||||
# bekommt ein Standardkonto die winreg-Pipe gar nicht zu sehen.
|
||||
(
|
||||
"pipe winreg",
|
||||
"Das Konto hat auf diesem Geraet keine Administratorrechte. Ein Neustart ueber "
|
||||
"RPC braucht sie: die Pipe, ueber die er laeuft, ist fuer Standardkonten nicht "
|
||||
"sichtbar. Ein Konto der lokalen Administratoren hinterlegen -- pruefen laesst "
|
||||
"sich das mit 'net rpc service status RemoteRegistry'.",
|
||||
),
|
||||
(
|
||||
"NT_STATUS_OBJECT_NAME_NOT_FOUND",
|
||||
"Der benoetigte RPC-Dienst antwortet auf dem Geraet nicht. Meist fehlen dem "
|
||||
"Konto die Administratorrechte; sonst ist der Dienst 'Remoteregistrierung' "
|
||||
"gestoppt.",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -83,32 +102,54 @@ def hint_for(output: str) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def reboot(
|
||||
host: str,
|
||||
def split_account(username: str, domain: str = "") -> tuple[str, str]:
|
||||
"""Trennt ``DOMAENE\\benutzer`` in Benutzer und Domaene.
|
||||
|
||||
Toleranter Split: mehrfache Backslashes werden zusammengefasst, leere Teile
|
||||
verworfen. Das ist kein Selbstzweck -- der Hinweistext dieser Datei empfahl
|
||||
selbst einmal ``DOMAENE\\\\benutzer`` mit zwei Backslashes, und genau so
|
||||
wurde es dann eingetragen. Ein einfacher ``split()`` schickte daraufhin
|
||||
``\\benutzer`` an Windows, und die Anmeldung scheiterte mit LOGON_FAILURE --
|
||||
bei formal richtigen Zugangsdaten.
|
||||
|
||||
Rueckgabe: ``(benutzer, domaene)``.
|
||||
"""
|
||||
if "\\" in username and not domain:
|
||||
teile = [teil.strip() for teil in username.split("\\") if teil.strip()]
|
||||
if len(teile) >= 2:
|
||||
return teile[-1], teile[0]
|
||||
if teile:
|
||||
return teile[0], domain
|
||||
return username, domain
|
||||
|
||||
|
||||
def _net_with_credentials(
|
||||
args: list[str],
|
||||
*,
|
||||
username: str,
|
||||
password: str,
|
||||
*,
|
||||
domain: str = "",
|
||||
comment: str = "Neustart durch TESM-Wartung",
|
||||
delay: int = 5,
|
||||
timeout: int = DEFAULT_TIMEOUT,
|
||||
) -> RemoteResult:
|
||||
"""Loest einen Neustart auf einem Windows-Geraet aus."""
|
||||
domain: str,
|
||||
timeout: int,
|
||||
) -> RemoteResult | tuple[int, str]:
|
||||
"""Ruft ``net`` mit einer 0600-Anmeldedatei auf und raeumt sie sicher weg.
|
||||
|
||||
Gemeinsam fuer Neustart und Pruefung -- die Datei traegt ein
|
||||
Klartextpasswort, und diese Behandlung will man genau einmal haben.
|
||||
Rueckgabe: ``(exitcode, ausgabe)`` oder ein fertiges ``RemoteResult`` bei
|
||||
einem Fehler noch vor dem Aufruf.
|
||||
"""
|
||||
binary = shutil.which("net")
|
||||
if binary is None:
|
||||
return RemoteResult(
|
||||
False,
|
||||
"",
|
||||
f"Auf diesem Server fehlt das Paket {REQUIRED_PACKAGE}. Ohne 'net' laesst sich "
|
||||
"ein Windows-Geraet von hier nicht neu starten.",
|
||||
"ein Windows-Geraet von hier nicht ansprechen.",
|
||||
)
|
||||
if not username:
|
||||
return RemoteResult(False, "", "Fuer das Geraet sind keine Zugangsdaten hinterlegt.")
|
||||
|
||||
# Ein Benutzer der Form DOMAENE\benutzer bringt seine Domaene selbst mit.
|
||||
if "\\" in username and not domain:
|
||||
domain, username = username.split("\\", 1)
|
||||
return RemoteResult(False, "", "Es sind keine Zugangsdaten hinterlegt.")
|
||||
|
||||
username, domain = split_account(username, domain)
|
||||
handle, path = tempfile.mkstemp(prefix="tesm-winauth-")
|
||||
try:
|
||||
# mkstemp legt die Datei auf POSIX bereits mit 0600 an. Hier trotzdem
|
||||
@@ -123,34 +164,22 @@ def reboot(
|
||||
if domain:
|
||||
stream.write(f"domain={domain}\n")
|
||||
|
||||
command = [
|
||||
binary,
|
||||
"rpc",
|
||||
"shutdown",
|
||||
"-r", # neu starten statt herunterfahren
|
||||
"-f", # laufende Anwendungen nicht blockieren lassen
|
||||
"-t",
|
||||
str(max(0, int(delay))),
|
||||
"-C",
|
||||
comment,
|
||||
"-I",
|
||||
host,
|
||||
"--authentication-file",
|
||||
path,
|
||||
]
|
||||
command = [binary, *args, "--authentication-file", path]
|
||||
try:
|
||||
completed = subprocess.run( # noqa: S603 - feste Argumentliste, keine Shell
|
||||
command, capture_output=True, text=True, timeout=timeout, check=False
|
||||
command,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=timeout,
|
||||
check=False,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
return RemoteResult(False, "", f"Zeitlimit von {timeout}s ueberschritten.")
|
||||
except OSError as exc:
|
||||
return RemoteResult(False, "", str(exc))
|
||||
|
||||
output = (completed.stdout + completed.stderr).strip()
|
||||
if completed.returncode == 0:
|
||||
return RemoteResult(True, output or "Neustart ausgeloest.")
|
||||
return RemoteResult(False, output, hint_for(output))
|
||||
return completed.returncode, (completed.stdout + completed.stderr).strip()
|
||||
finally:
|
||||
# Ueberschreiben, dann loeschen -- die Datei enthaelt ein Klartextpasswort.
|
||||
try:
|
||||
@@ -166,3 +195,85 @@ def reboot(
|
||||
os.unlink(path)
|
||||
except OSError: # pragma: no cover - defensiv
|
||||
pass
|
||||
|
||||
|
||||
def check(
|
||||
host: str,
|
||||
username: str,
|
||||
password: str,
|
||||
*,
|
||||
domain: str = "",
|
||||
timeout: int = DEFAULT_TIMEOUT,
|
||||
) -> RemoteResult:
|
||||
"""Prueft Zugangsdaten gegen ein Windows-Geraet -- **ohne** etwas zu aendern.
|
||||
|
||||
``net rpc info`` ist rein lesend. Der Sinn: ob Zugangsdaten stimmen, soll
|
||||
man erfahren, ohne einen Neustart auszuloesen und hinterher im Protokoll zu
|
||||
raten. Unterschieden wird ausdruecklich zwischen "Passwort falsch" und
|
||||
"Anmeldung in Ordnung, aber keine Administratorrechte" -- das sind zwei
|
||||
voellig verschiedene Aufgaben fuer den, der es beheben muss.
|
||||
|
||||
Ein Fehlversuch zaehlt fuer die Sperrrichtlinie der Domaene mit. Deshalb
|
||||
genau ein Aufruf je Klick und kein automatisches Wiederholen.
|
||||
"""
|
||||
ergebnis = _net_with_credentials(
|
||||
["rpc", "info", "-I", host],
|
||||
username=username,
|
||||
password=password,
|
||||
domain=domain,
|
||||
timeout=timeout,
|
||||
)
|
||||
if isinstance(ergebnis, RemoteResult):
|
||||
return ergebnis
|
||||
code, output = ergebnis
|
||||
|
||||
if code == 0:
|
||||
return RemoteResult(
|
||||
True, output, "Anmeldung erfolgreich, Administratorrechte vorhanden."
|
||||
)
|
||||
if "ACCESS_DENIED" in output:
|
||||
# Wichtig: die Anmeldung *hat* funktioniert. Nur die Rechte fehlen.
|
||||
return RemoteResult(
|
||||
False,
|
||||
output,
|
||||
"Anmeldung erfolgreich, aber das Konto hat auf diesem Geraet keine "
|
||||
"Administratorrechte. Fuer einen Neustart ueber RPC braucht es sie.",
|
||||
)
|
||||
return RemoteResult(False, output, hint_for(output) or "Pruefung fehlgeschlagen.")
|
||||
|
||||
|
||||
def reboot(
|
||||
host: str,
|
||||
username: str,
|
||||
password: str,
|
||||
*,
|
||||
domain: str = "",
|
||||
comment: str = "Neustart durch TESM-Wartung",
|
||||
delay: int = 5,
|
||||
timeout: int = DEFAULT_TIMEOUT,
|
||||
) -> RemoteResult:
|
||||
"""Loest einen Neustart auf einem Windows-Geraet aus."""
|
||||
ergebnis = _net_with_credentials(
|
||||
[
|
||||
"rpc",
|
||||
"shutdown",
|
||||
"-r", # neu starten statt herunterfahren
|
||||
"-f", # laufende Anwendungen nicht blockieren lassen
|
||||
"-t",
|
||||
str(max(0, int(delay))),
|
||||
"-C",
|
||||
comment,
|
||||
"-I",
|
||||
host,
|
||||
],
|
||||
username=username,
|
||||
password=password,
|
||||
domain=domain,
|
||||
timeout=timeout,
|
||||
)
|
||||
if isinstance(ergebnis, RemoteResult):
|
||||
return ergebnis
|
||||
code, output = ergebnis
|
||||
if code == 0:
|
||||
return RemoteResult(True, output or "Neustart ausgeloest.")
|
||||
return RemoteResult(False, output, hint_for(output))
|
||||
@@ -79,15 +79,6 @@ TESM_SETTINGS: tuple[SettingSpec, ...] = (
|
||||
minimum=1,
|
||||
maximum=168,
|
||||
),
|
||||
SettingSpec(
|
||||
"fileshare_upload_max_mb",
|
||||
512,
|
||||
"int",
|
||||
"Maximale Dateigroesse beim Hochladen (MB)",
|
||||
section="fileshare",
|
||||
minimum=1,
|
||||
maximum=8192,
|
||||
),
|
||||
# -- Wartung ------------------------------------------------------------ #
|
||||
SettingSpec(
|
||||
"maintenance_command_timeout",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{# Teilbereich der Statusuebersicht -- wird auch einzeln per fetch geladen. #}
|
||||
{# Teilbereich der Statusübersicht -- wird auch einzeln per fetch geladen. #}
|
||||
{% from "tesm_core/macros.html" import pill, empty_state %}
|
||||
|
||||
{# Beide Bloecke in einen Stapel: sonst klebt die erste Gruppe an den
|
||||
Filterkacheln, waehrend die Gruppen untereinander Abstand haben. #}
|
||||
{# Beide Blöcke in einen Stapel: sonst klebt die erste Gruppe an den
|
||||
Filterkacheln, während die Gruppen untereinander Abstand haben. #}
|
||||
<div class="stack">
|
||||
<div class="grid grid--4" data-behavior="tile-filter" data-filter-scope="tile-sections">
|
||||
<button type="button" class="stat" data-filter-value="" aria-pressed="false">
|
||||
@@ -25,7 +25,7 @@
|
||||
</button>
|
||||
{% else %}
|
||||
<div class="stat stat--warning">
|
||||
<span class="stat__label">Noch nicht geprueft</span>
|
||||
<span class="stat__label">Noch nicht geprüft</span>
|
||||
<span class="stat__value">{{ counts.unknown }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -49,9 +49,9 @@
|
||||
<div class="grid grid--tiles">
|
||||
{% for tile in section.tiles %}
|
||||
{# Kopf zuerst Kennzeichen, dann Name: das Auge findet die Kachel
|
||||
ueber Farbe und Symbol, den genauen Namen liest es danach.
|
||||
über Farbe und Symbol, den genauen Namen liest es danach.
|
||||
Die IP steht gross darunter statt in der Merkmalliste -- sie ist
|
||||
die Angabe, nach der man auf dieser Seite tatsaechlich sucht. #}
|
||||
die Angabe, nach der man auf dieser Seite tatsächlich sucht. #}
|
||||
<article class="tile tile--{{ tile.state }}" data-filter-item
|
||||
data-filter-text="{{ tile.name }} {{ tile.get('ip','') }} {{ tile.get('switch','') }} {{ tile.location }}">
|
||||
<div class="tile__head">
|
||||
@@ -73,7 +73,7 @@
|
||||
<dt>Switch</dt><dd class="truncate">{{ tile.switch }} / {{ tile.port }}</dd>
|
||||
{% endif %}
|
||||
{% if tile.latency_ms %}
|
||||
<dt>Antwortzeit</dt><dd>{{ '%.1f' | format(tile.latency_ms) }} ms</dd>
|
||||
<dt>Antwortzeit</dt><dd>{{ tile.latency_ms | millis }}</dd>
|
||||
{% endif %}
|
||||
{% if tile.last_change_at %}
|
||||
<dt>Seit</dt>
|
||||
@@ -92,7 +92,7 @@
|
||||
<input type="hidden" name="method" value="{{ method.key }}">
|
||||
<button class="btn btn--sm" type="submit" title="{{ method.hint }}"
|
||||
data-busy-label="Startet ..."
|
||||
data-confirm="{{ tile.name }} ueber {{ method.label }} neu starten? {{ method.hint }}"
|
||||
data-confirm="{{ tile.name }} über {{ method.label }} neu starten? {{ method.hint }}"
|
||||
data-confirm-title="Neustart" data-confirm-ok="Neu starten"
|
||||
data-confirm-style="primary">
|
||||
{{ icon('power', size=14) }} {{ method.label }}
|
||||
@@ -118,8 +118,8 @@
|
||||
{% endfor %}
|
||||
|
||||
{% if not sections | map(attribute='tiles') | select | list %}
|
||||
{{ empty_state('Keine Geraete erfasst',
|
||||
'Legen Sie das erste Geraet unter Ueberwachung an.', 'cpu') }}
|
||||
{{ empty_state('Keine Geräte erfasst',
|
||||
'Legen Sie das erste Gerät unter Überwachung an.', 'cpu') }}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1 +1 @@
|
||||
<pre class="log-view">{{ job.output or 'Noch keine Ausgabe.' }}</pre>
|
||||
<pre class="log-view" data-tail>{{ job.output or 'Noch keine Ausgabe.' }}</pre>
|
||||
@@ -1,14 +1,14 @@
|
||||
{% extends "tesm_core/base.html" %}
|
||||
{% from "tesm_core/macros.html" import field, checkbox, alert, pill %}
|
||||
|
||||
{% block page_title %}Sicherung und Uebertragung{% endblock %}
|
||||
{% block page_sub %}Verschluesselter Export · zweistufiger Import{% endblock %}
|
||||
{% block page_title %}Sicherung und Übertragung{% endblock %}
|
||||
{% block page_sub %}Verschlüsselter Export · zweistufiger Import{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% if license_hint %}{{ alert(license_hint, 'warning') }}{% endif %}
|
||||
|
||||
{{ alert('Der Export wird mit Argon2id aus Ihrer Passphrase verschluesselt (AES-256-GCM). Ohne
|
||||
diese Passphrase ist die Datei nicht wiederherstellbar -- es gibt keine Hintertuer. Bewahren Sie
|
||||
{{ 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. Bewahren Sie
|
||||
sie getrennt von der Sicherung auf.', 'warning', 'Passphrase') }}
|
||||
|
||||
<div class="grid grid--split">
|
||||
@@ -16,7 +16,7 @@ sie getrennt von der Sicherung auf.', 'warning', 'Passphrase') }}
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('download', size=17) }} Sicherung erstellen</div>
|
||||
<div class="card__sub">Auswahl je Kategorie · verlangt eine frische Passwortbestaetigung</div>
|
||||
<div class="card__sub">Auswahl je Kategorie · verlangt eine frische Passwortbestätigung</div>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" action="{{ url_for('backup.export') }}" data-no-guard>
|
||||
@@ -37,7 +37,7 @@ sie getrennt von der Sicherung auf.', 'warning', 'Passphrase') }}
|
||||
{% 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">Fuer diese Kategorie fehlt Ihnen das Recht.</div>
|
||||
<div class="fs-xs danger-text">Für diese Kategorie fehlt Ihnen das Recht.</div>
|
||||
{% endif %}
|
||||
</span>
|
||||
</label>
|
||||
@@ -46,9 +46,9 @@ sie getrennt von der Sicherung auf.', 'warning', 'Passphrase') }}
|
||||
</fieldset>
|
||||
|
||||
{% if can_secrets %}
|
||||
{{ checkbox('include_secrets', 'Passwoerter der Zugangsdaten mit exportieren',
|
||||
hint_text='Sie liegen dann im Klartext innerhalb der verschluesselten
|
||||
Datei. Ohne dieses Kennzeichen enthaelt die Sicherung nur Namen und
|
||||
{{ checkbox('include_secrets', 'Passwörter der Zugangsdaten mit exportieren',
|
||||
hint_text='Sie liegen dann im Klartext innerhalb der verschlüsselten
|
||||
Datei. Ohne dieses Kennzeichen enthält die Sicherung nur Namen und
|
||||
Benutzer.') }}
|
||||
{% endif %}
|
||||
|
||||
@@ -73,9 +73,9 @@ sie getrennt von der Sicherung auf.', 'warning', 'Passphrase') }}
|
||||
<section class="card card--accent">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('upload', size=17) }} Geoeffnete Sicherung</div>
|
||||
<div class="card__title">{{ icon('upload', size=17) }} Geöffnete Sicherung</div>
|
||||
<div class="card__sub">
|
||||
Waehlen Sie, was uebernommen werden soll. Gueltig fuer {{ preview_ttl }} Minuten.
|
||||
Wählen Sie, was übernommen werden soll. Gültig für {{ preview_ttl }} Minuten.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -84,7 +84,7 @@ sie getrennt von der Sicherung auf.', 'warning', 'Passphrase') }}
|
||||
<div class="card__body stack">
|
||||
<div class="table-wrap">
|
||||
<table class="table table--compact">
|
||||
<thead><tr><th></th><th>Kategorie</th><th class="numeric">Eintraege</th></tr></thead>
|
||||
<thead><tr><th></th><th>Kategorie</th><th class="numeric">Einträge</th></tr></thead>
|
||||
<tbody>
|
||||
{% for entry in preview.summary %}
|
||||
<tr>
|
||||
@@ -96,7 +96,7 @@ sie getrennt von der Sicherung auf.', 'warning', 'Passphrase') }}
|
||||
</td>
|
||||
<td>
|
||||
{{ entry.label }}
|
||||
{% if entry.has_secrets %}{{ pill('mit Passwoertern', 'danger') }}{% endif %}
|
||||
{% if entry.has_secrets %}{{ pill('mit Passwörtern', 'danger') }}{% endif %}
|
||||
</td>
|
||||
<td class="numeric">{{ entry.count }}</td>
|
||||
</tr>
|
||||
@@ -104,17 +104,17 @@ sie getrennt von der Sicherung auf.', 'warning', 'Passphrase') }}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{ alert('Bestehende Eintraege werden nach ihrem natuerlichen Schluessel abgeglichen
|
||||
(Name, Hostname, MAC, Benutzername) und aktualisiert. Eintraege, die im Papierkorb
|
||||
{{ alert('Bestehende Einträge werden nach ihrem natürlichen Schlüssel abgeglichen
|
||||
(Name, Hostname, MAC, Benutzername) und aktualisiert. Einträge, die im Papierkorb
|
||||
liegen, werden nicht wiederbelebt.', 'info') }}
|
||||
</div>
|
||||
<div class="card__footer modal__footer--split">
|
||||
<button class="btn btn--sm btn--ghost" type="submit"
|
||||
formaction="{{ url_for('backup.import_discard') }}">Verwerfen</button>
|
||||
<button class="btn btn--primary" type="submit"
|
||||
data-confirm="Ausgewaehlte Kategorien jetzt uebernehmen? Bestehende Daten werden aktualisiert."
|
||||
data-confirm-title="Sicherung uebernehmen" data-confirm-ok="Uebernehmen"
|
||||
data-confirm-style="primary">Uebernehmen</button>
|
||||
data-confirm="Ausgewählte Kategorien jetzt übernehmen? Bestehende Daten werden aktualisiert."
|
||||
data-confirm-title="Sicherung übernehmen" data-confirm-ok="Übernehmen"
|
||||
data-confirm-style="primary">Übernehmen</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
@@ -123,7 +123,7 @@ sie getrennt von der Sicherung auf.', 'warning', 'Passphrase') }}
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('upload', size=17) }} Sicherung einspielen</div>
|
||||
<div class="card__sub">Erst oeffnen und pruefen, dann uebernehmen</div>
|
||||
<div class="card__sub">Erst öffnen und prüfen, dann übernehmen</div>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" enctype="multipart/form-data" data-no-guard
|
||||
@@ -135,8 +135,8 @@ sie getrennt von der Sicherung auf.', 'warning', 'Passphrase') }}
|
||||
autocomplete='off') }}
|
||||
</div>
|
||||
<div class="card__footer">
|
||||
<button class="btn btn--primary" type="submit" data-busy-label="Oeffne ...">
|
||||
Datei oeffnen
|
||||
<button class="btn btn--primary" type="submit" data-busy-label="Öffne ...">
|
||||
Datei öffnen
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -150,8 +150,8 @@ sie getrennt von der Sicherung auf.', 'warning', 'Passphrase') }}
|
||||
<div class="card__body">
|
||||
<ul class="stack stack--tight fs-sm muted">
|
||||
<li>Verzeichniskonten werden nie exportiert -- sie entstehen am Ziel bei der ersten Anmeldung neu.</li>
|
||||
<li>Lizenz- und Aktivierungszustand bleiben ausgenommen: sie gehoeren zur Maschine, nicht zur Sicherung.</li>
|
||||
<li>Sitzungen, Ratenbegrenzung und das Aenderungsprotokoll sind nicht Teil der Sicherung.</li>
|
||||
<li>Lizenz- und Aktivierungszustand bleiben ausgenommen: sie gehören zur Maschine, nicht zur Sicherung.</li>
|
||||
<li>Sitzungen, Ratenbegrenzung und das Änderungsprotokoll sind nicht Teil der Sicherung.</li>
|
||||
<li>Der Zwischenstand des Imports liegt in der Datenbank -- ein Dienst-Neustall verliert ihn nicht.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -18,21 +18,21 @@
|
||||
|
||||
{% block content %}
|
||||
{% if not host_key_ready %}
|
||||
{{ alert('Fuer dieses Ziel ist kein SSH-Host-Schluessel freigegeben. Die Konsole verweigert
|
||||
{{ alert('Für dieses Ziel ist kein SSH-Host-Schlüssel freigegeben. Die Konsole verweigert
|
||||
den Aufbau, bis das geschehen ist -- die Freigabe erfolgt auf der Detailseite.',
|
||||
'danger', 'Host-Schluessel fehlt') }}
|
||||
<p><a class="btn btn--primary" href="{{ back_url }}">Zur Detailseite und Schluessel freigeben</a></p>
|
||||
'danger', 'Host-Schlüssel fehlt') }}
|
||||
<p><a class="btn btn--primary" href="{{ back_url }}">Zur Detailseite und Schlüssel freigeben</a></p>
|
||||
{% endif %}
|
||||
|
||||
{{ alert('Die Konsole ist zeilenorientiert: ein Kommando pro Zeile, Antwort darunter. Jede
|
||||
Eingabe und jede Ausgabe wird auf dem Server mitgeschrieben und im Aenderungsprotokoll
|
||||
vermerkt. Bei Untaetigkeit trennt die Verbindung nach ' ~ idle_timeout ~ ' Sekunden.',
|
||||
Eingabe und jede Ausgabe wird auf dem Server mitgeschrieben und im Änderungsprotokoll
|
||||
vermerkt. Bei Untätigkeit trennt die Verbindung nach ' ~ idle_timeout ~ ' Sekunden.',
|
||||
'warning', 'Mitschrift') }}
|
||||
|
||||
{% if not is_switch %}
|
||||
{{ alert('Jedes Kommando laeuft in einer eigenen Sitzung. Das heisst: ein "cd" wirkt nicht
|
||||
auf das naechste Kommando, und interaktive Programme (Editoren, Passwortabfragen) laufen
|
||||
hier nicht. Fuer solche Arbeiten einen richtigen SSH-Client nehmen.', 'info',
|
||||
{{ alert('Jedes Kommando läuft in einer eigenen Sitzung. Das heisst: ein "cd" wirkt nicht
|
||||
auf das nächste Kommando, und interaktive Programme (Editoren, Passwortabfragen) laufen
|
||||
hier nicht. Für solche Arbeiten einen richtigen SSH-Client nehmen.', 'info',
|
||||
'Ein Kommando, eine Sitzung') }}
|
||||
{% endif %}
|
||||
|
||||
@@ -49,7 +49,7 @@ vermerkt. Bei Untaetigkeit trennt die Verbindung nach ' ~ idle_timeout ~ ' Sekun
|
||||
{{ icon('copy', size=14) }} Mitschrift kopieren
|
||||
</button>
|
||||
</div>
|
||||
<pre class="log-view" id="console-output" style="max-height:56dvh"></pre>
|
||||
<pre class="log-view" data-tail id="console-output" style="max-height:56dvh"></pre>
|
||||
<div class="card__footer" style="justify-content:stretch;gap:var(--sp-2)">
|
||||
<select class="select" id="console-preset" style="max-width:280px">
|
||||
<option value="">Kommando aus der Liste ...</option>
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
</nav>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
{{ alert('Dieser Aufruf steht mit Zeitpunkt, Benutzer und IP im Aenderungsprotokoll.
|
||||
Schliessen Sie die Seite, sobald Sie den Wert uebernommen haben.', 'warning') }}
|
||||
{{ alert('Dieser Aufruf steht mit Zeitpunkt, Benutzer und IP im Änderungsprotokoll.
|
||||
Schliessen Sie die Seite, sobald Sie den Wert übernommen haben.', 'warning') }}
|
||||
<section class="card" style="max-width:560px">
|
||||
<div class="card__header">
|
||||
<div class="flex-1"><div class="card__title">{{ icon('key', size=17) }} {{ entry.name }}</div>
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
{% macro credential_form(entry=None) %}
|
||||
<div class="form-grid">
|
||||
{{ field('name', 'Name', value=entry.name if entry else '', required=True,
|
||||
hint_text='Frei waehlbar, z. B. "Switch-Admin" oder "Linux-Wartung".') }}
|
||||
hint_text='Frei wählbar, z. B. "Switch-Admin" oder "Linux-Wartung".') }}
|
||||
{{ field('username', 'Benutzer', value=entry.username if entry else '', autocomplete='off') }}
|
||||
{{ field('secret', 'Passwort' ~ (' (leer lassen = unveraendert)' if entry else ''),
|
||||
{{ field('secret', 'Passwort' ~ (' (leer lassen = unverändert)' if entry else ''),
|
||||
type='password', autocomplete='new-password',
|
||||
required=False if entry else True) }}
|
||||
<label class="field">
|
||||
@@ -24,7 +24,7 @@
|
||||
{% endmacro %}
|
||||
|
||||
{% block page_title %}Zugangsdaten{% endblock %}
|
||||
{% block page_sub %}{{ credentials | length }} Eintraege · verschluesselt mit AES-256-GCM{% endblock %}
|
||||
{% block page_sub %}{{ credentials | length }} Einträge · verschlüsselt mit AES-256-GCM{% endblock %}
|
||||
|
||||
{% block topbar_extra %}
|
||||
{{ search_box('credentials-table', 'Name, Benutzer ...') }}
|
||||
@@ -36,10 +36,10 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{{ alert('Passwoerter liegen AES-256-GCM-verschluesselt und an ihren Verwendungszweck gebunden.
|
||||
Sie im Klartext zu sehen braucht ein eigenes Recht, eine frische Passwortbestaetigung und wird
|
||||
protokolliert. Die Kategorie setzen Sie ausdruecklich -- sie wird bewusst nicht geraten, weil
|
||||
davon abhaengt, welche Geraete auf der Wartungsseite erscheinen.', 'info') }}
|
||||
{{ alert('Passwörter liegen AES-256-GCM-verschlüsselt und an ihren Verwendungszweck gebunden.
|
||||
Sie im Klartext zu sehen braucht ein eigenes Recht, eine frische Passwortbestätigung und wird
|
||||
protokolliert. Die Kategorie setzen Sie ausdrücklich -- sie wird bewusst nicht geraten, weil
|
||||
davon abhängt, welche Geräte auf der Wartungsseite erscheinen.', 'info') }}
|
||||
|
||||
<section class="card">
|
||||
<div class="table-wrap">
|
||||
@@ -89,10 +89,10 @@ davon abhaengt, welche Geraete auf der Wartungsseite erscheinen.', 'info') }}
|
||||
<form method="post" action="{{ url_for('credentials.delete', credential_id=entry.id) }}"
|
||||
style="display:inline" data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<button class="btn btn--sm btn--ghost danger-text" type="submit" aria-label="Loeschen"
|
||||
{% if entry.in_use %}disabled title="Wird noch von {{ entry.in_use }} Eintraegen verwendet"{% endif %}
|
||||
<button class="btn btn--sm btn--ghost danger-text" type="submit" aria-label="Löschen"
|
||||
{% if entry.in_use %}disabled title="Wird noch von {{ entry.in_use }} Einträgen verwendet"{% endif %}
|
||||
data-confirm="{{ entry.name }} in den Papierkorb verschieben?"
|
||||
data-confirm-title="Zugangsdaten loeschen" data-confirm-ok="Loeschen">
|
||||
data-confirm-title="Zugangsdaten löschen" data-confirm-ok="Löschen">
|
||||
{{ icon('trash', size=15) }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
@@ -1,22 +1,17 @@
|
||||
{% extends "tesm_core/base.html" %}
|
||||
{% from "tesm_core/macros.html" import search_box %}
|
||||
|
||||
{% block page_title %}Statusuebersicht{% endblock %}
|
||||
{% block page_title %}Statusübersicht{% endblock %}
|
||||
{% block page_sub %}
|
||||
{{ counts.total }} Geraete · Pruefintervall {{ interval }} s
|
||||
{% if not detailed %}· oeffentliche Ansicht{% endif %}
|
||||
{{ counts.total }} Geräte · Prüfintervall {{ interval }} s
|
||||
{% if not detailed %}· öffentliche Ansicht{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block topbar_extra %}
|
||||
{{ search_box('tile-sections', 'Geraet suchen ...') }}
|
||||
{% if current_user.has('settings_system.execute') %}
|
||||
<form method="post" action="{{ url_for('settings.run_check_now') }}" data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<button class="btn btn--sm" type="submit" title="Sofort pruefen">
|
||||
{{ icon('refresh', size=15) }} Jetzt pruefen
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{{ search_box('tile-sections', 'Gerät suchen ...') }}
|
||||
{# Der Knopf "Jetzt prüfen" sass hier ein zweites Mal: die Timer-Pille in
|
||||
der Topbar trägt ihn jetzt, und dort gehört er hin -- er setzt genau die
|
||||
Angabe zurück, die daneben steht. #}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
<input type="hidden" name="method" value="{{ method.key }}">
|
||||
<button class="btn btn--sm {{ 'btn--primary' if loop.first else 'btn--ghost' }}"
|
||||
type="submit" title="{{ method.hint }}" data-busy-label="Startet ..."
|
||||
data-confirm="{{ device.name }} ueber {{ method.label }} neu starten? {{ method.hint }}"
|
||||
data-confirm="{{ device.name }} über {{ method.label }} neu starten? {{ method.hint }}"
|
||||
data-confirm-title="Neustart" data-confirm-ok="Neu starten"
|
||||
data-confirm-style="primary">
|
||||
{{ icon('power', size=15) }} {{ method.label }}
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
{% block content %}
|
||||
{% if device.deleted_at %}
|
||||
{{ alert('Dieses Geraet liegt im Papierkorb (geloescht am ' ~ (device.deleted_at | dt) ~ ').',
|
||||
{{ alert('Dieses Gerät liegt im Papierkorb (gelöscht am ' ~ (device.deleted_at | dt) ~ ').',
|
||||
'warning') }}
|
||||
{% endif %}
|
||||
|
||||
@@ -58,8 +58,8 @@
|
||||
<div class="grid grid--4">
|
||||
<div class="stat">
|
||||
<span class="stat__label">Antwortzeit</span>
|
||||
<span class="stat__value">{{ '%.1f' | format(device.latency_ms) if device.latency_ms else '–' | safe }}</span>
|
||||
<span class="stat__hint">Millisekunden</span>
|
||||
<span class="stat__value">{{ device.latency_ms | millis if device.latency_ms else '–' | safe }}</span>
|
||||
<span class="stat__hint">letzte Messung</span>
|
||||
</div>
|
||||
<div class="stat {{ 'stat--danger' if device.fail_count else '' }}">
|
||||
<span class="stat__label">Fehlversuche</span>
|
||||
@@ -88,37 +88,41 @@
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('power', size=17) }} Neustarts</div>
|
||||
<div class="card__sub">
|
||||
Die letzten {{ events | length }} Neustarts -- ueber den PoE-Port wie per SSH
|
||||
Die letzten {{ events | length }} Neustarts -- über den PoE-Port wie per SSH
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if events %}
|
||||
<div class="table-wrap">
|
||||
<table class="table table--compact">
|
||||
<thead><tr><th>Zeit</th><th>Methode</th><th>Ausloeser</th><th>Ergebnis</th><th class="numeric">Dauer</th><th>Detail</th></tr></thead>
|
||||
<thead><tr><th>Zeit</th><th>Methode</th><th>Auslöser</th><th>Ergebnis</th><th class="numeric">Dauer</th><th>Detail</th></tr></thead>
|
||||
<tbody>
|
||||
{% for event in events %}
|
||||
<tr>
|
||||
<td class="fs-sm nowrap">{{ event.ts | dt }}</td>
|
||||
<td class="fs-sm">
|
||||
{# Drei Wege, drei Anzeigen: alles ausser 'ssh' als "PoE-Port"
|
||||
zu zeigen hat RPC-Neustarts als PoE ausgegeben. #}
|
||||
{% if event.method == 'ssh' %}{{ pill('SSH', 'info') }}
|
||||
{% else %}{{ pill('PoE-Port', '') }}{% endif %}
|
||||
{% elif event.method == 'rpc' %}{{ pill('RPC', 'accent') }}
|
||||
{% elif event.method == 'pö' %}{{ pill('PoE-Port', '') }}
|
||||
{% else %}{{ pill(event.method or 'unbekannt', 'warning') }}{% endif %}
|
||||
</td>
|
||||
<td class="fs-sm">{{ event.trigger }} <span class="faint">/ {{ event.actor }}</span></td>
|
||||
<td>
|
||||
{% if event.result == 'ok' %}{{ pill('erfolgreich', 'online') }}
|
||||
{% elif event.result == 'skipped' %}{{ pill('uebersprungen', 'warning') }}
|
||||
{% elif event.result == 'skipped' %}{{ pill('übersprungen', 'warning') }}
|
||||
{% else %}{{ pill('fehlgeschlagen', 'danger') }}{% endif %}
|
||||
</td>
|
||||
<td class="numeric fs-sm">{{ event.duration_ms or 0 }} ms</td>
|
||||
<td class="fs-xs muted break-all">{{ event.detail }}</td>
|
||||
<td class="numeric fs-sm nowrap">{{ event.duration_ms | millis }}</td>
|
||||
<td class="fs-xs muted break-all">{{ event.detail | audit_detail }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
{{ empty_state('Noch keine Neustarts', 'Dieses Geraet wurde noch nie neu gestartet.', 'power') }}
|
||||
{{ empty_state('Noch keine Neustarts', 'Dieses Gerät wurde noch nie neu gestartet.', 'power') }}
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
@@ -126,12 +130,12 @@
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">
|
||||
{{ icon('shield', size=17) }} SSH-Host-Schluessel
|
||||
{{ icon('shield', size=17) }} SSH-Host-Schlüssel
|
||||
{% if host_keys %}{{ pill('freigegeben', 'online') }}
|
||||
{% else %}{{ pill('offen', 'warning') }}{% endif %}
|
||||
</div>
|
||||
<div class="card__sub">
|
||||
Ohne freigegebenen Schluessel verweigern Wartung und Konsole die Verbindung.
|
||||
Ohne freigegebenen Schlüssel verweigern Wartung und Konsole die Verbindung.
|
||||
</div>
|
||||
</div>
|
||||
{% if current_user.has('devices.edit') %}
|
||||
@@ -151,11 +155,11 @@
|
||||
<div class="alert alert--warning">
|
||||
{{ icon('alert', size=18) }}
|
||||
<div class="alert__body">
|
||||
<div class="alert__title">Gelesener Schluessel</div>
|
||||
<div class="alert__title">Gelesener Schlüssel</div>
|
||||
<div class="mono fs-sm break-all">{{ probed_type }} {{ probed_fingerprint }}</div>
|
||||
<p class="fs-sm mt-2">
|
||||
Vergleichen Sie den Fingerprint mit einer unabhaengigen Quelle (Konsole am
|
||||
Geraet, Inventardokumentation), bevor Sie ihn freigeben.
|
||||
Vergleichen Sie den Fingerprint mit einer unabhängigen Quelle (Konsole am
|
||||
Gerät, Inventardokumentation), bevor Sie ihn freigeben.
|
||||
</p>
|
||||
<form method="post" class="mt-2"
|
||||
action="{{ url_for('devices.trust_host_key', device_id=device.id) }}">
|
||||
@@ -163,8 +167,8 @@
|
||||
<input type="hidden" name="key_type" value="{{ probed_type }}">
|
||||
<input type="hidden" name="fingerprint" value="{{ probed_fingerprint }}">
|
||||
<button class="btn btn--sm btn--primary" type="submit"
|
||||
data-confirm="Diesen Fingerprint dauerhaft als vertrauenswuerdig hinterlegen?"
|
||||
data-confirm-title="Host-Schluessel freigeben" data-confirm-ok="Freigeben"
|
||||
data-confirm="Diesen Fingerprint dauerhaft als vertrauenswürdig hinterlegen?"
|
||||
data-confirm-title="Host-Schlüssel freigeben" data-confirm-ok="Freigeben"
|
||||
data-confirm-style="primary">Freigeben</button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -184,15 +188,15 @@
|
||||
data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<button class="btn btn--sm btn--ghost" type="submit"
|
||||
data-confirm="Gespeicherte Host-Schluessel dieses Geraets entfernen? Danach muss der Schluessel erneut freigegeben werden."
|
||||
data-confirm-title="Schluessel verwerfen" data-confirm-ok="Verwerfen">
|
||||
data-confirm="Gespeicherte Host-Schlüssel dieses Geräts entfernen? Danach muss der Schlüssel erneut freigegeben werden."
|
||||
data-confirm-title="Schlüssel verwerfen" data-confirm-ok="Verwerfen">
|
||||
Verwerfen
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<p class="muted fs-sm">
|
||||
Noch kein Schluessel hinterlegt. Erst lesen, Fingerprint pruefen, dann freigeben.
|
||||
Noch kein Schlüssel hinterlegt. Erst lesen, Fingerprint prüfen, dann freigeben.
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -223,7 +227,7 @@
|
||||
<dt>Zugangsdaten</dt><dd>{{ device.credential_name or 'keine' }}</dd>
|
||||
<dt>Automatischer Neustart</dt><dd>{{ device.auto_restart | yes_no }}</dd>
|
||||
<dt>Angelegt</dt><dd>{{ device.created_at | dt }}</dd>
|
||||
<dt>Geaendert</dt>
|
||||
<dt>Geändert</dt>
|
||||
<dd>{{ device.updated_at | dt }} <span class="faint">von {{ device.updated_by or '-' }}</span></dd>
|
||||
</dl>
|
||||
{% if device.notes %}<p class="fs-sm muted mt-3">{{ device.notes }}</p>{% endif %}
|
||||
@@ -234,7 +238,7 @@
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="active" value="{{ '0' if device.is_active else '1' }}">
|
||||
<button class="btn btn--sm" type="submit">
|
||||
{{ 'Ueberwachung aussetzen' if device.is_active else 'Ueberwachung aktivieren' }}
|
||||
{{ 'Überwachung aussetzen' if device.is_active else 'Überwachung aktivieren' }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
|
||||
{# Neustart-Bedienelement.
|
||||
|
||||
Ein einziger moeglicher Weg: ein Knopf, der ihn benennt. Mehrere: ein Knopf
|
||||
fuer den bevorzugten Weg (PoE, weil er auch bei haengendem Betriebssystem
|
||||
Ein einziger möglicher Weg: ein Knopf, der ihn benennt. Mehrere: ein Knopf
|
||||
für den bevorzugten Weg (PoE, weil er auch bei hängendem Betriebssystem
|
||||
wirkt) und daneben die Auswahl. Kein Weg: gar nichts -- ein Knopf, der
|
||||
verlaesslich nichts bewirkt, ist schlimmer als kein Knopf. #}
|
||||
verlässlich nichts bewirkt, ist schlimmer als kein Knopf. #}
|
||||
{% macro restart_control(device, methods, compact=False) %}
|
||||
{% if methods %}
|
||||
{% set first = methods[0] %}
|
||||
@@ -17,7 +17,7 @@
|
||||
<input type="hidden" name="method" value="{{ first.key }}">
|
||||
<button class="btn btn--sm{{ ' btn--ghost' if compact else '' }}" type="submit"
|
||||
title="{{ first.hint }}" data-busy-label="Startet ..."
|
||||
data-confirm="{{ device.name }} ueber {{ first.label }} neu starten? {{ first.hint }}"
|
||||
data-confirm="{{ device.name }} über {{ first.label }} neu starten? {{ first.hint }}"
|
||||
data-confirm-title="Neustart" data-confirm-ok="Neu starten"
|
||||
data-confirm-style="primary">
|
||||
{{ icon('power', size=15) }}{% if not compact %} {{ first.label }}{% endif %}
|
||||
@@ -31,7 +31,7 @@
|
||||
<input type="hidden" name="method" value="{{ other.key }}">
|
||||
<button class="btn btn--sm btn--ghost" type="submit" title="{{ other.hint }}"
|
||||
data-busy-label="Startet ..."
|
||||
data-confirm="{{ device.name }} ueber {{ other.label }} neu starten? {{ other.hint }}"
|
||||
data-confirm="{{ device.name }} über {{ other.label }} neu starten? {{ other.hint }}"
|
||||
data-confirm-title="Neustart" data-confirm-ok="Neu starten"
|
||||
data-confirm-style="primary">
|
||||
{{ other.label }}
|
||||
@@ -65,7 +65,7 @@
|
||||
placeholder='1 oder 1/1/3',
|
||||
hint_text='Pflicht, sobald ein Switch zugeordnet ist.') }}
|
||||
<label class="field">
|
||||
<span class="field__label">Zugangsdaten (fuer Wartung)</span>
|
||||
<span class="field__label">Zugangsdaten (für Wartung)</span>
|
||||
<select class="select" name="credential_id" data-behavior="toggle-target"
|
||||
data-toggle-value="__neu__" data-toggle-target="#new-credential-{{ scope }}">
|
||||
<option value="">keine</option>
|
||||
@@ -85,8 +85,8 @@
|
||||
<legend class="field__label">Neue Zugangsdaten</legend>
|
||||
<div class="form-grid">
|
||||
{{ field('new_credential_name', 'Bezeichnung', placeholder='z. B. Wartung Linux',
|
||||
hint_text='Der Eintrag wird zusaetzlich unter Zugangsdaten gespeichert und
|
||||
ist danach fuer weitere Geraete verwendbar.') }}
|
||||
hint_text='Der Eintrag wird zusätzlich unter Zugangsdaten gespeichert und
|
||||
ist danach für weitere Geräte verwendbar.') }}
|
||||
{{ field('new_credential_username', 'Benutzer') }}
|
||||
{{ field('new_credential_secret', 'Passwort', type='password') }}
|
||||
<label class="field">
|
||||
@@ -101,7 +101,7 @@
|
||||
</div>
|
||||
</fieldset>
|
||||
{% endif %}
|
||||
{{ field('ssh_port', 'SSH-Port des Geraets', value=device.ssh_port if device else 22, type='number') }}
|
||||
{{ field('ssh_port', 'SSH-Port des Geräts', value=device.ssh_port if device else 22, type='number') }}
|
||||
<label class="field">
|
||||
<span class="field__label">Kategorie</span>
|
||||
<select class="select" name="category">
|
||||
@@ -115,23 +115,23 @@
|
||||
extra_class='field--full') }}
|
||||
</div>
|
||||
<div class="stack stack--tight">
|
||||
{{ checkbox('is_active', 'Geraet wird ueberwacht', checked=device.is_active if device else True) }}
|
||||
{{ checkbox('is_active', 'Gerät wird überwacht', checked=device.is_active if device else True) }}
|
||||
{{ checkbox('auto_restart', 'Automatischer PoE-Neustart bei Ausfall',
|
||||
checked=device.auto_restart if device else True,
|
||||
hint_text='Greift nur mit gueltiger Lizenz und zugeordnetem Switch-Port.') }}
|
||||
hint_text='Greift nur mit gültiger Lizenz und zugeordnetem Switch-Port.') }}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% block page_title %}Clients{% endblock %}
|
||||
{% block page_sub %}
|
||||
{{ devices | length }} Geraete · {{ counts.online }} erreichbar, {{ counts.offline }} nicht erreichbar
|
||||
{{ devices | length }} Geräte · {{ counts.online }} erreichbar, {{ counts.offline }} nicht erreichbar
|
||||
{% endblock %}
|
||||
|
||||
{% block topbar_extra %}
|
||||
{{ search_box('devices-table', 'Name, IP, MAC, Switch ...', count_id='devices-visible') }}
|
||||
{% if current_user.has('devices.create') %}
|
||||
<button class="btn btn--primary btn--sm" type="button" data-dialog-open="device-new">
|
||||
{{ icon('plus', size=15) }} Geraet anlegen
|
||||
{{ icon('plus', size=15) }} Gerät anlegen
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -155,8 +155,8 @@
|
||||
<th class="sortable">MAC</th>
|
||||
<th class="sortable">Zugangsdaten</th>
|
||||
<th class="sortable">Switch / Port</th>
|
||||
<th class="sortable">Letzte Pruefung</th>
|
||||
<th class="sortable">Host-Schluessel</th>
|
||||
<th class="sortable">Letzte Prüfung</th>
|
||||
<th class="sortable">Host-Schlüssel</th>
|
||||
<th class="actions">Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -167,13 +167,14 @@
|
||||
<a class="fw-600" href="{{ url_for('devices.detail', device_id=device.id) }}">{{ device.name }}</a>
|
||||
{% if not device.is_active %}{{ pill('deaktiviert', '') }}{% endif %}
|
||||
{% if not device.auto_restart %}
|
||||
<span class="hint-icon" title="Automatischer PoE-Neustart ist fuer dieses Geraet abgeschaltet">!</span>
|
||||
<span class="hint-icon" title="Automatischer PoE-Neustart ist für dieses Gerät abgeschaltet">!</span>
|
||||
{% endif %}
|
||||
{% if device.location %}<div class="fs-xs faint">{{ device.location }}</div>{% endif %}
|
||||
</td>
|
||||
<td data-sort-value="{{ device.state }}">
|
||||
{% if not device.is_active %}{{ pill('aus', '') }}
|
||||
{% elif device.state == 'online' %}{{ pill('online', 'online') }}
|
||||
{% elif device.state == 'restarting' %}{{ pill('startet neu', 'warning') }}
|
||||
{% elif device.state == 'offline' %}{{ pill('offline', 'offline') }}
|
||||
{% else %}{{ pill('unbekannt', 'warning') }}{% endif %}
|
||||
{% if device.fail_count %}<span class="fs-xs faint">({{ device.fail_count }}×)</span>{% endif %}
|
||||
@@ -200,8 +201,8 @@
|
||||
</td>
|
||||
<td data-sort-value="{{ '1' if device.host_key_known else '0' }}">
|
||||
{% if device.credential_category == 'windows' %}
|
||||
<span class="faint fs-xs" title="Windows wird nicht ueber SSH verwaltet">
|
||||
nicht noetig
|
||||
<span class="faint fs-xs" title="Windows wird nicht über SSH verwaltet">
|
||||
nicht nötig
|
||||
</span>
|
||||
{% elif device.host_key_known %}{{ pill('freigegeben', 'online') }}
|
||||
{% else %}
|
||||
@@ -225,9 +226,9 @@
|
||||
<form method="post" action="{{ url_for('devices.delete', device_id=device.id) }}"
|
||||
style="display:inline" data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<button class="btn btn--sm btn--ghost danger-text" type="submit" aria-label="Loeschen"
|
||||
<button class="btn btn--sm btn--ghost danger-text" type="submit" aria-label="Löschen"
|
||||
data-confirm="{{ device.name }} in den Papierkorb verschieben? Die MAC bleibt dabei reserviert."
|
||||
data-confirm-title="Geraet loeschen" data-confirm-ok="Loeschen">
|
||||
data-confirm-title="Gerät löschen" data-confirm-ok="Löschen">
|
||||
{{ icon('trash', size=15) }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
@@ -238,19 +239,19 @@
|
||||
</table>
|
||||
</div>
|
||||
{% if not devices %}
|
||||
{{ empty_state('Keine Geraete', 'Legen Sie das erste ueberwachte Geraet an.', 'cpu') }}
|
||||
{{ empty_state('Keine Geräte', 'Legen Sie das erste überwachte Gerät an.', 'cpu') }}
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
{{ alert('Ein PoE-Neustart setzt Switch und Port voraus, ein Neustart ueber SSH oder
|
||||
RPC setzt passende Zugangsdaten voraus. Ist keine Methode moeglich, erscheint der Knopf
|
||||
nicht -- damit kein Neustart vorgetaeuscht wird, der nichts bewirkt.', 'info') }}
|
||||
{{ alert('Ein PoE-Neustart setzt Switch und Port voraus, ein Neustart über SSH oder
|
||||
RPC setzt passende Zugangsdaten voraus. Ist keine Methode möglich, erscheint der Knopf
|
||||
nicht -- damit kein Neustart vorgetäuscht wird, der nichts bewirkt.', 'info') }}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block dialogs %}
|
||||
{% if current_user.has('devices.create') %}
|
||||
{% call modal('device-new', 'Geraet anlegen', 'MAC und IP sind Pflicht', 'wide') %}
|
||||
{% call modal('device-new', 'Gerät anlegen', 'MAC und IP sind Pflicht', 'wide') %}
|
||||
<form method="post" action="{{ url_for('devices.create') }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<div class="modal__body">{{ device_form(scope='neu') }}</div>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
{% block content %}
|
||||
{% for warning in warnings %}{{ alert(warning, 'warning') }}{% endfor %}
|
||||
{% if not sysops_available %}
|
||||
{{ alert('Systemhelfer nicht verfuegbar -- Konfiguration und Dienstzustand koennen hier nur
|
||||
{{ alert('Systemhelfer nicht verfügbar -- Konfiguration und Dienstzustand können hier nur
|
||||
angezeigt, nicht geschrieben werden.', 'warning') }}
|
||||
{% endif %}
|
||||
|
||||
@@ -27,13 +27,13 @@
|
||||
<div class="stat">
|
||||
<span class="stat__label">Reservierungen</span>
|
||||
<span class="stat__value">{{ reservations | length }}</span>
|
||||
<span class="stat__hint">automatisch + manuell</span>
|
||||
<span class="stat__hint">automatisch + manüll</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat__label">Leases</span>
|
||||
<span class="stat__value">{{ leases | rejectattr('is_expired') | list | length }}</span>
|
||||
<span class="stat__hint">
|
||||
gueltig{% if leases | selectattr('is_expired') | list %},
|
||||
gültig{% if leases | selectattr('is_expired') | list %},
|
||||
{{ leases | selectattr('is_expired') | list | length }} abgelaufen{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
@@ -58,7 +58,7 @@
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<button class="btn btn--sm {{ 'btn--' ~ style if style }}" type="submit"
|
||||
data-busy-label="Arbeitet ..." data-confirm="{{ question }}"
|
||||
data-confirm-title="{{ label }}" data-confirm-ok="Ausfuehren"
|
||||
data-confirm-title="{{ label }}" data-confirm-ok="Ausführen"
|
||||
data-confirm-style="{{ style or 'primary' }}">{{ label }}</button>
|
||||
</form>
|
||||
{% endfor %}
|
||||
@@ -164,8 +164,8 @@
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('puzzle', size=17) }} DHCP-Optionen</div>
|
||||
<div class="card__sub">
|
||||
{{ options | length }} Optionen · Standardoptionen sind nicht loeschbar, ihr Wert
|
||||
laesst sich leeren
|
||||
{{ options | length }} Optionen · Standardoptionen sind nicht löschbar, ihr Wert
|
||||
lässt sich leeren
|
||||
</div>
|
||||
</div>
|
||||
{{ search_box('options-table', 'Option suchen ...') }}
|
||||
@@ -202,8 +202,8 @@
|
||||
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 Option {{ option.name }} loeschen?"
|
||||
data-confirm-title="Option loeschen" data-confirm-ok="Loeschen">
|
||||
data-confirm="Eigene Option {{ option.name }} löschen?"
|
||||
data-confirm-title="Option löschen" data-confirm-ok="Löschen">
|
||||
{{ icon('trash', size=14) }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
@@ -232,16 +232,16 @@
|
||||
<div class="card__footer"><button class="btn" type="submit">Eigene Option anlegen</button></div>
|
||||
</form>
|
||||
|
||||
{# Der Wert einer Option gilt sonst fuer alle. Genau dafuer gibt es
|
||||
``dhcp_option_values.device_id``: ein eigener Wert je Geraet, der in
|
||||
{# Der Wert einer Option gilt sonst für alle. Genau dafür gibt es
|
||||
``dhcp_option_values.device_id``: ein eigener Wert je Gerät, der in
|
||||
dessen Reservierung geschrieben wird -- etwa eine Terminal-URL, die je
|
||||
Geraet anders lautet. Auswertbar war das von Anfang an, eingeben liess
|
||||
Gerät anders lautet. Auswertbar war das von Anfang an, eingeben liess
|
||||
es sich nicht. #}
|
||||
<div class="card__header" style="border-top:1px solid var(--border-subtle)">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('sliders', size=17) }} Option nur fuer ein Geraet</div>
|
||||
<div class="card__title">{{ icon('sliders', size=17) }} Option nur für ein Gerät</div>
|
||||
<div class="card__sub">
|
||||
Der Wert landet in der Reservierung dieses Geraets und ueberschreibt dort den
|
||||
Der Wert landet in der Reservierung dieses Geräts und überschreibt dort den
|
||||
globalen Wert. Leeres Feld entfernt ihn wieder.
|
||||
</div>
|
||||
</div>
|
||||
@@ -251,7 +251,7 @@
|
||||
<div class="card__body">
|
||||
<div class="form-grid">
|
||||
<label class="field">
|
||||
<span class="field__label">Geraet<span class="req" aria-hidden="true">*</span></span>
|
||||
<span class="field__label">Gerät<span class="req" aria-hidden="true">*</span></span>
|
||||
<select class="select" name="device_id" required>
|
||||
{% for reservation in reservations if reservation.device_id %}
|
||||
<option value="{{ reservation.device_id }}">
|
||||
@@ -269,11 +269,11 @@
|
||||
</select>
|
||||
</label>
|
||||
{{ field('value', 'Wert', extra_class='field--full',
|
||||
hint_text='Leer = Wert fuer dieses Geraet entfernen') }}
|
||||
hint_text='Leer = Wert für dieses Gerät entfernen') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card__footer">
|
||||
<button class="btn" type="submit">Fuer dieses Geraet speichern</button>
|
||||
<button class="btn" type="submit">Für dieses Gerät speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endif %}
|
||||
@@ -286,16 +286,16 @@
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('credit-card', size=17) }} Reservierungen</div>
|
||||
<div class="card__sub">
|
||||
Automatisch aus dem Geraetebestand; bei MAC-Kollision gewinnt der Geraetebestand.
|
||||
Automatisch aus dem Gerätebestand; bei MAC-Kollision gewinnt der Gerätebestand.
|
||||
Nur Adressen aus eingerichteten Bereichen.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if skipped_reservations %}
|
||||
{# Nicht verschweigen, sondern benennen: vorher standen diese Eintraege in
|
||||
{# Nicht verschweigen, sondern benennen: vorher standen diese Einträge in
|
||||
der Liste, obwohl Kea sie nie ausgeliefert hat. #}
|
||||
<div class="card__body pt-0">
|
||||
{{ alert(skipped_reservations | length ~ ' Geraet(e) liegen in keinem eingerichteten
|
||||
{{ alert(skipped_reservations | length ~ ' Gerät(e) liegen in keinem eingerichteten
|
||||
Bereich und bekommen keine Reservierung. Legen Sie den passenden Bereich an, wenn sie
|
||||
eine bekommen sollen.', 'warning') }}
|
||||
<details class="fs-xs faint">
|
||||
@@ -339,7 +339,7 @@
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="actions">
|
||||
{% if reservation.source == 'manuell' and current_user.has('dhcp.edit') %}
|
||||
{% if reservation.source == 'manüll' and current_user.has('dhcp.edit') %}
|
||||
<form method="post" data-no-guard
|
||||
action="{{ url_for('dhcp.delete_reservation', reservation_id=reservation.reservation_id) }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
@@ -381,20 +381,20 @@
|
||||
</div>
|
||||
</div>
|
||||
{% if leases and service.get('active') != 'active' %}
|
||||
{# Sonst sieht man alte Eintraege und haelt sie fuer den aktuellen Stand:
|
||||
{# Sonst sieht man alte Einträge und hält sie für den aktüllen Stand:
|
||||
ohne laufenden Dienst wird keine Lease erneuert, und die Datei
|
||||
veraltet still. #}
|
||||
<div class="card__body pt-0">
|
||||
{{ alert('Der DHCP-Dienst laeuft nicht (' ~ service.get('active', 'unbekannt') ~ ').
|
||||
{{ alert('Der DHCP-Dienst läuft nicht (' ~ service.get('active', 'unbekannt') ~ ').
|
||||
Es wird keine Lease erneuert -- die Liste zeigt den letzten Stand der Datei, nicht die
|
||||
aktuelle Lage. Der Grund steht im Kea-Protokoll.', 'warning') }}
|
||||
aktülle Lage. Der Grund steht im Kea-Protokoll.', 'warning') }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if leases %}
|
||||
<div class="table-wrap" style="max-height:420px;overflow:auto">
|
||||
<table class="table table--compact">
|
||||
<thead><tr><th>IP</th><th>MAC</th><th>Hostname</th><th>Art</th><th>Zustand</th>
|
||||
<th>Laeuft ab</th></tr></thead>
|
||||
<th>Läuft ab</th></tr></thead>
|
||||
<tbody>
|
||||
{% for lease in leases %}
|
||||
<tr>
|
||||
@@ -407,7 +407,7 @@
|
||||
</td>
|
||||
<td>
|
||||
{% if lease.is_expired %}{{ pill('abgelaufen', 'warning') }}
|
||||
{% elif lease.is_active %}{{ pill('gueltig', 'online') }}
|
||||
{% elif lease.is_active %}{{ pill('gültig', 'online') }}
|
||||
{% else %}{{ pill('inaktiv', '') }}{% endif %}
|
||||
</td>
|
||||
<td class="fs-sm">{{ lease.expires_text }}
|
||||
@@ -429,7 +429,7 @@
|
||||
<summary class="card__header" style="cursor:pointer">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('file-text', size=17) }} Erzeugte Kea-Konfiguration</div>
|
||||
<div class="card__sub">Vorschau – so wuerde die Datei geschrieben</div>
|
||||
<div class="card__sub">Vorschau – so würde die Datei geschrieben</div>
|
||||
</div>
|
||||
{{ icon('chevron-down', size=16) }}
|
||||
</summary>
|
||||
|
||||
@@ -59,8 +59,8 @@
|
||||
<tr>
|
||||
{% if current_user.has('fileshare.delete') %}<th style="width:40px"></th>{% endif %}
|
||||
<th>Name</th>
|
||||
<th class="numeric">Groesse</th>
|
||||
<th>Geaendert</th>
|
||||
<th class="numeric">Grösse</th>
|
||||
<th>Geändert</th>
|
||||
<th class="actions">Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -69,9 +69,9 @@
|
||||
<tr>
|
||||
{% if current_user.has('fileshare.delete') %}<td></td>{% endif %}
|
||||
<td colspan="4">
|
||||
<a href="{{ url_for('fileshare.browse', freigabe=token,
|
||||
<a class="link-icon" href="{{ url_for('fileshare.browse', freigabe=token,
|
||||
pfad=(listing.breadcrumb[-2].relative if listing.breadcrumb | length > 1 else '')) }}">
|
||||
{{ icon('chevron-right', size=14) }} eine Ebene hoeher
|
||||
{{ icon('chevron-right', size=14) }} eine Ebene höher
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -79,7 +79,7 @@
|
||||
{% for entry in listing.directories %}
|
||||
<tr>
|
||||
{% if current_user.has('fileshare.delete') %}
|
||||
<td><label class="check"><input type="checkbox" name="pfade" value="{{ entry.relative }}"><span class="sr-only">auswaehlen</span></label></td>
|
||||
<td><label class="check"><input type="checkbox" name="pfade" value="{{ entry.relative }}"><span class="sr-only">auswählen</span></label></td>
|
||||
{% endif %}
|
||||
<td>
|
||||
{{ icon('folder', size=15) }}
|
||||
@@ -93,16 +93,49 @@
|
||||
{% for entry in listing.files %}
|
||||
<tr>
|
||||
{% if current_user.has('fileshare.delete') %}
|
||||
<td><label class="check"><input type="checkbox" name="pfade" value="{{ entry.relative }}"><span class="sr-only">auswaehlen</span></label></td>
|
||||
<td><label class="check"><input type="checkbox" name="pfade" value="{{ entry.relative }}"><span class="sr-only">auswählen</span></label></td>
|
||||
{% endif %}
|
||||
<td>{{ icon('file-text', size=15) }} {{ entry.name }}</td>
|
||||
{# Der Name ist die grösste Fläche der Zeile -- er soll auch
|
||||
das Naheliegende tun. Was sich anzeigen lässt, öffnet die
|
||||
Vorschau; alles andere lädt herunter. #}
|
||||
<td>
|
||||
{% if entry.previewable %}
|
||||
<button class="link-icon file-name" type="button"
|
||||
data-dialog-open="file-preview"
|
||||
data-fill-Src="{{ url_for('fileshare.preview', token=token, pfad=entry.relative) }}"
|
||||
data-fill-Kind="{{ entry.preview_kind }}"
|
||||
data-fill-Pfad="{{ entry.relative }}"
|
||||
data-fill-Name="{{ entry.name }}"
|
||||
data-fill-Download="{{ url_for('fileshare.download', token=token, pfad=entry.relative) }}">
|
||||
{{ icon('file-text', size=15) }} {{ entry.name }}
|
||||
</button>
|
||||
{% else %}
|
||||
<a class="link-icon"
|
||||
href="{{ url_for('fileshare.download', token=token, pfad=entry.relative) }}">
|
||||
{{ icon('file-text', size=15) }} {{ entry.name }}
|
||||
</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="numeric fs-sm">{{ entry.size | bytes }}</td>
|
||||
<td class="fs-sm">{{ entry.modified | dt }}</td>
|
||||
<td class="actions">
|
||||
{% if entry.previewable %}
|
||||
<a class="btn btn--sm btn--ghost" target="_blank" rel="noopener"
|
||||
href="{{ url_for('fileshare.preview', token=token, pfad=entry.relative) }}"
|
||||
aria-label="Vorschau">{{ icon('eye', size=15) }}</a>
|
||||
<button class="btn btn--sm btn--ghost" type="button"
|
||||
data-dialog-open="file-preview"
|
||||
data-fill-Src="{{ url_for('fileshare.preview', token=token, pfad=entry.relative) }}"
|
||||
data-fill-Kind="{{ entry.preview_kind }}"
|
||||
data-fill-Pfad="{{ entry.relative }}"
|
||||
data-fill-Name="{{ entry.name }}"
|
||||
data-fill-Download="{{ url_for('fileshare.download', token=token, pfad=entry.relative) }}"
|
||||
aria-label="Vorschau">{{ icon('eye', size=15) }}</button>
|
||||
{% endif %}
|
||||
{% if current_user.has('fileshare.edit') %}
|
||||
<button class="btn btn--sm btn--ghost" type="button"
|
||||
data-dialog-open="file-rename"
|
||||
data-fill-Pfad="{{ entry.relative }}"
|
||||
data-fill-Name="{{ entry.stem }}"
|
||||
data-fill-Endung="{{ entry.suffix or '' }}"
|
||||
aria-label="Umbenennen">{{ icon('pencil', size=15) }}</button>
|
||||
{% endif %}
|
||||
<a class="btn btn--sm btn--ghost"
|
||||
href="{{ url_for('fileshare.download', token=token, pfad=entry.relative) }}"
|
||||
@@ -119,9 +152,9 @@
|
||||
{% if current_user.has('fileshare.delete') %}
|
||||
<div class="card__footer">
|
||||
<button class="btn btn--sm btn--ghost danger-text" type="submit"
|
||||
data-confirm="Die ausgewaehlten Eintraege endgueltig loeschen? Auf einer Netzwerkfreigabe gibt es keinen Papierkorb."
|
||||
data-confirm-title="Loeschen" data-confirm-ok="Loeschen">
|
||||
Ausgewaehlte loeschen
|
||||
data-confirm="Die ausgewählten Einträge endgültig löschen? Auf einer Netzwerkfreigabe gibt es keinen Papierkorb."
|
||||
data-confirm-title="Löschen" data-confirm-ok="Löschen">
|
||||
Ausgewählte löschen
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -142,7 +175,7 @@
|
||||
<input type="hidden" name="pfad" value="{{ listing.relative }}">
|
||||
<div class="card__body row">
|
||||
<input class="input" type="file" name="files" multiple required style="flex:1">
|
||||
<button class="btn btn--primary" type="submit" data-busy-label="Laedt ...">Hochladen</button>
|
||||
<button class="btn btn--primary" type="submit" data-busy-label="Lädt ...">Hochladen</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
@@ -165,4 +198,102 @@
|
||||
</form>
|
||||
{% endcall %}
|
||||
{% endif %}
|
||||
|
||||
{% if listing %}
|
||||
{# Vorschau im Rahmen statt in einem neuen Tab: der Zusammenhang bleibt
|
||||
erhalten, und man ist mit Escape wieder in der Dateiliste. Titel, Quelle
|
||||
und Download-Ziel werden beim Öffnen aus dem Auslöser gefüllt -- ein
|
||||
Dialog für alle Dateien statt einer je Zeile. #}
|
||||
{% call modal('file-preview', 'Vorschau', '', 'wide') %}
|
||||
<div class="modal__caption" data-fill-target="Name"></div>
|
||||
{# Drei Anzeigen, eine je Art. Ein Rahmen für alles zeigte ein Foto vom
|
||||
iPad in Originalgrösse (also einen Ausschnitt) und ein PDF auf iPadOS
|
||||
nur als Standbild -- Formularfelder liessen sich nicht anfassen. #}
|
||||
<div class="modal__body modal__body--flush" data-viewer
|
||||
data-viewer-pdfjs="{{ asset_url('vendor/pdfjs/pdf.min.js') }}"
|
||||
data-viewer-pdflib="{{ asset_url('vendor/pdflib/pdf-lib.min.js') }}">
|
||||
<div class="viewer">
|
||||
<div class="viewer__stage" data-viewer-bildbuehne hidden>
|
||||
<img class="viewer__img" alt="" data-viewer-bild>
|
||||
</div>
|
||||
<div class="viewer__pdf" data-viewer-pdf hidden>
|
||||
{# Zwei Ebenen: aussen ein normaler Kasten, der sich die Hoehe mit
|
||||
Leiste und Speicherzeile teilt, innen der absolute Scrollbereich,
|
||||
den pdf.js verlangt. #}
|
||||
<div class="viewer__pdfscroll" data-viewer-pdfscroll>
|
||||
<div class="pdfViewer" data-viewer-pdfseiten></div>
|
||||
</div>
|
||||
</div>
|
||||
<iframe class="file-preview" title="Vorschau" data-viewer-rahmen
|
||||
src="about:blank" referrerpolicy="same-origin" hidden></iframe>
|
||||
{# Gefragt wird erst, wenn gespeichert werden soll -- vorher waere es
|
||||
eine Zeile Formular ueber jeder Bildvorschau. #}
|
||||
<div class="viewer__save" data-viewer-speichernzeile hidden>
|
||||
<label class="viewer__feld">
|
||||
<span class="field__label">Ordner</span>
|
||||
<input class="input" list="freigabe-ordner" data-viewer-ordner
|
||||
placeholder="Wurzelverzeichnis der Freigabe" autocomplete="off">
|
||||
</label>
|
||||
<label class="viewer__feld">
|
||||
<span class="field__label">Dateiname (ohne Endung)</span>
|
||||
<input class="input" data-viewer-name autocomplete="off">
|
||||
</label>
|
||||
<label class="check">
|
||||
<input type="checkbox" data-viewer-ersetzen>
|
||||
<span>Vorhandene ersetzen</span>
|
||||
</label>
|
||||
<button class="btn btn--sm btn--primary" type="button" data-viewer-jetzt>Speichern</button>
|
||||
<button class="btn btn--sm btn--ghost" type="button" data-viewer-abbrechen>Abbrechen</button>
|
||||
</div>
|
||||
<div class="viewer__bar" data-viewer-leiste hidden>
|
||||
<button class="btn btn--sm btn--ghost" type="button" data-viewer-zoom="aus"
|
||||
aria-label="Verkleinern">{{ icon('minus', size=15) }}</button>
|
||||
<button class="btn btn--sm btn--ghost" type="button" data-viewer-zoom="fit">Anpassen</button>
|
||||
<button class="btn btn--sm btn--ghost" type="button" data-viewer-zoom="ein"
|
||||
aria-label="Vergrössern">{{ icon('plus', size=15) }}</button>
|
||||
<span class="viewer__hint fs-xs faint" data-viewer-hinweis></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal__footer">
|
||||
<a class="btn btn--sm" data-fill-target="Download" data-fill-as="href" href="#" download>
|
||||
{{ icon('download', size=15) }} Herunterladen
|
||||
</a>
|
||||
{% if current_user.has('fileshare.create') %}
|
||||
<button class="btn btn--sm" type="button" data-viewer-speichern hidden
|
||||
data-url="{{ url_for('fileshare.save_form', token=token) }}">
|
||||
{{ icon('save', size=15) }} Ausgefüllt speichern
|
||||
</button>
|
||||
{% endif %}
|
||||
<button class="btn btn--primary" type="button" data-dialog-close>Schliessen</button>
|
||||
</div>
|
||||
{% endcall %}
|
||||
{# Die Ordner der Freigabe als Vorschlag; getippt werden darf trotzdem. #}
|
||||
<datalist id="freigabe-ordner">
|
||||
{% for eintrag in verzeichnisse %}<option value="{{ eintrag }}"></option>{% endfor %}
|
||||
</datalist>
|
||||
{% endif %}
|
||||
|
||||
{% if listing and current_user.has('fileshare.edit') %}
|
||||
{% call modal('file-rename', 'Umbenennen', '', 'narrow') %}
|
||||
<form method="post" action="{{ url_for('fileshare.rename', token=token) }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="parent" value="{{ listing.relative }}">
|
||||
<input type="hidden" name="pfad" data-fill-target="Pfad" value="">
|
||||
<div class="modal__body">
|
||||
{{ field('name', 'Neuer Name (ohne Endung)', required=True,
|
||||
attrs='data-fill-target="Name"') }}
|
||||
<p class="fs-xs faint">
|
||||
Die Endung <code data-fill-target="Endung"></code> bleibt, wie sie ist --
|
||||
eine Datei ohne passende Endung öffnet auf der Freigabe nichts mehr.
|
||||
Umbenannt wird dort direkt; einen Papierkorb gibt es nicht.
|
||||
</p>
|
||||
</div>
|
||||
<div class="modal__footer">
|
||||
<button class="btn" type="button" data-dialog-close>Abbrechen</button>
|
||||
<button class="btn btn--primary" type="submit">Umbenennen</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endcall %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -1,13 +1,13 @@
|
||||
{% extends "tesm_core/base.html" %}
|
||||
{% from "tesm_core/_group_picker.html" import group_picker %}
|
||||
{% from "tesm_core/macros.html" import field, empty_state, alert %}
|
||||
{% from "tesm_core/macros.html" import field, empty_state, alert, modal %}
|
||||
{% block page_title %}Freigaben einrichten{% endblock %}
|
||||
{% block page_sub %}{{ mappings | length }} Zuordnungen · Wurzel {{ mount_root }}{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
{{ alert('Eine Freigabe wird bei der Anmeldung eingebunden, wenn der Benutzer Mitglied der
|
||||
zugeordneten Verzeichnisgruppe ist. Eingebunden wird mit dem Konto dieses Benutzers -- deshalb
|
||||
ist die Verzeichnisgruppe das einzige moegliche Ziel, und lokale Konten bekommen keine
|
||||
ist die Verzeichnisgruppe das einzige mögliche Ziel, und lokale Konten bekommen keine
|
||||
Einbindungen.', 'info') }}
|
||||
|
||||
{% if not directory_enabled %}
|
||||
@@ -33,6 +33,11 @@ Einbindungen.', 'info') }}
|
||||
<td class="fs-sm">{{ entry.ad_group_name }}
|
||||
<div class="fs-xs faint break-all">{{ entry.ad_group_dn }}</div></td>
|
||||
<td class="actions">
|
||||
{% if current_user.has('fileshare.edit') %}
|
||||
<button class="btn btn--sm btn--ghost" type="button"
|
||||
data-dialog-open="mapping-edit-{{ entry.id }}"
|
||||
aria-label="Bearbeiten">{{ icon('pencil', size=15) }}</button>
|
||||
{% endif %}
|
||||
<form method="post" action="{{ url_for('fileshare.mapping_delete', mapping_id=entry.id) }}" data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<button class="btn btn--sm btn--ghost danger-text" type="submit"
|
||||
@@ -61,7 +66,7 @@ Einbindungen.', 'info') }}
|
||||
<div class="form-grid">
|
||||
{{ field('label', 'Anzeigename', required=True, placeholder='Abteilung Technik') }}
|
||||
{{ field('unc', 'UNC-Pfad', required=True, placeholder='\\\\dc2022\\daten',
|
||||
hint_text='Schraegstriche gehen auch: //dc2022/daten') }}
|
||||
hint_text='Schrägstriche gehen auch: //dc2022/daten') }}
|
||||
{{ group_picker('ad_group_name', 'ad_group_dn', 'share', required=True,
|
||||
label='Verzeichnisgruppe, die diese Freigabe bekommt') }}
|
||||
</div>
|
||||
@@ -70,3 +75,33 @@ Einbindungen.', 'info') }}
|
||||
</form>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
{% block dialogs %}
|
||||
{# Bearbeiten statt neu anlegen: bei einem Tippfehler im UNC-Pfad oder im
|
||||
Anzeigenamen soll man ihn korrigieren können. Sonst legt man daneben einen
|
||||
zweiten Eintrag an und vergisst, den ersten zu löschen. #}
|
||||
{% if current_user.has('fileshare.edit') %}
|
||||
{% for entry in mappings %}
|
||||
{% call modal('mapping-edit-' ~ entry.id, entry.label, 'Zuordnung ändern') %}
|
||||
<form method="post" action="{{ url_for('fileshare.mapping_update', mapping_id=entry.id) }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<div class="modal__body">
|
||||
<div class="form-grid">
|
||||
{{ field('label', 'Anzeigename', value=entry.label, required=True) }}
|
||||
{{ field('unc', 'UNC-Pfad', value=entry.unc, required=True,
|
||||
hint_text='Schrägstriche gehen auch: //dc2022/daten') }}
|
||||
{{ group_picker('ad_group_name', 'ad_group_dn', 'edit-' ~ entry.id,
|
||||
name_value=entry.ad_group_name, dn_value=entry.ad_group_dn,
|
||||
required=True,
|
||||
label='Verzeichnisgruppe, die diese Freigabe bekommt') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal__footer">
|
||||
<button class="btn" type="button" data-dialog-close>Abbrechen</button>
|
||||
<button class="btn btn--primary" type="submit">Speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endcall %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -1,40 +1,44 @@
|
||||
{% extends "tesm_core/base.html" %}
|
||||
{% from "tesm_core/macros.html" import pill, empty_state, alert, search_box %}
|
||||
|
||||
{% block page_title %}Host-Schluessel{% endblock %}
|
||||
{% block page_title %}Host-Schlüssel{% endblock %}
|
||||
{% block page_sub %}
|
||||
{{ targets | length }} Ziele mit SSH-Zugang
|
||||
{%- if open_count %} · {{ open_count }} ohne Freigabe{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block topbar_extra %}{{ search_box('hostkey-table', 'Geraet, IP, Fingerprint ...') }}{% endblock %}
|
||||
{% block topbar_extra %}{{ search_box('hostkey-table', 'Gerät, IP, Fingerprint ...') }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% if open_count %}
|
||||
{{ alert('Fuer ' ~ open_count ~ ' Ziel(e) ist noch kein Schluessel freigegeben. Wartung,
|
||||
Konsole und PoE-Neustart brechen dort mit "Schluessel unbekannt" ab -- absichtlich: ein
|
||||
Hintergrundauftrag darf keinen fremden Schluessel annehmen.', 'warning',
|
||||
'Freigaben offen') }}
|
||||
{# Der PoE-Neustart hängt am Schlüssel des *Switches*, nicht am Schlüssel des
|
||||
Clients: geschaltet wird der Port auf dem Switch. Der alte Satz warf beides
|
||||
zusammen und liess vermuten, ein Client ohne freigegebenen Schlüssel sei
|
||||
nicht per PoE neu startbar. #}
|
||||
{{ alert('Für ' ~ open_count ~ ' Ziel(e) ist noch kein Schlüssel freigegeben. Wartung und
|
||||
Konsole brechen dort mit "Schlüssel unbekannt" ab -- absichtlich: ein Hintergrundauftrag
|
||||
darf keinen fremden Schlüssel annehmen. Ein PoE-Neustart braucht dagegen den Schlüssel
|
||||
des Switches, nicht den des Clients.', 'warning', 'Freigaben offen') }}
|
||||
{% else %}
|
||||
{{ alert('Fuer jedes Ziel mit SSH-Zugang liegt ein freigegebener Schluessel vor.',
|
||||
'success', 'Vollstaendig') }}
|
||||
{{ alert('Für jedes Ziel mit SSH-Zugang liegt ein freigegebener Schlüssel vor.',
|
||||
'success', 'Vollständig') }}
|
||||
{% endif %}
|
||||
|
||||
{{ alert('Ein Schluessel wird in zwei Schritten uebernommen: erst auslesen, dann -- nach
|
||||
Vergleich mit einer unabhaengigen Quelle -- freigeben. Gespeichert wird nach IP und Port;
|
||||
aendert sich die Adresse eines Geraets, ist erneut freizugeben. Ein *geaenderter* Schluessel
|
||||
wird nie stillschweigend uebernommen: das ist der Fall, der bei einem Angriff auftritt.',
|
||||
{{ alert('Ein Schlüssel wird in zwei Schritten übernommen: erst auslesen, dann -- nach
|
||||
Vergleich mit einer unabhängigen Quelle -- freigeben. Gespeichert wird nach IP und Port;
|
||||
ändert sich die Adresse eines Geräts, ist erneut freizugeben. Ein *geänderter* Schlüssel
|
||||
wird nie stillschweigend übernommen: das ist der Fall, der bei einem Angriff auftritt.',
|
||||
'info', 'So funktioniert die Freigabe') }}
|
||||
|
||||
{% if probed_fingerprint %}
|
||||
<div class="alert alert--warning">
|
||||
{{ icon('alert', size=18) }}
|
||||
<div class="alert__body">
|
||||
<div class="alert__title">Gelesener Schluessel von {{ probed_host }}:{{ probed_port }}</div>
|
||||
<div class="alert__title">Gelesener Schlüssel von {{ probed_host }}:{{ probed_port }}</div>
|
||||
<div class="mono fs-sm break-all">{{ probed_type }} {{ probed_fingerprint }}</div>
|
||||
<p class="fs-sm mt-2">
|
||||
Vergleichen Sie den Fingerprint mit einer unabhaengigen Quelle -- Konsolenzugang am
|
||||
Geraet, Inventardokumentation --, bevor Sie ihn unten freigeben.
|
||||
Vergleichen Sie den Fingerprint mit einer unabhängigen Quelle -- Konsolenzugang am
|
||||
Gerät, Inventardokumentation --, bevor Sie ihn unten freigeben.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -102,8 +106,8 @@ wird nie stillschweigend uebernommen: das ist der Fall, der bei einem Angriff au
|
||||
<input type="hidden" name="key_type" value="{{ probed_type }}">
|
||||
<input type="hidden" name="fingerprint" value="{{ probed_fingerprint }}">
|
||||
<button class="btn btn--sm btn--primary" type="submit"
|
||||
data-confirm="Fingerprint {{ probed_fingerprint }} fuer {{ row.label }} dauerhaft als vertrauenswuerdig hinterlegen?"
|
||||
data-confirm-title="Host-Schluessel freigeben"
|
||||
data-confirm="Fingerprint {{ probed_fingerprint }} für {{ row.label }} dauerhaft als vertrauenswürdig hinterlegen?"
|
||||
data-confirm-title="Host-Schlüssel freigeben"
|
||||
data-confirm-ok="Freigeben" data-confirm-style="primary">
|
||||
{{ icon('check', size=14) }} Freigeben
|
||||
</button>
|
||||
@@ -114,7 +118,7 @@ wird nie stillschweigend uebernommen: das ist der Fall, der bei einem Angriff au
|
||||
action="{{ url_for('hostkeys.forget', kind=row.kind, target_id=row.id) }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<button class="btn btn--sm btn--ghost" type="submit"
|
||||
data-confirm="Freigabe fuer {{ row.label }} entfernen? Wartung und Konsole verweigern danach die Verbindung, bis erneut freigegeben wurde."
|
||||
data-confirm="Freigabe für {{ row.label }} entfernen? Wartung und Konsole verweigern danach die Verbindung, bis erneut freigegeben wurde."
|
||||
data-confirm-title="Freigabe verwerfen" data-confirm-ok="Verwerfen">
|
||||
Verwerfen
|
||||
</button>
|
||||
@@ -141,7 +145,7 @@ wird nie stillschweigend uebernommen: das ist der Fall, der bei einem Angriff au
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('alert', size=17) }} Verwaiste Freigaben</div>
|
||||
<div class="card__sub">
|
||||
Zu diesen Eintraegen gibt es kein Ziel mehr -- geloescht oder mit neuer IP.
|
||||
Zu diesen Einträgen gibt es kein Ziel mehr -- gelöscht oder mit neuer IP.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -162,7 +166,7 @@ wird nie stillschweigend uebernommen: das ist der Fall, der bei einem Angriff au
|
||||
<input type="hidden" name="host" value="{{ row.host }}">
|
||||
<input type="hidden" name="port" value="{{ row.port }}">
|
||||
<button class="btn btn--sm btn--ghost" type="submit"
|
||||
data-confirm="Verwaiste Freigabe fuer {{ row.host }}:{{ row.port }} entfernen?"
|
||||
data-confirm="Verwaiste Freigabe für {{ row.host }}:{{ row.port }} entfernen?"
|
||||
data-confirm-title="Eintrag entfernen" data-confirm-ok="Entfernen">
|
||||
Entfernen
|
||||
</button>
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
{% block page_title %}Lizenz{% endblock %}
|
||||
{% block page_sub %}
|
||||
{% if state and state.valid and state.activated %}aktiv
|
||||
{% elif state and state.valid %}gueltig, noch nicht aktiviert
|
||||
{% elif state and state.present %}ungueltig
|
||||
{% elif state and state.valid %}gültig, noch nicht aktiviert
|
||||
{% elif state and state.present %}ungültig
|
||||
{% else %}keine Lizenz installiert{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
<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>Ausgestellt</dt><dd>{{ state.status.issued_at | dt }}</dd>
|
||||
<dt>Laeuft ab</dt>
|
||||
<dt>Läuft ab</dt>
|
||||
<dd>
|
||||
{% if state.status.lifetime %}{{ pill('unbefristet', 'online') }}
|
||||
{% else %}
|
||||
@@ -46,7 +46,7 @@
|
||||
{{ pill('abgelaufen seit ' ~ state.status.days_since_expiry ~ ' Tagen', 'danger') }}
|
||||
{% if state.status.grace_active %}
|
||||
<div class="fs-xs warning-text">
|
||||
Kulanzfrist laeuft ({{ grace_days }} Tage) – Funktionen bleiben vorerst nutzbar.
|
||||
Kulanzfrist läuft ({{ grace_days }} Tage) – Funktionen bleiben vorerst nutzbar.
|
||||
</div>
|
||||
{% endif %}
|
||||
{% elif state.status.expiring_soon %}
|
||||
@@ -88,7 +88,7 @@
|
||||
{% else %}
|
||||
<form method="post" action="{{ url_for('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 pruefen</button>
|
||||
<button class="btn btn--sm" type="submit">{{ icon('refresh', size=14) }} Jetzt prüfen</button>
|
||||
</form>
|
||||
<form method="post" action="{{ url_for('license.deactivate') }}" data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
@@ -118,14 +118,14 @@
|
||||
{% if pending.reason %}
|
||||
{{ alert(pending.reason, 'warning', 'Warum der Online-Weg nicht ging') }}
|
||||
{% endif %}
|
||||
{{ alert('Beide Codes sind signiert -- sie lassen sich unterwegs nicht veraendern.
|
||||
Der Anfragecode gehoert zu genau dieser Maschine und genau dieser Anfrage; eine
|
||||
{{ alert('Beide Codes sind signiert -- sie lassen sich unterwegs nicht verändern.
|
||||
Der Anfragecode gehört zu genau dieser Maschine und genau dieser Anfrage; eine
|
||||
Antwort auf einen anderen Code wird abgelehnt.', 'info') }}
|
||||
|
||||
{% if pending.code %}
|
||||
<div class="field">
|
||||
<span class="field__label">
|
||||
Anfragecode -- an den Anbieter uebermitteln
|
||||
Anfragecode -- an den Anbieter übermitteln
|
||||
</span>
|
||||
<pre class="log-view" id="offline-request" style="max-height:14dvh">{{ pending.code }}</pre>
|
||||
<div class="row row--end mt-2">
|
||||
@@ -135,8 +135,8 @@
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
{{ alert('Zu dieser Anfrage liegt kein Code vor -- sie stammt aus einer aelteren
|
||||
Fassung. Loesen Sie die Aktion erneut aus, dann entsteht ein neuer Code.',
|
||||
{{ alert('Zu dieser Anfrage liegt kein Code vor -- sie stammt aus einer älteren
|
||||
Fassung. Lösen Sie die Aktion erneut aus, dann entsteht ein neuer Code.',
|
||||
'warning') }}
|
||||
{% endif %}
|
||||
|
||||
@@ -145,10 +145,10 @@
|
||||
<label class="field">
|
||||
<span class="field__label">Antwortcode des Lizenzservers</span>
|
||||
<textarea class="textarea input--mono" name="code" rows="4" required
|
||||
placeholder="Code einfuegen ..."></textarea>
|
||||
placeholder="Code einfügen ..."></textarea>
|
||||
</label>
|
||||
<div class="row row--end mt-3">
|
||||
<button class="btn btn--primary" type="submit">Antwort uebernehmen</button>
|
||||
<button class="btn btn--primary" type="submit">Antwort übernehmen</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -161,15 +161,15 @@
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('shield', size=17) }} Zertifikat des Lizenzservers</div>
|
||||
<div class="card__sub">
|
||||
Nur noetig, wenn der Lizenzserver ein eigenes Zertifikat benutzt
|
||||
Nur nötig, wenn der Lizenzserver ein eigenes Zertifikat benutzt
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card__body stack">
|
||||
{{ alert('Ein Lizenzserver im eigenen Netz hat meist ein selbstsigniertes
|
||||
Zertifikat. Ohne Anker scheitert die Pruefung, und jeder Versuch faellt auf den
|
||||
Offline-Weg zurueck. Die Pruefung abzuschalten waere der falsche Weg -- hinterlegen
|
||||
Sie stattdessen das Zertifikat, dann wird gegen genau dieses geprueft.', 'info') }}
|
||||
Zertifikat. Ohne Anker scheitert die Prüfung, und jeder Versuch fällt auf den
|
||||
Offline-Weg zurück. Die Prüfung abzuschalten wäre der falsche Weg -- hinterlegen
|
||||
Sie stattdessen das Zertifikat, dann wird gegen genau dieses geprüft.', 'info') }}
|
||||
<form method="post" action="{{ url_for('license.server_certificate') }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<label class="field">
|
||||
@@ -178,7 +178,7 @@
|
||||
placeholder="-----BEGIN CERTIFICATE----- ... -----END CERTIFICATE-----"
|
||||
>{{ server_certificate }}</textarea>
|
||||
<span class="field__hint">
|
||||
Leer lassen, um wieder gegen die Systemanker zu pruefen. Auf dem Lizenzserver
|
||||
Leer lassen, um wieder gegen die Systemanker zu prüfen. Auf dem Lizenzserver
|
||||
liegt es unter <span class="mono">/etc/tesm/certs/<name>/fullchain.pem</span>.
|
||||
</span>
|
||||
</label>
|
||||
@@ -196,7 +196,7 @@
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('upload', size=17) }} Lizenzdatei einspielen</div>
|
||||
<div class="card__sub">
|
||||
Die Datei enthaelt das Lizenzdokument und den zugehoerigen Client-Schluessel
|
||||
Die Datei enthält das Lizenzdokument und den zugehörigen Client-Schlüssel
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -206,7 +206,7 @@
|
||||
<div class="card__body stack">
|
||||
<input class="input" type="file" name="license_file" accept=".json,.lic" required>
|
||||
{% if trusted_keys and current_user.is_admin %}
|
||||
{{ checkbox('allow_new_issuer', 'Wechsel des Ausstellers ausdruecklich zulassen',
|
||||
{{ checkbox('allow_new_issuer', 'Wechsel des Ausstellers ausdrücklich zulassen',
|
||||
hint_text='Nur ankreuzen, wenn die neue Lizenz bewusst von einem anderen
|
||||
Lizenzserver kommt.') }}
|
||||
{% endif %}
|
||||
@@ -233,8 +233,8 @@
|
||||
<dt>Fingerprint</dt><dd class="mono fs-xs break-all">{{ fingerprint }}</dd>
|
||||
</dl>
|
||||
<p class="fs-xs faint mt-3">
|
||||
Der Fingerprint stammt aus der stabilen Maschinenidentitaet. Eine Umbenennung des Hosts
|
||||
aendert ihn <strong>nicht</strong> mehr -- im Vorgaenger fuehrte genau das zu
|
||||
Der Fingerprint stammt aus der stabilen Maschinenidentität. Eine Umbenennung des Hosts
|
||||
ändert ihn <strong>nicht</strong> mehr -- im Vorgänger führte genau das zu
|
||||
„bereits anderweitig gebunden“.
|
||||
</p>
|
||||
</div>
|
||||
@@ -243,28 +243,32 @@
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('shield', size=17) }} Vertrauenswuerdige Aussteller</div>
|
||||
<div class="card__sub">{{ trusted_keys | length }} gepinnte Schluessel</div>
|
||||
<div class="card__title">{{ icon('shield', size=17) }} Vertrauenswürdige Aussteller</div>
|
||||
<div class="card__sub">{{ trusted_keys | length }} gepinnte Schlüssel</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if trusted_keys %}
|
||||
<div class="table-wrap">
|
||||
<table class="table table--compact">
|
||||
<thead><tr><th>Schluessel-ID</th><th>Bezeichnung</th><th>Quelle</th><th class="actions"></th></tr></thead>
|
||||
<thead><tr><th>Schlüssel-ID</th><th>Bezeichnung</th><th>Quelle</th><th class="actions"></th></tr></thead>
|
||||
<tbody>
|
||||
{% for key in trusted_keys %}
|
||||
<tr>
|
||||
<td class="mono fs-xs">{{ key.key_id }}</td>
|
||||
<td class="fs-sm">{{ key.label }}
|
||||
<div class="fs-xs faint">{{ key.added_at | dt }}</div></td>
|
||||
<td class="fs-xs">{{ key.source }}</td>
|
||||
<td class="fs-xs">
|
||||
{{ key.source | issuer_source }}
|
||||
{% set erklaerung = key.source | issuer_source_hint %}
|
||||
{% if erklaerung %}<div class="faint">{{ erklaerung }}</div>{% endif %}
|
||||
</td>
|
||||
<td class="actions">
|
||||
{% if current_user.is_admin %}
|
||||
<form method="post" data-no-guard
|
||||
action="{{ url_for('license.remove_trusted_key', key_id=key.key_id) }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<button class="btn btn--sm btn--ghost danger-text" type="submit"
|
||||
data-confirm="Diesen Aussteller-Schluessel entfernen? Danach gilt die von ihm ausgestellte Lizenz als nicht vertrauenswuerdig."
|
||||
data-confirm="Diesen Aussteller-Schlüssel entfernen? Danach gilt die von ihm ausgestellte Lizenz als nicht vertrauenswürdig."
|
||||
data-confirm-title="Aussteller entfernen" data-confirm-ok="Entfernen">
|
||||
{{ icon('trash', size=14) }}</button>
|
||||
</form>
|
||||
@@ -277,7 +281,7 @@
|
||||
</div>
|
||||
{% else %}
|
||||
{{ empty_state('Kein Aussteller gepinnt',
|
||||
'Beim ersten Import wird der Schluessel des Lizenzservers uebernommen und danach
|
||||
'Beim ersten Import wird der Schlüssel des Lizenzservers übernommen und danach
|
||||
erwartet.', 'shield') }}
|
||||
{% endif %}
|
||||
</section>
|
||||
@@ -303,7 +307,7 @@
|
||||
</ul>
|
||||
<p class="fs-xs faint mt-3">
|
||||
Diese Liste ist die einzige Wahrheit: was hier steht, wird serverseitig durchgesetzt --
|
||||
nicht nur in der Oberflaeche ausgegraut.
|
||||
nicht nur in der Oberfläche ausgegraut.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
{% from "tesm_core/macros.html" import pill, empty_state, alert, search_box %}
|
||||
{% block page_title %}Neustarts{% endblock %}
|
||||
{% block page_sub %}{{ rows | length }} Neustarts (neueste zuerst){% endblock %}
|
||||
{% block topbar_extra %}{{ search_box('restart-table', 'Geraet, Ergebnis, Methode ...') }}{% endblock %}
|
||||
{% block topbar_extra %}{{ search_box('restart-table', 'Gerät, Ergebnis, Methode ...') }}{% endblock %}
|
||||
{% block content %}
|
||||
{{ alert('Jeder Neustart wird mit Methode, Ergebnis, Dauer und Ausloeser festgehalten -- automatisch
|
||||
wie manuell, ueber den PoE-Port des Switches ebenso wie per Neustart-Befehl aus der Wartung.
|
||||
Der Vorgaenger protokollierte Neustarts ausdruecklich nicht.', 'info') }}
|
||||
{{ alert('Jeder Neustart wird mit Methode, Ergebnis, Dauer und Auslöser festgehalten -- automatisch
|
||||
wie manüll, über den PoE-Port des Switches ebenso wie per Neustart-Befehl aus der Wartung.
|
||||
Der Vorgänger protokollierte Neustarts ausdrücklich nicht.', 'info') }}
|
||||
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
@@ -25,8 +25,8 @@ Der Vorgaenger protokollierte Neustarts ausdruecklich nicht.', 'info') }}
|
||||
<div class="table-wrap">
|
||||
<table class="table table--compact" id="restart-table" data-behavior="sortable">
|
||||
<thead><tr>
|
||||
<th class="sortable">Zeit</th><th class="sortable">Geraet</th>
|
||||
<th class="sortable">Methode</th><th class="sortable">Ausloeser</th>
|
||||
<th class="sortable">Zeit</th><th class="sortable">Gerät</th>
|
||||
<th class="sortable">Methode</th><th class="sortable">Auslöser</th>
|
||||
<th class="sortable">Benutzer</th><th class="sortable">Ergebnis</th>
|
||||
<th class="sortable numeric">Dauer</th><th>Detail</th>
|
||||
</tr></thead>
|
||||
@@ -36,17 +36,22 @@ Der Vorgaenger protokollierte Neustarts ausdruecklich nicht.', 'info') }}
|
||||
<td class="fs-sm nowrap" data-sort-value="{{ row.ts }}">{{ row.ts | dt }}</td>
|
||||
<td class="fw-600 fs-sm">{{ row.device_name or '–' | safe }}</td>
|
||||
<td class="fs-sm">
|
||||
{# Kein if/else mit zwei Zweigen: es gibt drei Wege, und alles
|
||||
ausser 'ssh' als "PoE-Port" anzuzeigen hat RPC-Neustarts
|
||||
jahrelang als PoE ausgegeben. #}
|
||||
{% if row.method == 'ssh' %}{{ pill('SSH', 'info') }}
|
||||
{% else %}{{ pill('PoE-Port', '') }}{% endif %}
|
||||
{% elif row.method == 'rpc' %}{{ pill('RPC', 'accent') }}
|
||||
{% elif row.method == 'pö' %}{{ pill('PoE-Port', '') }}
|
||||
{% else %}{{ pill(row.method or 'unbekannt', 'warning') }}{% endif %}
|
||||
</td>
|
||||
<td class="fs-sm">{{ row.trigger }}</td>
|
||||
<td class="fs-sm">{{ row.actor }}</td>
|
||||
<td>
|
||||
{% if row.result == 'ok' %}{{ pill('erfolgreich', 'online') }}
|
||||
{% elif row.result == 'skipped' %}{{ pill('uebersprungen', 'warning') }}
|
||||
{% elif row.result == 'skipped' %}{{ pill('übersprungen', 'warning') }}
|
||||
{% else %}{{ pill('fehlgeschlagen', 'danger') }}{% endif %}
|
||||
</td>
|
||||
<td class="numeric fs-sm">{{ row.duration_ms or 0 }} ms</td>
|
||||
<td class="numeric fs-sm nowrap">{{ row.duration_ms | millis }}</td>
|
||||
<td class="fs-xs muted break-all" style="max-width:420px">{{ row.detail }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
||||
@@ -3,29 +3,29 @@
|
||||
|
||||
{% block page_title %}Wartung{% endblock %}
|
||||
{% block page_sub %}
|
||||
{{ devices | length }} Geraete mit Zugangsdaten der Kategorie
|
||||
{{ devices | length }} Geräte mit Zugangsdaten der Kategorie
|
||||
„Linux-Client“ oder „Windows-Client“
|
||||
{% endblock %}
|
||||
|
||||
{% block topbar_extra %}{{ search_box('maintenance-table', 'Geraet suchen ...') }}{% endblock %}
|
||||
{% block topbar_extra %}{{ search_box('maintenance-table', 'Gerät suchen ...') }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% if devices | rejectattr('host_key_ready') | list %}
|
||||
{{ alert('Fuer mindestens ein Geraet ist der SSH-Host-Schluessel noch nicht freigegeben.
|
||||
Die Wartung bricht dort mit "Schluessel unbekannt" ab. Die Freigabe erfolgt auf der
|
||||
Detailseite des Geraets -- erst auslesen, Fingerprint pruefen, dann freigeben.',
|
||||
'warning', 'Host-Schluessel offen') }}
|
||||
{{ alert('Für mindestens ein Gerät ist der SSH-Host-Schlüssel noch nicht freigegeben.
|
||||
Die Wartung bricht dort mit "Schlüssel unbekannt" ab. Die Freigabe erfolgt auf der
|
||||
Detailseite des Geräts -- erst auslesen, Fingerprint prüfen, dann freigeben.',
|
||||
'warning', 'Host-Schlüssel offen') }}
|
||||
{% endif %}
|
||||
|
||||
{% if windows_tool_missing %}
|
||||
{# Die Meldung im Auftrag nennt das fehlende Paket -- ohne diesen Knopf waere sie
|
||||
{# Die Meldung im Auftrag nennt das fehlende Paket -- ohne diesen Knopf wäre sie
|
||||
eine Sackgasse. #}
|
||||
<div class="alert alert--warning">
|
||||
{{ icon('alert', size=18) }}
|
||||
<div class="alert__body">
|
||||
<strong>Windows-Neustart nicht moeglich</strong>
|
||||
<strong>Windows-Neustart nicht möglich</strong>
|
||||
Auf diesem Server fehlt das Paket <code>{{ windows_package }}</code>. Es bringt
|
||||
<code>net</code> mit, ueber das der Neustart eines Windows-Geraetes laeuft.
|
||||
<code>net</code> mit, über das der Neustart eines Windows-Gerätes läuft.
|
||||
</div>
|
||||
<form method="post" action="{{ url_for('maintenance.install_windows_tool') }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
@@ -38,10 +38,10 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{{ alert('Welche Geraete hier erscheinen und was mit ihnen moeglich ist, entscheidet die
|
||||
Kategorie ihrer Zugangsdaten. Linux-Clients werden ueber SSH aktualisiert und neu gestartet.
|
||||
Windows-Clients werden nur neu gestartet, und zwar ueber RPC (wie "shutdown /m") -- sie
|
||||
brauchen dafuer weder SSH noch einen Host-Schluessel, sondern die offene Datei- und
|
||||
{{ alert('Welche Geräte hier erscheinen und was mit ihnen möglich ist, entscheidet die
|
||||
Kategorie ihrer Zugangsdaten. Linux-Clients werden über SSH aktualisiert und neu gestartet.
|
||||
Windows-Clients werden nur neu gestartet, und zwar über RPC (wie "shutdown /m") -- sie
|
||||
brauchen dafür weder SSH noch einen Host-Schlüssel, sondern die offene Datei- und
|
||||
Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Port.',
|
||||
'info') }}
|
||||
|
||||
@@ -51,20 +51,20 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
|
||||
<div class="table-toolbar">
|
||||
<label class="check">
|
||||
<input type="checkbox" id="select-all">
|
||||
<span>Alle auswaehlen</span>
|
||||
<span>Alle auswählen</span>
|
||||
</label>
|
||||
<span class="table-toolbar__spacer"></span>
|
||||
{% if current_user.has('maintenance.execute') %}
|
||||
<button class="btn btn--sm btn--primary" type="submit"
|
||||
formaction="{{ url_for('maintenance.start_update') }}"
|
||||
title="Nur fuer Linux-Clients -- Windows-Geraete werden dabei uebersprungen."
|
||||
data-confirm="Paketaktualisierung auf den ausgewaehlten Geraeten starten?"
|
||||
title="Nur für Linux-Clients -- Windows-Geräte werden dabei übersprungen."
|
||||
data-confirm="Paketaktualisierung auf den ausgewählten Geräten starten?"
|
||||
data-confirm-title="Aktualisieren" data-confirm-ok="Starten" data-confirm-style="primary">
|
||||
{{ icon('download', size=15) }} Aktualisieren
|
||||
</button>
|
||||
<button class="btn btn--sm btn--danger" type="submit"
|
||||
formaction="{{ url_for('maintenance.start_reboot') }}"
|
||||
data-confirm="Die ausgewaehlten Geraete jetzt neu starten? Sie sind dabei kurzzeitig nicht erreichbar."
|
||||
data-confirm="Die ausgewählten Geräte jetzt neu starten? Sie sind dabei kurzzeitig nicht erreichbar."
|
||||
data-confirm-title="Neustarten" data-confirm-ok="Neu starten">
|
||||
{{ icon('refresh', size=15) }} Neustarten
|
||||
</button>
|
||||
@@ -76,11 +76,11 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:44px"></th>
|
||||
<th class="sortable">Geraet</th>
|
||||
<th class="sortable">Gerät</th>
|
||||
<th class="sortable">IP</th>
|
||||
<th class="sortable">Zustand</th>
|
||||
<th class="sortable">Zugangsdaten</th>
|
||||
<th class="sortable">Host-Schluessel</th>
|
||||
<th class="sortable">Host-Schlüssel</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -92,7 +92,7 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
|
||||
<input type="checkbox" name="device_ids" value="{{ device.id }}"
|
||||
class="device-select" data-category="{{ device.category }}"
|
||||
{% if not current_user.has('maintenance.execute') %}disabled{% endif %}>
|
||||
<span class="sr-only">{{ device.name }} auswaehlen</span>
|
||||
<span class="sr-only">{{ device.name }} auswählen</span>
|
||||
</label>
|
||||
</td>
|
||||
<td class="fw-600">{{ device.name }}</td>
|
||||
@@ -108,13 +108,13 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
|
||||
</td>
|
||||
<td>
|
||||
{% if not device.ssh_managed %}
|
||||
<span class="faint fs-xs" title="Windows wird ueber RPC verwaltet, nicht ueber SSH">
|
||||
nicht noetig
|
||||
<span class="faint fs-xs" title="Windows wird über RPC verwaltet, nicht über SSH">
|
||||
nicht nötig
|
||||
</span>
|
||||
{% elif device.host_key_ready %}{{ pill('freigegeben', 'online') }}
|
||||
{% else %}
|
||||
<a href="{{ url_for('devices.detail', device_id=device.id) }}"
|
||||
title="Ohne freigegebenen Schluessel bricht die Wartung ab">
|
||||
title="Ohne freigegebenen Schlüssel bricht die Wartung ab">
|
||||
{{ pill('offen', 'danger') }}
|
||||
</a>
|
||||
{% endif %}
|
||||
@@ -134,9 +134,9 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
|
||||
</table>
|
||||
</div>
|
||||
{% if not devices %}
|
||||
{{ empty_state('Keine passenden Geraete',
|
||||
{{ empty_state('Keine passenden Geräte',
|
||||
'Setzen Sie bei den Zugangsdaten die Kategorie "Linux-Client" und ordnen Sie
|
||||
sie einem Geraet zu.', 'wrench') }}
|
||||
sie einem Gerät zu.', 'wrench') }}
|
||||
{% endif %}
|
||||
</section>
|
||||
</form>
|
||||
@@ -144,12 +144,12 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
|
||||
{# Kein data-refresh-url: die Schleife in app.js schreibt die Antwort in
|
||||
innerHTML. Hier zeigte sie auf die Seite selbst -- die komplette
|
||||
Wartungsseite landete damit in dieser Karte, mitsamt Seitenleiste und
|
||||
Suchfeld. Aktualisiert wird ueber die Abfrage unten, die bei laufenden
|
||||
Auftraegen die ganze Seite neu laedt. #}
|
||||
Suchfeld. Aktualisiert wird über die Abfrage unten, die bei laufenden
|
||||
Aufträgen die ganze Seite neu lädt. #}
|
||||
<section class="card" id="jobs">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('clock', size=17) }} Auftraege</div>
|
||||
<div class="card__title">{{ icon('clock', size=17) }} Aufträge</div>
|
||||
<div class="card__sub">
|
||||
Zustand liegt in der Datenbank -- ein Dienst-Neustart verliert ihn nicht mehr
|
||||
</div>
|
||||
@@ -158,7 +158,7 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
|
||||
{% if jobs %}
|
||||
<div class="table-wrap">
|
||||
<table class="table table--compact">
|
||||
<thead><tr><th>Gestartet</th><th>Geraet</th><th>Art</th><th>Zustand</th><th>Fortschritt</th><th>Meldung</th></tr></thead>
|
||||
<thead><tr><th>Gestartet</th><th>Gerät</th><th>Art</th><th>Zustand</th><th>Fortschritt</th><th>Meldung</th></tr></thead>
|
||||
<tbody>
|
||||
{% for job in jobs %}
|
||||
<tr>
|
||||
@@ -170,7 +170,7 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
|
||||
<td>
|
||||
{% if job.state == 'succeeded' %}{{ pill('erfolgreich', 'online') }}
|
||||
{% elif job.state == 'failed' %}{{ pill('fehlgeschlagen', 'danger') }}
|
||||
{% elif job.state == 'running' %}{{ pill('laeuft', 'info') }}
|
||||
{% elif job.state == 'running' %}{{ pill('läuft', 'info') }}
|
||||
{% elif job.state == 'unknown' %}{{ pill('unklar', 'warning') }}
|
||||
{% else %}{{ pill('wartet', '') }}{% endif %}
|
||||
</td>
|
||||
@@ -186,16 +186,16 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
{{ empty_state('Keine Auftraege', 'Es wurde noch keine Wartung gestartet.', 'clock') }}
|
||||
{{ empty_state('Keine Aufträge', 'Es wurde noch keine Wartung gestartet.', 'clock') }}
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script type="module" nonce="{{ csp_nonce }}">
|
||||
// Auswahl beim Laden bewusst zuruecksetzen: Browser stellen den Checked-Zustand
|
||||
// Auswahl beim Laden bewusst zurücksetzen: Browser stellen den Checked-Zustand
|
||||
// sonst aus dem Verlauf wieder her -- und dann wird bei "Neustarten"
|
||||
// versehentlich das falsche Geraet erwischt.
|
||||
// versehentlich das falsche Gerät erwischt.
|
||||
const boxes = Array.from(document.querySelectorAll(".device-select"));
|
||||
boxes.forEach((box) => (box.checked = false));
|
||||
|
||||
@@ -205,7 +205,7 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
|
||||
.forEach((box) => (box.checked = all.checked));
|
||||
});
|
||||
|
||||
// Auftragsliste nachladen, solange etwas laeuft.
|
||||
// Auftragsliste nachladen, solange etwas läuft.
|
||||
async function poll() {
|
||||
try {
|
||||
const state = await window.tesm.api("{{ url_for('maintenance.status') }}");
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
{{ icon('wrench', size=17) }} Auftrag
|
||||
{% if job.state == 'succeeded' %}{{ pill('erfolgreich', 'online') }}
|
||||
{% elif job.state == 'failed' %}{{ pill('fehlgeschlagen', 'danger') }}
|
||||
{% elif job.state == 'running' %}{{ pill('laeuft', 'info') }}
|
||||
{% elif job.state == 'running' %}{{ pill('läuft', 'info') }}
|
||||
{% elif job.state == 'unknown' %}{{ pill('unklar', 'warning') }}
|
||||
{% else %}{{ pill('wartet', '') }}{% endif %}
|
||||
</div>
|
||||
@@ -37,14 +37,14 @@
|
||||
</div>
|
||||
</section>
|
||||
<div class="row row--end">
|
||||
<a class="btn" href="{{ url_for('maintenance.overview') }}">Zurueck</a>
|
||||
<a class="btn" href="{{ url_for('maintenance.overview') }}">Zurück</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script type="module" nonce="{{ csp_nonce }}">
|
||||
// Beim Oeffnen ans Ende springen: bei einem laufenden Auftrag ist die
|
||||
// juengste Zeile die interessante, nicht die erste. Das Nachfuehren beim
|
||||
// Beim Öffnen ans Ende springen: bei einem laufenden Auftrag ist die
|
||||
// jüngste Zeile die interessante, nicht die erste. Das Nachführen beim
|
||||
// Aktualisieren erledigt die Schleife in app.js.
|
||||
const view = document.querySelector("#job-output .log-view");
|
||||
if (view) view.scrollTop = view.scrollHeight;
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
{% from "tesm_core/macros.html" import field, select_field, checkbox, alert, pill %}
|
||||
|
||||
{% block page_title %}Systemeinstellungen{% endblock %}
|
||||
{% block page_sub %}Ueberwachung, Protokolle, Host, Navigation{% endblock %}
|
||||
{% block page_sub %}Überwachung, Protokolle, Host, Navigation{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% if not sysops_available %}
|
||||
{{ alert('Systemhelfer nicht verfuegbar -- Hostname und Zeitzone koennen hier nur angezeigt,
|
||||
nicht geaendert werden. In einer Entwicklungsumgebung ist das der erwartete Zustand.',
|
||||
{{ alert('Systemhelfer nicht verfügbar -- Hostname und Zeitzone können hier nur angezeigt,
|
||||
nicht geändert werden. In einer Entwicklungsumgebung ist das der erwartete Zustand.',
|
||||
'info') }}
|
||||
{% endif %}
|
||||
|
||||
@@ -16,13 +16,13 @@
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('activity', size=17) }} Ueberwachung</div>
|
||||
<div class="card__sub">Wie oft geprueft und wann automatisch geschaltet wird</div>
|
||||
<div class="card__title">{{ icon('activity', size=17) }} Überwachung</div>
|
||||
<div class="card__sub">Wie oft geprüft und wann automatisch geschaltet wird</div>
|
||||
</div>
|
||||
{% if current_user.has('settings_system.execute') %}
|
||||
<form method="post" action="{{ url_for('settings.run_check_now') }}" 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 pruefen</button>
|
||||
<button class="btn btn--sm" type="submit">{{ icon('refresh', size=14) }} Jetzt prüfen</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -31,7 +31,7 @@
|
||||
<div class="card__body stack">
|
||||
{% if license_hint %}{{ alert(license_hint, 'warning') }}{% endif %}
|
||||
<div class="form-grid">
|
||||
{{ field('monitor_interval_seconds', 'Pruefintervall (Sekunden)',
|
||||
{{ field('monitor_interval_seconds', 'Prüfintervall (Sekunden)',
|
||||
value=values.monitor_interval_seconds, type='number',
|
||||
disabled=bool(license_hint),
|
||||
hint_text=specs['monitor_interval_seconds'].hint) }}
|
||||
@@ -41,9 +41,9 @@
|
||||
</div>
|
||||
{{ checkbox('monitor_auto_restart', 'Automatischen PoE-Neustart bei Ausfall aktivieren',
|
||||
checked=values.monitor_auto_restart,
|
||||
hint_text='Zusaetzlich lizenzpflichtig. Der manuelle Knopf im Dashboard
|
||||
funktioniert unabhaengig davon.') }}
|
||||
{{ checkbox('dashboard_public', 'Statusuebersicht ohne Anmeldung erreichbar',
|
||||
hint_text='Zusätzlich lizenzpflichtig. Der manülle Knopf im Dashboard
|
||||
funktioniert unabhängig davon.') }}
|
||||
{{ checkbox('dashboard_public', 'Statusübersicht ohne Anmeldung erreichbar',
|
||||
checked=values.dashboard_public,
|
||||
hint_text=specs['dashboard_public'].hint) }}
|
||||
</div>
|
||||
@@ -56,17 +56,17 @@
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('terminal', size=17) }} Switch-Konsole</div>
|
||||
<div class="card__sub">
|
||||
Auch bei aktiver Konsole gilt: eigenes Recht, frische Passwortbestaetigung,
|
||||
vollstaendige Mitschrift
|
||||
Auch bei aktiver Konsole gilt: eigenes Recht, frische Passwortbestätigung,
|
||||
vollständige Mitschrift
|
||||
</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('terminal_enabled', 'Browser-Konsole fuer Switche zulassen',
|
||||
{{ checkbox('terminal_enabled', 'Browser-Konsole für Switche zulassen',
|
||||
checked=values.terminal_enabled) }}
|
||||
{{ field('terminal_idle_timeout', 'Trennen bei Untaetigkeit (Sekunden)',
|
||||
{{ field('terminal_idle_timeout', 'Trennen bei Untätigkeit (Sekunden)',
|
||||
value=values.terminal_idle_timeout, type='number') }}
|
||||
</div>
|
||||
<div class="card__footer"><button class="btn btn--primary" type="submit">Speichern</button></div>
|
||||
@@ -87,6 +87,9 @@
|
||||
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',
|
||||
hint_text=specs['trash_retention_days'].hint) }}
|
||||
@@ -134,7 +137,7 @@
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('sliders', size=17) }} Navigationsreihenfolge</div>
|
||||
<div class="card__sub">Gilt fuer alle Benutzer; jeder sieht weiter nur, was er darf</div>
|
||||
<div class="card__sub">Gilt für alle Benutzer; jeder sieht weiter nur, was er darf</div>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" action="{{ url_for('admin.nav_order') }}">
|
||||
@@ -154,8 +157,8 @@
|
||||
{% endfor %}
|
||||
</ol>
|
||||
<p class="fs-xs faint mt-3">
|
||||
Die Reihenfolge folgt der Liste. Zum Umsortieren die Eintraege in der gewuenschten
|
||||
Reihenfolge speichern (Drag-and-drop folgt in einer spaeteren Version).
|
||||
Die Reihenfolge folgt der Liste. Zum Umsortieren die Einträge in der gewünschten
|
||||
Reihenfolge speichern (Drag-and-drop folgt in einer späteren Version).
|
||||
</p>
|
||||
</div>
|
||||
<div class="card__footer">
|
||||
|
||||
@@ -18,6 +18,6 @@
|
||||
<pre class="log-view" id="cmd-output">{{ output }}</pre>
|
||||
</section>
|
||||
<div class="row row--end">
|
||||
<a class="btn" href="{{ url_for('switches.detail', switch_id=switch.id) }}">Zurueck</a>
|
||||
<a class="btn" href="{{ url_for('switches.detail', switch_id=switch.id) }}">Zurück</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -25,11 +25,11 @@
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">
|
||||
{{ icon('lock', size=17) }} SSH-Host-Schluessel
|
||||
{{ icon('lock', size=17) }} SSH-Host-Schlüssel
|
||||
{% if host_keys %}{{ pill('freigegeben', 'online') }}{% else %}{{ pill('offen', 'danger') }}{% endif %}
|
||||
</div>
|
||||
<div class="card__sub">
|
||||
Ohne freigegebenen Schluessel verweigert die Anwendung jeden Zugriff auf diesen Switch.
|
||||
Ohne freigegebenen Schlüssel verweigert die Anwendung jeden Zugriff auf diesen Switch.
|
||||
</div>
|
||||
</div>
|
||||
{% if current_user.has('switches.edit') %}
|
||||
@@ -45,12 +45,12 @@
|
||||
<div class="alert alert--warning">
|
||||
{{ icon('alert', size=18) }}
|
||||
<div class="alert__body">
|
||||
<div class="alert__title">Gelesener Schluessel</div>
|
||||
<div class="alert__title">Gelesener Schlüssel</div>
|
||||
<div class="mono fs-sm break-all">
|
||||
{{ request.args.get('probed_type') }} {{ request.args.get('probed_fingerprint') }}
|
||||
</div>
|
||||
<p class="fs-sm mt-2">
|
||||
Vergleichen Sie den Fingerprint mit einer unabhaengigen Quelle (Konsolenzugang am
|
||||
Vergleichen Sie den Fingerprint mit einer unabhängigen Quelle (Konsolenzugang am
|
||||
Switch, Inventardokumentation), bevor Sie ihn freigeben.
|
||||
</p>
|
||||
{% if current_user.has('switches.edit') %}
|
||||
@@ -60,8 +60,8 @@
|
||||
<input type="hidden" name="key_type" value="{{ request.args.get('probed_type') }}">
|
||||
<input type="hidden" name="fingerprint" value="{{ request.args.get('probed_fingerprint') }}">
|
||||
<button class="btn btn--sm btn--primary" type="submit"
|
||||
data-confirm="Diesen Fingerprint dauerhaft als vertrauenswuerdig hinterlegen?"
|
||||
data-confirm-title="Host-Schluessel freigeben" data-confirm-ok="Freigeben"
|
||||
data-confirm="Diesen Fingerprint dauerhaft als vertrauenswürdig hinterlegen?"
|
||||
data-confirm-title="Host-Schlüssel freigeben" data-confirm-ok="Freigeben"
|
||||
data-confirm-style="primary">Freigeben</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
@@ -83,14 +83,14 @@
|
||||
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="Gespeicherte Host-Schluessel entfernen? Danach ist kein Zugriff mehr moeglich, bis erneut freigegeben wird."
|
||||
data-confirm-title="Schluessel verwerfen" data-confirm-ok="Verwerfen">
|
||||
Schluessel verwerfen
|
||||
data-confirm="Gespeicherte Host-Schlüssel entfernen? Danach ist kein Zugriff mehr möglich, bis erneut freigegeben wird."
|
||||
data-confirm-title="Schlüssel verwerfen" data-confirm-ok="Verwerfen">
|
||||
Schlüssel verwerfen
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<p class="muted fs-sm">Noch kein Schluessel hinterlegt. Bitte auslesen und pruefen.</p>
|
||||
<p class="muted fs-sm">Noch kein Schlüssel hinterlegt. Bitte auslesen und prüfen.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
@@ -99,9 +99,9 @@
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('terminal', size=17) }} Kommando ausfuehren</div>
|
||||
<div class="card__title">{{ icon('terminal', size=17) }} Kommando ausführen</div>
|
||||
<div class="card__sub">
|
||||
Positivliste lesender Kommandos – verlangt eine frische Passwortbestaetigung
|
||||
Positivliste lesender Kommandos – verlangt eine frische Passwortbestätigung
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -118,8 +118,8 @@
|
||||
</label>
|
||||
</div>
|
||||
<div class="card__footer">
|
||||
<button class="btn btn--primary" type="submit" data-busy-label="Fuehre aus ...">
|
||||
Ausfuehren
|
||||
<button class="btn btn--primary" type="submit" data-busy-label="Führe aus ...">
|
||||
Ausführen
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -140,7 +140,7 @@
|
||||
<dt>Zugangsdaten</dt><dd>{{ switch.credential_name or 'keine' }}</dd>
|
||||
<dt>Standort</dt><dd>{{ switch.location or '–' | safe }}</dd>
|
||||
<dt>Zuletzt gesehen</dt><dd>{{ switch.last_seen_at | dt }}</dd>
|
||||
<dt>Geaendert</dt><dd>{{ switch.updated_at | dt }} <span class="faint">von {{ switch.updated_by or '-' }}</span></dd>
|
||||
<dt>Geändert</dt><dd>{{ switch.updated_at | dt }} <span class="faint">von {{ switch.updated_by or '-' }}</span></dd>
|
||||
</dl>
|
||||
{% if switch.notes %}<p class="fs-sm muted mt-3">{{ switch.notes }}</p>{% endif %}
|
||||
</div>
|
||||
@@ -149,14 +149,14 @@
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('cpu', size=17) }} Angeschlossene Geraete</div>
|
||||
<div class="card__title">{{ icon('cpu', size=17) }} Angeschlossene Geräte</div>
|
||||
<div class="card__sub">{{ devices | length }} zugeordnet</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if devices %}
|
||||
<div class="table-wrap">
|
||||
<table class="table table--compact">
|
||||
<thead><tr><th>Port</th><th>Geraet</th><th>Zustand</th></tr></thead>
|
||||
<thead><tr><th>Port</th><th>Gerät</th><th>Zustand</th></tr></thead>
|
||||
<tbody>
|
||||
{% for device in devices %}
|
||||
<tr>
|
||||
@@ -176,7 +176,7 @@
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
{{ empty_state('Keine Geraete', 'Diesem Switch ist noch kein Geraet zugeordnet.', 'cpu') }}
|
||||
{{ empty_state('Keine Geräte', 'Diesem Switch ist noch kein Gerät zugeordnet.', 'cpu') }}
|
||||
{% endif %}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -39,9 +39,9 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{{ alert('Vor dem ersten Zugriff muss der SSH-Host-Schluessel eines Switches ausdruecklich
|
||||
freigegeben werden. Unbekannte oder geaenderte Schluessel werden abgewiesen -- nicht
|
||||
stillschweigend uebernommen.', 'info', 'Host-Schluessel') }}
|
||||
{{ alert('Vor dem ersten Zugriff muss der SSH-Host-Schlüssel eines Switches ausdrücklich
|
||||
freigegeben werden. Unbekannte oder geänderte Schlüssel werden abgewiesen -- nicht
|
||||
stillschweigend übernommen.', 'info', 'Host-Schlüssel') }}
|
||||
|
||||
<section class="card">
|
||||
<div class="table-wrap">
|
||||
@@ -52,8 +52,8 @@ stillschweigend uebernommen.', 'info', 'Host-Schluessel') }}
|
||||
<th class="sortable">IP</th>
|
||||
<th class="sortable numeric">Port</th>
|
||||
<th class="sortable">Zugangsdaten</th>
|
||||
<th class="sortable numeric">Geraete</th>
|
||||
<th class="sortable">Host-Schluessel</th>
|
||||
<th class="sortable numeric">Geräte</th>
|
||||
<th class="sortable">Host-Schlüssel</th>
|
||||
<th class="actions">Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -99,9 +99,9 @@ stillschweigend uebernommen.', 'info', 'Host-Schluessel') }}
|
||||
<form method="post" action="{{ url_for('switches.delete', switch_id=switch.id) }}"
|
||||
style="display:inline" data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<button class="btn btn--sm btn--ghost danger-text" type="submit" aria-label="Loeschen"
|
||||
<button class="btn btn--sm btn--ghost danger-text" type="submit" aria-label="Löschen"
|
||||
data-confirm="{{ switch.hostname }} in den Papierkorb verschieben?"
|
||||
data-confirm-title="Switch loeschen" data-confirm-ok="Loeschen">
|
||||
data-confirm-title="Switch löschen" data-confirm-ok="Löschen">
|
||||
{{ icon('trash', size=15) }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
@@ -116,37 +116,15 @@ stillschweigend uebernommen.', 'info', 'Host-Schluessel') }}
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
{% if host_keys %}
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('lock', size=17) }} Freigegebene Host-Schluessel</div>
|
||||
<div class="card__sub">{{ host_keys | length }} Eintraege</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table class="table table--compact">
|
||||
<thead><tr><th>Host</th><th>Port</th><th>Typ</th><th>Fingerprint</th><th>Freigegeben</th></tr></thead>
|
||||
<tbody>
|
||||
{% for key in host_keys %}
|
||||
<tr>
|
||||
<td class="mono fs-sm">{{ key.host }}</td>
|
||||
<td class="fs-sm">{{ key.port }}</td>
|
||||
<td class="fs-sm">{{ key.key_type }}</td>
|
||||
<td class="mono fs-xs break-all">{{ key.fingerprint }}</td>
|
||||
<td class="fs-sm">{{ key.added_at | dt }} <span class="faint">von {{ key.added_by }}</span></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
{# Die freigegebenen Host-Schlüssel standen hier ein zweites Mal. Gepflegt
|
||||
werden sie unter Überwachung -> Host-Schlüssel: dort mit Auslesen,
|
||||
Fingerprint-Vergleich, Freigeben, Verwerfen und Aufräumen. Eine reine
|
||||
Zweitanzeige lädt dazu ein, sie an der falschen Stelle zu suchen. #}
|
||||
{% endblock %}
|
||||
|
||||
{% block dialogs %}
|
||||
{% if current_user.has('switches.create') %}
|
||||
{% call modal('switch-new', 'Switch anlegen', 'Host-Schluessel wird danach freigegeben') %}
|
||||
{% call modal('switch-new', 'Switch anlegen', 'Host-Schlüssel wird danach freigegeben') %}
|
||||
<form method="post" action="{{ url_for('switches.create') }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<div class="modal__body">{{ switch_form() }}</div>
|
||||
|
||||
@@ -22,12 +22,11 @@ from types import FrameType
|
||||
from tesm_core.extension import core
|
||||
|
||||
from . import create_app
|
||||
from .services import fileshare, inventory, monitor
|
||||
from .services import inventory, monitor
|
||||
|
||||
logger = logging.getLogger("tesm.monitor")
|
||||
|
||||
MIN_INTERVAL = 30
|
||||
FILESHARE_SWEEP_EVERY = 30 * 60
|
||||
|
||||
|
||||
class MonitorDaemon:
|
||||
@@ -35,7 +34,6 @@ class MonitorDaemon:
|
||||
self.app = create_app()
|
||||
self.extension = core(self.app)
|
||||
self.running = True
|
||||
self._last_fileshare_sweep = 0.0
|
||||
|
||||
def stop(self, signum: int, _frame: FrameType | None) -> None:
|
||||
logger.info("Signal %s empfangen -- beende nach dem laufenden Durchlauf.", signum)
|
||||
@@ -100,15 +98,12 @@ class MonitorDaemon:
|
||||
logger.exception("Durchlauf fehlgeschlagen")
|
||||
live.error("monitor", "Durchlauf fehlgeschlagen -- siehe app.log")
|
||||
|
||||
if time.monotonic() - self._last_fileshare_sweep > FILESHARE_SWEEP_EVERY:
|
||||
self._last_fileshare_sweep = time.monotonic()
|
||||
try:
|
||||
with self.app.app_context():
|
||||
released = fileshare.sweep_expired(self.extension.database)
|
||||
if released:
|
||||
live.info("fileshare", f"{released} abgelaufene Einbindungen geloest")
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("Aufraeumen der Dateifreigaben fehlgeschlagen")
|
||||
# Das Aufraeumen abgelaufener Freigabe-Einbindungen laeuft **nicht**
|
||||
# hier, sondern im Webprozess: eingebunden wird in dessen
|
||||
# Mount-Namensraum, und dieser Dienst sieht diese Einbindungen nicht
|
||||
# einmal. Ein umount von hier waere ein Aufruf ins Leere -- die
|
||||
# Datenbankzeile als geloest markiert, die Freigabe aber weiter
|
||||
# eingebunden. Siehe tesm/__init__.py::_start_fileshare_sweep.
|
||||
|
||||
elapsed = time.monotonic() - started
|
||||
remaining = max(1.0, interval - elapsed)
|
||||
|
||||
@@ -28,6 +28,9 @@ set -euo pipefail
|
||||
|
||||
APP=""
|
||||
INSTANCE=""
|
||||
#: Verzeichnis der Umgebungsdateien. Ueberschreibbar, damit sich die
|
||||
#: Portsuche in einem Test wirklich ausfuehren laesst.
|
||||
ETC_TESM="${TESM_ETC_DIR:-/etc/tesm}"
|
||||
PORT=""
|
||||
HTTP_PORT=""
|
||||
SKIP_PACKAGES=0
|
||||
@@ -46,6 +49,62 @@ warn() { printf '%s !!%s %s\n' "$COLOR_WARN" "$COLOR_OFF" "$*"; }
|
||||
fail() { printf '%s !!%s %s\n' "$COLOR_ERR" "$COLOR_OFF" "$*" >&2; exit 1; }
|
||||
info() { printf ' %s\n' "$*"; }
|
||||
|
||||
#: Merker fuer die EXIT-Falle. Zurueckgerollt wird nur, was auch gesichert ist.
|
||||
SICHERUNG_STEHT=0
|
||||
INSTALL_FERTIG=0
|
||||
ZURUECKGEROLLT=0
|
||||
|
||||
zurueckrollen_install() {
|
||||
[[ $SICHERUNG_STEHT -eq 1 && $ZURUECKGEROLLT -eq 0 ]] || return 0
|
||||
# Kommt nur zum Zug, wenn die Sicherung steht -- dann sind diese Variablen
|
||||
# gesetzt. Der Vollstaendigkeit halber trotzdem gepruefte Namen.
|
||||
[[ -n "${BACKUP_DIR:-}" && -d "$BACKUP_DIR" ]] || { warn "Keine Sicherung gefunden -- nichts wiederhergestellt."; return 0; }
|
||||
ZURUECKGEROLLT=1
|
||||
warn "Alten Stand wiederherstellen."
|
||||
systemctl stop "$UNIT" 2>/dev/null || true
|
||||
[[ "$APP" == "tesm" ]] && systemctl stop "$MONITOR_UNIT" 2>/dev/null || true
|
||||
rm -rf "$FAILED_DIR"
|
||||
mv "$ROOT" "$FAILED_DIR" 2>/dev/null || true
|
||||
cp -a "$BACKUP_DIR" "$ROOT"
|
||||
systemctl start "$UNIT" 2>/dev/null || true
|
||||
[[ "$APP" == "tesm" ]] && systemctl start "$MONITOR_UNIT" 2>/dev/null || true
|
||||
warn "Wiederhergestellt aus $BACKUP_DIR"
|
||||
warn "Fehlgeschlagener Stand liegt unter $FAILED_DIR"
|
||||
}
|
||||
|
||||
# Bei **jedem** Fehlschlag, nicht nur bei einem, an den ich gedacht habe:
|
||||
# Systempakete, venv, sudoers, nginx, ein Ctrl-C. Vorher blieb der Dienst
|
||||
# gestoppt und das Verzeichnis teils ersetzt zurueck.
|
||||
bei_fehler() {
|
||||
local code=$?
|
||||
[[ $INSTALL_FERTIG -eq 1 || "$code" == "0" ]] && return "$code"
|
||||
if [[ $SICHERUNG_STEHT -eq 1 ]]; then
|
||||
zurueckrollen_install
|
||||
else
|
||||
# Neuinstallation: es gibt keinen alten Stand. Nichts loeschen -- im
|
||||
# Zielverzeichnis kann eine Fassung 1.x liegen, deren Sicherung ein
|
||||
# anderes Skript haelt (upgrade.sh).
|
||||
# Vorsichtige Ersetzung: bricht es schon beim Auswerten der Argumente ab,
|
||||
# gibt es $ROOT noch nicht -- und "set -u" wuerde die Falle selbst zum
|
||||
# Fehler machen.
|
||||
if [[ ${IS_UPDATE:-0} -eq 1 ]]; then
|
||||
# Abbruch vor dem Sicherungsschritt: die Installation ist unangetastet.
|
||||
warn "Abgebrochen (Code $code), bevor etwas geaendert wurde."
|
||||
warn "Die bestehende Installation ist unberuehrt -- nichts zurueckzurollen."
|
||||
else
|
||||
warn "Abgebrochen (Code $code). Es gab keinen vorherigen Stand dieser Fassung,"
|
||||
warn "also wurde nichts wiederhergestellt und nichts geloescht."
|
||||
# Nur sagen, was es wirklich gibt: bei einem Abbruch an der Rueckfrage
|
||||
# existiert das Verzeichnis noch nicht.
|
||||
if [[ -n "${ROOT:-}" && -d "${ROOT:-}" ]]; then
|
||||
warn "Angelegt wurde bis hierhin: $ROOT (Daten unter data/ unberuehrt)."
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
return "$code"
|
||||
}
|
||||
trap bei_fehler EXIT
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Verwendung: install.sh --app <tesm|tesm-license> [Optionen]
|
||||
@@ -112,10 +171,62 @@ MONITOR_UNIT="$NAME-monitor.service"
|
||||
NGINX_SITE="/etc/nginx/sites-available/$NAME"
|
||||
ENV_PREFIX="TESM_"
|
||||
[[ "$APP" == "tesm-license" ]] && ENV_PREFIX="TESM_LICENSE_"
|
||||
|
||||
# Ausgabe des Lizenzservers: die Master-Ausgabe bringt das Teilpaket
|
||||
# tesm_license/master mit -- den einzigen Weg, auf dem eine Lizenzdatei die
|
||||
# Rolle "master" bekommt. Das Release fuer Sub-Lizenzserver hat es nicht.
|
||||
# Die Ausgabe haengt an der Anwesenheit des Verzeichnisses, nicht an einer
|
||||
# Einstellung: eine Einstellung liesse sich vor Ort umstellen.
|
||||
MASTER_TEILPAKET="apps/tesm-license/src/tesm_license/master"
|
||||
EDITION="standard"
|
||||
if [[ "$APP" == "tesm-license" ]]; then
|
||||
if [[ -d "$REPO_DIR/$MASTER_TEILPAKET" ]]; then EDITION="master"; else EDITION="sub"; fi
|
||||
fi
|
||||
# Bei einer Aktualisierung gilt zuerst, was schon eingerichtet ist. Sonst
|
||||
# verschiebt ein Update ohne Portangaben die Anwendung: eine benannte Instanz
|
||||
# faellt auf 8080/8443 zurueck, auch wenn sie seit der Installation auf Port 80
|
||||
# lief -- und ein Reverse Proxy davor zeigt dann ins Leere. Dieselbe Regel wie
|
||||
# beim Secure-Flag weiter unten: eine Aktualisierung nimmt nichts weg, was
|
||||
# nicht ausdruecklich neu gesetzt wird.
|
||||
ENV_FILE="$ETC_TESM/$NAME.env"
|
||||
# Wo die Daten liegen, steht in der Umgebungsdatei; ohne sie beim Vorgabeort.
|
||||
# "|| true": bei einer Neuinstallation gibt es die Datei noch nicht, und ein
|
||||
# scheiterndes sed in einer Zuweisung beendet unter "set -e" das ganze Skript.
|
||||
DATEN_DIR="$(sed -n "s/^${ENV_PREFIX}DATA_DIR=//p" "$ENV_FILE" 2>/dev/null || true)"
|
||||
DATEN_DIR="$(printf '%s' "$DATEN_DIR" | tail -1)"
|
||||
DATEN_DIR="${DATEN_DIR:-$ROOT/data}"
|
||||
if [[ -z "$PORT" && -f "$ENV_FILE" ]]; then
|
||||
PORT="$(sed -n "s/^${ENV_PREFIX}BIND=127\.0\.0\.1:\([0-9]\{1,5\}\)$/\1/p" "$ENV_FILE" | head -1)"
|
||||
fi
|
||||
if [[ -f "$NGINX_SITE" ]]; then
|
||||
[[ -z "$HTTP_PORT" ]] && HTTP_PORT="$(sed -n 's/^[[:space:]]*listen[[:space:]]\([0-9]\{1,5\}\);.*/\1/p' "$NGINX_SITE" | head -1)"
|
||||
[[ -z "$HTTPS_PORT" ]] && HTTPS_PORT="$(sed -n 's/^[[:space:]]*listen[[:space:]]\([0-9]\{1,5\}\)[[:space:]]\+ssl.*/\1/p' "$NGINX_SITE" | head -1)"
|
||||
fi
|
||||
|
||||
port_belegt() {
|
||||
local p="$1"
|
||||
# Schon von einer anderen Installation vergeben? Die Umgebungsdateien sind
|
||||
# die Wahrheit -- ein Dienst kann gerade gestoppt sein und den Port trotzdem
|
||||
# beanspruchen.
|
||||
grep -qs "BIND=127\.0\.0\.1:$p\$" "$ETC_TESM"/*.env 2>/dev/null &&
|
||||
return 0
|
||||
# Oder gerade in Benutzung, von was auch immer.
|
||||
command -v ss >/dev/null 2>&1 && [[ -n "$(ss -Hltn "sport = :$p" 2>/dev/null)" ]] && return 0
|
||||
return 1
|
||||
}
|
||||
|
||||
if [[ -z "$PORT" ]]; then
|
||||
PORT=5000
|
||||
[[ "$APP" == "tesm-license" ]] && PORT=5001
|
||||
[[ -n "$INSTANCE" ]] && PORT=$((PORT + 100))
|
||||
BASISPORT=5000
|
||||
[[ "$APP" == "tesm-license" ]] && BASISPORT=5001
|
||||
if [[ -n "$INSTANCE" ]]; then
|
||||
# Frueher bekam **jede** Instanz Basis+100. Die zweite startete nie
|
||||
# ("Address already in use") -- und die Gesundheitspruefung merkte es
|
||||
# nicht, weil die erste auf demselben Port antwortete.
|
||||
PORT=$((BASISPORT + 100))
|
||||
while port_belegt "$PORT"; do PORT=$((PORT + 1)); done
|
||||
else
|
||||
PORT=$BASISPORT
|
||||
fi
|
||||
fi
|
||||
if [[ -z "$HTTP_PORT" ]]; then
|
||||
HTTP_PORT=80
|
||||
@@ -134,7 +245,7 @@ fi
|
||||
# Browser sendet das Cookie dann weiter nur ueber HTTPS, die Anwendung setzt es
|
||||
# aber ohne das Flag neu, und beide reden aneinander vorbei.
|
||||
COOKIE_SECURE=0
|
||||
if [[ -f "/etc/tesm/$NAME.env" ]] && grep -q "^${ENV_PREFIX}COOKIE_SECURE=1" "/etc/tesm/$NAME.env"; then
|
||||
if [[ -f "$ENV_FILE" ]] && grep -q "^${ENV_PREFIX}COOKIE_SECURE=1" "$ENV_FILE"; then
|
||||
COOKIE_SECURE=1
|
||||
fi
|
||||
[[ $HTTPS -eq 1 ]] && COOKIE_SECURE=1
|
||||
@@ -197,12 +308,41 @@ fi
|
||||
# Der Vorgaenger lief als root. Hier laeuft der Dienst unprivilegiert; Rootrechte
|
||||
# gibt es ausschliesslich ueber den Helfer.
|
||||
|
||||
# Ein Sub-Paket auf eine Master-Installation waere ein stiller Rueckbau: der
|
||||
# Abgleich unten laeuft mit --delete und wuerde das Teilpaket entfernen. Danach
|
||||
# gaebe es auf dem Master keinen Weg mehr, eine Master-Lizenz auszustellen --
|
||||
# und niemand haette es gesagt.
|
||||
# Die Rolle steht in der eingespielten Lizenz. Das ist die zuverlaessigere
|
||||
# Haelfte der Pruefung: eine Master-Installation aus einer aelteren Fassung
|
||||
# kennt das Teilpaket noch nicht, ist aber trotzdem eine Wurzelinstanz.
|
||||
ROLLE_ALT=""
|
||||
if [[ "$EDITION" == "sub" && -s "$DATEN_DIR/license.json" ]]; then
|
||||
ROLLE_ALT="$(python3 -c '
|
||||
import json, sys
|
||||
try:
|
||||
print(json.load(open(sys.argv[1])).get("role", ""))
|
||||
except Exception:
|
||||
pass
|
||||
' "$DATEN_DIR/license.json" 2>/dev/null || true)"
|
||||
fi
|
||||
|
||||
if [[ "$EDITION" == "sub" && ( -d "$ROOT/$MASTER_TEILPAKET" || "$ROLLE_ALT" == "master" ) ]]; then
|
||||
fail "Hier laeuft eine Wurzelinstanz${ROLLE_ALT:+ (Lizenz mit Rolle $ROLLE_ALT)}, dieses Paket
|
||||
ist die Ausgabe fuer Sub-Lizenzserver.
|
||||
Eine Aktualisierung damit wuerde die Master-Ausgabe entfernen.
|
||||
Passendes Paket: Repository tesm-license-master (privat).
|
||||
Bewusster Rueckbau: eine vom Master ausgestellte Lizenz einspielen (Rolle
|
||||
license_server) und $ROOT/$MASTER_TEILPAKET entfernen -- danach ist dies ein
|
||||
Sub-Lizenzserver und dieses Paket das richtige."
|
||||
fi
|
||||
|
||||
# -- Backup ------------------------------------------------------------------
|
||||
if [[ $IS_UPDATE -eq 1 ]]; then
|
||||
step "Sicherung vor der Aktualisierung"
|
||||
systemctl stop "$UNIT" 2>/dev/null || true
|
||||
[[ "$APP" == "tesm" ]] && systemctl stop "$MONITOR_UNIT" 2>/dev/null || true
|
||||
cp -a "$ROOT" "$BACKUP_DIR"
|
||||
SICHERUNG_STEHT=1
|
||||
info "Sicherung: $BACKUP_DIR"
|
||||
fi
|
||||
|
||||
@@ -280,6 +420,42 @@ install -o root -g root -m 0440 "$TMP_SUDO" "/etc/sudoers.d/$APP"
|
||||
rm -f "$TMP_SUDO"
|
||||
info "sudoers: $SERVICE_USER darf ausschliesslich tesm-helper als root aufrufen"
|
||||
|
||||
# -- Anmeldebanner (MOTD) ----------------------------------------------------
|
||||
# Das Banner liest die Datenbank dieser Installation und laeuft dabei mit dem
|
||||
# System-Python, ohne das venv und ohne die Anwendung zu importieren. Der
|
||||
# Vorgaenger importierte dafuer die ganze Flask-App -- bei jeder Anmeldung, und
|
||||
# nach einem Wechsel des Quellverzeichnisses zeigte er stillschweigend die
|
||||
# Zahlen einer Datenbank, die niemand mehr fuellte.
|
||||
#
|
||||
# "timeout" verhindert, dass ein haengender Dateizugriff die Anmeldung
|
||||
# blockiert; "|| true" sorgt dafuer, dass ein Fehler hier den Login nie stoert.
|
||||
step "Anmeldebanner"
|
||||
if [[ -d /etc/update-motd.d ]]; then
|
||||
install_if_changed "$ROOT/deploy/tesm-motd" /usr/local/lib/tesm/tesm-motd 0755 >/dev/null \
|
||||
&& info "Bannerskript aktualisiert" || info "Bannerskript unveraendert"
|
||||
TMP_MOTD="$(mktemp)"
|
||||
{
|
||||
echo "#!/bin/sh"
|
||||
echo "# Von TESM erzeugt ($NAME). Aenderungen werden bei der naechsten"
|
||||
echo "# Installation ueberschrieben."
|
||||
echo "timeout 3 /usr/local/lib/tesm/tesm-motd --app $APP --root $ROOT 2>/dev/null || true"
|
||||
} > "$TMP_MOTD"
|
||||
install -o root -g root -m 0755 "$TMP_MOTD" "/etc/update-motd.d/50-$NAME"
|
||||
rm -f "$TMP_MOTD"
|
||||
info "Banner: /etc/update-motd.d/50-$NAME"
|
||||
# Ein Banner der Vorgaengerfassung zeigt Zahlen aus einer Datenbank, die
|
||||
# niemand mehr fuellt. Er wird nur entfernt, wenn er genau darauf zeigt --
|
||||
# nichts anderes wird angefasst.
|
||||
ALT_MOTD="/etc/update-motd.d/50-tesm"
|
||||
if [[ "$ALT_MOTD" != "/etc/update-motd.d/50-$NAME" && -f "$ALT_MOTD" ]] \
|
||||
&& grep -q "motd_banner.py" "$ALT_MOTD" 2>/dev/null; then
|
||||
rm -f "$ALT_MOTD"
|
||||
info "Banner der Vorgaengerfassung entfernt ($ALT_MOTD)"
|
||||
fi
|
||||
else
|
||||
info "Kein /etc/update-motd.d -- Banner uebersprungen"
|
||||
fi
|
||||
|
||||
# -- Rechte ------------------------------------------------------------------
|
||||
step "Dateirechte"
|
||||
chown -R "$SERVICE_USER:$SERVICE_USER" "$ROOT/data" "$LOG_DIR"
|
||||
@@ -296,8 +472,8 @@ step "Umgebung"
|
||||
# EnvironmentFile, die Kommandozeilenwerkzeuge ueber ihren Wrapper. Ohne das
|
||||
# wuesste "tesm-admin" nicht, wo die Instanz liegt, und wuerde sich eine leere
|
||||
# Datenbank im Arbeitsverzeichnis anlegen.
|
||||
install -d -o root -g root -m 0755 /etc/tesm
|
||||
ENV_FILE="/etc/tesm/$NAME.env"
|
||||
install -d -o root -g root -m 0755 "$ETC_TESM"
|
||||
ENV_FILE="$ETC_TESM/$NAME.env"
|
||||
TMP_ENV="$(mktemp)"
|
||||
{
|
||||
echo "# Von install.sh erzeugt fuer $NAME -- nicht von Hand bearbeiten."
|
||||
@@ -440,7 +616,11 @@ fi
|
||||
step "Gesundheitspruefung"
|
||||
HEALTHY=0
|
||||
for attempt in $(seq 1 20); do
|
||||
if curl -fsS --max-time 3 "http://127.0.0.1:$PORT/gesundheit" >/dev/null 2>&1; then
|
||||
# Beides: der Dienst muss laufen **und** antworten. Nur die Antwort zu
|
||||
# pruefen ging schon einmal schief -- auf einem gemeinsamen Port antwortete
|
||||
# eine andere Instanz, und ein toter Dienst galt als gesund.
|
||||
if systemctl is-active --quiet "$UNIT" &&
|
||||
curl -fsS --max-time 3 "http://127.0.0.1:$PORT/gesundheit" >/dev/null 2>&1; then
|
||||
HEALTHY=1
|
||||
info "Antwort nach ${attempt}x"
|
||||
break
|
||||
@@ -451,13 +631,9 @@ done
|
||||
if [[ $HEALTHY -eq 0 ]]; then
|
||||
warn "Die Anwendung antwortet nicht."
|
||||
if [[ $IS_UPDATE -eq 1 ]]; then
|
||||
warn "Rollback wird ausgefuehrt."
|
||||
systemctl stop "$UNIT" 2>/dev/null || true
|
||||
[[ "$APP" == "tesm" ]] && systemctl stop "$MONITOR_UNIT" 2>/dev/null || true
|
||||
mv "$ROOT" "$FAILED_DIR"
|
||||
cp -a "$BACKUP_DIR" "$ROOT"
|
||||
systemctl start "$UNIT" || true
|
||||
[[ "$APP" == "tesm" ]] && systemctl start "$MONITOR_UNIT" || true
|
||||
# Dieselbe Funktion wie die EXIT-Falle -- zwei Wege, die dasselbe tun
|
||||
# sollen, laufen sonst irgendwann auseinander.
|
||||
zurueckrollen_install
|
||||
fail "Aktualisierung fehlgeschlagen. Alter Stand wiederhergestellt.
|
||||
Fehlgeschlagene Version : $FAILED_DIR
|
||||
Sicherung : $BACKUP_DIR
|
||||
@@ -517,12 +693,41 @@ if [[ $HTTPS -eq 1 ]]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
# -- Lizenzserver einrichten -------------------------------------------------
|
||||
# Bei einer Neuinstallation: Signaturschluessel anlegen (braucht jeder
|
||||
# Lizenzserver, um Kundenlizenzen zu signieren) und in der Master-Ausgabe
|
||||
# gleich die eigene Master-Lizenz. Bei einer Aktualisierung nichts davon --
|
||||
# ein bestehender Schluessel darf nie angetastet werden.
|
||||
if [[ "$APP" == "tesm-license" && $IS_UPDATE -eq 0 ]]; then
|
||||
step "Signaturschluessel"
|
||||
if sudo -u "$SERVICE_USER" "/usr/local/bin/$NAME-admin" init-key; then
|
||||
info "Bitte die Datei $DATEN_DIR/master_signing_key.json SOFORT sichern."
|
||||
else
|
||||
fail "Der Signaturschluessel liess sich nicht erzeugen. Ohne ihn kann dieser Server
|
||||
keine Lizenz ausstellen. Protokoll: journalctl -u $UNIT -n 50"
|
||||
fi
|
||||
|
||||
if [[ "$EDITION" == "master" ]]; then
|
||||
step "Eigene Master-Lizenz"
|
||||
if [[ -s "$DATEN_DIR/license.json" ]]; then
|
||||
info "Es liegt schon eine Lizenzdatei -- unangetastet."
|
||||
elif sudo -u "$SERVICE_USER" "/usr/local/bin/$NAME-admin" bootstrap; then
|
||||
systemctl restart "$UNIT"
|
||||
info "Dienst neu gestartet, damit die Lizenz gelesen wird."
|
||||
else
|
||||
fail "Die Master-Lizenz liess sich nicht ausstellen.
|
||||
Nachtraeglich: sudo -u $SERVICE_USER $NAME-admin bootstrap"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ $IS_UPDATE -eq 1 ]]; then
|
||||
rm -rf "$BACKUP_DIR"
|
||||
info "Sicherung entfernt (Aktualisierung erfolgreich)"
|
||||
fi
|
||||
|
||||
# -- Abschluss ---------------------------------------------------------------
|
||||
INSTALL_FERTIG=1
|
||||
step "Fertig"
|
||||
ADMIN_CMD="tesm-admin"
|
||||
[[ "$APP" == "tesm-license" ]] && ADMIN_CMD="tesm-license-admin"
|
||||
@@ -538,14 +743,24 @@ cat <<SUMMARY
|
||||
Naechste Schritte:
|
||||
SUMMARY
|
||||
|
||||
if [[ "$APP" == "tesm-license" ]]; then
|
||||
if [[ "$APP" == "tesm-license" && "$EDITION" == "master" ]]; then
|
||||
cat <<SUMMARY
|
||||
1. Signaturschluessel erzeugen und sofort sichern:
|
||||
sudo -u $SERVICE_USER $NAME-admin init-key
|
||||
2. Eigene Master-Lizenz ausstellen:
|
||||
sudo -u $SERVICE_USER $NAME-admin bootstrap
|
||||
3. Administrator anlegen:
|
||||
1. Administrator anlegen:
|
||||
sudo -u $SERVICE_USER $NAME-admin create-admin
|
||||
2. Signaturschluessel sichern -- ohne ihn ist keine ausgestellte Lizenz
|
||||
mehr pruefbar:
|
||||
$ROOT/data/master_signing_key.json
|
||||
SUMMARY
|
||||
elif [[ "$APP" == "tesm-license" ]]; then
|
||||
cat <<SUMMARY
|
||||
1. Administrator anlegen:
|
||||
sudo -u $SERVICE_USER $NAME-admin create-admin
|
||||
2. Beim Master-Lizenzserver eine Lizenz fuer diesen Server anfordern und
|
||||
unter "Eigene Lizenz" einspielen. Diese Ausgabe ist ein Sub-Lizenzserver
|
||||
und stellt keine eigene Master-Lizenz aus.
|
||||
3. Signaturschluessel sichern -- mit ihm signiert dieser Server seine
|
||||
Kundenlizenzen:
|
||||
$ROOT/data/master_signing_key.json
|
||||
SUMMARY
|
||||
else
|
||||
cat <<SUMMARY
|
||||
|
||||
@@ -14,13 +14,22 @@ TAG="${1:?Verwendung: release.sh vX.Y.Z [name]}"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
|
||||
# Vorgabe: der Name der einzigen Anwendung im Baum, sonst der Verzeichnisname.
|
||||
# Vorgabe: der Name des Repositoriums. Genau den erwartet der Bootstrap von
|
||||
# update.sh als Anhangsnamen ("$repo-$TAG.tar.gz"). Die Anwendung heisst nicht
|
||||
# immer so: im Repositorium tesm-license-master liegt die Anwendung
|
||||
# "tesm-license", und ein Anhang unter deren Namen waere nie zu finden.
|
||||
if [[ -n "${2:-}" ]]; then
|
||||
NAME="$2"
|
||||
else
|
||||
HERKUNFT="$(git -C "$REPO_DIR" config --get remote.origin.url 2>/dev/null || true)"
|
||||
if [[ -n "$HERKUNFT" ]]; then
|
||||
NAME="$(basename "$HERKUNFT" .git)"
|
||||
else
|
||||
# Ohne Gegenseite: die einzige Anwendung im Baum, sonst das Verzeichnis.
|
||||
mapfile -t APPS < <(ls -1 "$REPO_DIR/apps" 2>/dev/null || true)
|
||||
if [[ ${#APPS[@]} -eq 1 ]]; then NAME="${APPS[0]}"; else NAME="$(basename "$REPO_DIR")"; fi
|
||||
fi
|
||||
fi
|
||||
|
||||
OUT="$NAME-$TAG.tar.gz"
|
||||
git -c core.autocrlf=false -C "$REPO_DIR" archive --format=tar.gz --prefix="$NAME/" -o "$OUT" "$TAG"
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# Benennt eine Installation um -- aus einer Instanz wird die Hauptinstallation
|
||||
# (oder umgekehrt).
|
||||
#
|
||||
# sudo bash deploy/rename.sh --app tesm --from tesm-test --to tesm
|
||||
# sudo bash deploy/rename.sh --app tesm-license --from tesm-license-test \
|
||||
# --to tesm-license
|
||||
#
|
||||
# Der Name steckt an mehr Stellen, als man beim Verschieben eines Verzeichnisses
|
||||
# erwartet, und drei davon sind leicht zu vergessen:
|
||||
#
|
||||
# * ``SITE_KEY`` in der Umgebungsdatei -- daraus entsteht der Name des
|
||||
# Sitzungscookies. Bleibt er alt, heisst das Cookie weiter wie die alte
|
||||
# Instanz.
|
||||
# * ``web_cert_path``/``web_key_path``/``web_acme_webroot`` in der Datenbank.
|
||||
# Sie enthalten den Namen; ein Zertifikat unter dem alten Pfad findet nginx
|
||||
# nach dem Umzug nicht mehr.
|
||||
# * ``server_name`` der nginx-Site. install.sh schreibt die Datei neu, sobald
|
||||
# sich die alias-Pfade aendern -- und die aendern sich hier zwangslaeufig.
|
||||
# Ohne den Namen aus der Datenbank stuenden danach zwei Sites mit ``_`` auf
|
||||
# Port 80 und die erste gewinnt.
|
||||
#
|
||||
# Belegt der Zielname noch eine Installation der Fassung 1.x, wird sie beiseite
|
||||
# geschoben: Verzeichnis nach ``<ziel>-<suffix>``, ihre systemd-Units und ihre
|
||||
# nginx-Site als Kopie hinein. Danach ist der Name frei und nichts auf dem
|
||||
# System zeigt mehr ins Leere.
|
||||
#
|
||||
# Alle angemeldeten Benutzer werden abgemeldet: das Sitzungscookie heisst
|
||||
# danach anders.
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
APP=""
|
||||
FROM=""
|
||||
TO=""
|
||||
SUFFIX="sonnet5"
|
||||
ASSUME_YES=0
|
||||
DRY_RUN=0
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
COLOR_OK=$'\033[32m'; COLOR_WARN=$'\033[33m'; COLOR_ERR=$'\033[31m'; COLOR_OFF=$'\033[0m'
|
||||
step() { printf '\n%s==>%s %s\n' "$COLOR_OK" "$COLOR_OFF" "$*"; }
|
||||
warn() { printf '%s !!%s %s\n' "$COLOR_WARN" "$COLOR_OFF" "$*"; }
|
||||
fail() { printf '%s !!%s %s\n' "$COLOR_ERR" "$COLOR_OFF" "$*" >&2; exit 1; }
|
||||
info() { printf ' %s\n' "$*"; }
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Verwendung: rename.sh --app <tesm|tesm-license> --from <alt> --to <neu> [Optionen]
|
||||
|
||||
--app <name> Welche Anwendung (Pflicht)
|
||||
--from <name> Bisheriger Installationsname, z.B. tesm-test (Pflicht)
|
||||
--to <name> Neuer Installationsname, z.B. tesm (Pflicht)
|
||||
--suffix <text> Namenszusatz fuer eine beiseite geschobene Installation
|
||||
der Fassung 1.x (Vorgabe: sonnet5)
|
||||
--dry-run Nur zeigen, was geschehen wuerde
|
||||
--yes Keine Rueckfrage
|
||||
USAGE
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--app) APP="${2:?}"; shift 2 ;;
|
||||
--from) FROM="${2:?}"; shift 2 ;;
|
||||
--to) TO="${2:?}"; shift 2 ;;
|
||||
--suffix) SUFFIX="${2:?}"; shift 2 ;;
|
||||
--dry-run) DRY_RUN=1; shift ;;
|
||||
--yes|-y) ASSUME_YES=1; shift ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) usage; fail "unbekannte Option: $1" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ "$APP" == "tesm" || "$APP" == "tesm-license" ]] || { usage; fail "--app fehlt oder ist unbekannt"; }
|
||||
[[ -n "$FROM" && -n "$TO" ]] || { usage; fail "--from und --to sind Pflicht"; }
|
||||
[[ "$FROM" != "$TO" ]] || fail "--from und --to sind gleich"
|
||||
[[ "$TO" =~ ^[a-z0-9-]{1,32}$ ]] || fail "ungueltiger Zielname: $TO"
|
||||
[[ "$TO" == "$APP" || "$TO" == "$APP-"* ]] || fail "der Zielname muss mit '$APP' beginnen"
|
||||
[[ "$(id -u)" -eq 0 ]] || fail "bitte mit sudo ausfuehren"
|
||||
[[ -f "$SCRIPT_DIR/install.sh" ]] || fail "install.sh fehlt neben diesem Skript"
|
||||
[[ -d "/srv/$FROM" ]] || fail "/srv/$FROM gibt es nicht"
|
||||
[[ -f "/srv/$FROM/data/app.db" ]] || fail "/srv/$FROM sieht nicht nach einer Installation dieser Fassung aus"
|
||||
|
||||
ENV_PREFIX="TESM_"
|
||||
[[ "$APP" == "tesm-license" ]] && ENV_PREFIX="TESM_LICENSE_"
|
||||
|
||||
# Alles, was den Namen traegt. Reihenfolge: erst Daten, dann Konfiguration.
|
||||
VERSCHIEBEN=(
|
||||
"/srv/$FROM:/srv/$TO"
|
||||
"/var/log/$FROM:/var/log/$TO"
|
||||
"/etc/tesm/$FROM.env:/etc/tesm/$TO.env"
|
||||
"/var/www/$FROM-acme:/var/www/$TO-acme"
|
||||
"/etc/tesm/certs/$FROM:/etc/tesm/certs/$TO"
|
||||
)
|
||||
ENTFERNEN=(
|
||||
"/etc/systemd/system/$FROM.service"
|
||||
"/etc/systemd/system/$FROM-monitor.service"
|
||||
"/etc/nginx/sites-enabled/$FROM"
|
||||
"/etc/nginx/sites-available/$FROM"
|
||||
"/usr/local/bin/$FROM-admin"
|
||||
"/etc/logrotate.d/$FROM"
|
||||
"/etc/update-motd.d/50-$FROM"
|
||||
)
|
||||
|
||||
# -- Webeinstellungen auslesen, bevor irgendetwas umzieht -------------------- #
|
||||
# Mit dem System-Python und der Standardbibliothek: das venv zieht gleich mit
|
||||
# um, und ein zusaetzliches Werkzeug (sqlite3) ist auf einem schlanken Server
|
||||
# nicht garantiert.
|
||||
lese_einstellung() {
|
||||
python3 - "$1" "/srv/$FROM/data/app.db" <<'PY'
|
||||
import sqlite3, sys
|
||||
schluessel, datenbank = sys.argv[1], sys.argv[2]
|
||||
try:
|
||||
conn = sqlite3.connect(f"file:{datenbank}?mode=ro", uri=True)
|
||||
row = conn.execute("SELECT value FROM settings WHERE key=?", (schluessel,)).fetchone()
|
||||
conn.close()
|
||||
except sqlite3.Error:
|
||||
row = None
|
||||
print((row[0] if row and row[0] is not None else "").strip())
|
||||
PY
|
||||
}
|
||||
|
||||
DOMAIN="$(lese_einstellung web_domain || true)"
|
||||
HTTPS_AN="$(lese_einstellung web_https_enabled || true)"
|
||||
HSTS_AN="$(lese_einstellung web_hsts_enabled || true)"
|
||||
CERT_ALT="$(lese_einstellung web_cert_path || true)"
|
||||
|
||||
step "Vorhaben"
|
||||
info "Anwendung : $APP"
|
||||
info "Alt : $FROM"
|
||||
info "Neu : $TO"
|
||||
info "Domain : ${DOMAIN:-(keine)}"
|
||||
info "HTTPS : $([[ "$HTTPS_AN" == "1" ]] && echo "ja${CERT_ALT:+, Zertifikat $CERT_ALT}" || echo nein)"
|
||||
for paar in "${VERSCHIEBEN[@]}"; do
|
||||
quelle="${paar%%:*}"; ziel="${paar##*:}"
|
||||
[[ -e "$quelle" ]] && info "verschieben: $quelle -> $ziel"
|
||||
done
|
||||
for pfad in "${ENTFERNEN[@]}"; do
|
||||
[[ -e "$pfad" ]] && info "entfernen : $pfad (install.sh legt es unter dem neuen Namen neu an)"
|
||||
done
|
||||
|
||||
# -- Was belegt den Zielnamen? ----------------------------------------------- #
|
||||
# Nicht nur das Verzeichnis unter /srv: eine Installation der Fassung 1.x
|
||||
# hinterlaesst auch /var/log/<name>, /etc/logrotate.d/<name> und den
|
||||
# ACME-Pfad. Der Umzug bricht sonst mitten drin ab -- live genau so passiert.
|
||||
BELEGT=()
|
||||
for paar in "${VERSCHIEBEN[@]}"; do
|
||||
ziel="${paar##*:}"
|
||||
[[ -e "$ziel" ]] && BELEGT+=("$ziel")
|
||||
done
|
||||
for weiteres in "/etc/logrotate.d/$TO" "/etc/update-motd.d/50-$TO"; do
|
||||
[[ -e "$weiteres" ]] && BELEGT+=("$weiteres")
|
||||
done
|
||||
|
||||
BEISEITE=""
|
||||
if [[ ${#BELEGT[@]} -gt 0 ]]; then
|
||||
if [[ -f "/srv/$TO/data/app.db" ]]; then
|
||||
fail "/srv/$TO ist bereits eine Installation dieser Fassung. Welche gewinnt, entscheidet niemand automatisch."
|
||||
fi
|
||||
BEISEITE="/srv/$TO-$SUFFIX"
|
||||
if [[ -e "$BEISEITE" ]]; then
|
||||
# Ein vorhandenes Archiv derselben Verdraengung wird weiterbenutzt -- die
|
||||
# Reste einer Installation gehoeren zusammen und nicht in ein zweites
|
||||
# Verzeichnis. Alles andere unter diesem Namen bleibt unangetastet.
|
||||
if [[ -f "$BEISEITE/sqlite.db" || -d "$BEISEITE/systemd-alt" ]]; then
|
||||
info "Archiv $BEISEITE ist vorhanden und wird weiterbenutzt."
|
||||
else
|
||||
fail "$BEISEITE existiert und sieht nicht nach einem solchen Archiv aus -- bitte --suffix waehlen"
|
||||
fi
|
||||
fi
|
||||
info "Der Zielname ist belegt. Alles davon wandert nach $BEISEITE:"
|
||||
for pfad in "${BELEGT[@]}"; do info " $pfad"; done
|
||||
fi
|
||||
|
||||
if [[ $DRY_RUN -eq 1 ]]; then
|
||||
step "--dry-run: hier ist Schluss."
|
||||
exit 0
|
||||
fi
|
||||
if [[ $ASSUME_YES -eq 0 ]]; then
|
||||
read -r -p "Fortfahren? Alle angemeldeten Benutzer werden abgemeldet. [j/N] " answer
|
||||
[[ "$answer" =~ ^[jJyY]$ ]] || { echo "Abgebrochen."; exit 0; }
|
||||
fi
|
||||
|
||||
# -- 1. Dienste anhalten ----------------------------------------------------- #
|
||||
step "Dienste anhalten"
|
||||
for unit in "$FROM.service" "$FROM-monitor.service"; do
|
||||
if systemctl list-unit-files "$unit" --no-legend 2>/dev/null | grep -q .; then
|
||||
systemctl disable --now "$unit" >/dev/null 2>&1 || true
|
||||
info "$unit gestoppt und deaktiviert"
|
||||
fi
|
||||
done
|
||||
|
||||
# -- 2. Zielnamen freimachen ------------------------------------------------- #
|
||||
if [[ -n "$BEISEITE" ]]; then
|
||||
step "Zielnamen freimachen"
|
||||
# Alles wandert ins Archiv, nichts wird geloescht: unter /var/log/<name>
|
||||
# liegen Protokolle, unter /srv/<name> im Zweifel eine ganze Installation.
|
||||
# Die Units und die Site kommen mit -- sie zeigen auf das alte Verzeichnis
|
||||
# und waeren auf dem System nur noch Fehlerquellen, im Archiv aber ein Beleg.
|
||||
mkdir -p "$BEISEITE"
|
||||
for pfad in "${BELEGT[@]}"; do
|
||||
ziel="$BEISEITE/$(basename "$pfad")"
|
||||
if [[ "$pfad" == "/srv/$TO" ]]; then
|
||||
# Das Verzeichnis selbst wird das Archiv -- sein Inhalt bleibt, wie er ist.
|
||||
for eintrag in "$pfad"/* "$pfad"/.[!.]*; do
|
||||
[[ -e "$eintrag" ]] || continue
|
||||
mv "$eintrag" "$BEISEITE/"
|
||||
done
|
||||
rmdir "$pfad"
|
||||
info "/srv/$TO -> $BEISEITE/"
|
||||
continue
|
||||
fi
|
||||
if [[ -e "$ziel" ]]; then
|
||||
ziel="$ziel-$(date +%Y%m%d-%H%M%S)"
|
||||
warn "Im Archiv lag schon etwas mit diesem Namen -- ablegen als $(basename "$ziel")"
|
||||
fi
|
||||
mv "$pfad" "$ziel"
|
||||
info "$pfad -> $ziel"
|
||||
done
|
||||
mkdir -p "$BEISEITE/systemd-alt"
|
||||
for unit in "/etc/systemd/system/$TO.service" "/etc/systemd/system/$TO-check.service" \
|
||||
"/etc/systemd/system/$TO-check.timer" "/etc/systemd/system/$TO-check-restart.service" \
|
||||
"/etc/systemd/system/$TO-check-restart.timer"; do
|
||||
[[ -f "$unit" ]] || continue
|
||||
systemctl disable --now "$(basename "$unit")" >/dev/null 2>&1 || true
|
||||
mv "$unit" "$BEISEITE/systemd-alt/"
|
||||
info "$(basename "$unit") archiviert"
|
||||
done
|
||||
for site in "/etc/nginx/sites-enabled/$TO" "/etc/nginx/sites-available/$TO"; do
|
||||
[[ -e "$site" ]] || continue
|
||||
[[ -L "$site" ]] && { rm -f "$site"; continue; }
|
||||
mv "$site" "$BEISEITE/systemd-alt/nginx-site"
|
||||
info "nginx-Site archiviert"
|
||||
done
|
||||
for motd in "/etc/update-motd.d/50-$TO"; do
|
||||
[[ -f "$motd" ]] && grep -q "motd_banner.py" "$motd" 2>/dev/null && rm -f "$motd" \
|
||||
&& info "Banner der Fassung 1.x entfernt"
|
||||
done
|
||||
info "Alles Verdraengte liegt unter $BEISEITE"
|
||||
fi
|
||||
|
||||
# -- 3. Umziehen ------------------------------------------------------------- #
|
||||
step "Umziehen"
|
||||
for paar in "${VERSCHIEBEN[@]}"; do
|
||||
quelle="${paar%%:*}"; ziel="${paar##*:}"
|
||||
[[ -e "$quelle" ]] || continue
|
||||
[[ -e "$ziel" ]] && fail "$ziel existiert bereits -- Abbruch, damit nichts vermischt wird"
|
||||
mkdir -p "$(dirname "$ziel")"
|
||||
mv "$quelle" "$ziel"
|
||||
info "$quelle -> $ziel"
|
||||
done
|
||||
|
||||
# -- 4. Namen in den Dateien nachziehen -------------------------------------- #
|
||||
step "Namen nachziehen"
|
||||
ENV_FILE="/etc/tesm/$TO.env"
|
||||
if [[ -f "$ENV_FILE" ]]; then
|
||||
# Der SITE_KEY bestimmt den Namen des Sitzungscookies. Bleibt er alt, heisst
|
||||
# das Cookie weiter wie die alte Instanz -- unauffaellig und falsch.
|
||||
sed -i "s#^${ENV_PREFIX}SITE_KEY=.*#${ENV_PREFIX}SITE_KEY=$TO#" "$ENV_FILE"
|
||||
info "SITE_KEY=$TO"
|
||||
fi
|
||||
|
||||
# Pfade in der Datenbank, die den Namen enthalten.
|
||||
python3 - "$TO" "$FROM" "/srv/$TO/data/app.db" <<'PY'
|
||||
import sqlite3, sys
|
||||
neu, alt, datenbank = sys.argv[1], sys.argv[2], sys.argv[3]
|
||||
schluessel = ("web_cert_path", "web_key_path", "web_acme_webroot")
|
||||
try:
|
||||
conn = sqlite3.connect(datenbank, timeout=5.0)
|
||||
except sqlite3.Error as fehler:
|
||||
print(f" Datenbank nicht schreibbar: {fehler}")
|
||||
raise SystemExit(0)
|
||||
try:
|
||||
with conn:
|
||||
for key in schluessel:
|
||||
row = conn.execute("SELECT value FROM settings WHERE key=?", (key,)).fetchone()
|
||||
if not row or not row[0]:
|
||||
continue
|
||||
wert = str(row[0])
|
||||
ersetzt = wert.replace(alt, neu)
|
||||
if ersetzt != wert:
|
||||
conn.execute("UPDATE settings SET value=? WHERE key=?", (ersetzt, key))
|
||||
print(f" {key}: {wert} -> {ersetzt}")
|
||||
finally:
|
||||
conn.close()
|
||||
PY
|
||||
|
||||
# -- 5. Alte Konfiguration entfernen ----------------------------------------- #
|
||||
step "Alte Konfiguration entfernen"
|
||||
for pfad in "${ENTFERNEN[@]}"; do
|
||||
[[ -e "$pfad" ]] || continue
|
||||
rm -f "$pfad"
|
||||
info "$pfad"
|
||||
done
|
||||
systemctl daemon-reload
|
||||
|
||||
# -- 6. Unter dem neuen Namen einrichten ------------------------------------- #
|
||||
# Das venv wird verworfen und neu gebaut. Es ist abgeleiteter Zustand, und
|
||||
# seine Konsolenskripte tragen den absoluten Pfad im Shebang: nach dem Umzug
|
||||
# zeigt "venv/bin/gunicorn" auf einen Interpreter, den es nicht mehr gibt, und
|
||||
# systemd meldet nur "No such file or directory" -- fuer eine Datei, die
|
||||
# sichtbar da ist. Live auf POETEST genau so passiert.
|
||||
if [[ -d "/srv/$TO/venv" ]]; then
|
||||
step "venv verwerfen (Shebangs tragen den alten Pfad)"
|
||||
rm -rf "/srv/$TO/venv"
|
||||
info "wird von install.sh neu gebaut"
|
||||
fi
|
||||
|
||||
step "Unter dem neuen Namen einrichten"
|
||||
INSTALL_ARGS=(--app "$APP" --yes)
|
||||
[[ "$TO" != "$APP" ]] && INSTALL_ARGS+=(--instance "${TO#"$APP"-}")
|
||||
bash "$SCRIPT_DIR/install.sh" "${INSTALL_ARGS[@]}"
|
||||
|
||||
# -- 7. nginx-Site aus den Einstellungen neu schreiben ----------------------- #
|
||||
# install.sh schreibt die Site aus seiner Vorlage, sobald sich die alias-Pfade
|
||||
# aendern -- und die aendern sich hier zwangslaeufig. Der Name der Site steht
|
||||
# aber in der Datenbank, nicht in der Vorlage. Ohne diesen Schritt stuenden
|
||||
# zwei Sites mit "_" auf Port 80 und die erste gewinnt.
|
||||
if [[ -n "$DOMAIN" ]]; then
|
||||
step "Webserver-Konfiguration erneuern"
|
||||
# --apply nicht vergessen: ohne ihn speichert web-setup nur die Einstellungen
|
||||
# und laesst die Site-Datei stehen, die install.sh aus seiner Vorlage
|
||||
# geschrieben hat -- mit "server_name _" und ohne TLS.
|
||||
WEB_ARGS=(web-setup --apply --domain "$DOMAIN")
|
||||
if [[ "$HTTPS_AN" == "1" ]]; then
|
||||
WEB_ARGS+=(--https)
|
||||
[[ "$HSTS_AN" == "1" ]] && WEB_ARGS+=(--hsts)
|
||||
# Das Zertifikat ist mitgezogen und sein Pfad in der Datenbank nachgefuehrt
|
||||
# -- "web-setup --https" laesst es dann unangetastet. Ein neues erzeugen
|
||||
# wuerde nur eine weitere Browserwarnung produzieren. Nur wenn nach dem
|
||||
# Umzug wirklich keines da ist, wird eines erzeugt.
|
||||
CERT_NEU="${CERT_ALT//$FROM/$TO}"
|
||||
if [[ -n "$CERT_NEU" && -f "$CERT_NEU" ]]; then
|
||||
info "HTTPS mit dem vorhandenen Zertifikat: $CERT_NEU"
|
||||
else
|
||||
WEB_ARGS+=(--self-signed)
|
||||
warn "Zertifikat nicht gefunden (${CERT_NEU:-keins hinterlegt}) -- es wird ein neues"
|
||||
warn "selbstsigniertes erzeugt. Der Browser warnt dann einmal erneut."
|
||||
fi
|
||||
fi
|
||||
if sudo -u "$APP" "/usr/local/bin/$TO-admin" "${WEB_ARGS[@]}"; then
|
||||
info "Site neu geschrieben: server_name $DOMAIN"
|
||||
else
|
||||
warn "web-setup ist fehlgeschlagen -- bitte in der Anwendung unter"
|
||||
warn "'Verwaltung -> Webserver und TLS' die Domain eintragen und uebernehmen."
|
||||
fi
|
||||
fi
|
||||
|
||||
# -- 8. Ergebnis ------------------------------------------------------------- #
|
||||
step "Fertig"
|
||||
info "Verzeichnis : /srv/$TO"
|
||||
info "Dienst : systemctl status $TO.service"
|
||||
info "Werkzeug : $TO-admin"
|
||||
info "Aktualisieren: sudo bash /srv/$TO/deploy/update.sh $APP$([[ "$TO" != "$APP" ]] && echo " latest --instance ${TO#"$APP"-}")"
|
||||
[[ -n "$BEISEITE" ]] && info "Fassung 1.x : $BEISEITE (Units und Site darin unter systemd-alt/)"
|
||||
echo
|
||||
warn "Alle Benutzer sind abgemeldet: das Sitzungscookie heisst jetzt anders."
|
||||
@@ -5,5 +5,11 @@
|
||||
#
|
||||
# Bewusst KEIN "ALL=(ALL) NOPASSWD: ALL". Der Vorgaenger loeste dasselbe
|
||||
# Problem, indem die gesamte Anwendung als root lief.
|
||||
Defaults:tesm !requiretty
|
||||
#
|
||||
# Hier stand "Defaults:tesm !requiretty". Auf Debian und Ubuntu ist
|
||||
# requiretty nie gesetzt, die Zeile war also von Anfang an wirkungslos -- und
|
||||
# ab sudo 1.9.16 kennt visudo die Einstellung nicht mehr und lehnt die ganze
|
||||
# Datei ab ("unknown setting: 'requiretty'"). Auf einem frisch aufgesetzten
|
||||
# Server brach die Installation deshalb mitten drin ab. Nicht wieder
|
||||
# hinzufuegen; ein Hygienetest haelt das fest.
|
||||
tesm ALL=(root) NOPASSWD: /usr/local/lib/tesm/tesm-helper
|
||||
@@ -5,5 +5,11 @@
|
||||
#
|
||||
# Bewusst KEIN "ALL=(ALL) NOPASSWD: ALL". Der Vorgaenger loeste dasselbe
|
||||
# Problem, indem die gesamte Anwendung als root lief.
|
||||
Defaults:tesm-license !requiretty
|
||||
#
|
||||
# Hier stand "Defaults:tesm-license !requiretty". Auf Debian und Ubuntu ist
|
||||
# requiretty nie gesetzt, die Zeile war also von Anfang an wirkungslos -- und
|
||||
# ab sudo 1.9.16 kennt visudo die Einstellung nicht mehr und lehnt die ganze
|
||||
# Datei ab ("unknown setting: 'requiretty'"). Auf einem frisch aufgesetzten
|
||||
# Server brach die Installation deshalb mitten drin ab. Nicht wieder
|
||||
# hinzufuegen; ein Hygienetest haelt das fest.
|
||||
tesm-license ALL=(root) NOPASSWD: /usr/local/lib/tesm/tesm-helper
|
||||
@@ -59,7 +59,20 @@ RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
|
||||
LockPersonality=yes
|
||||
MemoryDenyWriteExecute=no
|
||||
SystemCallArchitectures=native
|
||||
SystemCallFilter=@system-service
|
||||
# "@mount" zusaetzlich zu @system-service: die Dateifreigaben werden ueber
|
||||
# mount.cifs eingebunden, und @system-service enthaelt mount/umount2 nicht.
|
||||
# Ohne diese Ergaenzung scheitert jede Einbindung mit "mount error(1):
|
||||
# Operation not permitted" -- gemessen, und zwar unabhaengig von Rechten,
|
||||
# Zugangsdaten und Mount-Optionen: aus einer normalen Root-Shell gelingt
|
||||
# derselbe Aufruf sofort. Der Umweg ueber nsenter in den Namensraum des Wirts
|
||||
# ist keine Alternative, weil "RestrictNamespaces=yes" setns verbietet.
|
||||
#
|
||||
# Eingebunden wird damit im Mount-Namensraum *dieses* Dienstes. Das ist genau
|
||||
# richtig: nur er liest die Dateien, andere Prozesse haben dort nichts zu
|
||||
# suchen, und mit dem Dienst verschwinden die Einbindungen wieder. Die
|
||||
# Aufraeumaufgabe fuer abgelaufene Einbindungen laeuft deshalb im Webprozess --
|
||||
# der Ueberwachungsdienst koennte sie nicht einmal sehen.
|
||||
SystemCallFilter=@system-service @mount
|
||||
SystemCallErrorNumber=EPERM
|
||||
# Bewusst **kein** einschraenkendes CapabilityBoundingSet: sudo braucht
|
||||
# CAP_SETUID/CAP_SETGID, und der Helfer dahinter braucht Root. Was hier fehlt,
|
||||
|
||||
@@ -47,7 +47,7 @@ check_in_list() {
|
||||
}
|
||||
|
||||
# Name einer Installation auf diesem Host: entweder die Hauptinstallation
|
||||
# ("tesm") oder eine benannte Instanz daneben ("tesm-opus"). Mehr als diese
|
||||
# ("tesm") oder eine benannte Instanz daneben ("tesm-test"). Mehr als diese
|
||||
# beiden Formen gibt es nicht -- so kann keine erfundene Zeichenkette einen
|
||||
# Pfad ausserhalb des vorgesehenen Namensraums erzeugen.
|
||||
check_app_key() {
|
||||
@@ -258,6 +258,15 @@ case "$verb" in
|
||||
# Kommandozeile (Prozessliste) und nie ueber die Umgebung
|
||||
# (/proc/<pid>/environ).
|
||||
read_stdin_limited > "$creds"
|
||||
# Eingebunden wird im Mount-Namensraum des aufrufenden Dienstes -- absichtlich:
|
||||
# nur dieser Dienst liest die Dateien, und mit ihm verschwinden die
|
||||
# Einbindungen wieder. Damit der Syscall durchkommt, braucht die Unit
|
||||
# "@mount" im SystemCallFilter. Ohne das scheitert es mit
|
||||
# "mount error(1): Operation not permitted"; gemessen war der Syscall-Filter
|
||||
# die Ursache, nicht der Namensraum. Ein Umweg ueber nsenter in den
|
||||
# Namensraum des Wirts ist keine Option -- "RestrictNamespaces=yes"
|
||||
# verbietet setns, und das Recht dafuer wieder aufzugeben waere teurer als
|
||||
# der Gewinn.
|
||||
mkdir -p "$target"
|
||||
if mount -t cifs "$share" "$target" \
|
||||
-o "credentials=$creds,uid=$(id -u tesm 2>/dev/null || echo 0),gid=$(id -g tesm 2>/dev/null || echo 0),file_mode=0640,dir_mode=0750,vers=3.1.1,seal"
|
||||
@@ -271,6 +280,10 @@ case "$verb" in
|
||||
|
||||
umount)
|
||||
check_mount_target "${1:?Ziel fehlt}"
|
||||
# Gegenstueck zu mount-cifs, und deshalb im selben Namensraum: loesen kann
|
||||
# nur, wer die Einbindung auch sieht. Ein Aufruf aus einem anderen Dienst
|
||||
# greift ins Leere -- genau deshalb laeuft die Aufraeumaufgabe fuer
|
||||
# abgelaufene Einbindungen im Webprozess und nicht im Ueberwachungsdienst.
|
||||
umount -l "$1" || true
|
||||
rmdir "$1" 2>/dev/null || true
|
||||
;;
|
||||
@@ -280,9 +293,27 @@ case "$verb" in
|
||||
for package in "$@"; do
|
||||
check_in_list "$package" "${PACKAGES_ALLOWED[@]}" || fail "Paket nicht erlaubt: $package"
|
||||
done
|
||||
# apt laeuft ausserhalb des Sandkastens des aufrufenden Dienstes. Der wird
|
||||
# sonst an dpkg und dessen Maintainer-Skripte weitervererbt, und dort
|
||||
# scheitert etwas voellig Beilaeufiges: mit "ProtectKernelLogs=yes" gibt
|
||||
# "dmesg" ein EPERM zurueck, ein Postinst-Skript bricht daran ab, und dpkg
|
||||
# endet mit Code 1 -- die Installation schlaegt fehl, obwohl mit dem Paket
|
||||
# alles in Ordnung ist. Gemessen und mit systemd-run reproduzierbar
|
||||
# behoben: PID 1 startet den Prozess, also ohne unsere Haertung.
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
if command -v systemd-run >/dev/null 2>&1 && [[ -S /run/dbus/system_bus_socket ]]; then
|
||||
# Zwei getrennte Aufrufe, damit keine Shell dazwischen steht: die
|
||||
# Paketnamen gehen als eigene Argumente an apt-get, nicht durch eine
|
||||
# Kommandozeile, die sie erst wieder zerlegen muesste.
|
||||
ausserhalb=(systemd-run --wait --pipe --collect --quiet
|
||||
--setenv=DEBIAN_FRONTEND=noninteractive)
|
||||
"${ausserhalb[@]}" apt-get update -qq
|
||||
"${ausserhalb[@]}" apt-get install -y -qq "$@"
|
||||
else
|
||||
# Kein systemd (Container, Entwicklungsrechner): direkt, wie bisher.
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq "$@"
|
||||
fi
|
||||
;;
|
||||
|
||||
certbot-issue)
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Live-Banner fuer die SSH-Anmeldung (MOTD).
|
||||
|
||||
Eingebunden ueber ``/etc/update-motd.d/50-<name>``, das ``install.sh``
|
||||
erzeugt. Aufruf mit ``--app`` und ``--root``; alles andere steht in der
|
||||
Datenbank der Installation.
|
||||
|
||||
**Nur Standardbibliothek, kein Flask, kein venv.** Der Vorgaenger importierte
|
||||
dafuer die ganze Anwendung -- bei jeder SSH-Anmeldung. Das ist teuer, und es
|
||||
faellt aus, sobald das venv nicht mehr passt: nach dem Umstieg auf diese
|
||||
Fassung zeigte das alte Banner monatelang eingefrorene Zahlen aus einer
|
||||
Datenbank, die niemand mehr fuellte. Hier wird gelesen, was auf der Platte
|
||||
liegt, und zwar **nur lesend** (``mode=ro``).
|
||||
|
||||
Ein Banner darf eine Anmeldung niemals stoeren: jede Auskunft, die nicht zu
|
||||
holen ist, wird zu einem kurzen Hinweis, und am Ende faengt ``main`` alles ab.
|
||||
Der Aufrufer setzt zusaetzlich ein Zeitlimit.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sqlite3
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
AMBER = "\033[38;2;226;166;60m"
|
||||
DIM = "\033[2m"
|
||||
BOLD = "\033[1m"
|
||||
RESET = "\033[0m"
|
||||
|
||||
#: Kea schreibt seine Leases als CSV; die letzte Zeile je Adresse gilt.
|
||||
#: Dieselbe Datei liest die Anwendung (``services/kea.py``: ``LEASE_FILE``).
|
||||
LEASE_FILE = "/var/lib/kea/kea-leases4.csv"
|
||||
|
||||
#: Zeile unter der Wortmarke. Wortlaut wie in der jeweiligen Anwendung
|
||||
#: (``AUTH_TAGLINE`` in deren ``__init__.py``) -- ein Hygienetest haelt beides
|
||||
#: zusammen.
|
||||
TAGLINE = {
|
||||
"tesm": "ÜBERWACHEN · BOOTEN · ANBINDEN",
|
||||
"tesm-license": "LIZENZIEREN",
|
||||
}
|
||||
|
||||
TITEL = {"tesm": "TimEShepManager", "tesm-license": "Lizenzserver"}
|
||||
|
||||
#: Wortgleich mit ``services/monitor.py``: ``DASHBOARD_COUNT_SQL``. Die
|
||||
#: Statusuebersicht und dieses Banner sollen nicht zwei Wahrheiten haben; ein
|
||||
#: Hygienetest vergleicht die beiden Zeichenketten.
|
||||
DASHBOARD_COUNT_SQL = """
|
||||
SELECT
|
||||
COUNT(*) AS total,
|
||||
SUM(CASE WHEN d.is_active=0 THEN 1 ELSE 0 END) AS disabled,
|
||||
SUM(CASE WHEN d.is_active=1 AND COALESCE(st.state,'unknown')='online' THEN 1 ELSE 0 END) AS online,
|
||||
SUM(CASE WHEN d.is_active=1 AND COALESCE(st.state,'unknown')='offline' THEN 1 ELSE 0 END) AS offline,
|
||||
SUM(CASE WHEN d.is_active=1 AND COALESCE(st.state,'unknown')='unknown' THEN 1 ELSE 0 END) AS unknown,
|
||||
SUM(CASE WHEN d.is_active=1 AND st.state='restarting' THEN 1 ELSE 0 END) AS restarting
|
||||
FROM devices d LEFT JOIN device_status st ON st.device_id = d.id
|
||||
WHERE d.deleted_at IS NULL
|
||||
"""
|
||||
|
||||
|
||||
def _zahl(anzahl: int, singular: str, plural: str) -> str:
|
||||
"""Zahl mit passendem Wort. "1 Clients" liest man jeden Tag einmal zu oft."""
|
||||
return f"{BOLD}{anzahl}{RESET} {singular if anzahl == 1 else plural}"
|
||||
|
||||
|
||||
def _oeffnen(root: Path) -> sqlite3.Connection:
|
||||
pfad = root / "data" / "app.db"
|
||||
conn = sqlite3.connect(f"file:{pfad.as_posix()}?mode=ro", uri=True, timeout=1.0)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
def _flotte(conn: sqlite3.Connection) -> str:
|
||||
row = conn.execute(DASHBOARD_COUNT_SQL).fetchone()
|
||||
online = int(row["online"] or 0)
|
||||
offline = int(row["offline"] or 0)
|
||||
teile = [
|
||||
_zahl(online, "Client online", "Clients online"),
|
||||
f"{BOLD}{offline}{RESET} nicht erreichbar",
|
||||
]
|
||||
# Nur nennen, wenn es sie gibt: eine Null bei "startet neu" ist keine
|
||||
# Auskunft, sondern Fuellmaterial.
|
||||
restarting = int(row["restarting"] or 0)
|
||||
if restarting:
|
||||
teile.append(_zahl(restarting, "startet neu", "starten neu"))
|
||||
unbekannt = int(row["unknown"] or 0)
|
||||
if unbekannt:
|
||||
teile.append(f"{DIM}{unbekannt} ohne Pruefung{RESET}")
|
||||
return " · ".join(teile)
|
||||
|
||||
|
||||
def _dhcp() -> str:
|
||||
"""Aktive Leases aus der Kea-CSV. Laeuft als root, braucht keinen Helfer."""
|
||||
datei = Path(LEASE_FILE)
|
||||
if not datei.is_file():
|
||||
return "keine Lease-Datei"
|
||||
try:
|
||||
zeilen = datei.read_text(encoding="utf-8", errors="replace").splitlines()
|
||||
except OSError:
|
||||
return "Lease-Datei nicht lesbar"
|
||||
if len(zeilen) < 2:
|
||||
return "0 Leases aktiv"
|
||||
kopf = [name.strip().strip('"') for name in zeilen[0].split(",")]
|
||||
try:
|
||||
i_adresse = kopf.index("address")
|
||||
i_ablauf = kopf.index("expire")
|
||||
i_zustand = kopf.index("state")
|
||||
except ValueError:
|
||||
return "Lease-Datei mit unerwarteten Spalten"
|
||||
|
||||
jetzt = int(time.time())
|
||||
letzte: dict[str, tuple[int, str]] = {}
|
||||
for zeile in zeilen[1:]:
|
||||
felder = [feld.strip().strip('"') for feld in zeile.split(",")]
|
||||
if len(felder) < len(kopf):
|
||||
continue
|
||||
try:
|
||||
letzte[felder[i_adresse]] = (int(felder[i_ablauf] or 0), felder[i_zustand] or "0")
|
||||
except ValueError:
|
||||
continue
|
||||
aktiv = sum(1 for ablauf, zustand in letzte.values() if zustand == "0" and ablauf > jetzt)
|
||||
return f"{aktiv} {'Lease' if aktiv == 1 else 'Leases'} aktiv"
|
||||
|
||||
|
||||
def _lizenzen(conn: sqlite3.Connection) -> str:
|
||||
kunden = int(conn.execute("SELECT COUNT(*) FROM license_customers").fetchone()[0] or 0)
|
||||
lizenzen = int(
|
||||
conn.execute(
|
||||
"SELECT COUNT(*) FROM licenses WHERE status IN ('issued','active')"
|
||||
).fetchone()[0]
|
||||
or 0
|
||||
)
|
||||
return (
|
||||
_zahl(kunden, "Kunde", "Kunden")
|
||||
+ " · "
|
||||
+ _zahl(lizenzen, "Lizenz ausgestellt", "Lizenzen ausgestellt")
|
||||
)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(prog="tesm-motd", description="Banner fuer die SSH-Anmeldung")
|
||||
parser.add_argument("--app", required=True, choices=sorted(TITEL))
|
||||
parser.add_argument("--root", required=True, help="Verzeichnis der Installation")
|
||||
parser.add_argument("--no-color", action="store_true")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if args.no_color:
|
||||
globals().update(AMBER="", DIM="", BOLD="", RESET="")
|
||||
|
||||
root = Path(args.root)
|
||||
# Kein isatty()-Test: dieses Skript schreibt beim Login nicht auf ein
|
||||
# Terminal, sondern ueber run-parts in eine Datei. Der Betrachter ist
|
||||
# trotzdem ein echtes Terminal -- deshalb immer Farbe.
|
||||
print(f"{BOLD}{AMBER}TESM{RESET} · {TITEL[args.app]}")
|
||||
if TAGLINE[args.app]:
|
||||
print(f"{DIM}{TAGLINE[args.app]}{RESET}")
|
||||
print()
|
||||
try:
|
||||
conn = _oeffnen(root)
|
||||
except sqlite3.Error:
|
||||
print(f"{DIM}Datenbank nicht lesbar: {root}{RESET}")
|
||||
print()
|
||||
return 0
|
||||
try:
|
||||
if args.app == "tesm":
|
||||
print(f"{_flotte(conn)} · DHCP: {_dhcp()}")
|
||||
else:
|
||||
print(_lizenzen(conn))
|
||||
except sqlite3.Error as fehler:
|
||||
print(f"{DIM}Zustand nicht verfuegbar ({fehler}){RESET}")
|
||||
finally:
|
||||
conn.close()
|
||||
print()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except SystemExit:
|
||||
raise
|
||||
except Exception: # noqa: BLE001 - ein Banner darf keinen Login stoeren
|
||||
sys.exit(0)
|
||||
@@ -1,56 +1,199 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# Bootstrapper: laedt ein Release herunter und ruft dessen install.sh auf.
|
||||
# Aktualisierung: laedt das aktuelle Release und ruft dessen install.sh auf.
|
||||
# Enthaelt selbst keinerlei Installationslogik.
|
||||
#
|
||||
# Zwei Fallen des Vorgaengers sind hier bewusst vermieden:
|
||||
# * Der Tag "latest" wird ueber die API aufgeloest, nie direkt in die
|
||||
# Download-URL geschrieben. Gitea liefert sonst mit HTTP 200 ein
|
||||
# automatisch erzeugtes Quell-Snapshot statt des echten Release-Assets.
|
||||
# * Nach dem Entpacken wird geprueft, dass ein install.sh existiert -- faengt
|
||||
# denselben Fehler ein zweites Mal ab.
|
||||
# Eine einzelne Datei genuegt -- sie holt sich alles Weitere:
|
||||
#
|
||||
# curl -fsSLO https://gitea.int.eertmoed.net/alientim/tesm/raw/branch/main/deploy/update.sh
|
||||
# sudo bash update.sh tesm
|
||||
#
|
||||
# Auf einer bestehenden Installation liegt sie schon bereit:
|
||||
#
|
||||
# sudo bash /srv/tesm/deploy/update.sh tesm
|
||||
#
|
||||
# Laeuft hier die Master-Ausgabe des Lizenzservers, kommt das Release aus dem
|
||||
# privaten Repositorium tesm-license-master -- dafuer braucht es einen
|
||||
# Zugriffstoken in TESM_RELEASE_TOKEN oder in /etc/tesm-release.token.
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
APP="${1:-}"
|
||||
TAG="${2:-latest}"
|
||||
BASE="${TESM_RELEASE_BASE:-https://gitea.example.invalid}"
|
||||
OWNER="${TESM_RELEASE_OWNER:-wis}"
|
||||
REPO="${TESM_RELEASE_REPO:-tesm}"
|
||||
|
||||
# Alles nach dem Tag geht unveraendert an install.sh weiter. Ohne das liesse
|
||||
# sich eine benannte Instanz nicht aktualisieren: install.sh leitet den Namen
|
||||
# aus "--instance" ab, und ohne den Schalter entstuende neben "tesm-test" eine
|
||||
# zweite Installation namens "tesm" -- mit eigener Datenbank, eigener
|
||||
# nginx-Site und demselben Port.
|
||||
shift $(( $# > 2 ? 2 : $# ))
|
||||
EXTRA=("$@")
|
||||
|
||||
[[ "$APP" == "tesm" || "$APP" == "tesm-license" ]] || {
|
||||
echo "Verwendung: update.sh <tesm|tesm-license> [tag]" >&2
|
||||
echo "Verwendung: update.sh <tesm|tesm-license> [tag] [install.sh-Argumente ...]" >&2
|
||||
echo "Beispiel : update.sh tesm latest --instance test" >&2
|
||||
echo "Ohne Rueckfrage: --yes anhaengen (ohne Terminal zwingend)." >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
WORK="$(mktemp -d /tmp/tesm-update-XXXXXX)"
|
||||
trap 'rm -rf "$WORK"' EXIT # anders als im Vorgaenger wird aufgeraeumt
|
||||
# Repositorium: die Master-Ausgabe des Lizenzservers liegt in einem eigenen,
|
||||
# privaten Repositorium, heisst als Installation aber weiter "tesm-license".
|
||||
# Erkannt wird sie an der Installation und nicht an einem Schalter -- wer
|
||||
# aktualisiert, soll nicht erst wissen muessen, welche Ausgabe hier laeuft.
|
||||
# Sonst holte der Aufruf die Ausgabe fuer Sub-Lizenzserver, und der Installer
|
||||
# muesste sie abweisen.
|
||||
REPO="$APP"
|
||||
if [[ "$APP" == "tesm-license" ]]; then
|
||||
NAME="$APP"
|
||||
for ((i = 0; i < ${#EXTRA[@]}; i++)); do
|
||||
[[ "${EXTRA[i]}" == "--instance" ]] && NAME="$APP-${EXTRA[i + 1]:-}"
|
||||
done
|
||||
# Die Wurzel ist ueberschreibbar, damit ein Test die Erkennung wirklich
|
||||
# ausfuehren kann und nicht nur den Quelltext liest.
|
||||
if [[ -d "${TESM_SRV_ROOT:-/srv}/$NAME/apps/tesm-license/src/tesm_license/master" ]]; then
|
||||
REPO="tesm-license-master"
|
||||
echo "==> Master-Ausgabe erkannt -- Release aus $REPO."
|
||||
fi
|
||||
fi
|
||||
|
||||
# --8<-- Bootstrap: Release holen --------------------------------------------
|
||||
# Wortgleich in update.sh und upgrade.sh; ein Hygienetest vergleicht die beiden
|
||||
# Bloecke. Zwei eigenstaendige Skripte koennen sich keine Datei teilen, und
|
||||
# beide sollen als **einzelne Datei** genuegen: auf das Zielsystem legen,
|
||||
# ausfuehren, fertig -- auf einer 1.2.8 liegt nichts von diesem Projekt.
|
||||
#
|
||||
# Erwartet: $APP und $TAG, wahlweise $REPO (Vorgabe: $APP). Setzt: $TAG
|
||||
# (aufgeloest) und $SRC (entpacktes Release). Raeumt sein Arbeitsverzeichnis
|
||||
# beim Beenden auf.
|
||||
#
|
||||
# $REPO gibt es, weil Installationsname und Repositorium auseinanderfallen
|
||||
# koennen: die Master-Ausgabe des Lizenzservers liegt in
|
||||
# "tesm-license-master", die Installation heisst weiter "tesm-license".
|
||||
#
|
||||
# Zwei Fallen des Vorgaengers sind hier vermieden:
|
||||
# * Der Tag "latest" wird ueber die API aufgeloest, nie direkt in die
|
||||
# Download-URL geschrieben. Gitea liefert sonst mit HTTP 200 ein
|
||||
# automatisch erzeugtes Quell-Snapshot statt des echten Release-Anhangs.
|
||||
# * Nach dem Entpacken wird geprueft, dass ein install.sh existiert.
|
||||
BASE="${TESM_RELEASE_BASE:-https://gitea.int.eertmoed.net}"
|
||||
OWNER="${TESM_RELEASE_OWNER:-alientim}"
|
||||
|
||||
# Ein privates Repositorium -- tesm-license-master -- gibt sein Release nur mit
|
||||
# Zugriffstoken heraus. Reihenfolge: Umgebungsvariable, dann eine Datei, die
|
||||
# der Betreiber selbst anlegt. Der Installer legt sie nie an: ob ein Token auf
|
||||
# dem Server liegt, ist keine Entscheidung eines Skripts.
|
||||
TOKEN="${TESM_RELEASE_TOKEN:-}"
|
||||
if [[ -z "$TOKEN" && -r /etc/tesm-release.token ]]; then
|
||||
TOKEN="$(tr -d '[:space:]' < /etc/tesm-release.token)"
|
||||
fi
|
||||
|
||||
# Ein Wagenruecklauf im Paket macht jedes Shell-Skript auf dem Ziel unbrauchbar
|
||||
# ("bad interpreter"). Die Pruefung darf dabei nicht selbst von einem
|
||||
# Zusatzpaket abhaengen: mit "file" -- das auf einem schlanken Server fehlt --
|
||||
# endete sie in "command not found", grep bekam nichts zu sehen, und das Skript
|
||||
# machte weiter, als waere alles in Ordnung.
|
||||
hat_crlf() {
|
||||
local cr
|
||||
cr="$(printf '\r')"
|
||||
# -U (binaer lesen) ist unter Linux ohne Wirkung, auf einer
|
||||
# Windows-Buildmaschine aber entscheidend: dort entfernt grep den
|
||||
# Wagenruecklauf beim Lesen und findet deshalb nie einen.
|
||||
LC_ALL=C grep -qU "$cr" "$1"
|
||||
}
|
||||
|
||||
BOOTSTRAP_WORK=""
|
||||
bootstrap_aufraeumen() {
|
||||
# Den Ausstiegscode zuerst festhalten: alles danach ueberschreibt ihn.
|
||||
local code=$?
|
||||
[[ -n "$BOOTSTRAP_WORK" ]] && rm -rf "$BOOTSTRAP_WORK"
|
||||
# Wer einen eigenen Abschluss braucht -- etwa einen Rueckweg nach einem
|
||||
# Fehlschlag --, definiert "beim_beenden". Ein zweiter "trap ... EXIT" waere
|
||||
# der falsche Weg: er wuerde diesen hier ersetzen, und das entpackte Release
|
||||
# bliebe unter /tmp liegen.
|
||||
declare -F beim_beenden >/dev/null 2>&1 && beim_beenden "$code"
|
||||
return "$code"
|
||||
}
|
||||
trap bootstrap_aufraeumen EXIT
|
||||
|
||||
release_holen() {
|
||||
local repo="${TESM_RELEASE_REPO:-${REPO:-$APP}}" asset
|
||||
# Leeres Array und "set -u" vertragen sich nur mit dieser Schreibweise.
|
||||
local -a kopf=()
|
||||
[[ -n "$TOKEN" ]] && kopf=(-H "Authorization: token $TOKEN")
|
||||
for werkzeug in curl tar python3 find; do
|
||||
command -v "$werkzeug" >/dev/null 2>&1 || {
|
||||
echo "Auf diesem System fehlt $werkzeug -- ohne das geht es nicht." >&2
|
||||
return 1
|
||||
}
|
||||
done
|
||||
BOOTSTRAP_WORK="$(mktemp -d /tmp/tesm-release-XXXXXX)"
|
||||
|
||||
if [[ "$TAG" == "latest" ]]; then
|
||||
echo "==> Ermittle aktuellen Release-Tag ..."
|
||||
TAG="$(curl -fsS "$BASE/api/v1/repos/$OWNER/$REPO/releases/latest" \
|
||||
| python3 -c 'import json,sys; print(json.load(sys.stdin)["tag_name"])')"
|
||||
TAG="$(curl -fsS ${kopf[0]+"${kopf[@]}"} "$BASE/api/v1/repos/$OWNER/$repo/releases/latest" \
|
||||
| python3 -c 'import json,sys; print(json.load(sys.stdin)["tag_name"])')" || {
|
||||
echo "Kein Release gefunden unter $BASE/$OWNER/$repo." >&2
|
||||
[[ -z "$TOKEN" ]] &&
|
||||
echo "Ist das Repositorium privat? Dann TESM_RELEASE_TOKEN setzen." >&2
|
||||
echo "Andere Quelle: TESM_RELEASE_BASE, TESM_RELEASE_OWNER, TESM_RELEASE_REPO." >&2
|
||||
return 1
|
||||
}
|
||||
echo " $TAG"
|
||||
fi
|
||||
|
||||
ASSET="$REPO-$TAG.tar.gz"
|
||||
echo "==> Lade $ASSET ..."
|
||||
curl -fsSL -o "$WORK/$ASSET" "$BASE/$OWNER/$REPO/releases/download/$TAG/$ASSET"
|
||||
asset="$repo-$TAG.tar.gz"
|
||||
echo "==> Lade $asset ..."
|
||||
curl -fsSL ${kopf[0]+"${kopf[@]}"} -o "$BOOTSTRAP_WORK/$asset" \
|
||||
"$BASE/$OWNER/$repo/releases/download/$TAG/$asset" || {
|
||||
echo "Download fehlgeschlagen: $BASE/$OWNER/$repo/releases/download/$TAG/$asset" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
echo "==> Entpacke ..."
|
||||
tar -xzf "$WORK/$ASSET" -C "$WORK"
|
||||
SRC="$(find "$WORK" -maxdepth 2 -name install.sh -path '*/deploy/*' -printf '%h/..\n' | head -1)"
|
||||
[[ -n "$SRC" ]] || { echo "Im Paket fehlt deploy/install.sh -- Download unbrauchbar." >&2; exit 1; }
|
||||
tar -xzf "$BOOTSTRAP_WORK/$asset" -C "$BOOTSTRAP_WORK"
|
||||
# Drei Ebenen, nicht zwei: release.sh packt mit dem Praefix "<name>/", also
|
||||
# liegt das Skript unter <name>/deploy/install.sh. Mit "-maxdepth 2" fand die
|
||||
# Suche nie etwas -- und die Fehlermeldung schob es auf den Download.
|
||||
SRC="$(find "$BOOTSTRAP_WORK" -maxdepth 3 -name install.sh -path '*/deploy/*' \
|
||||
-printf '%h/..\n' | head -1)"
|
||||
[[ -n "$SRC" ]] || {
|
||||
echo "Im Paket fehlt deploy/install.sh -- Download unbrauchbar." >&2
|
||||
return 1
|
||||
}
|
||||
SRC="$(cd "$SRC" && pwd)"
|
||||
|
||||
echo "==> Pruefe auf CRLF (Windows-Buildmaschinen) ..."
|
||||
if file "$SRC/deploy/install.sh" | grep -q CRLF; then
|
||||
if hat_crlf "$SRC/deploy/install.sh"; then
|
||||
echo "Das Paket enthaelt CRLF-Zeilenenden. Beim Paketieren fehlte" >&2
|
||||
echo "'git -c core.autocrlf=false archive'." >&2
|
||||
exit 1
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
# --8<-- Ende Bootstrap ------------------------------------------------------
|
||||
|
||||
release_holen
|
||||
|
||||
# --yes darf auch hier stehen, nicht nur bei install.sh und upgrade.sh. Es
|
||||
# bleibt in EXTRA: install.sh bekommt es ohnehin, doppelt schadet nicht.
|
||||
OHNE_RUECKFRAGE=0
|
||||
for ((i = 0; i < ${#EXTRA[@]}; i++)); do
|
||||
[[ "${EXTRA[i]}" == "--yes" || "${EXTRA[i]}" == "-y" ]] && OHNE_RUECKFRAGE=1
|
||||
done
|
||||
|
||||
if [[ $OHNE_RUECKFRAGE -eq 1 ]]; then
|
||||
echo "==> Version $TAG wird ohne Rueckfrage installiert."
|
||||
elif [[ -t 0 ]]; then
|
||||
read -r -p "Version $TAG fuer $APP${EXTRA[0]+ (${EXTRA[*]})} installieren? [j/N] " answer
|
||||
[[ "$answer" =~ ^[jJyY]$ ]] || exit 0
|
||||
else
|
||||
# Ohne Terminal las "read" hier EOF, die Antwort blieb leer, und das Skript
|
||||
# endete mit 0 -- wer es aus einem anderen Skript aufrief, hielt das Update
|
||||
# fuer erledigt. Lieber laut abbrechen.
|
||||
echo "Kein Terminal fuer die Rueckfrage, und --yes fehlt -- es wurde nichts" >&2
|
||||
echo "installiert. Aufruf mit --yes wiederholen." >&2
|
||||
exit 3
|
||||
fi
|
||||
|
||||
read -r -p "Version $TAG fuer $APP installieren? [j/N] " answer
|
||||
[[ "$answer" =~ ^[jJyY]$ ]] || exit 0
|
||||
|
||||
exec sudo bash "$SRC/deploy/install.sh" --app "$APP" --yes
|
||||
# Kein exec: sonst laeuft die Aufraeumfalle nie, und jedes Update liesse ein
|
||||
# entpacktes Release unter /tmp liegen.
|
||||
bash "$SRC/deploy/install.sh" --app "$APP" --yes ${EXTRA[0]+"${EXTRA[@]}"}
|
||||
@@ -0,0 +1,687 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# Upgrade vom Stand SONNET5 (Version 1.x) auf diese Fassung -- mit Datenuebernahme.
|
||||
#
|
||||
# Eine einzelne Datei genuegt -- sie holt sich das Release selbst:
|
||||
#
|
||||
# curl -fsSLO https://gitea.int.eertmoed.net/alientim/tesm/raw/branch/main/deploy/upgrade.sh
|
||||
# sudo bash upgrade.sh --app tesm
|
||||
#
|
||||
# Auf einer 1.2.8 liegt nichts von diesem Projekt; alles andere waere die
|
||||
# falsche Voraussetzung. Liegt das Skript in einem entpackten Release, wird
|
||||
# dessen install.sh benutzt statt eines Downloads:
|
||||
#
|
||||
# sudo bash deploy/upgrade.sh --app tesm
|
||||
# sudo bash deploy/upgrade.sh --app tesm --instance test --source /srv/tesm
|
||||
#
|
||||
# Der Ablauf ist die Vorgabe und braucht keine Schalter:
|
||||
#
|
||||
# 1. **Sichern.** Vollstaendige Kopie der alten Installation nach
|
||||
# /srv/<name>-upgrade-<zeitstempel>/alt. Erst danach wird irgendetwas
|
||||
# angefasst -- und aus dieser Kopie stammen auch alle weiteren Schritte.
|
||||
# 2. **Altes System abschalten.** Dienst und Timer werden gestoppt und
|
||||
# deaktiviert, damit sie nach einem Neustart nicht zurueckkommen.
|
||||
# 3. **Neu installieren.** install.sh dieses Verzeichnisses.
|
||||
# 4. **Exportieren.** Aus der Kopie entsteht eine Exportdatei im **alten**
|
||||
# Format -- genau die, die der Vorgaenger selbst geschrieben haette.
|
||||
# 5. **Uebersetzen.** Daraus eine Datei im neuen Format.
|
||||
# 6. **Einspielen.** Diese Datei geht durch denselben Import wie eine
|
||||
# Sicherung.
|
||||
#
|
||||
# Warum die Sicherung vor dem Export steht und nicht danach: laeuft das
|
||||
# Upgrade **in dasselbe Verzeichnis** (also ohne --instance), ueberschreibt
|
||||
# install.sh die alte Installation. Wer erst exportiert und dann sichert,
|
||||
# sichert im schlechtesten Fall nichts mehr. Gelesen wird ausschliesslich aus
|
||||
# der Kopie -- das Ergebnis ist dasselbe, die Reihenfolge ist die sichere.
|
||||
#
|
||||
# Der Lizenzserver kennt Schritt 4 und 5 nicht: seine Uebernahme liest die alte
|
||||
# Datenbank unmittelbar und **signiert jede Lizenz neu**. Dabei wird der alte
|
||||
# Signaturschluessel uebernommen -- sonst muesste jeder Kunde einen
|
||||
# Ausstellerwechsel bestaetigen. Wer das nicht will: --new-key. Dort ist die
|
||||
# Kopie aus Schritt 1 der Export.
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
APP=""
|
||||
INSTANCE=""
|
||||
SOURCE=""
|
||||
PASSPHRASE=""
|
||||
ASSUME_YES=0
|
||||
DRY_RUN=0
|
||||
WITH_DATA=1
|
||||
STOP_OLD=1
|
||||
ADOPT_KEY=1
|
||||
TAG="latest"
|
||||
ROLLBACK=""
|
||||
|
||||
#: Steht die Sicherung schon, und ist der Lauf durch? Daran entscheidet
|
||||
#: die EXIT-Falle, ob sie zurueckrollt.
|
||||
SICHERUNG_STEHT=0
|
||||
UPGRADE_FERTIG=0
|
||||
#: Namenszusatz, unter dem ``rename.sh`` eine verdraengte Installation der
|
||||
#: Fassung 1.x ablegt. Beide Skripte muessen denselben Wert benutzen; ein
|
||||
#: Hygienetest vergleicht sie.
|
||||
#:
|
||||
#: Auf einer echten 1.2.8 gibt es das **nicht** -- dort liegt die Installation
|
||||
#: unter ihrem Hauptnamen. Der Zusatz entsteht erst, wenn ``rename.sh`` den
|
||||
#: Hauptnamen fuer die neue Fassung braucht. Deshalb ist er der zweite Ort, an
|
||||
#: dem gesucht wird, nicht der erste.
|
||||
ARCHIV_SUFFIX="sonnet5"
|
||||
|
||||
#: Nur fuer die Ausgabe: wurde die Quelle selbst gefunden oder angegeben?
|
||||
info_quelle=""
|
||||
EXTRA=()
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
|
||||
COLOR_OK=$'\033[32m'; COLOR_WARN=$'\033[33m'; COLOR_ERR=$'\033[31m'; COLOR_OFF=$'\033[0m'
|
||||
step() { printf '\n%s==>%s %s\n' "$COLOR_OK" "$COLOR_OFF" "$*"; }
|
||||
warn() { printf '%s !!%s %s\n' "$COLOR_WARN" "$COLOR_OFF" "$*"; }
|
||||
fail() { printf '%s !!%s %s\n' "$COLOR_ERR" "$COLOR_OFF" "$*" >&2; exit 1; }
|
||||
info() { printf ' %s\n' "$*"; }
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Verwendung: upgrade.sh --app <tesm|tesm-license> [Optionen]
|
||||
|
||||
--app <name> Welche Anwendung (Pflicht)
|
||||
--source <pfad> Verzeichnis der alten Installation
|
||||
(Vorgabe: /srv/tesm bzw. /srv/tesm-license)
|
||||
--instance <name> Neue Installation als benannte Instanz anlegen, statt
|
||||
das alte Verzeichnis zu ueberschreiben. Damit bleibt die
|
||||
alte Installation liegen und ist zurueckholbar.
|
||||
--passphrase <text> Passphrase der Zwischendateien. Ohne Angabe wird eine
|
||||
erzeugt und in der Sicherung abgelegt.
|
||||
--no-data Nur installieren, keine Datenuebernahme
|
||||
--keep-old-running Alten Dienst nicht stoppen (nur mit --instance sinnvoll)
|
||||
--new-key Nur Lizenzserver: einen neuen Signaturschluessel
|
||||
erzeugen, statt den alten zu uebernehmen. Dann muessen
|
||||
alle Kunden den Ausstellerwechsel bestaetigen -- die
|
||||
Vorgabe ist deshalb die Uebernahme.
|
||||
--tag <vX.Y.Z> Bestimmte Version installieren (Vorgabe: latest).
|
||||
Wird nur beachtet, wenn das Release geladen wird.
|
||||
--rollback [pfad] Einen fehlgeschlagenen Lauf zurueckdrehen: die alte
|
||||
Installation aus der Sicherung wiederherstellen und
|
||||
ihren Dienst wieder einschalten. Ohne Pfad wird die
|
||||
neueste Sicherung dieses Namens genommen.
|
||||
--dry-run Nur zeigen, was geschehen wuerde
|
||||
--yes Keine Rueckfrage
|
||||
-h, --help Diese Hilfe
|
||||
|
||||
Alles weitere wird unveraendert an install.sh weitergegeben, etwa --https
|
||||
oder --domain.
|
||||
|
||||
Beispiele:
|
||||
# In dasselbe Verzeichnis, alte Installation wird ersetzt:
|
||||
sudo bash deploy/upgrade.sh --app tesm
|
||||
|
||||
# Daneben, zum Vergleich, alte Installation bleibt unangetastet:
|
||||
sudo bash deploy/upgrade.sh --app tesm --instance test --keep-old-running
|
||||
USAGE
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--app) APP="${2:?}"; shift 2 ;;
|
||||
--source) SOURCE="${2:?}"; shift 2 ;;
|
||||
--instance) INSTANCE="${2:?}"; shift 2 ;;
|
||||
--passphrase) PASSPHRASE="${2:?}"; shift 2 ;;
|
||||
--no-data) WITH_DATA=0; shift ;;
|
||||
--keep-old-running) STOP_OLD=0; shift ;;
|
||||
--new-key) ADOPT_KEY=0; shift ;;
|
||||
--adopt-key) ADOPT_KEY=1; shift ;; # Vorgabe, hier nur zur Deutlichkeit
|
||||
--tag) TAG="${2:?}"; shift 2 ;;
|
||||
--rollback)
|
||||
# Der Pfad ist optional: allein genommen heisst es "die neueste".
|
||||
if [[ -n "${2:-}" && "${2:-}" != --* ]]; then ROLLBACK="$2"; shift 2
|
||||
else ROLLBACK="auto"; shift; fi
|
||||
;;
|
||||
--dry-run) DRY_RUN=1; shift ;;
|
||||
--yes|-y) ASSUME_YES=1; shift ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) EXTRA+=("$1"); shift ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ "$APP" == "tesm" || "$APP" == "tesm-license" ]] || { usage; fail "--app fehlt oder ist unbekannt"; }
|
||||
[[ "$(id -u)" -eq 0 ]] || fail "bitte mit sudo ausfuehren"
|
||||
|
||||
NAME="$APP"
|
||||
[[ -n "$INSTANCE" ]] && NAME="$APP-$INSTANCE"
|
||||
ADMIN="$NAME-admin"
|
||||
NEW_ROOT="/srv/$NAME"
|
||||
STAMP="$(date +%Y%m%d-%H%M%S)"
|
||||
BACKUP="/srv/$NAME-upgrade-$STAMP"
|
||||
ALT_KOPIE="$BACKUP/alt"
|
||||
SYSTEM_KOPIE="$BACKUP/system"
|
||||
EXPORT_ALT="$BACKUP/export-alt.json"
|
||||
IMPORT_NEU="$BACKUP/import-neu.json"
|
||||
|
||||
# Alte Einheiten der Vorgaengerfassung. Der Timer gehoert dazu: nur den Dienst
|
||||
# zu stoppen liesse ihn beim naechsten Ablauf wieder anlaufen.
|
||||
# -- Rueckweg ---------------------------------------------------------------- #
|
||||
# Alles, was install.sh unter demselben Namen ueberschreiben wuerde. Bei einem
|
||||
# Upgrade in dasselbe Verzeichnis sind das die Dateien der alten Fassung.
|
||||
SYSTEMDATEIEN=(
|
||||
"/etc/systemd/system/$NAME.service"
|
||||
"/etc/systemd/system/$NAME-monitor.service"
|
||||
"/etc/nginx/sites-available/$NAME"
|
||||
"/etc/logrotate.d/$NAME"
|
||||
"/etc/update-motd.d/50-$NAME"
|
||||
"/etc/tesm/$NAME.env"
|
||||
)
|
||||
|
||||
zurueckrollen() {
|
||||
local sicherung="$1" grund="${2:-}"
|
||||
local alt="$sicherung/alt" system="$sicherung/system"
|
||||
[[ -d "$alt" ]] || fail "In $sicherung fehlt das Verzeichnis 'alt' -- das ist keine Sicherung dieses Skripts."
|
||||
|
||||
# Quelle und Art des Umzugs stehen im Manifest. Aus dem laufenden Aufruf
|
||||
# abzuleiten waere falsch: ein Rueckweg kann Tage spaeter und mit anderen
|
||||
# Schaltern aufgerufen werden -- und beim automatischen Rueckweg steht
|
||||
# IN_PLACE zwar fest, beim spaeteren --rollback aber nicht.
|
||||
local ziel="${SOURCE:-}" gleiches_verzeichnis="${IN_PLACE:-0}" vermerkt
|
||||
if [[ -f "$system/manifest" ]]; then
|
||||
vermerkt="$(awk '$1=="quelle" {print $2}' "$system/manifest" | head -1)"
|
||||
[[ -n "$vermerkt" ]] && ziel="$vermerkt"
|
||||
vermerkt="$(awk '$1=="in-place" {print $2}' "$system/manifest" | head -1)"
|
||||
[[ -n "$vermerkt" ]] && gleiches_verzeichnis="$vermerkt"
|
||||
fi
|
||||
[[ -n "$ziel" ]] || fail "Im Manifest fehlt die Quelle -- bitte --source angeben."
|
||||
|
||||
[[ -n "$grund" ]] && warn "$grund"
|
||||
step "Rueckweg: alten Stand wiederherstellen"
|
||||
|
||||
# Der neue Dienst zuerst -- er haelt sonst Dateien offen, die gleich ersetzt
|
||||
# werden.
|
||||
for unit in "$NAME.service" "$NAME-monitor.service"; do
|
||||
if systemctl list-unit-files "$unit" --no-legend 2>/dev/null | grep -q .; then
|
||||
systemctl disable --now "$unit" >/dev/null 2>&1 || true
|
||||
info "$unit gestoppt"
|
||||
fi
|
||||
done
|
||||
|
||||
# Verzeichnis: nur bei gleichem Ziel -- bei einer eigenen Instanz war die
|
||||
# alte Installation nie angefasst.
|
||||
if [[ "$gleiches_verzeichnis" == "1" ]]; then
|
||||
rm -rf "$ziel"
|
||||
mkdir -p "$ziel"
|
||||
tar -C "$alt" -cf - . | tar -C "$ziel" -xf -
|
||||
info "$ziel aus der Sicherung wiederhergestellt"
|
||||
[[ -d "$ziel/venv" ]] || warn "Im Backup war kein venv -- der alte Dienst braucht dann noch 'python3 -m venv venv' und seine requirements.txt."
|
||||
else
|
||||
info "$ziel war nicht betroffen (eigene Instanz) -- bleibt, wie es ist"
|
||||
if [[ -d "$NEW_ROOT" && "$NEW_ROOT" != "$ziel" ]]; then
|
||||
rm -rf "$NEW_ROOT"
|
||||
info "$NEW_ROOT entfernt"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Systemdateien zurueck, alles andere entfernen, was der Lauf angelegt hat.
|
||||
local site_wieder=0
|
||||
if [[ -f "$system/manifest" ]]; then
|
||||
while read -r zeile; do
|
||||
case "$zeile" in
|
||||
unit-enabled\ *|site-enabled\ *) continue ;;
|
||||
esac
|
||||
local name; name="$(basename "$zeile")"
|
||||
if [[ -e "$system/$name" ]]; then
|
||||
cp -a "$system/$name" "$zeile"
|
||||
info "wiederhergestellt: $zeile"
|
||||
[[ "$zeile" == /etc/nginx/* ]] && site_wieder=1
|
||||
fi
|
||||
done < <(grep -v '^\(unit\|site\)-enabled ' "$system/manifest" || true)
|
||||
fi
|
||||
|
||||
systemctl daemon-reload
|
||||
if [[ $site_wieder -eq 1 ]] || [[ -e "/etc/nginx/sites-enabled/$NAME" ]]; then
|
||||
nginx -t >/dev/null 2>&1 && systemctl reload nginx >/dev/null 2>&1 \
|
||||
&& info "nginx neu geladen" || warn "nginx meldet einen Fehler -- bitte 'nginx -t' ansehen"
|
||||
fi
|
||||
|
||||
# Und die Dienste wieder einschalten, die vorher liefen.
|
||||
if [[ -f "$system/manifest" ]]; then
|
||||
while read -r _ unit; do
|
||||
systemctl enable --now "$unit" >/dev/null 2>&1 \
|
||||
&& info "$unit wieder eingeschaltet" || warn "$unit liess sich nicht starten"
|
||||
done < <(grep '^unit-enabled ' "$system/manifest" || true)
|
||||
fi
|
||||
|
||||
step "Zurueckgerollt"
|
||||
info "Sicherung bleibt liegen: $sicherung"
|
||||
# Nicht ueber ALTE_UNITS: die Liste entsteht erst nach der Quellensuche, und
|
||||
# bei "--rollback" laeuft diese Funktion vorher. Das Manifest weiss es.
|
||||
local hinweis
|
||||
hinweis="$(awk '$1=="unit-enabled" {print $2; exit}' "$system/manifest" 2>/dev/null || true)"
|
||||
info "Zustand pruefen: systemctl status ${hinweis:-$APP.service}"
|
||||
}
|
||||
|
||||
# Wird von der EXIT-Falle des Bootstraps aufgerufen -- mit dem Ausstiegscode.
|
||||
# Damit gilt der Rueckweg fuer **jeden** Fehlschlag und nicht nur fuer den, an
|
||||
# den ich gedacht habe: Export, Uebersetzen, Einspielen, nginx, ein Ctrl-C.
|
||||
beim_beenden() {
|
||||
local code="${1:-0}"
|
||||
[[ "$code" == "0" ]] && return 0
|
||||
[[ $UPGRADE_FERTIG -eq 1 ]] && return 0
|
||||
if [[ $SICHERUNG_STEHT -eq 0 ]]; then
|
||||
warn "Abgebrochen (Code $code), bevor eine Sicherung stand -- nichts zurueckzurollen."
|
||||
return 0
|
||||
fi
|
||||
zurueckrollen "$BACKUP" "Abgebrochen (Code $code)."
|
||||
}
|
||||
|
||||
neueste_sicherung() {
|
||||
local kandidat
|
||||
kandidat="$(ls -1dt /srv/"$NAME"-upgrade-*/ 2>/dev/null | head -1)"
|
||||
[[ -n "$kandidat" ]] || return 1
|
||||
printf '%s' "${kandidat%/}"
|
||||
}
|
||||
|
||||
if [[ -n "$ROLLBACK" ]]; then
|
||||
if [[ "$ROLLBACK" == "auto" ]]; then
|
||||
ROLLBACK="$(neueste_sicherung)" || fail "Keine Sicherung unter /srv/$NAME-upgrade-* gefunden. Pfad angeben."
|
||||
info "Neueste Sicherung: $ROLLBACK"
|
||||
fi
|
||||
[[ -d "$ROLLBACK" ]] || fail "$ROLLBACK gibt es nicht"
|
||||
if [[ $DRY_RUN -eq 1 ]]; then
|
||||
step "--dry-run: es wuerde $ROLLBACK zurueckgerollt."
|
||||
exit 0
|
||||
fi
|
||||
if [[ $ASSUME_YES -eq 0 ]]; then
|
||||
read -r -p "Stand aus $ROLLBACK wiederherstellen? [j/N] " answer
|
||||
[[ "$answer" =~ ^[jJyY]$ ]] || { echo "Abgebrochen."; exit 0; }
|
||||
fi
|
||||
zurueckrollen "$ROLLBACK"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Ab hier wird wirklich installiert -- ein Rueckweg hat oben schon
|
||||
# beendet. Erst jetzt braucht es ein Release und eine Quelle.
|
||||
|
||||
# --8<-- Bootstrap: Release holen --------------------------------------------
|
||||
# Wortgleich in update.sh und upgrade.sh; ein Hygienetest vergleicht die beiden
|
||||
# Bloecke. Zwei eigenstaendige Skripte koennen sich keine Datei teilen, und
|
||||
# beide sollen als **einzelne Datei** genuegen: auf das Zielsystem legen,
|
||||
# ausfuehren, fertig -- auf einer 1.2.8 liegt nichts von diesem Projekt.
|
||||
#
|
||||
# Erwartet: $APP und $TAG, wahlweise $REPO (Vorgabe: $APP). Setzt: $TAG
|
||||
# (aufgeloest) und $SRC (entpacktes Release). Raeumt sein Arbeitsverzeichnis
|
||||
# beim Beenden auf.
|
||||
#
|
||||
# $REPO gibt es, weil Installationsname und Repositorium auseinanderfallen
|
||||
# koennen: die Master-Ausgabe des Lizenzservers liegt in
|
||||
# "tesm-license-master", die Installation heisst weiter "tesm-license".
|
||||
#
|
||||
# Zwei Fallen des Vorgaengers sind hier vermieden:
|
||||
# * Der Tag "latest" wird ueber die API aufgeloest, nie direkt in die
|
||||
# Download-URL geschrieben. Gitea liefert sonst mit HTTP 200 ein
|
||||
# automatisch erzeugtes Quell-Snapshot statt des echten Release-Anhangs.
|
||||
# * Nach dem Entpacken wird geprueft, dass ein install.sh existiert.
|
||||
BASE="${TESM_RELEASE_BASE:-https://gitea.int.eertmoed.net}"
|
||||
OWNER="${TESM_RELEASE_OWNER:-alientim}"
|
||||
|
||||
# Ein privates Repositorium -- tesm-license-master -- gibt sein Release nur mit
|
||||
# Zugriffstoken heraus. Reihenfolge: Umgebungsvariable, dann eine Datei, die
|
||||
# der Betreiber selbst anlegt. Der Installer legt sie nie an: ob ein Token auf
|
||||
# dem Server liegt, ist keine Entscheidung eines Skripts.
|
||||
TOKEN="${TESM_RELEASE_TOKEN:-}"
|
||||
if [[ -z "$TOKEN" && -r /etc/tesm-release.token ]]; then
|
||||
TOKEN="$(tr -d '[:space:]' < /etc/tesm-release.token)"
|
||||
fi
|
||||
|
||||
# Ein Wagenruecklauf im Paket macht jedes Shell-Skript auf dem Ziel unbrauchbar
|
||||
# ("bad interpreter"). Die Pruefung darf dabei nicht selbst von einem
|
||||
# Zusatzpaket abhaengen: mit "file" -- das auf einem schlanken Server fehlt --
|
||||
# endete sie in "command not found", grep bekam nichts zu sehen, und das Skript
|
||||
# machte weiter, als waere alles in Ordnung.
|
||||
hat_crlf() {
|
||||
local cr
|
||||
cr="$(printf '\r')"
|
||||
# -U (binaer lesen) ist unter Linux ohne Wirkung, auf einer
|
||||
# Windows-Buildmaschine aber entscheidend: dort entfernt grep den
|
||||
# Wagenruecklauf beim Lesen und findet deshalb nie einen.
|
||||
LC_ALL=C grep -qU "$cr" "$1"
|
||||
}
|
||||
|
||||
BOOTSTRAP_WORK=""
|
||||
bootstrap_aufraeumen() {
|
||||
# Den Ausstiegscode zuerst festhalten: alles danach ueberschreibt ihn.
|
||||
local code=$?
|
||||
[[ -n "$BOOTSTRAP_WORK" ]] && rm -rf "$BOOTSTRAP_WORK"
|
||||
# Wer einen eigenen Abschluss braucht -- etwa einen Rueckweg nach einem
|
||||
# Fehlschlag --, definiert "beim_beenden". Ein zweiter "trap ... EXIT" waere
|
||||
# der falsche Weg: er wuerde diesen hier ersetzen, und das entpackte Release
|
||||
# bliebe unter /tmp liegen.
|
||||
declare -F beim_beenden >/dev/null 2>&1 && beim_beenden "$code"
|
||||
return "$code"
|
||||
}
|
||||
trap bootstrap_aufraeumen EXIT
|
||||
|
||||
release_holen() {
|
||||
local repo="${TESM_RELEASE_REPO:-${REPO:-$APP}}" asset
|
||||
# Leeres Array und "set -u" vertragen sich nur mit dieser Schreibweise.
|
||||
local -a kopf=()
|
||||
[[ -n "$TOKEN" ]] && kopf=(-H "Authorization: token $TOKEN")
|
||||
for werkzeug in curl tar python3 find; do
|
||||
command -v "$werkzeug" >/dev/null 2>&1 || {
|
||||
echo "Auf diesem System fehlt $werkzeug -- ohne das geht es nicht." >&2
|
||||
return 1
|
||||
}
|
||||
done
|
||||
BOOTSTRAP_WORK="$(mktemp -d /tmp/tesm-release-XXXXXX)"
|
||||
|
||||
if [[ "$TAG" == "latest" ]]; then
|
||||
echo "==> Ermittle aktuellen Release-Tag ..."
|
||||
TAG="$(curl -fsS ${kopf[0]+"${kopf[@]}"} "$BASE/api/v1/repos/$OWNER/$repo/releases/latest" \
|
||||
| python3 -c 'import json,sys; print(json.load(sys.stdin)["tag_name"])')" || {
|
||||
echo "Kein Release gefunden unter $BASE/$OWNER/$repo." >&2
|
||||
[[ -z "$TOKEN" ]] &&
|
||||
echo "Ist das Repositorium privat? Dann TESM_RELEASE_TOKEN setzen." >&2
|
||||
echo "Andere Quelle: TESM_RELEASE_BASE, TESM_RELEASE_OWNER, TESM_RELEASE_REPO." >&2
|
||||
return 1
|
||||
}
|
||||
echo " $TAG"
|
||||
fi
|
||||
|
||||
asset="$repo-$TAG.tar.gz"
|
||||
echo "==> Lade $asset ..."
|
||||
curl -fsSL ${kopf[0]+"${kopf[@]}"} -o "$BOOTSTRAP_WORK/$asset" \
|
||||
"$BASE/$OWNER/$repo/releases/download/$TAG/$asset" || {
|
||||
echo "Download fehlgeschlagen: $BASE/$OWNER/$repo/releases/download/$TAG/$asset" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
echo "==> Entpacke ..."
|
||||
tar -xzf "$BOOTSTRAP_WORK/$asset" -C "$BOOTSTRAP_WORK"
|
||||
# Drei Ebenen, nicht zwei: release.sh packt mit dem Praefix "<name>/", also
|
||||
# liegt das Skript unter <name>/deploy/install.sh. Mit "-maxdepth 2" fand die
|
||||
# Suche nie etwas -- und die Fehlermeldung schob es auf den Download.
|
||||
SRC="$(find "$BOOTSTRAP_WORK" -maxdepth 3 -name install.sh -path '*/deploy/*' \
|
||||
-printf '%h/..\n' | head -1)"
|
||||
[[ -n "$SRC" ]] || {
|
||||
echo "Im Paket fehlt deploy/install.sh -- Download unbrauchbar." >&2
|
||||
return 1
|
||||
}
|
||||
SRC="$(cd "$SRC" && pwd)"
|
||||
|
||||
echo "==> Pruefe auf CRLF (Windows-Buildmaschinen) ..."
|
||||
if hat_crlf "$SRC/deploy/install.sh"; then
|
||||
echo "Das Paket enthaelt CRLF-Zeilenenden. Beim Paketieren fehlte" >&2
|
||||
echo "'git -c core.autocrlf=false archive'." >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
# --8<-- Ende Bootstrap ------------------------------------------------------
|
||||
|
||||
# Woher kommt install.sh? Liegt dieses Skript in einem entpackten Release,
|
||||
# nimm dessen Verzeichnis -- wer ein Paket ausgepackt hat, will nicht, dass
|
||||
# ungefragt ein anderes geladen wird. Sonst hol das Release.
|
||||
if [[ -f "$SCRIPT_DIR/install.sh" ]]; then
|
||||
SRC="$REPO_DIR"
|
||||
RELEASE_HERKUNFT="$SRC (entpacktes Release neben diesem Skript)"
|
||||
else
|
||||
release_holen || fail "Das Release liess sich nicht holen."
|
||||
RELEASE_HERKUNFT="$TAG von $BASE/$OWNER"
|
||||
fi
|
||||
|
||||
# -- Wo liegt die alte Fassung? ---------------------------------------------- #
|
||||
# Zwei Orte kommen in Frage, und der zweite ist der Grund fuer diesen Block:
|
||||
# ``rename.sh`` schiebt eine Installation der Fassung 1.x nach
|
||||
# ``/srv/<app>-sonnet5``, sobald der Hauptname fuer die neue gebraucht wird.
|
||||
# Danach liegt unter ``/srv/<app>`` die **laufende** Installation, und die
|
||||
# Vorgabe zeigte ins Leere. Erkannt wird an ``sqlite.db`` im Wurzelverzeichnis
|
||||
# -- die neue Fassung legt ihre Datenbank unter ``data/app.db`` ab.
|
||||
ist_alte_fassung() { [[ -f "$1/sqlite.db" ]]; }
|
||||
|
||||
if [[ -z "$SOURCE" ]]; then
|
||||
for kandidat in "/srv/$APP" "/srv/$APP-$ARCHIV_SUFFIX"; do
|
||||
if ist_alte_fassung "$kandidat"; then SOURCE="$kandidat"; break; fi
|
||||
done
|
||||
if [[ -z "$SOURCE" ]]; then
|
||||
warn "Keine Installation der Fassung 1.x gefunden. Gesucht wurde in:"
|
||||
warn " /srv/$APP (dort liegt sie normalerweise)"
|
||||
warn " /srv/$APP-$ARCHIV_SUFFIX (dorthin verschiebt rename.sh sie, falls"
|
||||
warn " der Hauptname schon vergeben war)"
|
||||
[[ -f "/srv/$APP/data/app.db" ]] && warn "Unter /srv/$APP liegt die laufende Installation dieser Fassung."
|
||||
fail "Bitte --source setzen."
|
||||
fi
|
||||
info_quelle="gefunden: $SOURCE"
|
||||
fi
|
||||
[[ -d "$SOURCE" ]] || fail "Die alte Installation $SOURCE gibt es nicht (--source setzen)"
|
||||
ist_alte_fassung "$SOURCE" || fail "In $SOURCE liegt keine sqlite.db -- das ist keine Installation der Vorgaengerfassung"
|
||||
|
||||
IN_PLACE=0
|
||||
[[ "$(cd "$SOURCE" && pwd)" == "$NEW_ROOT" ]] && IN_PLACE=1
|
||||
|
||||
# Die Units der alten Fassung erkennt man daran, dass sie in genau dieses
|
||||
# Verzeichnis zeigen -- nicht am Namen der Anwendung. Nach einem rename.sh
|
||||
# heisst die *laufende* Installation "tesm", und eine Namensliste haette sie
|
||||
# gestoppt und deaktiviert, sobald jemand die archivierte 1.x in eine eigene
|
||||
# Instanz hochzieht.
|
||||
#
|
||||
# Timer und Hilfsdienste tragen keinen Pfad (der Restart-Timer ruft nur
|
||||
# "systemctl restart ..."). Sie kommen ueber den Namen der gefundenen Unit mit.
|
||||
alte_units_finden() {
|
||||
local quelle="$1" gefunden=() datei name runde
|
||||
shopt -s nullglob
|
||||
for datei in /etc/systemd/system/*.service; do
|
||||
grep -qs -- "$quelle/" "$datei" && gefunden+=("$(basename "$datei")")
|
||||
done
|
||||
# Zwei Runden: erst Timer und Helfer, die eine gefundene Unit nennen, dann
|
||||
# noch einmal fuer deren eigene Timer.
|
||||
for runde in 1 2; do
|
||||
for datei in /etc/systemd/system/*.service /etc/systemd/system/*.timer; do
|
||||
name="$(basename "$datei")"
|
||||
[[ " ${gefunden[*]-} " == *" $name "* ]] && continue
|
||||
for treffer in ${gefunden[*]-}; do
|
||||
if grep -qs -- "$treffer" "$datei"; then gefunden+=("$name"); break; fi
|
||||
done
|
||||
done
|
||||
done
|
||||
shopt -u nullglob
|
||||
printf '%s\n' ${gefunden[*]-}
|
||||
}
|
||||
|
||||
mapfile -t ALTE_UNITS < <(alte_units_finden "$SOURCE")
|
||||
if [[ ${#ALTE_UNITS[@]} -eq 0 ]]; then
|
||||
if [[ "$SOURCE" == "/srv/$APP" ]]; then
|
||||
# Kein Fund, aber die Quelle ist der Hauptpfad: die historischen Namen der
|
||||
# Fassung 1.x als Rueckfall.
|
||||
if [[ "$APP" == "tesm" ]]; then
|
||||
ALTE_UNITS=(tesm.service tesm-check.timer tesm-check.service
|
||||
tesm-check-restart.timer tesm-check-restart.service)
|
||||
else
|
||||
ALTE_UNITS=(tesm-license.service)
|
||||
fi
|
||||
else
|
||||
ALTE_UNITS=()
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
step "Vorhaben"
|
||||
info "Anwendung : $APP${INSTANCE:+ (Instanz $INSTANCE)}"
|
||||
info "Neue Fassung : $RELEASE_HERKUNFT"
|
||||
info "Alt : $SOURCE${info_quelle:+ (automatisch gefunden)}"
|
||||
info "Neu : $NEW_ROOT$([[ $IN_PLACE -eq 1 ]] && echo ' << dasselbe Verzeichnis')"
|
||||
info "Sicherung : $ALT_KOPIE"
|
||||
info "Datenuebernahme: $([[ $WITH_DATA -eq 1 ]] && echo ja || echo nein)"
|
||||
info "Altes System : $([[ $STOP_OLD -eq 1 ]] && echo 'stoppen und deaktivieren' || echo 'laeuft weiter')"
|
||||
if [[ $IN_PLACE -eq 1 && $STOP_OLD -eq 0 ]]; then
|
||||
warn "--keep-old-running bei gleichem Verzeichnis: der alte Dienst laeuft auf Dateien,"
|
||||
warn "die gleich ersetzt werden. Das geht schief. Bitte --instance verwenden."
|
||||
fi
|
||||
|
||||
if [[ $DRY_RUN -eq 1 ]]; then
|
||||
step "--dry-run: hier ist Schluss."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ $ASSUME_YES -eq 0 ]]; then
|
||||
read -r -p "Fortfahren? [j/N] " answer
|
||||
[[ "$answer" =~ ^[jJyY]$ ]] || { echo "Abgebrochen."; exit 0; }
|
||||
fi
|
||||
|
||||
# -- 1. Sichern -------------------------------------------------------------- #
|
||||
step "Sichern"
|
||||
mkdir -p "$ALT_KOPIE" "$SYSTEM_KOPIE"
|
||||
chmod 700 "$BACKUP"
|
||||
|
||||
# Das venv kommt nur mit, wenn in dasselbe Verzeichnis installiert wird -- dort
|
||||
# ersetzt install.sh es, und ohne das alte startet der alte Dienst nach einem
|
||||
# Rueckweg nicht mehr. Bei einer eigenen Instanz bleibt die alte Installation
|
||||
# unangetastet, dann waere es nur Ballast (und seine Shebangs zeigen auf einen
|
||||
# anderen Pfad).
|
||||
if [[ $IN_PLACE -eq 1 ]]; then
|
||||
tar -C "$SOURCE" --exclude=__pycache__ -cf - . | tar -C "$ALT_KOPIE" -xf -
|
||||
info "$(du -sh "$ALT_KOPIE" | cut -f1) kopiert (mit venv, weil dasselbe Verzeichnis)"
|
||||
else
|
||||
tar -C "$SOURCE" --exclude=venv --exclude=__pycache__ -cf - . | tar -C "$ALT_KOPIE" -xf -
|
||||
info "$(du -sh "$ALT_KOPIE" | cut -f1) kopiert (ohne venv)"
|
||||
fi
|
||||
[[ -f "$ALT_KOPIE/sqlite.db" ]] || fail "In der Kopie fehlt die Datenbank -- Abbruch"
|
||||
|
||||
# Und die Systemdateien: bei gleichem Verzeichnis schreibt install.sh Unit,
|
||||
# nginx-Site, logrotate und Banner neu. Ohne Kopie liesse sich der alte Stand
|
||||
# nicht wiederherstellen -- das Verzeichnis allein genuegt nicht.
|
||||
: > "$SYSTEM_KOPIE/manifest"
|
||||
# Woher kam der Stand, und ging es in dasselbe Verzeichnis? Ein spaeterer
|
||||
# Rueckweg darf das nicht raten muessen -- er laeuft womoeglich Tage danach
|
||||
# und mit anderen Schaltern.
|
||||
printf 'quelle %s\n' "$SOURCE" >> "$SYSTEM_KOPIE/manifest"
|
||||
printf 'in-place %s\n' "$IN_PLACE" >> "$SYSTEM_KOPIE/manifest"
|
||||
for pfad in "${SYSTEMDATEIEN[@]}"; do
|
||||
[[ -e "$pfad" ]] || continue
|
||||
cp -a "$pfad" "$SYSTEM_KOPIE/$(basename "$pfad")"
|
||||
printf '%s\n' "$pfad" >> "$SYSTEM_KOPIE/manifest"
|
||||
done
|
||||
# Welche Units liefen, und war die Site eingeschaltet?
|
||||
for unit in "${ALTE_UNITS[@]}"; do
|
||||
systemctl is-enabled "$unit" >/dev/null 2>&1 && printf 'unit-enabled %s\n' "$unit" >> "$SYSTEM_KOPIE/manifest"
|
||||
done
|
||||
[[ -L "/etc/nginx/sites-enabled/$NAME" ]] && printf 'site-enabled %s\n' "$NAME" >> "$SYSTEM_KOPIE/manifest"
|
||||
info "$(grep -c . "$SYSTEM_KOPIE/manifest") Systemdatei(en) und Zustaende vermerkt"
|
||||
SICHERUNG_STEHT=1
|
||||
|
||||
# -- 2. Altes System abschalten ---------------------------------------------- #
|
||||
if [[ $STOP_OLD -eq 1 ]]; then
|
||||
step "Altes System abschalten"
|
||||
for unit in "${ALTE_UNITS[@]}"; do
|
||||
if systemctl list-unit-files "$unit" --no-legend 2>/dev/null | grep -q .; then
|
||||
systemctl disable --now "$unit" >/dev/null 2>&1 || true
|
||||
info "$unit gestoppt und deaktiviert"
|
||||
fi
|
||||
done
|
||||
else
|
||||
step "Altes System bleibt in Betrieb"
|
||||
fi
|
||||
|
||||
# -- 3. Neu installieren ----------------------------------------------------- #
|
||||
step "Neu installieren"
|
||||
INSTALL_ARGS=(--app "$APP" --yes)
|
||||
[[ -n "$INSTANCE" ]] && INSTALL_ARGS+=(--instance "$INSTANCE")
|
||||
# Kein eigener Fehlerzweig mehr: die EXIT-Falle rollt bei jedem Fehlschlag
|
||||
# zurueck -- hier, beim Export, beim Einspielen oder bei einem Ctrl-C.
|
||||
bash "$SRC/deploy/install.sh" "${INSTALL_ARGS[@]}" ${EXTRA[0]+"${EXTRA[@]}"}
|
||||
|
||||
if [[ $WITH_DATA -eq 0 ]]; then
|
||||
UPGRADE_FERTIG=1
|
||||
step "Fertig -- ohne Datenuebernahme"
|
||||
info "Sicherung der alten Installation: $ALT_KOPIE"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
command -v "$ADMIN" >/dev/null 2>&1 || fail "$ADMIN nicht gefunden -- die Installation ist unvollstaendig"
|
||||
|
||||
if [[ -z "$PASSPHRASE" ]]; then
|
||||
PASSPHRASE="$(head -c 24 /dev/urandom | base64 | tr -d '=+/' | cut -c1-24)"
|
||||
# Die Passphrase liegt neben den Dateien, die sie schuetzt. Das ist hier kein
|
||||
# Widerspruch: das alte Format verlangt eine, das Verzeichnis gehoert root
|
||||
# allein (0700), und ohne sie waeren die beiden Dateien spaeter unbrauchbar --
|
||||
# auch als Rueckweg.
|
||||
printf '%s\n' "$PASSPHRASE" > "$BACKUP/passphrase.txt"
|
||||
chmod 600 "$BACKUP/passphrase.txt"
|
||||
fi
|
||||
|
||||
if [[ "$APP" == "tesm" ]]; then
|
||||
# -- 4. Exportieren -------------------------------------------------------- #
|
||||
step "Daten der alten Installation lesen"
|
||||
"$ADMIN" legacy-export --source "$ALT_KOPIE" --out "$EXPORT_ALT" \
|
||||
--passphrase "$PASSPHRASE" --force
|
||||
chmod 600 "$EXPORT_ALT"
|
||||
|
||||
# -- 5. Uebersetzen -------------------------------------------------------- #
|
||||
step "Uebersetzen"
|
||||
"$ADMIN" convert-legacy "$EXPORT_ALT" --ausgabe "$IMPORT_NEU" \
|
||||
--passphrase "$PASSPHRASE" --neue-passphrase "$PASSPHRASE" --force
|
||||
chmod 600 "$IMPORT_NEU"
|
||||
|
||||
# -- 6. Einspielen --------------------------------------------------------- #
|
||||
step "Einspielen"
|
||||
"$ADMIN" legacy-import "$IMPORT_NEU" --passphrase "$PASSPHRASE" --actor upgrade
|
||||
else
|
||||
# -- 4.-6. in einem: die Uebernahme liest die alte Datenbank unmittelbar --- #
|
||||
step "Lizenzbestand uebernehmen"
|
||||
# Der alte Signaturschluessel wird uebernommen. Fuer ein Upgrade ist das
|
||||
# die einzige sinnvolle Vorgabe: es ist derselbe Server mit neuerer Software,
|
||||
# und ohne den alten Schluessel muesste jeder Kunde den Ausstellerwechsel
|
||||
# bestaetigen. Ueberschrieben wird dabei nichts -- die Uebernahme lehnt ab,
|
||||
# wenn die neue Installation schon einen eigenen Schluessel hat.
|
||||
MIGRATE_ARGS=(migrate-legacy --source "$ALT_KOPIE")
|
||||
[[ $ADOPT_KEY -eq 1 ]] && MIGRATE_ARGS+=(--adopt-key)
|
||||
"$ADMIN" "${MIGRATE_ARGS[@]}"
|
||||
if [[ $ADOPT_KEY -eq 0 ]]; then
|
||||
warn "--new-key: es gilt ein neuer Signaturschluessel. Die Kunden muessen den"
|
||||
warn "Ausstellerwechsel einmal bestaetigen."
|
||||
fi
|
||||
fi
|
||||
|
||||
# -- Abschluss --------------------------------------------------------------- #
|
||||
UPGRADE_FERTIG=1
|
||||
step "Fertig"
|
||||
info "Sicherung der alten Installation : $ALT_KOPIE"
|
||||
if [[ "$APP" == "tesm" ]]; then
|
||||
info "Export im alten Format : $EXPORT_ALT"
|
||||
info "Uebersetzte Datei : $IMPORT_NEU"
|
||||
[[ -f "$BACKUP/passphrase.txt" ]] && info "Passphrase beider Dateien : $BACKUP/passphrase.txt"
|
||||
fi
|
||||
cat <<TEXT
|
||||
|
||||
Von Hand nachzuziehen -- das laesst sich nicht uebersetzen:
|
||||
* Lokale Konten: der alte Hash ist bcrypt, hier gilt Argon2id. Ein Hash
|
||||
ist nicht umrechenbar; das ist sein Zweck. Erstes Konto anlegen mit
|
||||
sudo -u $APP $ADMIN create-admin
|
||||
* Gruppen und Rechte: die Rechteschluessel wurden neu geschnitten.
|
||||
* Verzeichnisdienst (LDAP), Webserver und Zertifikat.
|
||||
TEXT
|
||||
|
||||
# Der Rueckweg sieht in den beiden Faellen verschieden aus. Ein Hinweis, der
|
||||
# das alte Verzeichnis wiederherstellen laesst, obwohl es nie angefasst wurde,
|
||||
# waere schlimmer als keiner: er wuerde die Daten der neuen Installation mit
|
||||
# einem alten Stand ueberschreiben.
|
||||
if [[ $IN_PLACE -eq 1 ]]; then
|
||||
cat <<TEXT
|
||||
|
||||
Zurueck auf den alten Stand -- das Verzeichnis wurde ersetzt, die Kopie
|
||||
bringt es zurueck:
|
||||
sudo systemctl disable --now $NAME
|
||||
sudo rm -rf $SOURCE && sudo mkdir -p $SOURCE
|
||||
sudo tar -C $ALT_KOPIE -cf - . | sudo tar -C $SOURCE -xf -
|
||||
# venv der alten Installation neu bauen:
|
||||
sudo python3 -m venv $SOURCE/venv
|
||||
sudo $SOURCE/venv/bin/python -m pip install -r $SOURCE/requirements.txt
|
||||
sudo systemctl enable --now ${ALTE_UNITS[0]}
|
||||
|
||||
TEXT
|
||||
else
|
||||
cat <<TEXT
|
||||
|
||||
Zurueck auf den alten Stand -- $SOURCE wurde nicht angefasst, es genuegt,
|
||||
die neue Instanz abzuschalten:
|
||||
sudo systemctl disable --now $NAME $NAME-monitor
|
||||
sudo rm -f /etc/nginx/sites-enabled/$NAME && sudo systemctl reload nginx
|
||||
sudo systemctl enable --now ${ALTE_UNITS[0]}
|
||||
|
||||
TEXT
|
||||
fi
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Dieses Dokument richtet sich an die Person, die die Anlage installiert,
|
||||
aktualisiert und im Fehlerfall wieder zum Laufen bringt. Es beschreibt, was auf
|
||||
dem Host tatsaechlich passiert -- nicht, wie die Oberflaeche bedient wird.
|
||||
dem Host tatsächlich passiert -- nicht, wie die Oberfläche bedient wird.
|
||||
|
||||
---
|
||||
|
||||
@@ -11,7 +11,7 @@ dem Host tatsaechlich passiert -- nicht, wie die Oberflaeche bedient wird.
|
||||
| Zweck | Pfad |
|
||||
|---|---|
|
||||
| Anwendung (Code, venv, statische Dateien) | `/srv/<name>/` |
|
||||
| Instanzdaten (Datenbank, Schluessel, Lizenz) | `/srv/<name>/data/` |
|
||||
| Instanzdaten (Datenbank, Schlüssel, Lizenz) | `/srv/<name>/data/` |
|
||||
| Protokolle | `/var/log/<name>/` |
|
||||
| Umgebungsdatei | `/etc/tesm/<name>.env` |
|
||||
| Zertifikate (selbstsigniert) | `/etc/tesm/certs/<name>/` |
|
||||
@@ -20,13 +20,13 @@ dem Host tatsaechlich passiert -- nicht, wie die Oberflaeche bedient wird.
|
||||
| Privilegierter Helfer | `/usr/local/lib/tesm/tesm-helper` |
|
||||
| Verwaltungswerkzeug | `/usr/local/bin/<name>-admin` |
|
||||
|
||||
`<name>` ist `tesm` bzw. `tesm-license` -- oder, bei einer zusaetzlichen
|
||||
`<name>` ist `tesm` bzw. `tesm-license` -- oder, bei einer zusätzlichen
|
||||
Instanz, `tesm-<instanz>`. Dieser Name (der **SITE_KEY**) benennt alles, was
|
||||
ausserhalb von `/srv/<name>` liegt. Deshalb koennen zwei Installationen
|
||||
ausserhalb von `/srv/<name>` liegt. Deshalb können zwei Installationen
|
||||
derselben Anwendung nebeneinander laufen, ohne sich gegenseitig die
|
||||
nginx-Site, die netplan-Datei oder das Sitzungscookie zu ueberschreiben.
|
||||
nginx-Site, die netplan-Datei oder das Sitzungscookie zu überschreiben.
|
||||
|
||||
Alles, was ein Mensch verlieren wuerde, liegt unter `data/`. Das Verzeichnis
|
||||
Alles, was ein Mensch verlieren würde, liegt unter `data/`. Das Verzeichnis
|
||||
wird von `install.sh` nie angefasst.
|
||||
|
||||
---
|
||||
@@ -43,10 +43,10 @@ Wichtige Optionen:
|
||||
| Option | Wirkung |
|
||||
|---|---|
|
||||
| `--https` | Port 443 mit TLS, Port 80 leitet dorthin um. Ohne `--domain` entsteht ein selbstsigniertes Zertifikat. |
|
||||
| `--domain <name>` | Oeffentlicher Name. Setzt `server_name`; die Site ist dann **nicht** mehr die Vorgabe-Site. |
|
||||
| `--acme-email <a>` | Erst zusammen mit `--domain` wird ein Let's-Encrypt-Zertifikat angefordert (und HSTS gesetzt). Ohne die Adresse bleibt es selbstsigniert -- interne Namen wie `lizenz.firma.local` kann keine CA bestaetigen. |
|
||||
| `--instance <name>` | Zusaetzliche Instanz neben der Hauptinstallation. |
|
||||
| `--port` / `--http-port` / `--https-port` | Abweichende Ports (nur fuer Testinstanzen sinnvoll). |
|
||||
| `--domain <name>` | Öffentlicher Name. Setzt `server_name`; die Site ist dann **nicht** mehr die Vorgabe-Site. |
|
||||
| `--acme-email <a>` | Erst zusammen mit `--domain` wird ein Let's-Encrypt-Zertifikat angefordert (und HSTS gesetzt). Ohne die Adresse bleibt es selbstsigniert -- interne Namen wie `lizenz.firma.local` kann keine CA bestätigen. |
|
||||
| `--instance <name>` | Zusätzliche Instanz neben der Hauptinstallation. |
|
||||
| `--port` / `--http-port` / `--https-port` | Abweichende Ports (nur für Testinstanzen sinnvoll). |
|
||||
| `--skip-packages` | Keine Systempakete installieren. |
|
||||
|
||||
Danach:
|
||||
@@ -55,17 +55,17 @@ Danach:
|
||||
sudo -u tesm tesm-admin create-admin
|
||||
```
|
||||
|
||||
Beim Lizenzserver zusaetzlich, **in dieser Reihenfolge**:
|
||||
Beim Lizenzserver zusätzlich, **in dieser Reihenfolge**:
|
||||
|
||||
```bash
|
||||
sudo -u tesm-license tesm-license-admin init-key # Signaturschluessel -- sofort sichern!
|
||||
sudo -u tesm-license tesm-license-admin init-key # Signaturschlüssel -- sofort sichern!
|
||||
sudo -u tesm-license tesm-license-admin bootstrap # eigene Master-Lizenz
|
||||
sudo -u tesm-license tesm-license-admin create-admin
|
||||
```
|
||||
|
||||
> **Der Signaturschluessel ist nicht wiederherstellbar.** Geht
|
||||
> **Der Signaturschlüssel ist nicht wiederherstellbar.** Geht
|
||||
> `data/master_signing_key.json` verloren, ist keine ausgestellte Lizenz mehr
|
||||
> pruefbar. Sichern Sie ihn getrennt vom Server.
|
||||
> prüfbar. Sichern Sie ihn getrennt vom Server.
|
||||
|
||||
### Zwei Anwendungen auf einem Host
|
||||
|
||||
@@ -78,7 +78,7 @@ sudo bash deploy/install.sh --app tesm-license --https --domain lizenz.firma.loc
|
||||
|
||||
Die Installation **ohne** Domain wird zur Vorgabe-Site (`default_server`) und
|
||||
beantwortet alles, was zu keinem Namen passt. Zwei namenlose Sites auf
|
||||
demselben Port weist nginx ab -- richtig so, und beim Uebernehmen sofort
|
||||
demselben Port weist nginx ab -- richtig so, und beim Übernehmen sofort
|
||||
sichtbar.
|
||||
|
||||
---
|
||||
@@ -90,19 +90,162 @@ sudo bash deploy/install.sh --app tesm
|
||||
```
|
||||
|
||||
Derselbe Befehl. Das Skript erkennt an der vorhandenen Datenbank, dass es eine
|
||||
Aktualisierung ist, und tut dann zusaetzlich:
|
||||
Aktualisierung ist, und tut dann zusätzlich:
|
||||
|
||||
1. **Vollstaendige Sicherung** nach `/srv/<name>-backup-<zeitstempel>` --
|
||||
unabhaengig davon, ob sich das Schema geaendert hat.
|
||||
1. **Vollständige Sicherung** nach `/srv/<name>-backup-<zeitstempel>` --
|
||||
unabhängig davon, ob sich das Schema geändert hat.
|
||||
2. Dateiabgleich; `data/` bleibt unangetastet.
|
||||
3. Schema-Migrationen beim Start (versioniert, mit Pruefsumme).
|
||||
4. **Gesundheitspruefung.** Antwortet die Anwendung nicht, wird der alte Stand
|
||||
automatisch zurueckgeholt; die fehlgeschlagene Version bleibt unter
|
||||
3. Schema-Migrationen beim Start (versioniert, mit Prüfsumme).
|
||||
4. **Gesundheitsprüfung.** Antwortet die Anwendung nicht, wird der alte Stand
|
||||
automatisch zurückgeholt; die fehlgeschlagene Version bleibt unter
|
||||
`/srv/<name>-failed-<zeitstempel>` zur Analyse liegen.
|
||||
|
||||
Eine bereits eingerichtete HTTPS-Konfiguration bleibt erhalten, auch ohne
|
||||
`--https`.
|
||||
|
||||
### Ohne Quellbaum: `update.sh`
|
||||
|
||||
`update.sh` genügt als **einzelne Datei**. Auf einem System, auf dem noch nichts
|
||||
von diesem Projekt liegt:
|
||||
|
||||
```bash
|
||||
curl -fsSLO https://gitea.int.eertmoed.net/alientim/tesm/raw/branch/main/deploy/update.sh
|
||||
sudo bash update.sh tesm
|
||||
```
|
||||
|
||||
Auf einer bestehenden Installation liegt sie schon bereit:
|
||||
|
||||
```bash
|
||||
sudo bash /srv/tesm/deploy/update.sh tesm
|
||||
sudo bash /srv/tesm-license/deploy/update.sh tesm-license
|
||||
```
|
||||
|
||||
Alles nach dem Tag geht unverändert an `install.sh` weiter -- das ist der Weg,
|
||||
eine **benannte Instanz** zu aktualisieren:
|
||||
|
||||
```bash
|
||||
sudo bash /srv/tesm/deploy/update.sh tesm
|
||||
```
|
||||
|
||||
Ohne `--instance` entstünde daneben eine zweite Installation namens `tesm`, mit
|
||||
eigener Datenbank und eigener nginx-Site. Die Ports einer bestehenden
|
||||
Installation bleiben erhalten, auch wenn sie von den Vorgaben abweichen: eine
|
||||
Aktualisierung verschiebt nichts, was nicht ausdrücklich neu gesetzt wird.
|
||||
|
||||
Ohne Tag wird `latest` **über die API aufgelöst**, nicht in die URL geschrieben:
|
||||
Gitea liefert unter einem Tag-Namen sonst ein automatisch erzeugtes
|
||||
Quell-Archiv mit HTTP 200 statt des echten Anhangs -- und das enthält kein
|
||||
Release, sondern den Repositoriumsstand.
|
||||
|
||||
Die Bezugsquelle ist voreingestellt und lässt sich über die Umgebung umlenken,
|
||||
etwa auf eine Spiegelung:
|
||||
|
||||
| Variable | Vorgabe |
|
||||
|---|---|
|
||||
| `TESM_RELEASE_BASE` | `https://gitea.int.eertmoed.net` |
|
||||
| `TESM_RELEASE_OWNER` | `alientim` |
|
||||
| `TESM_RELEASE_REPO` | der Anwendungsname (`tesm` bzw. `tesm-license`) |
|
||||
|
||||
Der erwartete Anhang heisst `<repo>-<tag>.tar.gz`.
|
||||
|
||||
### Upgrade vom Stand 1.x (Branch `SONNET5`)
|
||||
|
||||
Ein eigenes Skript, weil dabei mehr passiert als ein Dateiabgleich: die alte
|
||||
Fassung hat ein anderes Schema, andere Rechteschlüssel und ein anderes
|
||||
Exportformat.
|
||||
|
||||
Auch `upgrade.sh` genügt als **einzelne Datei** -- und das ist hier der
|
||||
Regelfall: auf einer 1.2.8 liegt nichts von dieser Fassung.
|
||||
|
||||
```bash
|
||||
curl -fsSLO https://gitea.int.eertmoed.net/alientim/tesm/raw/branch/main/deploy/upgrade.sh
|
||||
sudo bash upgrade.sh --app tesm
|
||||
```
|
||||
|
||||
Sie lädt das aktuelle Release, entpackt es und benutzt dessen `install.sh`.
|
||||
Liegt das Skript in einem entpackten Release, wird dieses benutzt statt eines
|
||||
Downloads -- wer ein Paket ausgepackt hat, will nicht, dass ungefragt ein
|
||||
anderes geladen wird:
|
||||
|
||||
```bash
|
||||
sudo bash deploy/upgrade.sh --app tesm
|
||||
sudo bash deploy/upgrade.sh --app tesm-license
|
||||
```
|
||||
|
||||
Eine bestimmte Version statt `latest`: `--tag v2.0.8`.
|
||||
|
||||
**Wenn etwas schiefgeht.** Schlägt `install.sh` fehl, dreht `upgrade.sh` den
|
||||
Lauf selbst zurück: der neue Dienst wird gestoppt, das Verzeichnis aus der
|
||||
Sicherung wiederhergestellt und der alte Dienst wieder eingeschaltet. Das
|
||||
Verzeichnis allein genügt dafür nicht -- bei einem Upgrade in dasselbe
|
||||
Verzeichnis schreibt `install.sh` auch systemd-Unit, nginx-Site, logrotate und
|
||||
Banner neu und ersetzt das venv. Die Sicherung enthält deshalb alles davon,
|
||||
und ein Manifest hält fest, woher der Stand kam.
|
||||
|
||||
Später von Hand zurückdrehen, etwa nach einem Lauf, der durchlief aber nicht
|
||||
gefallen hat:
|
||||
|
||||
```bash
|
||||
sudo bash upgrade.sh --app tesm --rollback # neueste Sicherung
|
||||
sudo bash upgrade.sh --app tesm --rollback /srv/tesm-upgrade-20260908-225428
|
||||
```
|
||||
|
||||
Die Sicherung bleibt danach liegen; gelöscht wird nichts.
|
||||
|
||||
Ohne `--source` wird die alte Fassung an zwei Stellen gesucht: unter
|
||||
`/srv/<app>` und unter `/srv/<app>-sonnet5`. Der zweite Ort ist der, an den
|
||||
`rename.sh` sie verschiebt, sobald der Hauptname für die neue Installation
|
||||
gebraucht wird -- danach liegt unter `/srv/<app>` die **laufende** Installation.
|
||||
Erkannt wird eine 1.x an der `sqlite.db` im Wurzelverzeichnis; die neue Fassung
|
||||
legt ihre Datenbank unter `data/app.db` ab.
|
||||
|
||||
Der Ablauf ist die Vorgabe und braucht keine Schalter:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| 1. Sichern | Vollständige Kopie der alten Installation nach `/srv/<name>-upgrade-<zeitstempel>/alt` (ohne `venv`, Verzeichnis 0700) |
|
||||
| 2. Abschalten | Alter Dienst **und seine Timer** werden gestoppt und deaktiviert |
|
||||
| 3. Installieren | `install.sh` dieses Verzeichnisses |
|
||||
| 4. Exportieren | Aus der Kopie entsteht `export-alt.json` im **alten** Format |
|
||||
| 5. Übersetzen | Daraus `import-neu.json` im neuen Format |
|
||||
| 6. Einspielen | Diese Datei geht durch denselben Import wie eine Sicherung |
|
||||
|
||||
Warum die Sicherung **vor** dem Export steht: ohne `--instance` installiert
|
||||
`install.sh` in dasselbe Verzeichnis und ersetzt die alte Installation. Wer
|
||||
erst exportiert und dann sichert, sichert im schlechtesten Fall nichts mehr.
|
||||
Gelesen wird ausschliesslich aus der Kopie -- die alte Installation wird nie
|
||||
verändert.
|
||||
|
||||
Die beiden Zwischendateien bleiben liegen; ihre Passphrase steht daneben in
|
||||
`passphrase.txt` (0600, Verzeichnis 0700). `export-alt.json` ist ein echter
|
||||
Export des Vorgängers und taugt deshalb auch als Rückweg.
|
||||
|
||||
**Daneben statt darüber.** Mit `--instance` entsteht eine zweite Installation,
|
||||
und die alte bleibt vollständig liegen:
|
||||
|
||||
```bash
|
||||
sudo bash deploy/upgrade.sh --app tesm --instance test --keep-old-running --port 5100 --http-port 8080
|
||||
```
|
||||
|
||||
**Was nicht mitkommt** -- und warum:
|
||||
|
||||
* **Lokale Konten.** Der alte Hash ist bcrypt, hier gilt Argon2id. Ein Hash
|
||||
ist nicht umrechenbar; das ist sein Zweck. Erstes Konto mit
|
||||
`tesm-admin create-admin`.
|
||||
* **Gruppen und Rechte.** Die Rechteschlüssel wurden neu geschnitten.
|
||||
* **LDAP, Webserver, Zertifikat.** Von Hand nachziehen.
|
||||
|
||||
Beim **Lizenzserver** gibt es Schritt 4 und 5 nicht: die Übernahme liest die
|
||||
alte Datenbank unmittelbar, holt Endpunkt und Anbieterdaten aus ihren
|
||||
Einstellungen und **signiert jede Lizenz neu**. Der alte Signaturschlüssel wird
|
||||
dabei übernommen -- sonst müsste jeder Kunde einen Ausstellerwechsel
|
||||
bestätigen; `--new-key` erzwingt das Gegenteil. Der Bericht nennt namentlich,
|
||||
wer eine neue Lizenzdatei braucht, denn die Bindung an eine Maschine gilt nur
|
||||
für die alte Datei.
|
||||
|
||||
Nur die Anwendung, ohne Daten: `--no-data`. Nur zeigen, was geschähe:
|
||||
`--dry-run`.
|
||||
|
||||
---
|
||||
|
||||
## 4. HTTPS
|
||||
@@ -114,7 +257,7 @@ Drei Wege dorthin:
|
||||
|
||||
**a) Bei der Installation** -- siehe oben, `--https`.
|
||||
|
||||
**b) Nachtraeglich ueber die Kommandozeile:**
|
||||
**b) Nachträglich über die Kommandozeile:**
|
||||
|
||||
```bash
|
||||
sudo -u tesm tesm-admin web-setup --https --self-signed --apply
|
||||
@@ -124,42 +267,119 @@ sudo -u tesm tesm-admin web-setup --https --domain tesm.firma.de \
|
||||
|
||||
`--print-config` zeigt die Datei, ohne etwas zu schreiben.
|
||||
|
||||
**c) In der Oberflaeche** unter *Einstellungen -> Webserver und TLS*.
|
||||
**c) In der Oberfläche** unter *Einstellungen -> Webserver und TLS*.
|
||||
|
||||
Danach muss `COOKIE_SECURE` in `/etc/tesm/<name>.env` auf `1` stehen (alle drei
|
||||
Wege erledigen das selbst) und der Dienst neu gestartet werden.
|
||||
|
||||
### Die Site-Datei wird immer neu erzeugt
|
||||
|
||||
Sie ist **kein** Ort fuer Handarbeit. Jede Uebernahme schreibt sie vollstaendig
|
||||
aus der Vorlage neu. Was frueher von Hand hineingeschrieben wurde -- Domain,
|
||||
Sie ist **kein** Ort für Handarbeit. Jede Übernahme schreibt sie vollständig
|
||||
aus der Vorlage neu. Was früher von Hand hineingeschrieben wurde -- Domain,
|
||||
Ports, Zertifikatspfade, HSTS, Upload-Grenze -- ist heute Einstellung in der
|
||||
Anwendung.
|
||||
|
||||
Der Grund steht im Vorgaengerprojekt: dort wurde die Datei nur angelegt, wenn
|
||||
sie fehlte. Ein einmal falscher `alias`-Pfad ueberlebte dadurch jahrelang jedes
|
||||
Update, und die Oberflaeche kam die ganze Zeit ohne Design.
|
||||
Der Grund steht im Vorgängerprojekt: dort wurde die Datei nur angelegt, wenn
|
||||
sie fehlte. Ein einmal falscher `alias`-Pfad überlebte dadurch jahrelang jedes
|
||||
Update, und die Oberfläche kam die ganze Zeit ohne Design.
|
||||
|
||||
### Selbstsigniert oder Let's Encrypt?
|
||||
|
||||
* **Oeffentlich erreichbar, echter DNS-Name:** Let's Encrypt. Nur dann ist HSTS
|
||||
* **Öffentlich erreichbar, echter DNS-Name:** Let's Encrypt. Nur dann ist HSTS
|
||||
sinnvoll.
|
||||
* **Geschlossenes Netz:** selbstsigniert. Browser zeigen eine Warnung, die
|
||||
Verbindung ist trotzdem verschluesselt und das Sitzungscookie darf `Secure`
|
||||
Verbindung ist trotzdem verschlüsselt und das Sitzungscookie darf `Secure`
|
||||
tragen. **HSTS hier nicht einschalten** -- der Browser merkt sich die Vorgabe
|
||||
monatelang und die Warnung laesst sich dann nicht mehr wegklicken.
|
||||
monatelang und die Warnung lässt sich dann nicht mehr wegklicken.
|
||||
|
||||
Die Erneuerung von Let's Encrypt laeuft ueber `certbot certonly --webroot`
|
||||
(bewusst nicht ueber das nginx-Plugin, das die erzeugte Site umschreiben
|
||||
wuerde). Der ACME-Pfad bleibt auch bei aktiver Umleitung erreichbar.
|
||||
Die Erneuerung von Let's Encrypt läuft über `certbot certonly --webroot`
|
||||
(bewusst nicht über das nginx-Plugin, das die erzeugte Site umschreiben
|
||||
würde). Der ACME-Pfad bleibt auch bei aktiver Umleitung erreichbar.
|
||||
|
||||
---
|
||||
|
||||
## 4a. Anmeldebanner (MOTD)
|
||||
|
||||
`install.sh` legt `/etc/update-motd.d/50-<name>` an. Bei jeder SSH-Anmeldung
|
||||
steht dann der Zustand der Installation im Login-Banner:
|
||||
|
||||
```
|
||||
TESM · TimEShepManager
|
||||
UEBERWACHEN · BOOTEN · ANBINDEN (DHCP) · BETRIEBSSYSTEM GEBEN (PXE)
|
||||
|
||||
3 Clients online · 0 nicht erreichbar · DHCP: 2 Leases aktiv
|
||||
```
|
||||
|
||||
Das Skript liegt unter `/usr/local/lib/tesm/tesm-motd` und benutzt **nur die
|
||||
Standardbibliothek**: kein venv, kein Import der Anwendung, die Datenbank wird
|
||||
nur lesend geöffnet. Ein Lauf dauert Millisekunden. Der Vorgänger importierte
|
||||
dafür die ganze Flask-Anwendung -- bei jeder Anmeldung, und nach einem Wechsel
|
||||
des Quellverzeichnisses zeigte er stillschweigend die Zahlen einer Datenbank,
|
||||
die niemand mehr füllte.
|
||||
|
||||
Von Hand prüfen:
|
||||
|
||||
```bash
|
||||
sudo /usr/local/lib/tesm/tesm-motd --app tesm --root /srv/tesm-test
|
||||
```
|
||||
|
||||
Der Aufruf im Banner ist mit `timeout 3` und `|| true` abgesichert: ein
|
||||
hängender oder fehlschlagender Bannerlauf darf eine Anmeldung nie blockieren.
|
||||
Ein Banner der Vorgängerfassung (`50-tesm`, das `motd_banner.py` aufruft) wird
|
||||
entfernt -- nur dieses, nichts anderes in dem Verzeichnis.
|
||||
|
||||
---
|
||||
|
||||
## 4b. Installation umbenennen
|
||||
|
||||
Aus einer Instanz die Hauptinstallation machen (oder umgekehrt):
|
||||
|
||||
```bash
|
||||
sudo bash deploy/rename.sh --app tesm --from tesm-test --to tesm
|
||||
sudo bash deploy/rename.sh --app tesm-license --from tesm-license-test --to tesm-license
|
||||
```
|
||||
|
||||
Der Name steckt an mehr Stellen als im Verzeichnisnamen, und drei davon sind
|
||||
leicht zu übersehen:
|
||||
|
||||
* **`SITE_KEY`** in der Umgebungsdatei -- daraus entsteht der Name des
|
||||
Sitzungscookies. Bleibt er alt, heisst das Cookie weiter wie die alte
|
||||
Instanz.
|
||||
* **`web_cert_path`, `web_key_path`, `web_acme_webroot`** in der Datenbank. Sie
|
||||
enthalten den Namen; ein Zertifikat unter dem alten Pfad findet nginx nach
|
||||
dem Umzug nicht mehr.
|
||||
* **`server_name`** der nginx-Site. `install.sh` schreibt die Datei aus seiner
|
||||
Vorlage neu, sobald sich die `alias`-Pfade ändern -- und die ändern sich hier
|
||||
zwangsläufig. Ohne den Namen aus der Datenbank stünden danach zwei Sites mit
|
||||
`server_name _` auf Port 80, und die erste gewinnt.
|
||||
|
||||
Ausserdem wird das **venv verworfen und neu gebaut**: seine Konsolenskripte
|
||||
tragen den absoluten Pfad im Shebang, und nach dem Umzug zeigt
|
||||
`venv/bin/gunicorn` auf einen Interpreter, den es nicht mehr gibt. systemd
|
||||
meldet dann `No such file or directory` -- für eine Datei, die sichtbar da ist.
|
||||
|
||||
Belegt der Zielname noch eine Installation der Fassung 1.x, wandert **alles**,
|
||||
was ihn belegt, in ein Archiv `/srv/<ziel>-sonnet5`: das Verzeichnis, die
|
||||
Protokolle unter `/var/log/<ziel>`, der ACME-Pfad, die logrotate-Datei, und die
|
||||
systemd-Units samt nginx-Site unter `systemd-alt/`. Gelöscht wird nichts.
|
||||
|
||||
Ports und HTTPS bleiben, wie sie waren; ein vorhandenes Zertifikat zieht mit
|
||||
und wird weiterverwendet. **Alle angemeldeten Benutzer werden abgemeldet** --
|
||||
das Sitzungscookie heisst danach anders.
|
||||
|
||||
Danach ist auch das Aktualisieren einfacher, weil der Instanzname wegfällt:
|
||||
|
||||
```bash
|
||||
sudo bash /srv/tesm/deploy/update.sh tesm
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Dienste
|
||||
|
||||
```bash
|
||||
systemctl status tesm.service # Weboberflaeche (gunicorn)
|
||||
systemctl status tesm-monitor.service # Ueberwachungsschleife (nur TESM)
|
||||
systemctl status tesm.service # Weboberfläche (gunicorn)
|
||||
systemctl status tesm-monitor.service # Überwachungsschleife (nur TESM)
|
||||
systemctl status tesm-license.service
|
||||
journalctl -u tesm -n 100 -f
|
||||
```
|
||||
@@ -179,8 +399,8 @@ Die Anwendung hat keine. Alles, was Root braucht, geht durch **einen** Pfad:
|
||||
/usr/local/lib/tesm/tesm-helper <verb> [argumente]
|
||||
```
|
||||
|
||||
Der Helfer gehoert `root`, ist per sudoers ausschliesslich fuer genau diesen
|
||||
Pfad mit `NOPASSWD` freigegeben und prueft jedes Argument gegen eine
|
||||
Der Helfer gehört `root`, ist per sudoers ausschliesslich für genau diesen
|
||||
Pfad mit `NOPASSWD` freigegeben und prüft jedes Argument gegen eine
|
||||
Positivliste. Er reicht nie etwas an eine Shell weiter (kein `eval`, kein
|
||||
`sh -c`).
|
||||
|
||||
@@ -191,16 +411,16 @@ sudo /usr/local/lib/tesm/tesm-helper
|
||||
grep -n ')$' /usr/local/lib/tesm/tesm-helper
|
||||
```
|
||||
|
||||
Regel fuer Aenderungen: **Jedes neue Verb braucht eine Argumentpruefung.**
|
||||
Regel für Änderungen: **Jedes neue Verb braucht eine Argumentprüfung.**
|
||||
`tests/test_repo_hygiene.py` erzwingt, dass jedes der Anwendung bekannte Verb
|
||||
im Helfer vorkommt und dass jede Pfadpruefung `..` abweist.
|
||||
im Helfer vorkommt und dass jede Pfadprüfung `..` abweist.
|
||||
|
||||
---
|
||||
|
||||
## 7. Sicherung und Wiederherstellung
|
||||
|
||||
In der Oberflaeche unter *Sicherung* -- verschluesselte Ausfuhr (Argon2id +
|
||||
AES-256-GCM) mit einem selbst gewaehlten Kennwort. Der Import zeigt zuerst eine
|
||||
In der Oberfläche unter *Sicherung* -- verschlüsselte Ausfuhr (Argon2id +
|
||||
AES-256-GCM) mit einem selbst gewählten Kennwort. Der Import zeigt zuerst eine
|
||||
Vorschau, bevor irgendetwas geschrieben wird.
|
||||
|
||||
Auf dem Host reicht:
|
||||
@@ -214,10 +434,10 @@ sudo systemctl start tesm
|
||||
Unverzichtbar:
|
||||
|
||||
* `data/app.db` -- alle Daten
|
||||
* `data/secret.key` -- ohne sie sind alle Sitzungen ungueltig
|
||||
* `data/secret.key` -- ohne sie sind alle Sitzungen ungültig
|
||||
* `data/data.keys` -- ohne sie sind **alle gespeicherten Geheimnisse verloren**
|
||||
* `data/license.json` und `data/license_key.json`
|
||||
* Lizenzserver zusaetzlich: `data/master_signing_key.json`
|
||||
* Lizenzserver zusätzlich: `data/master_signing_key.json`
|
||||
|
||||
---
|
||||
|
||||
@@ -230,17 +450,17 @@ curl -s http://127.0.0.1:5000/gesundheit
|
||||
```
|
||||
Antwortet gunicorn, aber der Browser nicht, liegt es an nginx.
|
||||
|
||||
**nginx laesst sich nicht neu laden.**
|
||||
**nginx lässt sich nicht neu laden.**
|
||||
```bash
|
||||
sudo nginx -t
|
||||
```
|
||||
Die Uebernahme aus der Anwendung nimmt eine abgelehnte Konfiguration selbst
|
||||
zurueck. Eine von Hand bearbeitete Datei tut das nicht -- in dem Fall hilft
|
||||
Die Übernahme aus der Anwendung nimmt eine abgelehnte Konfiguration selbst
|
||||
zurück. Eine von Hand bearbeitete Datei tut das nicht -- in dem Fall hilft
|
||||
`install.sh`, das eine unbrauchbare Site durch die Vorlage ersetzt.
|
||||
|
||||
**Anmeldung schlaegt ohne Fehlermeldung fehl.**
|
||||
**Anmeldung schlägt ohne Fehlermeldung fehl.**
|
||||
Fast immer `COOKIE_SECURE=1` ohne HTTPS. Der Browser bekommt dann ein Cookie,
|
||||
das er ueber HTTP nie zuruecksendet.
|
||||
das er über HTTP nie zurücksendet.
|
||||
```bash
|
||||
grep COOKIE_SECURE /etc/tesm/tesm.env
|
||||
```
|
||||
@@ -250,38 +470,38 @@ Der Reverse Proxy reicht den Host falsch durch. In der Site-Datei muss
|
||||
`proxy_set_header Host $http_host;` stehen -- **nicht** `$host`, das verwirft
|
||||
den Port.
|
||||
|
||||
**Statische Dateien fehlen, die Oberflaeche ist unformatiert.**
|
||||
**Statische Dateien fehlen, die Oberfläche ist unformatiert.**
|
||||
```bash
|
||||
grep alias /etc/nginx/sites-available/tesm
|
||||
ls /srv/tesm/static /srv/tesm/static-core
|
||||
```
|
||||
Beide `alias`-Pfade muessen in das Installationsverzeichnis zeigen.
|
||||
Beide `alias`-Pfade müssen in das Installationsverzeichnis zeigen.
|
||||
|
||||
**Zwei Instanzen stoeren sich.**
|
||||
**Zwei Instanzen stören sich.**
|
||||
`SITE_KEY` in beiden `/etc/tesm/*.env` vergleichen -- er muss sich
|
||||
unterscheiden. Sonst teilen sich beide nginx-Site und Sitzungscookie.
|
||||
|
||||
**Die Wartung meldet "Der SSH-Host-Schluessel ist unbekannt".**
|
||||
**Die Wartung meldet "Der SSH-Host-Schlüssel ist unbekannt".**
|
||||
Kein Fehler, sondern die Absicht: ein Hintergrundauftrag darf keinen fremden
|
||||
Schluessel akzeptieren. Die Freigabe erfolgt von Hand, in zwei Schritten, auf
|
||||
der Detailseite des Geraets (*Clients -> Geraet -> SSH-Host-Schluessel*):
|
||||
erst **Auslesen**, dann den Fingerprint mit einer unabhaengigen Quelle
|
||||
vergleichen, dann **Freigeben**. Die Wartungsuebersicht zeigt in einer eigenen
|
||||
Spalte, fuer welche Geraete das noch offen ist. Fuer Switche liegt dieselbe
|
||||
Schlüssel akzeptieren. Die Freigabe erfolgt von Hand, in zwei Schritten, auf
|
||||
der Detailseite des Geräts (*Clients -> Gerät -> SSH-Host-Schlüssel*):
|
||||
erst **Auslesen**, dann den Fingerprint mit einer unabhängigen Quelle
|
||||
vergleichen, dann **Freigeben**. Die Wartungsübersicht zeigt in einer eigenen
|
||||
Spalte, für welche Geräte das noch offen ist. Für Switche liegt dieselbe
|
||||
Freigabe auf der Switch-Detailseite.
|
||||
|
||||
Der Schluessel wird nach **IP und Port** gespeichert, nicht nach Name -- TESM
|
||||
verbindet sich immer ueber die hinterlegte IP-Adresse. Aendert sich die IP
|
||||
eines Geraets, ist der Schluessel erneut freizugeben.
|
||||
Der Schlüssel wird nach **IP und Port** gespeichert, nicht nach Name -- TESM
|
||||
verbindet sich immer über die hinterlegte IP-Adresse. Ändert sich die IP
|
||||
eines Geräts, ist der Schlüssel erneut freizugeben.
|
||||
|
||||
**Die Pruefkette des Aenderungsprotokolls ist gebrochen.**
|
||||
**Die Prüfkette des Änderungsprotokolls ist gebrochen.**
|
||||
Unter *Diagnose* sichtbar. Das Protokoll ist verkettet gehasht; ein Bruch heisst,
|
||||
dass jemand direkt in die Datenbank geschrieben hat. Der Eintrag, ab dem es
|
||||
nicht mehr stimmt, wird genannt.
|
||||
|
||||
---
|
||||
|
||||
## 9. Zurueck auf eine aeltere Version
|
||||
## 9. Zurück auf eine ältere Version
|
||||
|
||||
```bash
|
||||
sudo systemctl stop tesm
|
||||
@@ -290,6 +510,6 @@ sudo cp -a /srv/tesm-backup-<zeitstempel> /srv/tesm
|
||||
sudo systemctl start tesm
|
||||
```
|
||||
|
||||
Schema-Migrationen laufen nur vorwaerts. Ein Ruecksprung ueber eine
|
||||
Schema-Aenderung hinweg braucht deshalb auch die Datenbank aus der Sicherung --
|
||||
Schema-Migrationen laufen nur vorwärts. Ein Rücksprung über eine
|
||||
Schema-Änderung hinweg braucht deshalb auch die Datenbank aus der Sicherung --
|
||||
genau das liegt im Backup-Verzeichnis.
|
||||
@@ -1,6 +1,6 @@
|
||||
# Sicherheit
|
||||
|
||||
Was geschuetzt wird, womit, und wo bewusst Grenzen gezogen sind.
|
||||
Was geschützt wird, womit, und wo bewusst Grenzen gezogen sind.
|
||||
|
||||
---
|
||||
|
||||
@@ -9,35 +9,35 @@ Was geschuetzt wird, womit, und wo bewusst Grenzen gezogen sind.
|
||||
| | |
|
||||
|---|---|
|
||||
| Speicherung | Argon2id (argon2-cffi) |
|
||||
| Altbestand | bcrypt wird noch geprueft und bei der naechsten erfolgreichen Anmeldung stillschweigend auf Argon2id umgestellt |
|
||||
| Zweiter Faktor | TOTP, optional erzwingbar fuer Administratoren |
|
||||
| Altbestand | bcrypt wird noch geprüft und bei der nächsten erfolgreichen Anmeldung stillschweigend auf Argon2id umgestellt |
|
||||
| Zweiter Faktor | TOTP, optional erzwingbar für Administratoren |
|
||||
| Sperre je Konto | nach `LOGIN_MAX_ATTEMPTS` Versuchen |
|
||||
| Sperre je Adresse | zusaetzlich `LOGIN_MAX_ATTEMPTS_PER_IP` -- sonst laesst sich die Kontosperre umgehen, indem man viele Konten je einmal probiert |
|
||||
| Verzeichnisdienst | LDAP nur ueber TLS, Zertifikat wird geprueft. Ohne gueltiges Zertifikat keine Anmeldung -- kein Schalter, der das abschaltet |
|
||||
| Sperre je Adresse | zusätzlich `LOGIN_MAX_ATTEMPTS_PER_IP` -- sonst lässt sich die Kontosperre umgehen, indem man viele Konten je einmal probiert |
|
||||
| Verzeichnisdienst | LDAP nur über TLS, Zertifikat wird geprüft. Ohne gültiges Zertifikat keine Anmeldung -- kein Schalter, der das abschaltet |
|
||||
|
||||
Nach einer Anmeldung wird die Sitzungskennung neu vergeben (gegen Session
|
||||
Fixation) und das CSRF-Token gewechselt.
|
||||
|
||||
### Erneute Bestaetigung ("sudo-Modus")
|
||||
### Erneute Bestätigung ("sudo-Modus")
|
||||
|
||||
Alles, was Geheimnisse offenlegt oder Daten aus dem System heraustraegt,
|
||||
Alles, was Geheimnisse offenlegt oder Daten aus dem System herausträgt,
|
||||
verlangt eine frische Passworteingabe: Klartextanzeige von Zugangsdaten,
|
||||
Ausfuhr, Herunterladen eines Lizenzbundles, Schluesselrotation,
|
||||
2FA-Aenderungen, Deaktivierung einer Lizenz.
|
||||
Ausfuhr, Herunterladen eines Lizenzbundles, Schlüsselrotation,
|
||||
2FA-Änderungen, Deaktivierung einer Lizenz.
|
||||
|
||||
Ein uebernommenes Sitzungscookie allein reicht dafuer nicht.
|
||||
Ein übernommenes Sitzungscookie allein reicht dafür nicht.
|
||||
|
||||
---
|
||||
|
||||
## 2. Sitzungen
|
||||
|
||||
Serverseitig in SQLite, nicht im Cookie. Das Cookie traegt nur eine
|
||||
Serverseitig in SQLite, nicht im Cookie. Das Cookie trägt nur eine
|
||||
Zufallskennung.
|
||||
|
||||
* `HttpOnly`, `SameSite=Lax`, `Secure` sobald HTTPS eingerichtet ist
|
||||
* Leerlaufzeit **und** absolute Hoechstdauer
|
||||
* Der Cookiename enthaelt den `SITE_KEY` -- zwei Installationen auf einem Host
|
||||
koennen sich die Sitzung nicht gegenseitig ueberschreiben (Cookies
|
||||
* Leerlaufzeit **und** absolute Höchstdauer
|
||||
* Der Cookiename enthält den `SITE_KEY` -- zwei Installationen auf einem Host
|
||||
können sich die Sitzung nicht gegenseitig überschreiben (Cookies
|
||||
unterscheiden keine Ports)
|
||||
* Aktive Sitzungen sind einsehbar und einzeln beendbar
|
||||
|
||||
@@ -45,22 +45,22 @@ Zufallskennung.
|
||||
|
||||
## 3. CSRF
|
||||
|
||||
Drei voneinander unabhaengige Schichten:
|
||||
Drei voneinander unabhängige Schichten:
|
||||
|
||||
1. `SameSite=Lax` am Sitzungscookie.
|
||||
2. Ein Token je Sitzung, in jedem Formular, in konstanter Zeit verglichen.
|
||||
3. Origin-/Referer-Pruefung gegen den Host der Anfrage.
|
||||
3. Origin-/Referer-Prüfung gegen den Host der Anfrage.
|
||||
|
||||
Die dritte Schicht akzeptiert den Host mit und ohne Port sowie die
|
||||
Standardports -- **nicht** aber beliebige Ports: ein anderer Dienst auf
|
||||
demselben Rechner ist ein anderer Ursprung.
|
||||
|
||||
Der Vorgaenger hatte gar keinen CSRF-Schutz. Eine praeparierte Seite konnte im
|
||||
Namen eines angemeldeten Administrators Geraete loeschen oder Benutzer anlegen.
|
||||
Der Vorgänger hatte gar keinen CSRF-Schutz. Eine präparierte Seite konnte im
|
||||
Namen eines angemeldeten Administrators Geräte löschen oder Benutzer anlegen.
|
||||
|
||||
Ausnahmen (`@csrf.exempt`) gibt es nur fuer Maschinenschnittstellen, die sich
|
||||
**nicht** ueber Cookies autorisieren. Eine cookie-autorisierte Route ohne
|
||||
CSRF-Schutz waere eine Luecke.
|
||||
Ausnahmen (`@csrf.exempt`) gibt es nur für Maschinenschnittstellen, die sich
|
||||
**nicht** über Cookies autorisieren. Eine cookie-autorisierte Route ohne
|
||||
CSRF-Schutz wäre eine Lücke.
|
||||
|
||||
---
|
||||
|
||||
@@ -69,75 +69,75 @@ CSRF-Schutz waere eine Luecke.
|
||||
Bei jeder Antwort:
|
||||
|
||||
* `Content-Security-Policy` mit einem Nonce je Anfrage. **Kein**
|
||||
`unsafe-inline` fuer Skripte -- deshalb gibt es im ganzen Projekt kein
|
||||
Inline-Script ohne Nonce, und JavaScript haengt ueber
|
||||
`unsafe-inline` für Skripte -- deshalb gibt es im ganzen Projekt kein
|
||||
Inline-Script ohne Nonce, und JavaScript hängt über
|
||||
`data-behavior`-Attribute am DOM. `style-src` erlaubt `unsafe-inline`, weil
|
||||
einzelne berechnete Breiten (Fortschrittsbalken) als Style-Attribut gesetzt
|
||||
werden; Style-Attribute fuehren keinen Code aus.
|
||||
werden; Style-Attribute führen keinen Code aus.
|
||||
* `X-Content-Type-Options: nosniff`
|
||||
* `X-Frame-Options: DENY`, `frame-ancestors 'none'`
|
||||
* `Referrer-Policy: same-origin`
|
||||
* `Permissions-Policy` -- Kamera, Mikrofon, Ort, Zahlung, USB abgeschaltet
|
||||
* `Cross-Origin-Opener-Policy`, `Cross-Origin-Resource-Policy`
|
||||
* `Strict-Transport-Security` nur, wenn TLS tatsaechlich terminiert wird
|
||||
* `Strict-Transport-Security` nur, wenn TLS tatsächlich terminiert wird
|
||||
|
||||
> HSTS gehoert **nicht** zu einem selbstsignierten Zertifikat. Der Browser
|
||||
> merkt sich die Vorgabe monatelang; die Warnung laesst sich danach nicht mehr
|
||||
> HSTS gehört **nicht** zu einem selbstsignierten Zertifikat. Der Browser
|
||||
> merkt sich die Vorgabe monatelang; die Warnung lässt sich danach nicht mehr
|
||||
> wegklicken. `install.sh` setzt HSTS deshalb nur zusammen mit Let's Encrypt.
|
||||
|
||||
---
|
||||
|
||||
## 5. Geheimnisse im Ruhezustand
|
||||
|
||||
Ein Schluesselspeicher mit AES-256-GCM (`data/data.keys`, Modus 0600):
|
||||
Ein Schlüsselspeicher mit AES-256-GCM (`data/data.keys`, Modus 0600):
|
||||
|
||||
* Versionierte Schluessel, rotierbar. Chiffrate tragen ihre Schluesselkennung
|
||||
* Versionierte Schlüssel, rotierbar. Chiffrate tragen ihre Schlüsselkennung
|
||||
(`v1.<id>.<nonce>.<ct>`) und bleiben nach einer Rotation lesbar.
|
||||
* Der Verwendungszweck geht als AAD in die Verschluesselung ein. Ein Chiffrat
|
||||
aus einem Kontext laesst sich in einem anderen nicht entschluesseln.
|
||||
* Beim Start wird geprueft, ob die Rechte auf Schluessel- und Lizenzdateien
|
||||
* Der Verwendungszweck geht als AAD in die Verschlüsselung ein. Ein Chiffrat
|
||||
aus einem Kontext lässt sich in einem anderen nicht entschlüsseln.
|
||||
* Beim Start wird geprüft, ob die Rechte auf Schlüssel- und Lizenzdateien
|
||||
noch stimmen; Abweichungen erscheinen unter *Diagnose*.
|
||||
|
||||
Passwoerter fuer Mounts gehen ueber `stdin` an den privilegierten Helfer, der
|
||||
Passwörter für Mounts gehen über `stdin` an den privilegierten Helfer, der
|
||||
sie in eine 0600-Datei schreibt -- nie als Kommandozeilenargument (Prozessliste)
|
||||
und nie ueber die Umgebung (`/proc/<pid>/environ`).
|
||||
und nie über die Umgebung (`/proc/<pid>/environ`).
|
||||
|
||||
---
|
||||
|
||||
## 6. Rechtetrennung auf dem Host
|
||||
|
||||
Der Webprozess laeuft als `tesm` bzw. `tesm-license`. Root-Aktionen gehen durch
|
||||
Der Webprozess läuft als `tesm` bzw. `tesm-license`. Root-Aktionen gehen durch
|
||||
genau einen allowlisted Helfer (`/usr/local/lib/tesm/tesm-helper`), der per
|
||||
sudoers ausschliesslich fuer diesen Pfad freigegeben ist.
|
||||
sudoers ausschliesslich für diesen Pfad freigegeben ist.
|
||||
|
||||
Regeln, die im Repository durch Tests erzwungen werden:
|
||||
|
||||
* Jedes Verb, das die Anwendung kennt, existiert im Helfer.
|
||||
* Kein `eval`, kein `bash -c`, kein `sh -c`.
|
||||
* Jede Pfadpruefung weist `..` ab -- sonst laesst sich eine Positivliste ueber
|
||||
* Jede Pfadprüfung weist `..` ab -- sonst lässt sich eine Positivliste über
|
||||
den Basisnamen unterlaufen (`/etc/nginx/sites-available/../../../root/x` hat
|
||||
den zulaessigen Basisnamen `x`).
|
||||
den zulässigen Basisnamen `x`).
|
||||
* Der Instanzname ist auf `tesm[-<instanz>]` begrenzt; er landet in Pfaden.
|
||||
* Keine sudoers-Regel ausser der einen. `NOPASSWD: ALL` gibt es nicht.
|
||||
|
||||
Die systemd-Units haben `NoNewPrivileges=yes`, `ProtectSystem=strict`,
|
||||
`PrivateTmp=yes` und eine enge `RestrictAddressFamilies`-Liste.
|
||||
|
||||
Der Vorgaenger liess die komplette Flask-Anwendung als root laufen. Eine
|
||||
einzige Luecke in irgendeinem Pfad haette den Host bedeutet.
|
||||
Der Vorgänger liess die komplette Flask-Anwendung als root laufen. Eine
|
||||
einzige Lücke in irgendeinem Pfad hätte den Host bedeutet.
|
||||
|
||||
---
|
||||
|
||||
## 7. Aenderungsprotokoll
|
||||
## 7. Änderungsprotokoll
|
||||
|
||||
Verkettet gehasht: jeder Eintrag enthaelt den Hash seines Vorgaengers. Ein
|
||||
nachtraegliches Aendern oder Loeschen faellt auf und wird unter *Diagnose*
|
||||
Verkettet gehasht: jeder Eintrag enthält den Hash seines Vorgängers. Ein
|
||||
nachträgliches Ändern oder Löschen fällt auf und wird unter *Diagnose*
|
||||
angezeigt, mitsamt dem Eintrag, ab dem die Kette bricht.
|
||||
|
||||
Protokolliert wird, wer was wann von welcher Adresse getan hat -- ausdruecklich
|
||||
Protokolliert wird, wer was wann von welcher Adresse getan hat -- ausdrücklich
|
||||
auch Lesezugriffe auf Geheimnisse (`credential.secret_revealed`).
|
||||
|
||||
Aeltere Tage wandern in JSONL-Archive und lassen sich als ZIP herunterladen.
|
||||
Ältere Tage wandern in JSONL-Archive und lassen sich als ZIP herunterladen.
|
||||
|
||||
---
|
||||
|
||||
@@ -145,20 +145,20 @@ Aeltere Tage wandern in JSONL-Archive und lassen sich als ZIP herunterladen.
|
||||
|
||||
Siehe [ARCHITEKTUR.md](ARCHITEKTUR.md), Abschnitt Lizenzierung. Kurz:
|
||||
getrennte Signaturkontexte, kanonisches JSON, Nonce gegen Wiedereinspielung,
|
||||
Frischefenster, `seq` gegen Rueckstufung, Antwort an die Anfrage gebunden,
|
||||
Frischefenster, `seq` gegen Rückstufung, Antwort an die Anfrage gebunden,
|
||||
Aussteller-Pinning.
|
||||
|
||||
Das Auslieferungsbundle enthaelt den privaten Clientschluessel und ist damit
|
||||
Das Auslieferungsbundle enthält den privaten Clientschlüssel und ist damit
|
||||
selbst ein Geheimnis: eigenes Recht (`licenses.export`), frische
|
||||
Passwortbestaetigung, `Cache-Control: no-store`, Eintrag im Zustell- und im
|
||||
Aenderungsprotokoll.
|
||||
Passwortbestätigung, `Cache-Control: no-store`, Eintrag im Zustell- und im
|
||||
Änderungsprotokoll.
|
||||
|
||||
---
|
||||
|
||||
## 9. Ein- und Ausfuhr
|
||||
|
||||
Verschluesselte Umschlaege (Argon2id-Ableitung + AES-256-GCM) mit einem selbst
|
||||
gewaehlten Kennwort. Der Import zeigt eine Vorschau, bevor etwas geschrieben
|
||||
Verschlüsselte Umschläge (Argon2id-Ableitung + AES-256-GCM) mit einem selbst
|
||||
gewählten Kennwort. Der Import zeigt eine Vorschau, bevor etwas geschrieben
|
||||
wird; die Vorschau liegt in der Datenbank, nicht im Prozessspeicher -- deshalb
|
||||
braucht der Dienst kein `--workers 1` mehr.
|
||||
|
||||
@@ -166,11 +166,11 @@ braucht der Dienst kein `--workers 1` mehr.
|
||||
|
||||
## 10. Bewusst nicht getan
|
||||
|
||||
* **Kein Schalter, der die LDAP-Zertifikatspruefung abschaltet.** Wer ihn
|
||||
* **Kein Schalter, der die LDAP-Zertifikatsprüfung abschaltet.** Wer ihn
|
||||
einmal setzt, setzt ihn dauerhaft.
|
||||
* **Kein CSRF-Freibrief fuer cookie-autorisierte Routen.**
|
||||
* **Kein `unsafe-inline` fuer Skripte**, auch nicht "voruebergehend".
|
||||
* **Keine Rechteausweitung**, auch nicht fuer Administratoren untereinander.
|
||||
* **Kein CSRF-Freibrief für cookie-autorisierte Routen.**
|
||||
* **Kein `unsafe-inline` für Skripte**, auch nicht "vorübergehend".
|
||||
* **Keine Rechteausweitung**, auch nicht für Administratoren untereinander.
|
||||
* **Kein Hostname im Lizenz-Fingerabdruck** -- Umbenennen darf die Bindung
|
||||
nicht brechen.
|
||||
* **Keine von Hand gepflegte nginx-Site.** Sie wird immer neu erzeugt.
|
||||
@@ -179,5 +179,5 @@ braucht der Dienst kein `--workers 1` mehr.
|
||||
|
||||
## 11. Wenn Sie eine Schwachstelle finden
|
||||
|
||||
Bitte nicht oeffentlich melden. Wenden Sie sich an die im Anbieterprofil des
|
||||
Bitte nicht öffentlich melden. Wenden Sie sich an die im Anbieterprofil des
|
||||
Lizenzservers hinterlegte Adresse.
|
||||
@@ -0,0 +1,181 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# Installation -- der kurze Weg.
|
||||
#
|
||||
# Diese Datei liegt oben im Paket und ruft den Installer unter deploy/ auf:
|
||||
#
|
||||
# tar xzf tesm-vX.Y.Z.tar.gz && cd tesm
|
||||
# sudo bash install.sh --app tesm --https
|
||||
#
|
||||
# Sie genuegt aber auch **allein**: liegt kein Paket um sie herum, holt sie
|
||||
# sich das aktuelle Release selbst.
|
||||
#
|
||||
# curl -fsSLO https://gitea.int.eertmoed.net/alientim/tesm/raw/branch/main/install.sh
|
||||
# sudo bash install.sh --app tesm --https
|
||||
#
|
||||
# Die Master-Ausgabe des Lizenzservers liegt im privaten Repositorium
|
||||
# tesm-license-master. Allein-Betrieb dafuer:
|
||||
#
|
||||
# export TESM_RELEASE_REPO=tesm-license-master
|
||||
# export TESM_RELEASE_TOKEN=<Gitea-Token>
|
||||
# sudo -E bash install.sh --app tesm-license --https
|
||||
#
|
||||
# Aus einem entpackten Paket braucht es das nicht: dort entscheidet, welches
|
||||
# Paket es ist. Die Master-Ausgabe legt beim Installieren Signaturschluessel
|
||||
# und eigene Master-Lizenz selbst an.
|
||||
#
|
||||
# Alle Argumente gehen unveraendert an deploy/install.sh weiter; dessen Hilfe:
|
||||
#
|
||||
# bash install.sh --help
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
# --app wird nur gelesen, nicht verbraucht: der Bootstrap braucht es fuer den
|
||||
# Namen des Repositoriums, der Installer bekommt die Argumente unveraendert.
|
||||
APP=""
|
||||
TAG="latest"
|
||||
ARGUMENTE=("$@")
|
||||
for ((i = 0; i < $#; i++)); do
|
||||
case "${ARGUMENTE[i]}" in
|
||||
--app) APP="${ARGUMENTE[i + 1]:-}" ;;
|
||||
--tag) TAG="${ARGUMENTE[i + 1]:-latest}" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Ohne Paket drumherum brauchen wir den Namen, um das richtige Release zu
|
||||
# finden -- die beiden Anwendungen liegen in getrennten Repositorien.
|
||||
if [[ ! -f "$SCRIPT_DIR/deploy/install.sh" ]]; then
|
||||
[[ "$APP" == "tesm" || "$APP" == "tesm-license" ]] || {
|
||||
echo "Diese Datei liegt allein hier und muss sich das Release holen --" >&2
|
||||
echo "dafuer braucht sie --app <tesm|tesm-license>." >&2
|
||||
echo "Verwendung: install.sh --app <tesm|tesm-license> [weitere Optionen]" >&2
|
||||
exit 2
|
||||
}
|
||||
fi
|
||||
|
||||
# --8<-- Bootstrap: Release holen --------------------------------------------
|
||||
# Wortgleich in update.sh und upgrade.sh; ein Hygienetest vergleicht die beiden
|
||||
# Bloecke. Zwei eigenstaendige Skripte koennen sich keine Datei teilen, und
|
||||
# beide sollen als **einzelne Datei** genuegen: auf das Zielsystem legen,
|
||||
# ausfuehren, fertig -- auf einer 1.2.8 liegt nichts von diesem Projekt.
|
||||
#
|
||||
# Erwartet: $APP und $TAG, wahlweise $REPO (Vorgabe: $APP). Setzt: $TAG
|
||||
# (aufgeloest) und $SRC (entpacktes Release). Raeumt sein Arbeitsverzeichnis
|
||||
# beim Beenden auf.
|
||||
#
|
||||
# $REPO gibt es, weil Installationsname und Repositorium auseinanderfallen
|
||||
# koennen: die Master-Ausgabe des Lizenzservers liegt in
|
||||
# "tesm-license-master", die Installation heisst weiter "tesm-license".
|
||||
#
|
||||
# Zwei Fallen des Vorgaengers sind hier vermieden:
|
||||
# * Der Tag "latest" wird ueber die API aufgeloest, nie direkt in die
|
||||
# Download-URL geschrieben. Gitea liefert sonst mit HTTP 200 ein
|
||||
# automatisch erzeugtes Quell-Snapshot statt des echten Release-Anhangs.
|
||||
# * Nach dem Entpacken wird geprueft, dass ein install.sh existiert.
|
||||
BASE="${TESM_RELEASE_BASE:-https://gitea.int.eertmoed.net}"
|
||||
OWNER="${TESM_RELEASE_OWNER:-alientim}"
|
||||
|
||||
# Ein privates Repositorium -- tesm-license-master -- gibt sein Release nur mit
|
||||
# Zugriffstoken heraus. Reihenfolge: Umgebungsvariable, dann eine Datei, die
|
||||
# der Betreiber selbst anlegt. Der Installer legt sie nie an: ob ein Token auf
|
||||
# dem Server liegt, ist keine Entscheidung eines Skripts.
|
||||
TOKEN="${TESM_RELEASE_TOKEN:-}"
|
||||
if [[ -z "$TOKEN" && -r /etc/tesm-release.token ]]; then
|
||||
TOKEN="$(tr -d '[:space:]' < /etc/tesm-release.token)"
|
||||
fi
|
||||
|
||||
# Ein Wagenruecklauf im Paket macht jedes Shell-Skript auf dem Ziel unbrauchbar
|
||||
# ("bad interpreter"). Die Pruefung darf dabei nicht selbst von einem
|
||||
# Zusatzpaket abhaengen: mit "file" -- das auf einem schlanken Server fehlt --
|
||||
# endete sie in "command not found", grep bekam nichts zu sehen, und das Skript
|
||||
# machte weiter, als waere alles in Ordnung.
|
||||
hat_crlf() {
|
||||
local cr
|
||||
cr="$(printf '\r')"
|
||||
# -U (binaer lesen) ist unter Linux ohne Wirkung, auf einer
|
||||
# Windows-Buildmaschine aber entscheidend: dort entfernt grep den
|
||||
# Wagenruecklauf beim Lesen und findet deshalb nie einen.
|
||||
LC_ALL=C grep -qU "$cr" "$1"
|
||||
}
|
||||
|
||||
BOOTSTRAP_WORK=""
|
||||
bootstrap_aufraeumen() {
|
||||
# Den Ausstiegscode zuerst festhalten: alles danach ueberschreibt ihn.
|
||||
local code=$?
|
||||
[[ -n "$BOOTSTRAP_WORK" ]] && rm -rf "$BOOTSTRAP_WORK"
|
||||
# Wer einen eigenen Abschluss braucht -- etwa einen Rueckweg nach einem
|
||||
# Fehlschlag --, definiert "beim_beenden". Ein zweiter "trap ... EXIT" waere
|
||||
# der falsche Weg: er wuerde diesen hier ersetzen, und das entpackte Release
|
||||
# bliebe unter /tmp liegen.
|
||||
declare -F beim_beenden >/dev/null 2>&1 && beim_beenden "$code"
|
||||
return "$code"
|
||||
}
|
||||
trap bootstrap_aufraeumen EXIT
|
||||
|
||||
release_holen() {
|
||||
local repo="${TESM_RELEASE_REPO:-${REPO:-$APP}}" asset
|
||||
# Leeres Array und "set -u" vertragen sich nur mit dieser Schreibweise.
|
||||
local -a kopf=()
|
||||
[[ -n "$TOKEN" ]] && kopf=(-H "Authorization: token $TOKEN")
|
||||
for werkzeug in curl tar python3 find; do
|
||||
command -v "$werkzeug" >/dev/null 2>&1 || {
|
||||
echo "Auf diesem System fehlt $werkzeug -- ohne das geht es nicht." >&2
|
||||
return 1
|
||||
}
|
||||
done
|
||||
BOOTSTRAP_WORK="$(mktemp -d /tmp/tesm-release-XXXXXX)"
|
||||
|
||||
if [[ "$TAG" == "latest" ]]; then
|
||||
echo "==> Ermittle aktuellen Release-Tag ..."
|
||||
TAG="$(curl -fsS ${kopf[0]+"${kopf[@]}"} "$BASE/api/v1/repos/$OWNER/$repo/releases/latest" \
|
||||
| python3 -c 'import json,sys; print(json.load(sys.stdin)["tag_name"])')" || {
|
||||
echo "Kein Release gefunden unter $BASE/$OWNER/$repo." >&2
|
||||
[[ -z "$TOKEN" ]] &&
|
||||
echo "Ist das Repositorium privat? Dann TESM_RELEASE_TOKEN setzen." >&2
|
||||
echo "Andere Quelle: TESM_RELEASE_BASE, TESM_RELEASE_OWNER, TESM_RELEASE_REPO." >&2
|
||||
return 1
|
||||
}
|
||||
echo " $TAG"
|
||||
fi
|
||||
|
||||
asset="$repo-$TAG.tar.gz"
|
||||
echo "==> Lade $asset ..."
|
||||
curl -fsSL ${kopf[0]+"${kopf[@]}"} -o "$BOOTSTRAP_WORK/$asset" \
|
||||
"$BASE/$OWNER/$repo/releases/download/$TAG/$asset" || {
|
||||
echo "Download fehlgeschlagen: $BASE/$OWNER/$repo/releases/download/$TAG/$asset" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
echo "==> Entpacke ..."
|
||||
tar -xzf "$BOOTSTRAP_WORK/$asset" -C "$BOOTSTRAP_WORK"
|
||||
# Drei Ebenen, nicht zwei: release.sh packt mit dem Praefix "<name>/", also
|
||||
# liegt das Skript unter <name>/deploy/install.sh. Mit "-maxdepth 2" fand die
|
||||
# Suche nie etwas -- und die Fehlermeldung schob es auf den Download.
|
||||
SRC="$(find "$BOOTSTRAP_WORK" -maxdepth 3 -name install.sh -path '*/deploy/*' \
|
||||
-printf '%h/..\n' | head -1)"
|
||||
[[ -n "$SRC" ]] || {
|
||||
echo "Im Paket fehlt deploy/install.sh -- Download unbrauchbar." >&2
|
||||
return 1
|
||||
}
|
||||
SRC="$(cd "$SRC" && pwd)"
|
||||
|
||||
echo "==> Pruefe auf CRLF (Windows-Buildmaschinen) ..."
|
||||
if hat_crlf "$SRC/deploy/install.sh"; then
|
||||
echo "Das Paket enthaelt CRLF-Zeilenenden. Beim Paketieren fehlte" >&2
|
||||
echo "'git -c core.autocrlf=false archive'." >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
# --8<-- Ende Bootstrap ------------------------------------------------------
|
||||
|
||||
if [[ -f "$SCRIPT_DIR/deploy/install.sh" ]]; then
|
||||
# Im Paket: der Installer liegt daneben.
|
||||
exec bash "$SCRIPT_DIR/deploy/install.sh" ${ARGUMENTE[0]+"${ARGUMENTE[@]}"}
|
||||
fi
|
||||
|
||||
# Allein: Release holen und dessen Installer benutzen. Kein exec -- sonst
|
||||
# laeuft die Aufraeumfalle nie und das entpackte Release bliebe unter /tmp.
|
||||
release_holen || { echo "Das Release liess sich nicht holen." >&2; exit 1; }
|
||||
bash "$SRC/deploy/install.sh" ${ARGUMENTE[0]+"${ARGUMENTE[@]}"}
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "tesm-core"
|
||||
version = "2.0.0"
|
||||
version = "2.3.2"
|
||||
description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -8,7 +8,20 @@ Design-System.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
__version__ = "2.0.0"
|
||||
#: Version aus den Metadaten des installierten Pakets -- die einzige Quelle
|
||||
#: ist ``pyproject.toml``. Vorher stand hier eine feste Zahl, und die
|
||||
#: Fusszeile zeigte vier Releases lang 2.0.2.
|
||||
#:
|
||||
#: Im Quellbaum ohne ``pip install`` gibt es keine Metadaten; dann steht dort
|
||||
#: "unbekannt" statt einer erfundenen Nummer. In einem editierbaren Install
|
||||
#: zeigt sie den Stand des letzten ``pip install -e`` -- also die Version, die
|
||||
#: tatsaechlich installiert ist.
|
||||
try: # pragma: no cover - haengt an der Installationsart
|
||||
from importlib.metadata import PackageNotFoundError, version as _paketversion
|
||||
|
||||
__version__ = _paketversion("tesm-core")
|
||||
except (ImportError, PackageNotFoundError): # pragma: no cover
|
||||
__version__ = "unbekannt"
|
||||
|
||||
from .app_factory import create_base_app
|
||||
from .config import CoreConfig, SecurityConfig, load_core_config
|
||||
|
||||
@@ -31,6 +31,7 @@ from .security import csrf
|
||||
from .security import sessions as session_store
|
||||
from .security.sessions import SqliteSessionInterface
|
||||
from .settings_store import SettingSpec, SettingsStore
|
||||
from . import uploads
|
||||
from .sysops import SysOps
|
||||
from .auth import directory
|
||||
from .auth import routes as auth_routes
|
||||
@@ -43,6 +44,11 @@ from .views import hostsettings as hostsettings_views
|
||||
from .views import logs as logs_views
|
||||
from .views import trash as trash_views
|
||||
|
||||
#: Rechteinhaber, in dieser Reihenfolge. Steht an einer Stelle, weil die
|
||||
#: Fusszeile in zwei Vorlagen auftaucht (Anwendungsrahmen und Anmeldeseiten)
|
||||
#: und zwei Texte fuer dieselbe Aussage auseinanderlaufen.
|
||||
COPYRIGHT_NOTICE = "Tim Eertmoed · WiS IT-Solutions GmbH"
|
||||
|
||||
CORE_SETTINGS: tuple[SettingSpec, ...] = (
|
||||
SettingSpec("nav_order", [], "json", "Reihenfolge der Navigation", section="intern"),
|
||||
SettingSpec("nav_child_order", {}, "json", "Reihenfolge der Unterpunkte", section="intern"),
|
||||
@@ -56,6 +62,18 @@ CORE_SETTINGS: tuple[SettingSpec, ...] = (
|
||||
minimum=1,
|
||||
maximum=3650,
|
||||
),
|
||||
SettingSpec(
|
||||
"session_idle_minutes",
|
||||
30,
|
||||
"int",
|
||||
"Automatische Abmeldung nach Leerlauf (Minuten)",
|
||||
hint="Jede Anfrage setzt den Zaehler zurueck -- gezaehlt wird nur Untaetigkeit. "
|
||||
"Gilt auch fuer die eingebundenen Dateifreigaben: mit der Sitzung endet die "
|
||||
"Einbindung.",
|
||||
section="system",
|
||||
minimum=5,
|
||||
maximum=1440,
|
||||
),
|
||||
SettingSpec("timezone", "", "str", "Zeitzone des Hosts", section="system"),
|
||||
SettingSpec("hostname", "", "str", "Hostname", section="system"),
|
||||
SettingSpec(
|
||||
@@ -113,11 +131,11 @@ def create_base_app(
|
||||
import_name: str = "tesm_core",
|
||||
app_short: str = "TESM",
|
||||
app_edition: str = "",
|
||||
#: Zeile unter der Wortmarke auf den Anmeldeseiten. Leer = kein
|
||||
#: Seitenhintergrund. Der Lizenzserver setzt sie bewusst nicht: die
|
||||
#: Funktionsliste von TESM waere dort schlicht falsch.
|
||||
#: Zeile unter der Wortmarke auf den Anmeldeseiten. Leer = nur die
|
||||
#: Wortmarke, ohne Zeile darunter; die Marke selbst zeigen beide
|
||||
#: Anwendungen.
|
||||
auth_tagline: str = "",
|
||||
app_version: str = "2.0.0",
|
||||
app_version: str = "2.3.2",
|
||||
built_in_trusted_keys: tuple[str, ...] = (),
|
||||
enable_license: bool = True,
|
||||
template_folder: str | None = None,
|
||||
@@ -146,6 +164,10 @@ def create_base_app(
|
||||
config.data_dir.mkdir(parents=True, exist_ok=True)
|
||||
config.log_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Die Groessengrenze einer Anfrage kommt aus der Einstellung, nicht aus
|
||||
# der Konfiguration beim Start.
|
||||
app.request_class = uploads.UploadLimitRequest
|
||||
|
||||
app.config.update(
|
||||
SECRET_KEY=load_or_create_secret_key(config.secret_key_file),
|
||||
# site_key statt app_key: Cookies unterscheiden keine Ports. Zwei
|
||||
@@ -155,6 +177,9 @@ def create_base_app(
|
||||
SESSION_COOKIE_HTTPONLY=True,
|
||||
SESSION_COOKIE_SECURE=config.security.cookie_secure,
|
||||
SESSION_COOKIE_SAMESITE=config.security.cookie_samesite,
|
||||
# Nur die aeussere Schranke aus der Umgebung. Die eigentliche Grenze
|
||||
# steht in der Einstellung und wird pro Anfrage gelesen (uploads.py) --
|
||||
# sonst braeuchte jede Aenderung einen Neustart aller Arbeiter.
|
||||
MAX_CONTENT_LENGTH=config.security.max_content_length,
|
||||
JSON_SORT_KEYS=False,
|
||||
TRAP_HTTP_EXCEPTIONS=False,
|
||||
@@ -192,11 +217,25 @@ def create_base_app(
|
||||
)
|
||||
app.extensions["tesm"] = extension
|
||||
|
||||
def _leerlauf(conn: Any) -> int:
|
||||
"""Leerlaufgrenze aus den Einstellungen -- ueber die offene Verbindung.
|
||||
|
||||
Bewusst mit der Verbindung, die die Sitzungsverwaltung schon haelt: eine
|
||||
zweite zu oeffnen waere eine verschachtelte Datenbankverbindung, und die
|
||||
gilt hier als Fehler.
|
||||
"""
|
||||
try:
|
||||
wert = int(settings.get(conn, "session_idle_minutes") or 0)
|
||||
except (TypeError, ValueError):
|
||||
wert = 0
|
||||
return wert if wert > 0 else config.security.session_idle_minutes
|
||||
|
||||
app.session_interface = SqliteSessionInterface(
|
||||
database,
|
||||
cookie_name=app.config["SESSION_COOKIE_NAME"],
|
||||
idle_minutes=config.security.session_idle_minutes,
|
||||
absolute_hours=config.security.session_absolute_hours,
|
||||
idle_lookup=_leerlauf,
|
||||
)
|
||||
|
||||
# -- Schema und Startzustand ------------------------------------------ #
|
||||
@@ -228,6 +267,17 @@ def create_base_app(
|
||||
|
||||
@app.after_request
|
||||
def _finish_request(response): # type: ignore[no-untyped-def]
|
||||
# Ein Aktualisierungsdurchlauf, der mit Sitzungscookie kommt und
|
||||
# unangemeldet endet, hat gerade seine Sitzung verloren. Bei einer
|
||||
# geschuetzten Seite sagt das schon der Status (401); bei einer, die
|
||||
# auch oeffentlich antwortet -- die Statusuebersicht etwa -- kommt eine
|
||||
# 200 mit den oeffentlichen Kacheln zurueck, und der offene Tab merkt
|
||||
# nichts: er zeigt weiter eine Oberflaeche, als sei alles in Ordnung.
|
||||
# Deshalb ein Kopf, den der Browser unabhaengig vom Status auswerten
|
||||
# kann.
|
||||
if session_store.is_automatic(request) and not current_user.is_authenticated:
|
||||
if request.cookies.get(app.config["SESSION_COOKIE_NAME"], ""):
|
||||
response.headers["X-Session-Expired"] = "1"
|
||||
hsts = ""
|
||||
if config.security.hsts_enabled:
|
||||
hsts = f"max-age={config.security.hsts_max_age}; includeSubDomains"
|
||||
@@ -264,6 +314,7 @@ def create_base_app(
|
||||
"auth_tagline": auth_tagline,
|
||||
"app_version": app_version,
|
||||
"current_year": datetime.now(timezone.utc).year,
|
||||
"copyright_notice": COPYRIGHT_NOTICE,
|
||||
"current_user": current_user,
|
||||
"nav_items": nav_items,
|
||||
"palette_entries": palette,
|
||||
@@ -285,6 +336,16 @@ def create_base_app(
|
||||
# -- Fehlerseiten ------------------------------------------------------ #
|
||||
@app.errorhandler(HTTPException)
|
||||
def _http_error(error: HTTPException): # type: ignore[no-untyped-def]
|
||||
if error.code == 413:
|
||||
# "Request Entity Too Large" allein hilft niemandem weiter: die
|
||||
# Zahl steht in einer Einstellung, und wer hochlaedt, soll sie
|
||||
# sehen. Vorher stand hier nur der nackte Status.
|
||||
grenze = uploads.limit_mb()
|
||||
error.description = (
|
||||
f"Diese Datei ist groesser als erlaubt. Zurzeit sind {grenze} MB je Upload "
|
||||
"eingestellt; aendern kann das ein Administrator unter "
|
||||
'"Einstellungen -> Webserver und TLS".'
|
||||
)
|
||||
if _wants_json():
|
||||
return {
|
||||
"error": error.name,
|
||||
|
||||
@@ -42,7 +42,7 @@ AAD_LDAP_BIND = "service_accounts.ldap_bind"
|
||||
|
||||
#: userAccountControl-Bit fuer "Konto deaktiviert".
|
||||
ACCOUNTDISABLE = 0x0002
|
||||
#: OID fuer rekursive Gruppenauflösung in AD.
|
||||
#: OID fuer rekursive Gruppenaufloesung in AD.
|
||||
MATCHING_RULE_IN_CHAIN = "1.2.840.113556.1.4.1941"
|
||||
|
||||
Status = Literal["ok", "invalid", "unavailable", "disabled"]
|
||||
@@ -349,7 +349,7 @@ def _attr_int(entry: Any, name: str) -> int | None:
|
||||
|
||||
|
||||
def _resolve_groups(connection: Any, base: str, user_dn: str) -> tuple[str, ...]:
|
||||
"""Rekursive Gruppenauflösung ueber LDAP_MATCHING_RULE_IN_CHAIN."""
|
||||
"""Rekursive Gruppenaufloesung ueber LDAP_MATCHING_RULE_IN_CHAIN."""
|
||||
try:
|
||||
connection.search(
|
||||
search_base=base,
|
||||
@@ -359,7 +359,7 @@ def _resolve_groups(connection: Any, base: str, user_dn: str) -> tuple[str, ...]
|
||||
)
|
||||
return tuple(str(entry.entry_dn) for entry in connection.entries)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("Gruppenauflösung fehlgeschlagen: %s", exc)
|
||||
logger.warning("Gruppenaufloesung fehlgeschlagen: %s", exc)
|
||||
return ()
|
||||
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ from flask import abort, flash, g, redirect, request, session, url_for
|
||||
from werkzeug.local import LocalProxy
|
||||
|
||||
from ..rbac.model import Action, PermissionTree
|
||||
from ..security.sessions import is_automatic
|
||||
|
||||
AUTH_SOURCES = ("local", "ldap")
|
||||
|
||||
@@ -189,9 +190,22 @@ def _deny(message: str) -> Any:
|
||||
if not current_user.is_authenticated:
|
||||
if request.method == "GET" and not request.headers.get("X-Requested-With"):
|
||||
session["_after_login"] = request.full_path if request.query_string else request.path
|
||||
# Eine automatische Aktualisierung fragt einen Ausschnitt ab und
|
||||
# erwartet HTML. Eine Umleitung auf die Anmeldeseite wuerde sie
|
||||
# dankbar entgegennehmen und eine ganze Seite in eine Kachel setzen --
|
||||
# der Tab bliebe stumm stehen, obwohl die Sitzung abgelaufen ist. 401
|
||||
# sagt es deutlich; der Browser laedt daraufhin die Seite neu und der
|
||||
# Mensch sieht die Anmeldemaske.
|
||||
if is_automatic(request):
|
||||
abort(401, description="Sitzung abgelaufen.")
|
||||
if request.accept_mimetypes.accept_json and not request.accept_mimetypes.accept_html:
|
||||
abort(401, description="Anmeldung erforderlich.")
|
||||
flash("Bitte melden Sie sich an.", "info")
|
||||
# "Bitte melden Sie sich an." stand auf einer Seite, die aus einem
|
||||
# Anmeldefeld, einem Passwortfeld und einem Knopf "Anmelden" besteht --
|
||||
# eine Meldung, die nichts hinzufuegt. Wer dagegen gerade noch
|
||||
# angemeldet war, soll erfahren, warum er es nicht mehr ist.
|
||||
if getattr(g, "session_expired", False):
|
||||
flash("Ihre Sitzung ist abgelaufen. Bitte melden Sie sich erneut an.", "info")
|
||||
return redirect(url_for("auth.login"))
|
||||
abort(403, description=message)
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ class SecurityConfig:
|
||||
cookie_secure: bool = True
|
||||
cookie_samesite: str = "Lax"
|
||||
#: Leerlauf-Zeitfenster einer Sitzung.
|
||||
session_idle_minutes: int = 60
|
||||
session_idle_minutes: int = 30
|
||||
#: Harte Obergrenze unabhaengig von Aktivitaet.
|
||||
session_absolute_hours: int = 12
|
||||
#: Wie lange eine erneute Passwortabfrage ("sudo-Modus") gilt.
|
||||
@@ -76,7 +76,7 @@ class CoreConfig:
|
||||
"""Technischer Kurzname, z. B. ``tesm`` -- Namensraum fuer Pfade und Units."""
|
||||
|
||||
site_key: str
|
||||
"""Name *dieser Installation* auf dem Host, z. B. ``tesm-opus``.
|
||||
"""Name *dieser Installation* auf dem Host, z. B. ``tesm-test``.
|
||||
|
||||
Weicht vom ``app_key`` ab, sobald neben der Hauptinstallation eine zweite
|
||||
Instanz laeuft. Alles, was ausserhalb von ``/srv/<name>`` liegt und damit
|
||||
@@ -147,7 +147,7 @@ def load_core_config(
|
||||
security = SecurityConfig(
|
||||
cookie_secure=_env_bool(env_prefix, "COOKIE_SECURE", True),
|
||||
cookie_samesite=_env(env_prefix, "COOKIE_SAMESITE") or "Lax",
|
||||
session_idle_minutes=_env_int(env_prefix, "SESSION_IDLE_MINUTES", 60),
|
||||
session_idle_minutes=_env_int(env_prefix, "SESSION_IDLE_MINUTES", 30),
|
||||
session_absolute_hours=_env_int(env_prefix, "SESSION_ABSOLUTE_HOURS", 12),
|
||||
reauth_valid_minutes=_env_int(env_prefix, "REAUTH_VALID_MINUTES", 15),
|
||||
login_max_attempts=_env_int(env_prefix, "LOGIN_MAX_ATTEMPTS", 8),
|
||||
|
||||
@@ -48,6 +48,10 @@ SETTING_STATUS = "license_activation_status"
|
||||
SETTING_PENDING = "license_pending_request"
|
||||
SETTING_LAST_HEARTBEAT = "license_last_heartbeat_at"
|
||||
SETTING_HEARTBEAT_ERROR = "license_heartbeat_error"
|
||||
|
||||
#: Ab wann die Restlaufzeit als *Hinweis* erscheint (gruen). Unterhalb von
|
||||
#: ``EXPIRY_WARNING_DAYS`` uebernimmt die Warnung (orange), nach Ablauf Rot.
|
||||
EXPIRY_HINT_DAYS = 90
|
||||
SETTING_EXPORT_GRACE = "license_export_grace"
|
||||
|
||||
STATUS_INACTIVE = "inactive"
|
||||
@@ -596,6 +600,14 @@ class LicenseManager:
|
||||
message = "Verbindung zum Lizenzserver bestaetigt."
|
||||
|
||||
self.settings.set(conn, SETTING_PENDING, "", actor=actor)
|
||||
# Ein erfolgreicher Heartbeat laeuft alle paar Minuten und ist kein
|
||||
# Ereignis, sondern der Normalzustand. Im Auditlog stand dadurch
|
||||
# praktisch nur noch er, und die Eintraege, um die es dort geht --
|
||||
# Aktivierung, Deaktivierung, Widerruf --, gingen darin unter. Der
|
||||
# Zeitpunkt des letzten Heartbeats steht ohnehin in den Einstellungen
|
||||
# und auf der Lizenzseite. Ein *fehlgeschlagener* Heartbeat wird
|
||||
# weiterhin protokolliert (siehe die Fehlerpfade oben).
|
||||
if action != "heartbeat":
|
||||
audit.log(
|
||||
conn,
|
||||
actor=actor,
|
||||
@@ -704,6 +716,19 @@ class LicenseManager:
|
||||
except LicenseError as exc:
|
||||
with self.database.transaction(conn):
|
||||
self.settings.set(conn, SETTING_HEARTBEAT_ERROR, str(exc), actor="system")
|
||||
# Der *erfolgreiche* Heartbeat wird nicht protokolliert -- er
|
||||
# ist der Normalzustand und hat das Auditlog zugeschuettet.
|
||||
# Ein Fehlschlag dagegen ist genau das, was dort stehen soll:
|
||||
# er kann bedeuten, dass die Lizenz demnaechst in die
|
||||
# Kulanzzeit laeuft.
|
||||
audit.log(
|
||||
conn,
|
||||
actor="system",
|
||||
action="license.heartbeat_failed",
|
||||
target=self._state.license_id or "",
|
||||
detail=str(exc)[:500],
|
||||
severity="warning",
|
||||
)
|
||||
return {"ok": False, "message": str(exc)}
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
@@ -761,6 +786,17 @@ class LicenseManager:
|
||||
"dot_class": "status-pill__dot--pulse",
|
||||
"title": "Bitte rechtzeitig verlaengern.",
|
||||
}
|
||||
# Zwischen der Warnschwelle und 90 Tagen: gruen, rein als Hinweis. Wer
|
||||
# eine Verlaengerung im Voraus plant, will das Datum sehen, ohne dass es
|
||||
# nach einem Problem aussieht.
|
||||
if status.days_left is not None and 0 < status.days_left <= EXPIRY_HINT_DAYS:
|
||||
return {
|
||||
"label": f"Lizenz laeuft in {status.days_left} Tagen ab",
|
||||
"css_class": "",
|
||||
"dot": "1",
|
||||
"dot_class": "",
|
||||
"title": "Nur ein Hinweis -- es ist nichts eingeschraenkt.",
|
||||
}
|
||||
if state.last_heartbeat_at:
|
||||
try:
|
||||
last = lic.parse_iso(state.last_heartbeat_at)
|
||||
|
||||
@@ -26,7 +26,8 @@ import sqlite3
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
from flask import Flask, Request, Response
|
||||
from flask import Flask, Request, Response, g
|
||||
from flask import request as aktuelle_anfrage
|
||||
from flask.sessions import SessionInterface, SessionMixin
|
||||
from werkzeug.datastructures import CallbackDict
|
||||
|
||||
@@ -34,6 +35,79 @@ from ..db import Database
|
||||
|
||||
SESSION_ID_BYTES = 32
|
||||
|
||||
#: Kopfzeile, mit der der Browser eine Anfrage als **automatisch** kennzeichnet:
|
||||
#: ein Aktualisierungsdurchlauf einer Kachel, kein Klick eines Menschen.
|
||||
#:
|
||||
#: Ohne diese Unterscheidung ist die automatische Abmeldung wirkungslos, und das
|
||||
#: war sie auch: ein offener Tab fragt seine Bereiche alle 15 Sekunden neu ab,
|
||||
#: jede Abfrage verlaengerte das Leerlauffenster, und niemand wurde je
|
||||
#: abgemeldet -- nachweisbar an einer Sitzung, deren ``last_seen_at`` eine
|
||||
#: Minute alt war, obwohl seit zehn Stunden niemand am Rechner sass.
|
||||
#:
|
||||
#: Die Entscheidung gehoert zum Client: nur er weiss, ob ein Zeitgeber oder ein
|
||||
#: Mensch die Anfrage ausgeloest hat. Ein Angreifer koennte den Kopf setzen --
|
||||
#: er wuerde damit seine *eigene* Sitzung schneller ablaufen lassen.
|
||||
AUTO_REFRESH_HEADER = "X-Auto-Refresh"
|
||||
|
||||
|
||||
#: Marke auf der Ansichtsfunktion. Steht an der Route, damit man sie dort sieht,
|
||||
#: wo sie gilt.
|
||||
FRAGMENT_ATTRIBUT = "_tesm_fragment"
|
||||
|
||||
|
||||
def fragment(view: Any) -> Any:
|
||||
"""Kennzeichnet eine Route als Aktualisierungsausschnitt.
|
||||
|
||||
Solche Routen ruft ein Zeitgeber auf, kein Mensch. Sie duerfen die Sitzung
|
||||
benutzen, halten sie aber nicht am Leben -- sonst bleibt jeder offene Tab
|
||||
unbegrenzt angemeldet.
|
||||
|
||||
Der Kopf ``X-Auto-Refresh`` allein genuegt dafuer nicht: er setzt voraus,
|
||||
dass der Browser das aktuelle Skript faehrt. Ein Tab, der seit Stunden
|
||||
offen ist, faehrt das alte -- und das ist genau der Tab, um den es geht.
|
||||
"""
|
||||
setattr(view, FRAGMENT_ATTRIBUT, True)
|
||||
return view
|
||||
|
||||
|
||||
def _ist_fragment_route() -> bool:
|
||||
"""Gehoert die laufende Anfrage zu einer als Ausschnitt markierten Route?
|
||||
|
||||
``request.endpoint`` steht hier nicht immer schon: Flask oeffnet die Sitzung
|
||||
**vor** dem Routing (``RequestContext.push`` ruft ``open_session`` und erst
|
||||
danach ``match_request``). In ``save_session`` ist der Endpunkt gesetzt, in
|
||||
``open_session`` nicht -- und ohne diesen Nachtrag griff die Erkennung
|
||||
genau dort nicht, wo sie gebraucht wird.
|
||||
"""
|
||||
from flask import current_app
|
||||
|
||||
try:
|
||||
endpunkt = getattr(aktuelle_anfrage, "endpoint", None)
|
||||
if not endpunkt:
|
||||
# Selbst nachsehen. Ein zweiter Routenabgleich je Anfrage ist
|
||||
# billiger als eine Abmeldung, die nicht stattfindet.
|
||||
adapter = current_app.url_map.bind_to_environ(aktuelle_anfrage.environ)
|
||||
endpunkt, _ = adapter.match(
|
||||
aktuelle_anfrage.path, method=aktuelle_anfrage.method
|
||||
)
|
||||
ansicht = current_app.view_functions.get(str(endpunkt))
|
||||
except Exception: # noqa: BLE001 - keine Route, Umleitung, kein App-Kontext
|
||||
return False
|
||||
return bool(getattr(ansicht, FRAGMENT_ATTRIBUT, False))
|
||||
|
||||
|
||||
def is_automatic(request: Request) -> bool:
|
||||
"""Kam die Anfrage von einem Zeitgeber statt von einem Menschen?
|
||||
|
||||
Zwei Kennzeichen, und eines genuegt: der Kopf, den das Skript setzt,
|
||||
und die Route selbst. Das zweite traegt auch dann, wenn ein lange
|
||||
offener Tab noch ein aelteres Skript faehrt -- und das ist der Tab,
|
||||
um den es geht.
|
||||
"""
|
||||
if request.headers.get(AUTO_REFRESH_HEADER, "") == "1":
|
||||
return True
|
||||
return _ist_fragment_route()
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
@@ -88,11 +162,24 @@ class SqliteSessionInterface(SessionInterface):
|
||||
cookie_name: str,
|
||||
idle_minutes: int,
|
||||
absolute_hours: int,
|
||||
idle_lookup: Any = None,
|
||||
) -> None:
|
||||
self.database = database
|
||||
self.cookie_name = cookie_name
|
||||
self.idle_minutes = idle_minutes
|
||||
self.absolute_hours = absolute_hours
|
||||
#: Optionale Nachschlagefunktion fuer die Leerlaufgrenze. Damit ist die
|
||||
#: Zeit zur Laufzeit einstellbar, ohne den Dienst neu zu starten -- und
|
||||
#: ohne eine zweite Datenbankverbindung, denn sie bekommt die offene.
|
||||
self._idle_lookup = idle_lookup
|
||||
|
||||
def _idle(self, conn: Any) -> int:
|
||||
if self._idle_lookup is None:
|
||||
return self.idle_minutes
|
||||
try:
|
||||
return int(self._idle_lookup(conn)) or self.idle_minutes
|
||||
except Exception: # pragma: no cover - Einstellung unlesbar
|
||||
return self.idle_minutes
|
||||
|
||||
# -- Flask-Schnittstelle ------------------------------------------- #
|
||||
|
||||
@@ -122,6 +209,11 @@ class SqliteSessionInterface(SessionInterface):
|
||||
"WHERE id=?",
|
||||
(_iso(now), sid),
|
||||
)
|
||||
# Fuer die Anmeldeseite vermerken, **warum** sie erscheint. Ohne
|
||||
# das steht dort entweder nichts -- dann wundert sich, wer
|
||||
# gerade noch angemeldet war -- oder eine Meldung, die jeder
|
||||
# Besucher zu sehen bekommt und die deshalb nichts sagt.
|
||||
g.session_expired = True
|
||||
return self.session_class(new=True)
|
||||
|
||||
try:
|
||||
@@ -141,12 +233,21 @@ class SqliteSessionInterface(SessionInterface):
|
||||
}
|
||||
# Leerlauffenster verlaengern, aber hoechstens einmal pro Minute
|
||||
# schreiben -- sonst kostet jeder AJAX-Poll einen Schreibvorgang.
|
||||
#
|
||||
# Eine automatische Abfrage verlaengert gar nichts. Sie darf die
|
||||
# Sitzung benutzen, solange diese lebt, aber sie haelt sie nicht am
|
||||
# Leben: sonst bleibt jeder offene Tab bis in alle Ewigkeit
|
||||
# angemeldet, und "Abmeldung nach 30 Minuten Leerlauf" ist eine
|
||||
# Zusage, die die Anwendung nicht einhaelt. Damit bleibt
|
||||
# ``last_seen_at`` ausserdem, was der Name sagt: der letzte
|
||||
# Zeitpunkt, an dem ein Mensch da war.
|
||||
last_seen = _parse(row.get("last_seen_at"))
|
||||
if last_seen is None or (now - last_seen) > timedelta(seconds=60):
|
||||
veraltet = last_seen is None or (now - last_seen) > timedelta(seconds=60)
|
||||
if veraltet and not is_automatic(request):
|
||||
with self.database.transaction(conn):
|
||||
conn.execute(
|
||||
"UPDATE user_sessions SET last_seen_at=?, idle_expires_at=? WHERE id=?",
|
||||
(_iso(now), _iso(now + timedelta(minutes=self.idle_minutes)), sid),
|
||||
(_iso(now), _iso(now + timedelta(minutes=self._idle(conn))), sid),
|
||||
)
|
||||
return session
|
||||
|
||||
@@ -190,7 +291,7 @@ class SqliteSessionInterface(SessionInterface):
|
||||
session.get("user_id"),
|
||||
_iso(now),
|
||||
_iso(now),
|
||||
_iso(now + timedelta(minutes=self.idle_minutes)),
|
||||
_iso(now + timedelta(minutes=self._idle(conn))),
|
||||
_iso(now + timedelta(hours=self.absolute_hours)),
|
||||
session.get("_ip", ""),
|
||||
session.get("_ua", ""),
|
||||
@@ -198,6 +299,31 @@ class SqliteSessionInterface(SessionInterface):
|
||||
),
|
||||
)
|
||||
session.new = False
|
||||
# ``save_session`` bekommt die Anfrage nicht als Argument -- anders als
|
||||
# ``open_session``, wo ein Parameter denselben Namen traegt. Hier ist es
|
||||
# der Anfragekontext von Flask; die Antwort wird darin erzeugt.
|
||||
elif is_automatic(aktuelle_anfrage):
|
||||
# Die zweite Schreibstelle, und die eigentliche Ursache dafuer,
|
||||
# dass die Abmeldung nie ausgeloest hat: hier wurde das
|
||||
# Leerlauffenster bei **jeder** Anfrage verlaengert, die die
|
||||
# Sitzung anfasst. Die Pruefung in open_session lief damit ins
|
||||
# Leere -- ein Aktualisierungsdurchlauf einer Kachel fasst die
|
||||
# Sitzung an (Flash-Speicher, CSRF), und schon stand das Fenster
|
||||
# wieder 30 Minuten offen. Auf dem Lizenzserver fiel es nicht
|
||||
# auf: dort pollt keine Kachel.
|
||||
#
|
||||
# Geaenderte Sitzungsdaten muessen trotzdem erhalten bleiben --
|
||||
# sie zu verwerfen waere eine zweite Fehlerquelle. Nur die
|
||||
# Fristen bleiben stehen.
|
||||
conn.execute(
|
||||
"UPDATE user_sessions SET user_id=?, data=?, reauth_at=? WHERE id=?",
|
||||
(
|
||||
session.get("user_id"),
|
||||
payload,
|
||||
session.get("_reauth_at"),
|
||||
session.sid,
|
||||
),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"UPDATE user_sessions SET user_id=?, data=?, last_seen_at=?, "
|
||||
@@ -206,7 +332,11 @@ class SqliteSessionInterface(SessionInterface):
|
||||
session.get("user_id"),
|
||||
payload,
|
||||
_iso(now),
|
||||
_iso(now + timedelta(minutes=self.idle_minutes)),
|
||||
# ``_idle(conn)`` und nicht ``idle_minutes``: die Zeit ist
|
||||
# zur Laufzeit einstellbar, und diese Stelle hat die
|
||||
# Einstellung ignoriert. Bei der Vorgabe von 30 Minuten
|
||||
# fiel das nicht auf -- bei einer geaenderten schon.
|
||||
_iso(now + timedelta(minutes=self._idle(conn))),
|
||||
session.get("_reauth_at"),
|
||||
session.sid,
|
||||
),
|
||||
|
||||
@@ -161,6 +161,40 @@
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-ring);
|
||||
}
|
||||
/* Der Knopf im Dateifeld kommt vom Browser und ignoriert jede Aussenfarbe:
|
||||
im dunklen Thema ein hellgraues Kaestchen mit dunkler Schrift, das weder
|
||||
zum Feld noch zu den uebrigen Knoepfen passt und kaum zu lesen war.
|
||||
Deshalb explizit wie ein Sekundaerknopf gestaltet -- und weil vier Felder
|
||||
betroffen sind (Sicherung, Lizenz, Profilbild, Freigaben), einmal hier. */
|
||||
.input[type="file"] {
|
||||
padding: 5px 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.input[type="file"]::file-selector-button {
|
||||
padding: 5px 12px;
|
||||
margin-right: var(--sp-1);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 570;
|
||||
cursor: pointer;
|
||||
transition: background var(--dur-fast), border-color var(--dur-fast);
|
||||
}
|
||||
.input[type="file"]:hover::file-selector-button {
|
||||
background: var(--surface-3);
|
||||
border-color: var(--text-faint);
|
||||
}
|
||||
.input[type="file"]:focus-visible::file-selector-button {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.input[readonly], .input[disabled], .select[disabled], .textarea[disabled] {
|
||||
background: var(--surface-inset);
|
||||
color: var(--text-muted);
|
||||
@@ -374,6 +408,18 @@
|
||||
background: var(--danger-soft);
|
||||
color: var(--danger);
|
||||
}
|
||||
/* Gefuellt statt getoent: "kritisch" muss sich von "Warnung" unterscheiden,
|
||||
und beide sind rot. */
|
||||
.pill--critical {
|
||||
background: var(--danger);
|
||||
/* Weiss wie bei .btn--danger. Vorher stand hier ein Token, das es nicht
|
||||
gibt -- die Deklaration war damit unwirksam, und die Schrift blieb in
|
||||
der Textfarbe: dunkelgrau auf Rot. */
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
}
|
||||
.pill--critical::before { background: currentColor; }
|
||||
|
||||
.pill--warning, .pill--unknown, .pill--orange {
|
||||
background: var(--warning-soft);
|
||||
color: var(--warning);
|
||||
@@ -402,6 +448,22 @@
|
||||
background: var(--success);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
/* Der Knopf sitzt *in* der Pille: kein Rahmen, keine Flaeche, nur das
|
||||
Symbol in der Farbe der Pille. Ein Standardknopf wuerde sie sprengen. */
|
||||
.status-pill__action { display: inline-flex; }
|
||||
.status-pill__action button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0;
|
||||
margin-left: 2px;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: inherit;
|
||||
opacity: 0.65;
|
||||
cursor: pointer;
|
||||
}
|
||||
.status-pill__action button:hover { opacity: 1; }
|
||||
|
||||
.status-pill__dot--pulse { animation: pulse-ring 2.4s var(--ease) infinite; }
|
||||
.status-pill--danger { border-color: color-mix(in srgb, var(--danger) 45%, var(--border)); color: var(--danger); }
|
||||
.status-pill--danger .status-pill__dot { background: var(--danger); }
|
||||
@@ -621,6 +683,191 @@
|
||||
}
|
||||
.modal__title { font-size: var(--fs-md); font-weight: 640; flex: 1; }
|
||||
.modal__sub { font-size: var(--fs-xs); color: var(--text-faint); margin-top: 2px; }
|
||||
/* Eine Datei-Vorschau soll die ganze Flaeche des Dialogs nutzen; der
|
||||
uebliche Innenabstand des Dialogkoerpers wuerde sie einrahmen. */
|
||||
/* Zwei Klassen am selben Element: diese hier muss gewinnen, obwohl
|
||||
.modal__body weiter unten steht. Mit der doppelten Klasse haengt das
|
||||
nicht mehr an der Reihenfolge im Stylesheet. */
|
||||
.modal__body.modal__body--flush { padding: 0; max-height: none; overflow: visible; }
|
||||
.modal__caption {
|
||||
padding: 0 var(--sp-5) var(--sp-2);
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-xs);
|
||||
word-break: break-all;
|
||||
}
|
||||
.file-preview {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: min(70dvh, 760px);
|
||||
border: 0;
|
||||
/* Die Vorschau zeigt fremde Dokumente; ein neutraler Grund gehoert
|
||||
darunter. --surface-0 gibt es nicht, der Rahmen blieb durchsichtig. */
|
||||
background: var(--surface-1);
|
||||
}
|
||||
|
||||
/* -- Betrachter: Bild, PDF, Rahmen ------------------------------------- */
|
||||
/* Eine feste Hoehe fuer alle drei Anzeigen: sonst springt der Dialog beim
|
||||
Wechsel zwischen Foto und PDF. */
|
||||
.viewer {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* Was der Dialog uebrig laesst: Kopf, Dateiname und Fusszeile brauchen
|
||||
zusammen rund 190 Pixel. Ein fester Anteil der Fensterhoehe schob die
|
||||
Zoomleiste auf kleinen Fenstern unter den Rand. */
|
||||
height: min(calc(100dvh - 240px), 760px);
|
||||
min-height: 220px;
|
||||
background: var(--surface-1);
|
||||
}
|
||||
/* Hochkant -- Tablet oder Monitor im Hochformat -- gibt es unter dem Dialog
|
||||
viel Platz, den die Obergrenze verschenkt. Beim Ausfuellen eines
|
||||
Formulars zaehlt jede sichtbare Zeile, also nehmen wir dort die Hoehe,
|
||||
die da ist. Der Rest (Kopf, Dateiname, Fusszeile) braucht rund 200 Pixel;
|
||||
die 240 lassen etwas Luft. */
|
||||
@media (orientation: portrait) {
|
||||
.viewer {
|
||||
height: calc(100dvh - 240px);
|
||||
}
|
||||
}
|
||||
.viewer > .file-preview {
|
||||
flex: 1;
|
||||
height: auto;
|
||||
}
|
||||
.viewer__stage {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
/* Ohne min-height waechst die Buehne mit dem Bild, und dessen
|
||||
max-height:100% haengt dann an sich selbst: ein 4032x3024-Foto stand
|
||||
36 Pixel ueber den Rand hinaus. */
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
/* Die Seite darf nicht mitwandern, wenn im Bild geschoben wird. */
|
||||
touch-action: none;
|
||||
cursor: zoom-in;
|
||||
}
|
||||
.viewer__stage--gezogen {
|
||||
cursor: grab;
|
||||
}
|
||||
.viewer__img {
|
||||
/* Der Ausgangszustand ist "passt in die Flaeche" -- genau das fehlte:
|
||||
ein Foto vom iPad ist 4032 Pixel breit und wurde im Rahmen 1:1
|
||||
gezeigt, also als Ausschnitt aus der Mitte. */
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
/* Zoomen soll nicht unscharf wirken, solange nicht vergroessert wird. */
|
||||
transform-origin: center center;
|
||||
will-change: transform;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
/* pdf.js verlangt einen absolut positionierten, scrollenden Behaelter --
|
||||
der liegt deshalb *innen*. Aussen ein gewoehnlicher Flex-Kasten, damit
|
||||
Zoomleiste und Speicherzeile ihren Platz behalten. Vorher rechnete der
|
||||
Bereich mit "bottom: var(--sp-10)", also mit genau einer Leiste; die
|
||||
zweite Zeile lag dann unter dem Dialogrand. */
|
||||
.viewer__pdf {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
/* Ein PDF ist weisses Papier. pdf.js malt Formularfelder mit Systemfarben
|
||||
und ueberlaesst Ankreuzfelder dem Browser -- im dunklen Design heisst
|
||||
CanvasText weiss, und die Haken waren auf dem Papier unsichtbar. Fuer
|
||||
diesen Ausschnitt gilt deshalb immer das helle Schema. */
|
||||
color-scheme: light;
|
||||
}
|
||||
/* pdf.js rechnet fuer die Seite mit dem Standard "content-box": ihr
|
||||
9-Pixel-Rahmen liegt *ausserhalb* der angegebenen Breite. Unser globales
|
||||
"border-box" rechnete ihn hinein -- die Leinwand wurde 18 Pixel schmaler
|
||||
als die Anmerkungsebene, und die Formularfelder wanderten nach rechts
|
||||
unten, immer weiter je weiter unten sie lagen. */
|
||||
.viewer__pdf .pdfViewer .page {
|
||||
box-sizing: content-box;
|
||||
}
|
||||
.viewer__pdfscroll {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
.viewer__bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-2) var(--sp-4);
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--surface-2, var(--surface-1));
|
||||
}
|
||||
.viewer__hint {
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
/* Bildfeld eines Formulars: der Kasten zeigt das gewaehlte Foto, die
|
||||
Schaltflaeche liegt darin. Acrobats eigener Knopf tut im Browser nichts --
|
||||
das hier ist der Ersatz. */
|
||||
.viewer__bildfeld [data-viewer-bildvorschau] {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
background: #fff;
|
||||
}
|
||||
.viewer__bildwahl {
|
||||
position: absolute;
|
||||
inset: auto 0 4px 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
.viewer__bildwahl > span {
|
||||
padding: 4px 10px;
|
||||
border-radius: var(--r-sm, 6px);
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: 600;
|
||||
box-shadow: var(--shadow-sm, 0 1px 3px rgb(0 0 0 / 0.3));
|
||||
}
|
||||
.viewer__bildfeld--belegt .viewer__bildwahl > span {
|
||||
opacity: 0.85;
|
||||
}
|
||||
.viewer__save {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: var(--sp-3);
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--surface-2, var(--surface-1));
|
||||
}
|
||||
.viewer__save .viewer__feld {
|
||||
flex: 1 1 12rem;
|
||||
margin: 0;
|
||||
}
|
||||
/* Eigener Name: ".field--inline" gibt es schon und steht dort fuer
|
||||
"waagerecht" -- diese Regel haette sie app-weit umgestellt. */
|
||||
.viewer__feld {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
/* Der Dateiname ist ein Knopf (er oeffnet die Vorschau), soll aber wie ein
|
||||
Link aussehen -- ein Knopf mit Rahmen mitten in einer Tabellenzeile waere
|
||||
unruhig. */
|
||||
.file-name {
|
||||
background: none;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
color: var(--accent);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.file-name:hover { text-decoration: underline; }
|
||||
|
||||
.modal__body {
|
||||
padding: var(--sp-5);
|
||||
overflow-y: auto;
|
||||
@@ -759,7 +1006,10 @@
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
border-radius: 5px;
|
||||
background: var(--surface-0);
|
||||
/* Dunkler als die Karte -- genau das war die Absicht: "dunkler heisst,
|
||||
es gibt das Recht nicht". Mit dem nicht existierenden --surface-0 war
|
||||
das Feld durchsichtig und damit so hell wie die Karte. */
|
||||
background: var(--canvas);
|
||||
border: 1px solid var(--border-subtle);
|
||||
box-shadow: inset 0 1px 2px rgb(0 0 0 / 28%);
|
||||
vertical-align: -3px;
|
||||
@@ -853,9 +1103,15 @@
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: var(--sp-6);
|
||||
/* Zwei warme Kreise, Geometrie und Staerke aus der Fassung 1.x
|
||||
uebernommen: oben links deutlich, unten rechts angedeutet. Die vorige
|
||||
Fassung setzte den zweiten Kreis auf --info und 10 % -- ein kaltes
|
||||
Blau, das man auf dunklem Grund nicht sah und das nicht zur Marke
|
||||
gehoert. Ueber color-mix statt festem rgba, damit es im hellen Thema
|
||||
nicht schmutzig wird. */
|
||||
background:
|
||||
radial-gradient(1100px 520px at 12% -10%, color-mix(in srgb, var(--accent) 12%, transparent), transparent 70%),
|
||||
radial-gradient(900px 480px at 100% 110%, color-mix(in srgb, var(--info) 10%, transparent), transparent 70%),
|
||||
radial-gradient(circle at 15% 15%, color-mix(in srgb, var(--accent) 14%, transparent), transparent 45%),
|
||||
radial-gradient(circle at 85% 85%, color-mix(in srgb, var(--accent) 8%, transparent), transparent 40%),
|
||||
var(--canvas);
|
||||
}
|
||||
/* Die Wortmarke liegt hinter allem (z-index 0), die Anmeldebox davor.
|
||||
@@ -936,24 +1192,35 @@
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
.auth-page { position: relative; overflow: hidden; align-content: center; }
|
||||
/* Marke und Karte stehen uebereinander im normalen Fluss. Vorher lag die
|
||||
Marke absolut positioniert hinter der Karte -- auf einem flachen Fenster
|
||||
schnitt die Karte mitten durch den Schriftzug und die Funktionszeile. Ein
|
||||
Stapel kann nicht kollidieren, und die Seite darf notfalls scrollen. */
|
||||
.auth-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--sp-8);
|
||||
width: 100%;
|
||||
}
|
||||
.auth-backdrop {
|
||||
position: absolute;
|
||||
top: 4vh;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: min(72vw, 880px);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
opacity: 0.5;
|
||||
/* Volle Deckung: solange die Marke hinter der Karte lag, war sie ein
|
||||
Wasserzeichen und musste zurueckhaltend sein. Jetzt steht sie ueber der
|
||||
Karte und ist das Erste, was man sieht -- halbtransparent sah sie dort
|
||||
nur blass aus. Die Farben kommen ohnehin aus zurueckhaltenden Token. */
|
||||
opacity: 1;
|
||||
}
|
||||
.auth-backdrop svg { width: 100%; height: auto; display: block; }
|
||||
@media (max-width: 640px) {
|
||||
.auth-backdrop { width: min(90vw, 460px); opacity: 0.32; }
|
||||
.auth-backdrop { width: min(90vw, 460px); }
|
||||
}
|
||||
/* Wer wenig Bewegung/Reize moechte, bekommt das Motiv dezenter. */
|
||||
/* Wer weniger Reize moechte, bekommt die Marke ruhiger -- aber nicht
|
||||
unsichtbar: sie ist die Ueberschrift der Seite. */
|
||||
@media (prefers-contrast: more) {
|
||||
.auth-backdrop { opacity: 0.18; }
|
||||
.auth-backdrop { opacity: 0.75; }
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
@@ -1001,6 +1268,16 @@
|
||||
.warning-text { color: var(--warning); }
|
||||
.accent-text { color: var(--accent); }
|
||||
.mono { font-family: var(--font-mono); font-size: 0.94em; }
|
||||
/* Ein Inline-SVG ist im Reset display:block (base.css). In einem Knopf
|
||||
stoert das nicht -- .btn ist inline-flex. In einem einfachen Link reisst
|
||||
es die Beschriftung in die naechste Zeile. Fuer solche Links deshalb
|
||||
ausdruecklich eine Zeile. */
|
||||
.link-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.nowrap { white-space: nowrap; }
|
||||
.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
|
||||
.break-all { overflow-wrap: anywhere; }
|
||||
|
||||
@@ -28,7 +28,7 @@ function store(key, value) {
|
||||
if (value === null) localStorage.removeItem(key);
|
||||
else localStorage.setItem(key, value);
|
||||
} catch {
|
||||
/* Privatmodus o. Ä. -- Einstellungen sind dann nur fluechtig. */
|
||||
/* Privatmodus o. Ae. -- Einstellungen sind dann nur fluechtig. */
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,22 +45,45 @@ function csrfToken() {
|
||||
return document.body?.dataset.csrf || "";
|
||||
}
|
||||
|
||||
/** fetch mit CSRF-Header und JSON-Erwartung. */
|
||||
/**
|
||||
* fetch mit CSRF-Header und JSON-Erwartung.
|
||||
*
|
||||
* `auto: true` kennzeichnet eine Anfrage, die ein Zeitgeber ausgeloest hat und
|
||||
* kein Mensch. Der Server verlaengert das Leerlauffenster der Sitzung dann
|
||||
* nicht -- ohne diese Unterscheidung haelt jeder offene Tab die Anmeldung
|
||||
* unbegrenzt am Leben, und die automatische Abmeldung findet nie statt.
|
||||
*
|
||||
* Der HTTP-Status haengt am geworfenen Fehler, damit der Aufrufer 401
|
||||
* ("Sitzung abgelaufen") von einem Netzfehler unterscheiden kann.
|
||||
*/
|
||||
export async function api(url, options = {}) {
|
||||
const opts = { credentials: "same-origin", ...options };
|
||||
const { auto = false, ...rest } = options;
|
||||
const opts = { credentials: "same-origin", ...rest };
|
||||
opts.headers = {
|
||||
"X-Requested-With": "fetch",
|
||||
"X-CSRF-Token": csrfToken(),
|
||||
...(auto ? { "X-Auto-Refresh": "1" } : {}),
|
||||
...(options.headers || {}),
|
||||
};
|
||||
const response = await fetch(url, opts);
|
||||
// Eine Seite, die auch oeffentlich antwortet -- die Statusuebersicht etwa --
|
||||
// liefert nach dem Ablauf der Sitzung eine 200 mit den oeffentlichen
|
||||
// Kacheln. Am Status ist der Verlust also nicht zu erkennen; an diesem Kopf
|
||||
// schon. Behandelt wird er wie eine 401.
|
||||
if (response.headers.get("X-Session-Expired") === "1") {
|
||||
const abgelaufen = new Error("Die Sitzung ist abgelaufen.");
|
||||
abgelaufen.status = 401;
|
||||
throw abgelaufen;
|
||||
}
|
||||
const type = response.headers.get("content-type") || "";
|
||||
const payload = type.includes("application/json") ? await response.json() : await response.text();
|
||||
if (!response.ok) {
|
||||
const message =
|
||||
(payload && typeof payload === "object" && (payload.error || payload.message)) ||
|
||||
`Anfrage fehlgeschlagen (HTTP ${response.status})`;
|
||||
throw new Error(message);
|
||||
const error = new Error(message);
|
||||
error.status = response.status;
|
||||
throw error;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
@@ -212,13 +235,21 @@ function initDialogs() {
|
||||
event.preventDefault();
|
||||
const dialog = document.getElementById(opener.dataset.dialogOpen);
|
||||
if (dialog) {
|
||||
// Felder aus data-fill-* des Auslösers in das Formular uebernehmen.
|
||||
// Felder aus data-fill-* des Ausloesers in das Formular uebernehmen.
|
||||
Object.entries(opener.dataset).forEach(([key, value]) => {
|
||||
if (!key.startsWith("fill")) return;
|
||||
const name = key.slice(4);
|
||||
const target = dialog.querySelector(`[data-fill-target="${name}"]`);
|
||||
if (!target) return;
|
||||
if ("value" in target) target.value = value;
|
||||
// Wohin der Wert gehoert, haengt am Element: ein Rahmen will die
|
||||
// Quelle, ein Link das Ziel, ein Feld einen Wert. Ohne diese
|
||||
// Unterscheidung landete die URL als *Beschriftung* im
|
||||
// Herunterladen-Knopf -- und weil das href dabei "#" blieb, lud ein
|
||||
// Klick die Seite selbst als "Herunterladen.htm" herunter.
|
||||
const als = target.dataset.fillAs;
|
||||
if (als === "src") target.src = value;
|
||||
else if (als === "href") target.href = value;
|
||||
else if ("value" in target) target.value = value;
|
||||
else target.textContent = value;
|
||||
});
|
||||
openDialog(dialog);
|
||||
@@ -548,7 +579,10 @@ function initAutoRefresh() {
|
||||
const tick = async () => {
|
||||
if (stopped || document.hidden) return schedule();
|
||||
try {
|
||||
const html = await api(region.dataset.refreshUrl, { headers: { Accept: "text/html" } });
|
||||
const html = await api(region.dataset.refreshUrl, {
|
||||
auto: true,
|
||||
headers: { Accept: "text/html" },
|
||||
});
|
||||
// Eine vollstaendige Seite gehoert nie in einen Ausschnitt. Zeigt die
|
||||
// URL versehentlich auf eine ganze Seite, waere das Ergebnis eine
|
||||
// Seite in der Seite -- bei jedem Durchlauf eine Ebene tiefer.
|
||||
@@ -563,7 +597,8 @@ function initAutoRefresh() {
|
||||
// von innerHTML entsteht ein neues Element -- mit scrollTop 0. Genau
|
||||
// deshalb sprang ein laufendes Auftragsprotokoll bei jeder
|
||||
// Aktualisierung an den Anfang zurueck.
|
||||
const scrollerOf = (el) => el.querySelector(".log-view, [data-scroll]") || el;
|
||||
const scrollerOf = (el) =>
|
||||
el.querySelector("[data-tail], .log-view, [data-scroll]") || el;
|
||||
const before = scrollerOf(region);
|
||||
const position = before.scrollTop;
|
||||
// "Am Ende" grosszuegig auslegen: wer mitliest, soll mitgezogen
|
||||
@@ -579,7 +614,16 @@ function initAutoRefresh() {
|
||||
initRelativeTimes(region);
|
||||
initCountdowns(region);
|
||||
}
|
||||
} catch {
|
||||
} catch (error) {
|
||||
// 401 heisst: die Sitzung ist abgelaufen. Weiterpollen waere sinnlos,
|
||||
// und stehenbleiben waere irrefuehrend -- die Kacheln zeigten dann
|
||||
// beliebig alte Zahlen, als sei alles in Ordnung. Neu laden bringt die
|
||||
// Anmeldemaske mit ihrem Hinweis.
|
||||
if (error && error.status === 401) {
|
||||
stopped = true;
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
/* Netzfehler: beim naechsten Intervall erneut versuchen. */
|
||||
}
|
||||
schedule();
|
||||
@@ -814,6 +858,25 @@ function initTileFilters() {
|
||||
|
||||
/* -- Start ---------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Protokollansichten stehen am Ende, nicht am Anfang.
|
||||
*
|
||||
* Bei einem Protokoll ist die neueste Zeile die interessante, und die steht
|
||||
* unten. Ohne diesen Anfangssprung blieb die Ansicht oben stehen -- und weil
|
||||
* die Aktualisierung nur mitzieht, wenn man ohnehin schon am Ende ist, zog
|
||||
* sie danach nie mit. Es sah aus, als waere der Autoscroll kaputt; in
|
||||
* Wirklichkeit stand man nur nie an der Stelle, an der er greift.
|
||||
*
|
||||
* Ausdruecklich per ``data-tail`` markiert: eine Konfigurationsvorschau
|
||||
* (nginx, Kea) und der Offline-Code sind auch <pre class="log-view">, sollen
|
||||
* aber oben beginnen.
|
||||
*/
|
||||
function initLogTails(root = document) {
|
||||
root.querySelectorAll("[data-tail]").forEach((view) => {
|
||||
view.scrollTop = view.scrollHeight;
|
||||
});
|
||||
}
|
||||
|
||||
function boot() {
|
||||
initTheme();
|
||||
initSidebar();
|
||||
@@ -830,7 +893,22 @@ function boot() {
|
||||
initPalette();
|
||||
initSubmitGuards();
|
||||
initTileFilters();
|
||||
initLogTails();
|
||||
unsavedGuard.init();
|
||||
initViewer();
|
||||
}
|
||||
|
||||
/* Der Dateibetrachter kommt nur auf Seiten mit Vorschau -- und er zieht bei
|
||||
einem PDF noch pdf.js nach. Beides erst dann, wenn es gebraucht wird. */
|
||||
function initViewer() {
|
||||
if (!document.querySelector("[data-viewer]")) return;
|
||||
// Dieselbe Version wie diese Datei: app.js wird als ".../app.js?v=<Zeit>"
|
||||
// eingebunden, und nginx liefert /core-assets/ mit "expires 7d". Ohne das
|
||||
// Anhaengsel bliebe der alte Betrachter nach einem Update im Cache liegen.
|
||||
const version = new URL(import.meta.url).search;
|
||||
import(`./viewer.js${version}`)
|
||||
.then((modul) => modul.initFilePreview())
|
||||
.catch((fehler) => console.error("Dateibetrachter", fehler));
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
|
||||
@@ -0,0 +1,647 @@
|
||||
/* =============================================================================
|
||||
* Dateivorschau: Bilder zoomen, PDF-Formulare ausfuellen.
|
||||
*
|
||||
* Vorher lief jede Vorschau durch einen <iframe> -- der Browser entschied
|
||||
* selbst. Zwei Dinge gingen dabei schief:
|
||||
*
|
||||
* * Ein Foto vom iPad ist 4032 Pixel breit. Der Rahmen zeigte es 1:1, also
|
||||
* einen Ausschnitt aus der Mitte, und zoomen liess sich nichts.
|
||||
* * Ein PDF im Rahmen ist auf iPadOS nur ein Standbild der ersten Seite.
|
||||
* Formularfelder sind dort nicht anfassbar.
|
||||
*
|
||||
* Jetzt entscheidet die Art der Datei (bild | pdf | text), welche Anzeige
|
||||
* aufgeht. Bilder bekommen einen eigenen Betrachter mit Zoom und Verschieben
|
||||
* (Finger, Rad, Knoepfe); PDFs werden von pdf.js gerendert -- damit sind
|
||||
* Formularfelder echte Eingabefelder, auch in Safari.
|
||||
*
|
||||
* pdf.js liegt im Paket (static/vendor/pdfjs) und wird erst geladen, wenn
|
||||
* wirklich ein PDF geoeffnet wird: 1,7 MB gehoeren nicht in jeden Seitenaufruf.
|
||||
*
|
||||
* Die Dateien heissen ".js" und nicht ".mjs": die mime.types von nginx kennen
|
||||
* ".mjs" nicht und liefern "application/octet-stream" -- damit lehnt jeder
|
||||
* Browser ein Modul ab, und zwar ohne dass der Benutzer erfaehrt, warum. Am
|
||||
* Inhalt aendert die Endung nichts.
|
||||
* ========================================================================== */
|
||||
|
||||
const MIN_ZOOM = 0.1;
|
||||
const MAX_ZOOM = 8;
|
||||
|
||||
/* -- Bilder ---------------------------------------------------------------- */
|
||||
|
||||
/** Zoom und Verschieben fuer ein einzelnes Bild. */
|
||||
function bildBetrachter(buehne, bild) {
|
||||
let zoom = 1;
|
||||
let x = 0;
|
||||
let y = 0;
|
||||
let angepasst = true; // noch nie von Hand gezoomt -> passt sich der Flaeche an
|
||||
|
||||
const anwenden = () => {
|
||||
bild.style.transform = `translate(${x}px, ${y}px) scale(${zoom})`;
|
||||
buehne.classList.toggle("viewer__stage--gezogen", zoom > 1);
|
||||
buehne.dispatchEvent(new CustomEvent("zoom", { detail: { zoom, angepasst } }));
|
||||
};
|
||||
|
||||
const grenzen = () => {
|
||||
// Nicht aus dem Bild herausschieben: was ueber die Buehne hinausragt,
|
||||
// darf verschoben werden, mehr nicht.
|
||||
const b = buehne.getBoundingClientRect();
|
||||
const breite = (bild.clientWidth * zoom - b.width) / 2;
|
||||
const hoehe = (bild.clientHeight * zoom - b.height) / 2;
|
||||
x = Math.min(Math.max(x, -Math.max(0, breite)), Math.max(0, breite));
|
||||
y = Math.min(Math.max(y, -Math.max(0, hoehe)), Math.max(0, hoehe));
|
||||
};
|
||||
|
||||
const setzen = (wert, { fest = false } = {}) => {
|
||||
zoom = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, wert));
|
||||
angepasst = !fest && Math.abs(zoom - 1) < 0.001;
|
||||
if (zoom <= 1) {
|
||||
x = 0;
|
||||
y = 0;
|
||||
} else {
|
||||
grenzen();
|
||||
}
|
||||
anwenden();
|
||||
};
|
||||
|
||||
const zuruecksetzen = () => {
|
||||
zoom = 1;
|
||||
x = 0;
|
||||
y = 0;
|
||||
angepasst = true;
|
||||
anwenden();
|
||||
};
|
||||
|
||||
/* Ziehen mit Maus oder einem Finger, sobald hineingezoomt ist. */
|
||||
let zieht = null;
|
||||
buehne.addEventListener("pointerdown", (event) => {
|
||||
if (zoom <= 1 || event.pointerType === "touch" && event.isPrimary === false) return;
|
||||
zieht = { id: event.pointerId, x: event.clientX - x, y: event.clientY - y };
|
||||
buehne.setPointerCapture(event.pointerId);
|
||||
});
|
||||
buehne.addEventListener("pointermove", (event) => {
|
||||
if (!zieht || event.pointerId !== zieht.id) return;
|
||||
event.preventDefault();
|
||||
x = event.clientX - zieht.x;
|
||||
y = event.clientY - zieht.y;
|
||||
grenzen();
|
||||
anwenden();
|
||||
});
|
||||
const loslassen = (event) => {
|
||||
if (zieht && event.pointerId === zieht.id) zieht = null;
|
||||
};
|
||||
buehne.addEventListener("pointerup", loslassen);
|
||||
buehne.addEventListener("pointercancel", loslassen);
|
||||
|
||||
/* Zwei Finger: aufziehen und zusammenziehen. Der Dialog liegt ueber der
|
||||
Seite, deren eigenes Zoomen hier nicht greift -- also selbst rechnen. */
|
||||
let start = null;
|
||||
const abstand = (t) => Math.hypot(t[0].clientX - t[1].clientX, t[0].clientY - t[1].clientY);
|
||||
buehne.addEventListener(
|
||||
"touchstart",
|
||||
(event) => {
|
||||
if (event.touches.length !== 2) return;
|
||||
start = { d: abstand(event.touches), zoom };
|
||||
},
|
||||
{ passive: true }
|
||||
);
|
||||
buehne.addEventListener(
|
||||
"touchmove",
|
||||
(event) => {
|
||||
if (!start || event.touches.length !== 2) return;
|
||||
event.preventDefault();
|
||||
setzen((start.zoom * abstand(event.touches)) / start.d, { fest: true });
|
||||
},
|
||||
{ passive: false }
|
||||
);
|
||||
buehne.addEventListener("touchend", () => {
|
||||
start = null;
|
||||
});
|
||||
|
||||
/* Rad zoomt, Umschalt oder Strg nicht noetig -- im Dialog gibt es nichts
|
||||
anderes zu scrollen. */
|
||||
buehne.addEventListener(
|
||||
"wheel",
|
||||
(event) => {
|
||||
event.preventDefault();
|
||||
setzen(zoom * (event.deltaY < 0 ? 1.12 : 1 / 1.12), { fest: true });
|
||||
},
|
||||
{ passive: false }
|
||||
);
|
||||
|
||||
/* Doppeltippen: zwischen angepasst und doppelt umschalten. */
|
||||
buehne.addEventListener("dblclick", () => setzen(zoom > 1 ? 1 : 2, { fest: zoom <= 1 }));
|
||||
|
||||
return {
|
||||
hinein: () => setzen(zoom * 1.25, { fest: true }),
|
||||
hinaus: () => setzen(zoom / 1.25, { fest: true }),
|
||||
anpassen: zuruecksetzen,
|
||||
zuruecksetzen,
|
||||
get zoom() {
|
||||
return zoom;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/* -- Bildfelder in Formularen ---------------------------------------------- */
|
||||
|
||||
//: Groesser muss ein Foto in einem Formular nicht sein.
|
||||
const FOTO_KANTE = 1600;
|
||||
|
||||
/** Macht aus einer gewaehlten Datei ein JPEG in vertretbarer Groesse.
|
||||
*
|
||||
* Ueber eine Leinwand, nicht roh: damit wird aus allem, was der Browser
|
||||
* anzeigen kann, ein JPEG -- auf einem iPad also auch aus HEIC, das pdf-lib
|
||||
* nicht kennt. Und ein 12-Megapixel-Foto blaeht das Formular nicht auf.
|
||||
*/
|
||||
async function fotoAlsJpeg(datei) {
|
||||
// Keine Objektadresse: die CSP erlaubt Bilder nur von "self" und als data:.
|
||||
// createImageBitmap nimmt die Datei unmittelbar; wo es fehlt, tut es eine
|
||||
// data:-Adresse.
|
||||
let bild;
|
||||
if (typeof createImageBitmap === "function") {
|
||||
try {
|
||||
bild = await createImageBitmap(datei);
|
||||
} catch {
|
||||
bild = null;
|
||||
}
|
||||
}
|
||||
if (!bild) {
|
||||
const daten = await new Promise((fertig, schiefgegangen) => {
|
||||
const leser = new FileReader();
|
||||
leser.onload = () => fertig(String(leser.result));
|
||||
leser.onerror = () => schiefgegangen(new Error("Die Datei liess sich nicht lesen."));
|
||||
leser.readAsDataURL(datei);
|
||||
});
|
||||
bild = await new Promise((fertig, schiefgegangen) => {
|
||||
const el = new Image();
|
||||
el.onload = () => fertig(el);
|
||||
el.onerror = () => schiefgegangen(new Error("Dieses Bild kann der Browser nicht anzeigen."));
|
||||
el.src = daten;
|
||||
});
|
||||
}
|
||||
const breiteRoh = bild.width || bild.naturalWidth;
|
||||
const hoeheRoh = bild.height || bild.naturalHeight;
|
||||
const faktor = Math.min(1, FOTO_KANTE / Math.max(breiteRoh, hoeheRoh));
|
||||
const leinwand = document.createElement("canvas");
|
||||
leinwand.width = Math.max(1, Math.round(breiteRoh * faktor));
|
||||
leinwand.height = Math.max(1, Math.round(hoeheRoh * faktor));
|
||||
leinwand.getContext("2d").drawImage(bild, 0, 0, leinwand.width, leinwand.height);
|
||||
bild.close?.();
|
||||
const klecks = await new Promise((fertig) => leinwand.toBlob(fertig, "image/jpeg", 0.85));
|
||||
return {
|
||||
bytes: new Uint8Array(await klecks.arrayBuffer()),
|
||||
breite: leinwand.width,
|
||||
hoehe: leinwand.height,
|
||||
vorschau: leinwand.toDataURL("image/jpeg", 0.6),
|
||||
};
|
||||
}
|
||||
|
||||
let pdflibGeladen = null;
|
||||
|
||||
/** pdf-lib nachladen -- nur wenn wirklich ein Bild eingesetzt wird. */
|
||||
async function pdflibLaden(adresse) {
|
||||
if (!pdflibGeladen) {
|
||||
pdflibGeladen = new Promise((fertig, schiefgegangen) => {
|
||||
const skript = document.createElement("script");
|
||||
skript.src = adresse;
|
||||
skript.onload = () => fertig(window.PDFLib);
|
||||
skript.onerror = () => {
|
||||
pdflibGeladen = null;
|
||||
schiefgegangen(new Error("pdf-lib liess sich nicht laden."));
|
||||
};
|
||||
document.head.append(skript);
|
||||
});
|
||||
}
|
||||
return pdflibGeladen;
|
||||
}
|
||||
|
||||
/** Stempelt die gewaehlten Fotos in die Bildfelder.
|
||||
*
|
||||
* Eingabe sind die Bytes, die pdf.js nach dem Ausfuellen geschrieben hat --
|
||||
* die Formularwerte bleiben also erhalten.
|
||||
*/
|
||||
async function fotosEinsetzen(bytes, bilder, adresse) {
|
||||
const PDFLib = await pdflibLaden(adresse);
|
||||
const dokument = await PDFLib.PDFDocument.load(bytes);
|
||||
const formular = dokument.getForm();
|
||||
for (const eintrag of bilder) {
|
||||
const seite = dokument.getPage(eintrag.seite - 1);
|
||||
const jpeg = await dokument.embedJpg(eintrag.foto.bytes);
|
||||
const [x1, y1, x2, y2] = eintrag.rechteck;
|
||||
const kastenBreite = Math.abs(x2 - x1);
|
||||
const kastenHoehe = Math.abs(y2 - y1);
|
||||
// Einpassen statt verzerren: das Seitenverhaeltnis des Fotos bleibt.
|
||||
const faktor = Math.min(kastenBreite / jpeg.width, kastenHoehe / jpeg.height);
|
||||
const breite = jpeg.width * faktor;
|
||||
const hoehe = jpeg.height * faktor;
|
||||
seite.drawImage(jpeg, {
|
||||
x: Math.min(x1, x2) + (kastenBreite - breite) / 2,
|
||||
y: Math.min(y1, y2) + (kastenHoehe - hoehe) / 2,
|
||||
width: breite,
|
||||
height: hoehe,
|
||||
});
|
||||
// Den Knopf aus der fertigen Kopie nehmen: seine Flaeche liegt ueber dem
|
||||
// Seiteninhalt und wuerde das Foto verdecken. Zu tun haette er dort auch
|
||||
// nichts mehr -- seine Aktion fuehrt kein Browser aus, und das Bild steht
|
||||
// jetzt in der Seite. Die Vorlage bleibt unberuehrt.
|
||||
try {
|
||||
const feld = formular.getFieldMaybe(eintrag.feldname);
|
||||
if (feld) formular.removeField(feld);
|
||||
} catch (fehler) {
|
||||
// Bleibt der Knopf liegen, ist das Foto verdeckt -- aber die Datei ist
|
||||
// heil. Das ist der bessere von zwei schlechten Ausgaengen.
|
||||
console.warn("Bildknopf liess sich nicht entfernen", fehler);
|
||||
}
|
||||
}
|
||||
return dokument.save();
|
||||
}
|
||||
|
||||
/* -- PDF ------------------------------------------------------------------- */
|
||||
|
||||
let pdfjsGeladen = null;
|
||||
|
||||
/** Die Adressen der pdf.js-Dateien -- alle mit derselben Version.
|
||||
*
|
||||
* Uebergeben wird die Adresse von pdf.min.js samt "?v=<Zeit>"; die
|
||||
* Geschwister liegen daneben. Ohne die Version bliebe nach einem Update die
|
||||
* alte Fassung im Cache von nginx ("expires 7d") haengen.
|
||||
*/
|
||||
function pdfjsAdressen(adresse) {
|
||||
const url = new URL(adresse, location.href);
|
||||
const verzeichnis = url.pathname.replace(/[^/]*$/, "");
|
||||
const mit = (datei) => `${url.origin}${verzeichnis}${datei}${url.search}`;
|
||||
return {
|
||||
lib: mit("pdf.min.js"),
|
||||
komponenten: mit("pdf_viewer.js"),
|
||||
worker: mit("pdf.worker.min.js"),
|
||||
stil: mit("pdf_viewer.css"),
|
||||
schriften: `${url.origin}${verzeichnis}standard_fonts/`,
|
||||
};
|
||||
}
|
||||
|
||||
/** Laedt pdf.js einmalig nach; das Paket liegt neben dieser Datei. */
|
||||
async function pdfjsLaden(basis) {
|
||||
const adressen = pdfjsAdressen(basis);
|
||||
if (!pdfjsGeladen) {
|
||||
pdfjsGeladen = (async () => {
|
||||
const lib = await import(adressen.lib);
|
||||
// Der Komponentenbau von pdf.js sucht die Bibliothek unter diesem Namen.
|
||||
globalThis.pdfjsLib = lib;
|
||||
const komponenten = await import(adressen.komponenten);
|
||||
// Das Stylesheet von pdf.js zeichnet die Formularfelder. 100 KB gehoeren
|
||||
// nicht in jeden Seitenaufruf -- also erst hier.
|
||||
const stil = document.createElement("link");
|
||||
stil.rel = "stylesheet";
|
||||
stil.href = adressen.stil;
|
||||
document.head.append(stil);
|
||||
lib.GlobalWorkerOptions.workerSrc = adressen.worker;
|
||||
return { lib, komponenten };
|
||||
})().catch((fehler) => {
|
||||
pdfjsGeladen = null;
|
||||
throw fehler;
|
||||
});
|
||||
}
|
||||
return pdfjsGeladen;
|
||||
}
|
||||
|
||||
/** Zeigt ein PDF mit ausfuellbaren Formularfeldern. */
|
||||
async function pdfBetrachter(behaelter, seiten, url, basis) {
|
||||
const { lib, komponenten } = await pdfjsLaden(basis);
|
||||
const adressen = pdfjsAdressen(basis);
|
||||
const eventBus = new komponenten.EventBus();
|
||||
const linkService = new komponenten.PDFLinkService({ eventBus });
|
||||
const viewer = new komponenten.PDFViewer({
|
||||
container: behaelter,
|
||||
viewer: seiten,
|
||||
eventBus,
|
||||
linkService,
|
||||
// Der Kern der Sache: Formularfelder werden zu echten Eingabefeldern.
|
||||
annotationMode: lib.AnnotationMode.ENABLE_FORMS,
|
||||
textLayerMode: 1,
|
||||
});
|
||||
linkService.setViewer(viewer);
|
||||
|
||||
const dokument = await lib.getDocument({
|
||||
url,
|
||||
// Ohne die Standardschriften bleibt jede Seite mit Text leer: Helvetica
|
||||
// und Verwandte liegen als Datei im Paket, nicht im Browser.
|
||||
standardFontDataUrl: adressen.schriften,
|
||||
isEvalSupported: false,
|
||||
}).promise;
|
||||
viewer.setDocument(dokument);
|
||||
linkService.setDocument(dokument, null);
|
||||
await new Promise((fertig) => eventBus.on("pagesinit", fertig, { once: true }));
|
||||
// Auf einem iPad ist die Seitenbreite das, was man sehen will.
|
||||
viewer.currentScaleValue = "page-width";
|
||||
|
||||
/* Zwei Finger zoomen auch hier. */
|
||||
let start = null;
|
||||
const abstand = (t) => Math.hypot(t[0].clientX - t[1].clientX, t[0].clientY - t[1].clientY);
|
||||
behaelter.addEventListener(
|
||||
"touchstart",
|
||||
(event) => {
|
||||
if (event.touches.length === 2) start = { d: abstand(event.touches), s: viewer.currentScale };
|
||||
},
|
||||
{ passive: true }
|
||||
);
|
||||
behaelter.addEventListener(
|
||||
"touchmove",
|
||||
(event) => {
|
||||
if (!start || event.touches.length !== 2) return;
|
||||
event.preventDefault();
|
||||
const wert = (start.s * abstand(event.touches)) / start.d;
|
||||
viewer.currentScale = Math.min(MAX_ZOOM, Math.max(0.25, wert));
|
||||
},
|
||||
{ passive: false }
|
||||
);
|
||||
behaelter.addEventListener("touchend", () => {
|
||||
start = null;
|
||||
});
|
||||
|
||||
/* Bildknoepfe: eigene Schaltflaeche darueber, denn Acrobats Aktion
|
||||
("buttonImportIcon") fuehrt kein Browser aus. Gesammelt wird, was beim
|
||||
Speichern in die Datei gestempelt werden soll. */
|
||||
const bilder = new Map();
|
||||
|
||||
const bildknoepfeAufsetzen = async (nurSeite) => {
|
||||
for (let nummer = 1; nummer <= dokument.numPages; nummer++) {
|
||||
if (nurSeite && nummer !== nurSeite) continue;
|
||||
const seite = await dokument.getPage(nummer);
|
||||
const annots = await seite.getAnnotations({ intent: "display" });
|
||||
for (const annot of annots) {
|
||||
const quelltext = (annot.actions?.Action || []).join(" ");
|
||||
if (!quelltext.includes("buttonImportIcon")) continue;
|
||||
const kasten = behaelter.querySelector(
|
||||
`section[data-annotation-id="${annot.id}"]`
|
||||
);
|
||||
if (!kasten || kasten.querySelector("[data-viewer-bildwahl]")) continue;
|
||||
kasten.classList.add("viewer__bildfeld");
|
||||
const knopf = document.createElement("label");
|
||||
knopf.className = "viewer__bildwahl";
|
||||
knopf.setAttribute("data-viewer-bildwahl", "");
|
||||
knopf.innerHTML =
|
||||
'<input type="file" accept="image/*" hidden>' +
|
||||
"<span>Bild einsetzen</span>";
|
||||
const feld = knopf.querySelector("input");
|
||||
feld.addEventListener("change", async () => {
|
||||
const datei = feld.files?.[0];
|
||||
if (!datei) return;
|
||||
try {
|
||||
const foto = await fotoAlsJpeg(datei);
|
||||
bilder.set(annot.id, {
|
||||
seite: nummer,
|
||||
rechteck: annot.rect,
|
||||
feldname: annot.fieldName,
|
||||
foto,
|
||||
});
|
||||
// Als eigenes Bild und nicht als Hintergrund: der Kasten traegt
|
||||
// eine Leinwand von pdf.js (das Aussehen des Knopfes aus dem PDF),
|
||||
// und die liegt ueber jedem Hintergrund.
|
||||
let vorschau = kasten.querySelector("[data-viewer-bildvorschau]");
|
||||
if (!vorschau) {
|
||||
vorschau = document.createElement("img");
|
||||
vorschau.setAttribute("data-viewer-bildvorschau", "");
|
||||
vorschau.alt = "";
|
||||
kasten.append(vorschau);
|
||||
}
|
||||
vorschau.src = foto.vorschau;
|
||||
kasten.classList.add("viewer__bildfeld--belegt");
|
||||
knopf.querySelector("span").textContent = "Anderes Bild";
|
||||
} catch (fehler) {
|
||||
window.tesm.showToast(fehler.message || "Bild nicht lesbar.", "danger");
|
||||
}
|
||||
});
|
||||
kasten.append(knopf);
|
||||
}
|
||||
}
|
||||
};
|
||||
// Die Knoepfe koennen erst auf eine gerenderte Anmerkungsebene -- die
|
||||
// entsteht je Seite, wenn sie sichtbar wird.
|
||||
eventBus.on("annotationlayerrendered", (ereignis) => {
|
||||
bildknoepfeAufsetzen(ereignis.pageNumber).catch((fehler) =>
|
||||
console.error("Bildfelder", fehler)
|
||||
);
|
||||
});
|
||||
await bildknoepfeAufsetzen();
|
||||
|
||||
return {
|
||||
hinein: () => {
|
||||
viewer.currentScale = Math.min(MAX_ZOOM, viewer.currentScale * 1.2);
|
||||
},
|
||||
hinaus: () => {
|
||||
viewer.currentScale = Math.max(0.25, viewer.currentScale / 1.2);
|
||||
},
|
||||
anpassen: () => {
|
||||
viewer.currentScaleValue = "page-width";
|
||||
},
|
||||
/** Das ausgefuellte Formular als Bytes.
|
||||
*
|
||||
* pdf.js schreibt die Feldwerte; gewaehlte Fotos kommen danach mit
|
||||
* pdf-lib hinein -- pdf.js kann nichts in ein PDF zeichnen.
|
||||
*/
|
||||
speichern: async (pdflibAdresse) => {
|
||||
const bytes = await dokument.saveDocument();
|
||||
if (!bilder.size) return bytes;
|
||||
return fotosEinsetzen(bytes, [...bilder.values()], pdflibAdresse);
|
||||
},
|
||||
/** Wurde ueberhaupt etwas eingetragen? */
|
||||
veraendert: () => {
|
||||
const s = dokument.annotationStorage;
|
||||
return Boolean(s && s.size);
|
||||
},
|
||||
schliessen: () => {
|
||||
viewer.setDocument(null);
|
||||
linkService.setDocument(null);
|
||||
dokument.destroy();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/* -- Zusammenbau ----------------------------------------------------------- */
|
||||
|
||||
export function initFilePreview(wurzel = document) {
|
||||
const rumpf = wurzel.querySelector("[data-viewer]");
|
||||
if (!rumpf) return;
|
||||
// Fusszeile und Titel liegen ausserhalb des Rumpfes -- gesucht wird deshalb
|
||||
// im ganzen Dialog. (Der Speichern-Knopf blieb sonst immer verborgen.)
|
||||
const dialog = rumpf.closest("dialog") || rumpf;
|
||||
|
||||
const stueck = (name) => dialog.querySelector(`[data-viewer-${name}]`);
|
||||
const bildBuehne = stueck("bildbuehne");
|
||||
const bild = stueck("bild");
|
||||
const pdfBehaelter = stueck("pdf");
|
||||
const pdfScroll = stueck("pdfscroll");
|
||||
const pdfSeiten = stueck("pdfseiten");
|
||||
const rahmen = stueck("rahmen");
|
||||
const leiste = stueck("leiste");
|
||||
const hinweis = stueck("hinweis");
|
||||
const speichern = stueck("speichern");
|
||||
const zeile = stueck("speichernzeile");
|
||||
const ordnerFeld = stueck("ordner");
|
||||
const nameFeld = stueck("name");
|
||||
const ersetzenFeld = stueck("ersetzen");
|
||||
const jetzt = stueck("jetzt");
|
||||
const abbrechen = stueck("abbrechen");
|
||||
const basis = rumpf.dataset.viewerPdfjs || "";
|
||||
|
||||
// Ein Format, das dieser Browser nicht kann (HEIC ausserhalb von Safari),
|
||||
// soll nicht als leere Flaeche enden.
|
||||
bild.addEventListener("error", () => {
|
||||
if (bildBuehne.hidden || !bild.getAttribute("src")) return;
|
||||
if (hinweis) {
|
||||
hinweis.textContent =
|
||||
"Dieses Bildformat kann Ihr Browser nicht anzeigen -- bitte herunterladen.";
|
||||
}
|
||||
});
|
||||
|
||||
const bilder = bildBetrachter(bildBuehne, bild);
|
||||
bildBuehne.addEventListener("zoom", (event) => {
|
||||
if (!hinweis || bildBuehne.hidden) return;
|
||||
const { zoom, angepasst } = event.detail;
|
||||
hinweis.textContent = angepasst ? "eingepasst" : `${Math.round(zoom * 100)} %`;
|
||||
});
|
||||
let pdf = null;
|
||||
let aktuell = null;
|
||||
|
||||
const zeigen = (art) => {
|
||||
bildBuehne.hidden = art !== "bild";
|
||||
pdfBehaelter.hidden = art !== "pdf";
|
||||
rahmen.hidden = art !== "text";
|
||||
leiste.hidden = art === "text";
|
||||
if (speichern) speichern.hidden = art !== "pdf";
|
||||
};
|
||||
|
||||
const aufraeumen = () => {
|
||||
bild.removeAttribute("src");
|
||||
bilder.zuruecksetzen();
|
||||
rahmen.src = "about:blank";
|
||||
if (pdf) {
|
||||
try {
|
||||
pdf.schliessen();
|
||||
} catch {
|
||||
/* ein halb geladenes Dokument darf beim Schliessen nicht stoeren */
|
||||
}
|
||||
pdf = null;
|
||||
}
|
||||
pdfSeiten.replaceChildren();
|
||||
if (hinweis) hinweis.textContent = "";
|
||||
if (zeile) zeile.hidden = true;
|
||||
aktuell = null;
|
||||
};
|
||||
|
||||
const oeffnen = async (ausloeser) => {
|
||||
aufraeumen();
|
||||
const art = ausloeser.dataset.fillKind || "text";
|
||||
const quelle = ausloeser.dataset.fillSrc || "";
|
||||
aktuell = { art, quelle, pfad: ausloeser.dataset.fillPfad || "" };
|
||||
zeigen(art);
|
||||
if (art === "bild") {
|
||||
bild.src = quelle;
|
||||
return;
|
||||
}
|
||||
if (art !== "pdf") {
|
||||
rahmen.src = quelle;
|
||||
return;
|
||||
}
|
||||
if (hinweis) hinweis.textContent = "PDF wird geladen ...";
|
||||
try {
|
||||
pdf = await pdfBetrachter(pdfScroll, pdfSeiten, quelle, basis);
|
||||
if (hinweis) hinweis.textContent = "";
|
||||
} catch (fehler) {
|
||||
// Kein weisses Feld: sagen, was los ist, und auf die einfache Anzeige
|
||||
// zurueckfallen. Die kann wenigstens lesen, wenn auch nicht ausfuellen.
|
||||
console.error("pdf.js", fehler);
|
||||
zeigen("text");
|
||||
rahmen.src = quelle;
|
||||
if (hinweis) {
|
||||
// Den Grund mitgeben: auf einem fremden Geraet ist die Meldung des
|
||||
// Browsers die einzige Spur, die man hat.
|
||||
const grund = String(fehler?.message || fehler || "").slice(0, 120);
|
||||
hinweis.textContent =
|
||||
"Die Formularansicht liess sich nicht laden -- es bleibt bei der einfachen Anzeige." +
|
||||
(grund ? ` (${grund})` : "");
|
||||
// Die Leiste traegt den Hinweis. "zeigen('text')" hat sie gerade
|
||||
// verborgen -- ohne diese Zeile blieb der Fehlschlag unsichtbar, und
|
||||
// es sah aus, als koennte die Vorschau einfach keine Formulare.
|
||||
leiste.hidden = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("click", (event) => {
|
||||
const ausloeser = event.target.closest('[data-dialog-open="file-preview"]');
|
||||
if (ausloeser) {
|
||||
// Der Dialog selbst wird von app.js geoeffnet; hier nur der Inhalt.
|
||||
oeffnen(ausloeser);
|
||||
return;
|
||||
}
|
||||
const knopf = event.target.closest("[data-viewer-zoom]");
|
||||
if (!knopf) return;
|
||||
event.preventDefault();
|
||||
const betrachter = pdf || bilder;
|
||||
({ ein: betrachter.hinein, aus: betrachter.hinaus, fit: betrachter.anpassen })[
|
||||
knopf.dataset.viewerZoom
|
||||
]?.();
|
||||
});
|
||||
|
||||
dialog.addEventListener("close", aufraeumen);
|
||||
|
||||
/* Ausgefuelltes Formular zurueck in die Freigabe -- erst fragen, wohin. */
|
||||
|
||||
const vorschlagen = () => {
|
||||
if (!aktuell) return;
|
||||
const teile = aktuell.pfad.split("/");
|
||||
const datei = teile.pop() || "formular.pdf";
|
||||
ordnerFeld.value = teile.join("/");
|
||||
// Ohne Endung: die haengt der Server an. Eine Datei, die auf ".pdf.txt"
|
||||
// oder auf gar nichts endet, oeffnet auf einer Freigabe nichts mehr.
|
||||
// Der Zusatz haelt die Vorlage heil, auch wenn jemand blind speichert.
|
||||
nameFeld.value = datei.replace(/\.pdf$/i, "") + "-ausgefuellt";
|
||||
ersetzenFeld.checked = false;
|
||||
};
|
||||
|
||||
speichern?.addEventListener("click", () => {
|
||||
if (!pdf || !aktuell) return;
|
||||
vorschlagen();
|
||||
zeile.hidden = false;
|
||||
nameFeld.focus();
|
||||
nameFeld.select();
|
||||
});
|
||||
|
||||
abbrechen?.addEventListener("click", () => {
|
||||
zeile.hidden = true;
|
||||
});
|
||||
|
||||
jetzt?.addEventListener("click", async () => {
|
||||
if (!pdf || !aktuell) return;
|
||||
const ziel = speichern.dataset.url;
|
||||
jetzt.disabled = true;
|
||||
const vorher = jetzt.textContent;
|
||||
jetzt.textContent = "Speichern ...";
|
||||
try {
|
||||
const bytes = await pdf.speichern(rumpf.dataset.viewerPdflib || "");
|
||||
const daten = new FormData();
|
||||
daten.append("pfad", aktuell.pfad);
|
||||
daten.append("ordner", ordnerFeld.value.trim());
|
||||
daten.append("name", nameFeld.value.trim());
|
||||
if (ersetzenFeld.checked) daten.append("ersetzen", "1");
|
||||
daten.append(
|
||||
"datei",
|
||||
new Blob([bytes], { type: "application/pdf" }),
|
||||
`${nameFeld.value.trim() || "formular"}.pdf`
|
||||
);
|
||||
const antwort = await window.tesm.api(ziel, { method: "POST", body: daten });
|
||||
window.tesm.showToast(antwort.message || "Gespeichert.", "success");
|
||||
dialog.close();
|
||||
window.location.reload();
|
||||
} catch (fehler) {
|
||||
// Die Meldung fuer den Benutzer, die Einzelheiten fuer die Konsole. Die
|
||||
// Zeile bleibt offen: meist ist es ein Name, der schon vergeben ist.
|
||||
console.error("Formular speichern", fehler);
|
||||
window.tesm.showToast(fehler.message || "Speichern fehlgeschlagen.", "danger");
|
||||
} finally {
|
||||
jetzt.disabled = false;
|
||||
jetzt.textContent = vorher;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
pdf.js 4.10.38 -- https://github.com/mozilla/pdf.js
|
||||
Aus dem npm-Paket pdfjs-dist; nur build/pdf.min.mjs, build/pdf.worker.min.mjs,
|
||||
web/pdf_viewer.mjs, web/pdf_viewer.css, web/images und standard_fonts.
|
||||
Apache-2.0, siehe LICENSE.
|
||||
|
||||
Die drei Skriptdateien sind von .mjs auf .js umbenannt: die mime.types von
|
||||
nginx kennen .mjs nicht, und mit application/octet-stream lehnt jeder Browser
|
||||
ein Modul ab. Der Inhalt ist unveraendert.
|
||||
|
||||
Warum eingebettet und nicht von einem CDN: TESM laeuft in abgeschotteten
|
||||
Netzen. Was die Oberflaeche braucht, muss im Paket liegen.
|
||||
@@ -0,0 +1,177 @@
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
@@ -0,0 +1 @@
|
||||
4.10.38
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg width="12" height="13" viewBox="0 0 12 13" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5.375 7.625V11.875C5.375 12.0408 5.44085 12.1997 5.55806 12.3169C5.67527 12.4342 5.83424 12.5 6 12.5C6.16576 12.5 6.32473 12.4342 6.44194 12.3169C6.55915 12.1997 6.625 12.0408 6.625 11.875V7.625L7.125 7.125H11.375C11.5408 7.125 11.6997 7.05915 11.8169 6.94194C11.9342 6.82473 12 6.66576 12 6.5C12 6.33424 11.9342 6.17527 11.8169 6.05806C11.6997 5.94085 11.5408 5.875 11.375 5.875H7.125L6.625 5.375V1.125C6.625 0.95924 6.55915 0.800269 6.44194 0.683058C6.32473 0.565848 6.16576 0.5 6 0.5C5.83424 0.5 5.67527 0.565848 5.55806 0.683058C5.44085 0.800269 5.375 0.95924 5.375 1.125V5.375L4.875 5.875H0.625C0.45924 5.875 0.300269 5.94085 0.183058 6.05806C0.065848 6.17527 0 6.33424 0 6.5C0 6.66576 0.065848 6.82473 0.183058 6.94194C0.300269 7.05915 0.45924 7.125 0.625 7.125H4.762L5.375 7.625Z" fill="black"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 920 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="17" height="16" viewBox="0 0 17 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M3.49073 1.3015L3.30873 2.1505C3.29349 2.22246 3.25769 2.28844 3.20568 2.34045C3.15368 2.39246 3.08769 2.42826 3.01573 2.4435L2.16673 2.6255C1.76473 2.7125 1.76473 3.2865 2.16673 3.3725L3.01573 3.5555C3.08769 3.57074 3.15368 3.60654 3.20568 3.65855C3.25769 3.71056 3.29349 3.77654 3.30873 3.8485L3.49073 4.6975C3.57773 5.0995 4.15173 5.0995 4.23773 4.6975L4.42073 3.8485C4.43598 3.77654 4.47177 3.71056 4.52378 3.65855C4.57579 3.60654 4.64178 3.57074 4.71373 3.5555L5.56173 3.3725C5.96373 3.2855 5.96373 2.7115 5.56173 2.6255L4.71273 2.4435C4.64083 2.42814 4.57491 2.3923 4.52292 2.34031C4.47093 2.28832 4.43509 2.2224 4.41973 2.1505L4.23773 1.3015C4.15073 0.8995 3.57673 0.8995 3.49073 1.3015ZM10.8647 13.9995C10.4853 14.0056 10.1158 13.8782 9.82067 13.6397C9.52553 13.4013 9.32347 13.0667 9.24973 12.6945L8.89273 11.0275C8.83676 10.7687 8.70738 10.5316 8.52009 10.3445C8.3328 10.1574 8.09554 10.0282 7.83673 9.9725L6.16973 9.6155C5.38873 9.4465 4.86473 8.7975 4.86473 7.9995C4.86473 7.2015 5.38873 6.5525 6.16973 6.3845L7.83673 6.0275C8.09551 5.97135 8.33267 5.84193 8.51992 5.65468C8.70716 5.46744 8.83658 5.23028 8.89273 4.9715L9.25073 3.3045C9.41773 2.5235 10.0667 1.9995 10.8647 1.9995C11.6627 1.9995 12.3117 2.5235 12.4797 3.3045L12.8367 4.9715C12.9507 5.4995 13.3647 5.9135 13.8927 6.0265L15.5597 6.3835C16.3407 6.5525 16.8647 7.2015 16.8647 7.9995C16.8647 8.7975 16.3407 9.4465 15.5597 9.6145L13.8927 9.9715C13.6337 10.0275 13.3963 10.157 13.209 10.3445C13.0217 10.5319 12.8925 10.7694 12.8367 11.0285L12.4787 12.6945C12.4054 13.0667 12.2036 13.4014 11.9086 13.6399C11.6135 13.8784 11.2441 14.0057 10.8647 13.9995ZM10.8647 3.2495C10.7667 3.2495 10.5337 3.2795 10.4727 3.5655L10.1147 5.2335C10.0081 5.72777 9.76116 6.18082 9.40361 6.53837C9.04606 6.89593 8.59301 7.14283 8.09873 7.2495L6.43173 7.6065C6.14573 7.6685 6.11473 7.9015 6.11473 7.9995C6.11473 8.0975 6.14573 8.3305 6.43173 8.3925L8.09873 8.7495C8.59301 8.85617 9.04606 9.10307 9.40361 9.46062C9.76116 9.81817 10.0081 10.2712 10.1147 10.7655L10.4727 12.4335C10.5337 12.7195 10.7667 12.7495 10.8647 12.7495C10.9627 12.7495 11.1957 12.7195 11.2567 12.4335L11.6147 10.7665C11.7212 10.272 11.9681 9.81878 12.3256 9.46103C12.6832 9.10329 13.1363 8.85624 13.6307 8.7495L15.2977 8.3925C15.5837 8.3305 15.6147 8.0975 15.6147 7.9995C15.6147 7.9015 15.5837 7.6685 15.2977 7.6065L13.6307 7.2495C13.1365 7.14283 12.6834 6.89593 12.3259 6.53837C11.9683 6.18082 11.7214 5.72777 11.6147 5.2335L11.2567 3.5655C11.1957 3.2795 10.9627 3.2495 10.8647 3.2495ZM3.30873 12.1505L3.49073 11.3015C3.57673 10.8995 4.15073 10.8995 4.23773 11.3015L4.41973 12.1505C4.43509 12.2224 4.47093 12.2883 4.52292 12.3403C4.57491 12.3923 4.64083 12.4281 4.71273 12.4435L5.56173 12.6255C5.96373 12.7115 5.96373 13.2855 5.56173 13.3725L4.71273 13.5545C4.64083 13.5699 4.57491 13.6057 4.52292 13.6577C4.47093 13.7097 4.43509 13.7756 4.41973 13.8475L4.23773 14.6965C4.15173 15.0985 3.57773 15.0985 3.49073 14.6965L3.30873 13.8475C3.29337 13.7756 3.25754 13.7097 3.20555 13.6577C3.15356 13.6057 3.08764 13.5699 3.01573 13.5545L2.16673 13.3725C1.76473 13.2865 1.76473 12.7125 2.16673 12.6255L3.01573 12.4435C3.08769 12.4283 3.15368 12.3925 3.20568 12.3405C3.25769 12.2884 3.29349 12.2225 3.30873 12.1505Z" fill="black"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.3 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg width="12" height="13" viewBox="0 0 12 13" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6 0.5C5.21207 0.5 4.43185 0.655195 3.7039 0.956723C2.97595 1.25825 2.31451 1.70021 1.75736 2.25736C1.20021 2.81451 0.758251 3.47595 0.456723 4.2039C0.155195 4.93185 0 5.71207 0 6.5C0 7.28793 0.155195 8.06815 0.456723 8.7961C0.758251 9.52405 1.20021 10.1855 1.75736 10.7426C2.31451 11.2998 2.97595 11.7417 3.7039 12.0433C4.43185 12.3448 5.21207 12.5 6 12.5C7.5913 12.5 9.11742 11.8679 10.2426 10.7426C11.3679 9.61742 12 8.0913 12 6.5C12 4.9087 11.3679 3.38258 10.2426 2.25736C9.11742 1.13214 7.5913 0.5 6 0.5ZM5.06 8.9L2.9464 6.7856C2.85273 6.69171 2.80018 6.56446 2.80033 6.43183C2.80048 6.29921 2.85331 6.17207 2.9472 6.0784C3.04109 5.98473 3.16834 5.93218 3.30097 5.93233C3.43359 5.93248 3.56073 5.98531 3.6544 6.0792L5.3112 7.7368L8.3464 4.7008C8.44109 4.6109 8.56715 4.56153 8.69771 4.56322C8.82827 4.56492 8.95301 4.61754 9.04534 4.70986C9.13766 4.80219 9.19028 4.92693 9.19198 5.05749C9.19367 5.18805 9.1443 5.31411 9.0544 5.4088L5.5624 8.9H5.06Z" fill="#FBFBFE"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,30 @@
|
||||
<!-- This Source Code Form is subject to the terms of the Mozilla Public
|
||||
- License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">
|
||||
<style>
|
||||
@media not (prefers-reduced-motion) {
|
||||
@keyframes loadingRotate {
|
||||
from { rotate: 0; } to { rotate: 360deg }
|
||||
}
|
||||
#circle-arrows {
|
||||
animation: loadingRotate 1.8s linear infinite;
|
||||
transform-origin: 50% 50%;
|
||||
}
|
||||
#hourglass {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion) {
|
||||
#circle-arrows {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<path id="circle-arrows" d="M9 5.528c0 .42.508.63.804.333l2.528-2.528a.47.47 0 0 0 0-.666L9.805.14A.471.471 0 0 0 9 .472v1.866A5.756 5.756 0 0 0 2.25 8c0 .942.232 1.83.635 2.615l1.143-1.143A4.208 4.208 0 0 1 3.75 8 4.254 4.254 0 0 1 8 3.75c.345 0 .68.042 1 .122v1.656zM7 10.472v1.656c.32.08.655.122 1 .122A4.254 4.254 0 0 0 12.25 8c0-.52-.107-1.013-.279-1.474l1.143-1.143c.404.786.636 1.674.636 2.617A5.756 5.756 0 0 1 7 13.662v1.866a.47.47 0 0 1-.804.333l-2.528-2.528a.47.47 0 0 1 0-.666l2.528-2.528a.47.47 0 0 1 .804.333z"/>
|
||||
<g id="hourglass">
|
||||
<path d="M13,1 C13.5522847,1 14,1.44771525 14,2 C14,2.55228475 13.5522847,3 13,3 L12.9854217,2.99990801 C12.9950817,3.16495885 13,3.33173274 13,3.5 C13,5.24679885 10.9877318,6.01090495 10.9877318,8.0017538 C10.9877318,9.99260264 13,10.7536922 13,12.5 C13,12.6686079 12.9950617,12.8357163 12.985363,13.0010943 L13,13 C13.5522847,13 14,13.4477153 14,14 C14,14.5522847 13.5522847,15 13,15 L3,15 C2.44771525,15 2,14.5522847 2,14 C2,13.4477153 2.44771525,13 3,13 L3.01463704,13.0010943 C3.00493827,12.8357163 3,12.6686079 3,12.5 C3,10.7536922 4.9877318,9.99260264 5,8.0017538 C5.0122682,6.01090495 3,5.24679885 3,3.5 C3,3.33173274 3.00491834,3.16495885 3.01457832,2.99990801 L3,3 C2.44771525,3 2,2.55228475 2,2 C2,1.44771525 2.44771525,1 3,1 L13,1 Z M10.987,3 L5.012,3 L5.00308914,3.24815712 C5.00103707,3.33163368 5,3.4155948 5,3.5 C5,5.36125069 6.99153646,6.01774089 6.99153646,8.0017538 C6.99153646,9.98576671 5,10.6393737 5,12.5 L5.00307746,12.7513676 L5.01222201,12.9998392 L5.60191711,12.9988344 L6.0425138,12.2959826 C7.02362731,10.7653275 7.67612271,10 8,10 C8.37014547,10 9.16950644,10.9996115 10.3980829,12.9988344 L10.987778,12.9998392 C10.9958674,12.8352104 11,12.66849 11,12.5 C11,10.6393737 8.98689779,10.0147381 8.98689779,8.0017538 C8.98689779,5.98876953 11,5.36125069 11,3.5 L10.9969109,3.24815712 L10.987,3 Z"/>
|
||||
<path d="M6,4 L10,4 C8.95166016,6 8.28499349,7 8,7 C7.71500651,7 7.04833984,6 6,4 Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg width="17" height="16" viewBox="0 0 17 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M8.78182 2.63903C8.58882 2.28803 8.25782 2.25003 8.12482 2.25003C7.99019 2.24847 7.85771 2.28393 7.74185 2.35253C7.62599 2.42113 7.5312 2.52023 7.46782 2.63903L1.97082 12.639C1.90673 12.7528 1.87406 12.8816 1.87617 13.0122C1.87828 13.1427 1.91509 13.2704 1.98282 13.382C2.04798 13.4951 2.14207 13.5888 2.25543 13.6535C2.36879 13.7182 2.49732 13.7515 2.62782 13.75H13.6218C13.7523 13.7515 13.8809 13.7182 13.9942 13.6535C14.1076 13.5888 14.2017 13.4951 14.2668 13.382C14.3346 13.2704 14.3714 13.1427 14.3735 13.0122C14.3756 12.8816 14.3429 12.7528 14.2788 12.639L8.78182 2.63903ZM6.37282 2.03703C6.75182 1.34603 7.43882 1.00003 8.12482 1.00003C8.48341 0.997985 8.83583 1.09326 9.14454 1.2757C9.45325 1.45814 9.70668 1.72092 9.87782 2.03603L15.3748 12.036C16.1078 13.369 15.1438 15 13.6228 15H2.62782C1.10682 15 0.141823 13.37 0.875823 12.037L6.37282 2.03703ZM8.74982 9.06203C8.74982 9.22779 8.68397 9.38676 8.56676 9.50397C8.44955 9.62118 8.29058 9.68703 8.12482 9.68703C7.95906 9.68703 7.80009 9.62118 7.68288 9.50397C7.56566 9.38676 7.49982 9.22779 7.49982 9.06203V5.62503C7.49982 5.45927 7.56566 5.3003 7.68288 5.18309C7.80009 5.06588 7.95906 5.00003 8.12482 5.00003C8.29058 5.00003 8.44955 5.06588 8.56676 5.18309C8.68397 5.3003 8.74982 5.45927 8.74982 5.62503V9.06203ZM7.74982 12L7.49982 11.75V11L7.74982 10.75H8.49982L8.74982 11V11.75L8.49982 12H7.74982Z" fill="black"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="40"
|
||||
height="40"
|
||||
viewBox="0 0 40 40">
|
||||
<path
|
||||
d="M 1.5006714,23.536225 6.8925879,18.994244 14.585721,26.037937 34.019683,4.5410479 38.499329,9.2235032 14.585721,35.458952 z"
|
||||
id="path4"
|
||||
style="fill:#ffff00;fill-opacity:1;stroke:#000000;stroke-width:1.25402856;stroke-opacity:1" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 415 B |
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
height="40"
|
||||
width="40"
|
||||
viewBox="0 0 40 40">
|
||||
<rect
|
||||
style="fill:#ffff00;fill-opacity:1;fill-rule:evenodd;stroke:#000000;stroke-width:1;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none"
|
||||
width="33.76017"
|
||||
height="33.76017"
|
||||
x="3.119915"
|
||||
y="3.119915" />
|
||||
<path
|
||||
d="m 20.677967,8.54499 c -7.342801,0 -13.295293,4.954293 -13.295293,11.065751 0,2.088793 0.3647173,3.484376 1.575539,5.150563 L 6.0267418,31.45501 13.560595,29.011117 c 2.221262,1.387962 4.125932,1.665377 7.117372,1.665377 7.3428,0 13.295291,-4.954295 13.295291,-11.065753 0,-6.111458 -5.952491,-11.065751 -13.295291,-11.065751 z"
|
||||
style="fill:#ffffff;fill-opacity:1;stroke:#000000;stroke-width:0.93031836;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 883 B |
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="40"
|
||||
height="40"
|
||||
viewBox="0 0 40 40">
|
||||
<g
|
||||
transform="translate(0,-60)"
|
||||
id="layer1">
|
||||
<rect
|
||||
width="36.460953"
|
||||
height="34.805603"
|
||||
x="1.7695236"
|
||||
y="62.597198"
|
||||
style="fill:#ffff00;fill-opacity:1;fill-rule:evenodd;stroke:#000000;stroke-width:1.30826771;stroke-opacity:1" />
|
||||
<g
|
||||
transform="matrix(0.88763677,0,0,0.88763677,2.2472646,8.9890584)">
|
||||
<path
|
||||
d="M 20,64.526342 C 11.454135,64.526342 4.5263421,71.454135 4.5263421,80 4.5263421,88.545865 11.454135,95.473658 20,95.473658 28.545865,95.473658 35.473658,88.545865 35.473658,80 35.473658,71.454135 28.545865,64.526342 20,64.526342 z m -0.408738,9.488564 c 3.527079,0 6.393832,2.84061 6.393832,6.335441 0,3.494831 -2.866753,6.335441 -6.393832,6.335441 -3.527079,0 -6.393832,-2.84061 -6.393832,-6.335441 0,-3.494831 2.866753,-6.335441 6.393832,-6.335441 z"
|
||||
style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:#000000;stroke-width:1.02768445;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none" />
|
||||
<path
|
||||
d="m 7.2335209,71.819938 4.9702591,4.161823 c -1.679956,2.581606 -1.443939,6.069592 0.159325,8.677725 l -5.1263071,3.424463 c 0.67516,1.231452 3.0166401,3.547686 4.2331971,4.194757 l 3.907728,-4.567277 c 2.541952,1.45975 5.730694,1.392161 8.438683,-0.12614 l 3.469517,6.108336 c 1.129779,-0.44367 4.742234,-3.449633 5.416358,-5.003859 l -5.46204,-4.415541 c 1.44319,-2.424098 1.651175,-5.267515 0.557303,-7.748623 l 5.903195,-3.833951 C 33.14257,71.704996 30.616217,69.018606 29.02952,67.99296 l -4.118813,4.981678 C 22.411934,71.205099 18.900853,70.937534 16.041319,72.32916 l -3.595408,-5.322091 c -1.345962,0.579488 -4.1293881,2.921233 -5.2123901,4.812869 z m 8.1010311,3.426672 c 2.75284,-2.446266 6.769149,-2.144694 9.048998,0.420874 2.279848,2.56557 2.113919,6.596919 -0.638924,9.043185 -2.752841,2.446267 -6.775754,2.13726 -9.055604,-0.428308 -2.279851,-2.565568 -2.107313,-6.589485 0.64553,-9.035751 z"
|
||||
style="fill:#000000;fill-opacity:1;stroke:none" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="64"
|
||||
height="64"
|
||||
viewBox="0 0 64 64">
|
||||
<path
|
||||
d="M 32.003143,1.4044602 57.432701,62.632577 6.5672991,62.627924 z"
|
||||
style="fill:#ffff00;fill-opacity:0.94117647;fill-rule:nonzero;stroke:#000000;stroke-width:1.00493038;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 408 B |
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="64"
|
||||
height="64"
|
||||
viewBox="0 0 64 64">
|
||||
<path
|
||||
d="M 25.470843,9.4933766 C 25.30219,12.141818 30.139101,14.445969 34.704831,13.529144 40.62635,12.541995 41.398833,7.3856498 35.97505,5.777863 31.400921,4.1549155 25.157674,6.5445892 25.470843,9.4933766 z M 4.5246282,17.652051 C 4.068249,11.832873 9.2742983,5.9270407 18.437379,3.0977088 29.751911,-0.87185184 45.495663,1.4008022 53.603953,7.1104009 c 9.275765,6.1889221 7.158128,16.2079421 -3.171076,21.5939521 -1.784316,1.635815 -6.380222,1.21421 -7.068351,3.186186 -1.04003,0.972427 -1.288046,2.050158 -1.232864,3.168203 1.015111,2.000108 -3.831548,1.633216 -3.270553,3.759574 0.589477,5.264544 -0.179276,10.53738 -0.362842,15.806257 -0.492006,2.184998 1.163456,4.574232 -0.734888,6.610642 -2.482919,2.325184 -7.30604,2.189143 -9.193497,-0.274767 -2.733688,-1.740626 -8.254447,-3.615254 -6.104247,-6.339626 3.468112,-1.708686 -2.116197,-3.449897 0.431242,-5.080274 5.058402,-1.39256 -2.393215,-2.304318 -0.146889,-4.334645 3.069198,-0.977415 2.056986,-2.518352 -0.219121,-3.540397 1.876567,-1.807151 1.484149,-4.868919 -2.565455,-5.942205 0.150866,-1.805474 2.905737,-4.136876 -1.679967,-5.20493 C 10.260902,27.882167 4.6872697,22.95045 4.5245945,17.652051 z"
|
||||
id="path604"
|
||||
style="fill:#ffff00;fill-opacity:1;stroke:#000000;stroke-width:1.72665179;stroke-opacity:1" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="64"
|
||||
height="64"
|
||||
viewBox="0 0 64 64">
|
||||
<path
|
||||
d="M 32.003143,10.913072 57.432701,53.086929 6.567299,53.083723 z"
|
||||
id="path2985"
|
||||
style="fill:#ffff00;fill-opacity:0.94117647;fill-rule:nonzero;stroke:#000000;stroke-width:0.83403099;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 426 B |
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="40"
|
||||
height="40"
|
||||
viewBox="0 0 40 40">
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 158 B |
@@ -0,0 +1,42 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="40"
|
||||
height="40"
|
||||
viewBox="0 0 40 40">
|
||||
<rect
|
||||
width="36.075428"
|
||||
height="31.096582"
|
||||
x="1.962286"
|
||||
y="4.4517088"
|
||||
id="rect4"
|
||||
style="fill:#ffff00;fill-opacity:1;fill-rule:evenodd;stroke:#000000;stroke-width:1.23004246;stroke-opacity:1" />
|
||||
<rect
|
||||
width="27.96859"
|
||||
height="1.5012145"
|
||||
x="6.0157046"
|
||||
y="10.285"
|
||||
id="rect6"
|
||||
style="fill:#000000;fill-opacity:1;stroke:none" />
|
||||
<rect
|
||||
width="27.96859"
|
||||
height="0.85783684"
|
||||
x="6.0157056"
|
||||
y="23.21689"
|
||||
id="rect8"
|
||||
style="fill:#000000;fill-opacity:1;stroke:none" />
|
||||
<rect
|
||||
width="27.96859"
|
||||
height="0.85783684"
|
||||
x="5.8130345"
|
||||
y="28.964394"
|
||||
id="rect10"
|
||||
style="fill:#000000;fill-opacity:1;stroke:none" />
|
||||
<rect
|
||||
width="27.96859"
|
||||
height="0.85783684"
|
||||
x="6.0157046"
|
||||
y="17.426493"
|
||||
id="rect12"
|
||||
style="fill:#000000;fill-opacity:1;stroke:none" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,6 @@
|
||||
<!-- This Source Code Form is subject to the terms of the Mozilla Public
|
||||
- License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" height="40" width="40">
|
||||
<path d="M9 3.5a1.5 1.5 0 0 0-3-.001v7.95C6 12.83 7.12 14 8.5 14s2.5-1.17 2.5-2.55V5.5a.5.5 0 0 1 1 0v6.03C11.955 13.427 10.405 15 8.5 15S5.044 13.426 5 11.53V3.5a2.5 2.5 0 0 1 5 0v7.003a1.5 1.5 0 0 1-3-.003v-5a.5.5 0 0 1 1 0v5a.5.5 0 0 0 1 0Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 552 B |
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="40"
|
||||
height="40"
|
||||
viewBox="0 0 40 40">
|
||||
<rect
|
||||
width="33.76017"
|
||||
height="33.76017"
|
||||
x="3.119915"
|
||||
y="3.119915"
|
||||
style="fill:#ffff00;fill-opacity:1;fill-rule:evenodd;stroke:#000000;stroke-width:1;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none" />
|
||||
<path
|
||||
d="m 17.692678,34.50206 0,-16.182224 c -1.930515,-0.103225 -3.455824,-0.730383 -4.57593,-1.881473 -1.12011,-1.151067 -1.680164,-2.619596 -1.680164,-4.405591 0,-1.992435 0.621995,-3.5796849 1.865988,-4.7617553 1.243989,-1.1820288 3.06352,-1.7730536 5.458598,-1.7730764 l 9.802246,0 0,2.6789711 -2.229895,0 0,26.3251486 -2.632515,0 0,-26.3251486 -3.45324,0 0,26.3251486 z"
|
||||
style="font-size:29.42051125px;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;text-align:start;line-height:125%;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#ffffff;fill-opacity:1;stroke:#000000;stroke-width:1.07795751;stroke-opacity:1;font-family:Arial;-inkscape-font-specification:Arial" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,7 @@
|
||||
<!-- This Source Code Form is subject to the terms of the Mozilla Public
|
||||
- License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" height="40" width="40">
|
||||
<path d="M8.156 12.5a.99.99 0 0 0 .707-.294l.523-2.574L10.5 8.499l1.058-1.04 2.65-.601a.996.996 0 0 0 0-1.414l-3.657-3.658a.996.996 0 0 0-1.414 0l-.523 2.576L7.5 5.499 6.442 6.535l-2.65.6a.996.996 0 0 0 0 1.413l3.657 3.658a.999.999 0 0 0 .707.295z"/>
|
||||
<path d="M9.842.996c-.386 0-.77.146-1.06.44a.5.5 0 0 0-.136.251l-.492 2.43-1.008 1.03-.953.933-2.511.566a.5.5 0 0 0-.243.133 1.505 1.505 0 0 0-.002 2.123l1.477 1.477-2.768 2.767a.5.5 0 0 0 0 .707.5.5 0 0 0 .708 0l2.767-2.767 1.475 1.474a1.494 1.494 0 0 0 2.123-.002.5.5 0 0 0 .135-.254l.492-2.427 1.008-1.024.953-.937 2.511-.57a.5.5 0 0 0 .243-.132c.586-.58.583-1.543.002-2.125l-3.659-3.656A1.501 1.501 0 0 0 9.842.996Zm.05 1.025a.394.394 0 0 1 .305.12l3.658 3.657c.18.18.141.432.002.627l-2.41.545a.5.5 0 0 0-.24.131L10.15 8.142a.5.5 0 0 0-.007.006L9.029 9.283a.5.5 0 0 0-.133.25l-.48 2.36c-.082.053-.165.109-.26.109a.492.492 0 0 1-.353-.149L4.145 8.195c-.18-.18-.141-.432-.002-.627l2.41-.545a.5.5 0 0 0 .238-.13L7.85 5.857a.5.5 0 0 0 .007-.008l1.114-1.138a.5.5 0 0 0 .133-.25l.472-2.323a.619.619 0 0 1 .317-.117Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,6 @@
|
||||
<svg width="18" height="19" viewBox="0 0 18 19" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M12.2 3.09C12.28 3.01 12.43 3 12.43 3C12.48 3 12.58 3.02 12.66 3.1L14.45 4.89C14.58 5.02 14.58 5.22 14.45 5.35L11.7713 8.02872L9.51628 5.77372L12.2 3.09ZM13.2658 5.12L11.7713 6.6145L10.9305 5.77372L12.425 4.27921L13.2658 5.12Z" fill="#FBFBFE"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.98 9.32L8.23 11.57L10.7106 9.08938L8.45562 6.83438L5.98 9.31V9.32ZM8.23 10.1558L9.29641 9.08938L8.45562 8.24859L7.38921 9.315L8.23 10.1558Z" fill="#FBFBFE"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.1526 13.1816L16.2125 7.1217C16.7576 6.58919 17.05 5.8707 17.05 5.12C17.05 4.36931 16.7576 3.65084 16.2126 3.11834L14.4317 1.33747C13.8992 0.79242 13.1807 0.5 12.43 0.5C11.6643 0.5 10.9529 0.812929 10.4329 1.33289L3.68289 8.08289C3.04127 8.72452 3.00459 9.75075 3.57288 10.4363L1.29187 12.7239C1.09186 12.9245 0.990263 13.1957 1.0007 13.4685L1 14.5C0.447715 14.5 0 14.9477 0 15.5V17.5C0 18.0523 0.447715 18.5 1 18.5H16C16.5523 18.5 17 18.0523 17 17.5V15.5C17 14.9477 16.5523 14.5 16 14.5H10.2325C9.83594 14.5 9.39953 13.9347 10.1526 13.1816ZM4.39 9.85L4.9807 10.4407L2.39762 13.0312H6.63877L7.10501 12.565L7.57125 13.0312H8.88875L15.51 6.41C15.86 6.07 16.05 5.61 16.05 5.12C16.05 4.63 15.86 4.17 15.51 3.83L13.72 2.04C13.38 1.69 12.92 1.5 12.43 1.5C11.94 1.5 11.48 1.7 11.14 2.04L4.39 8.79C4.1 9.08 4.1 9.56 4.39 9.85ZM16 17.5V15.5H1V17.5H16Z" fill="#FBFBFE"/>
|
||||
<path d="M15.1616 6.05136L15.1616 6.05132L15.1564 6.05645L8.40645 12.8064C8.35915 12.8537 8.29589 12.88 8.23 12.88C8.16411 12.88 8.10085 12.8537 8.05355 12.8064L7.45857 12.2115L7.10501 11.8579L6.75146 12.2115L6.03289 12.93H3.20465L5.33477 10.7937L5.6873 10.4402L5.33426 10.0871L4.74355 9.49645C4.64882 9.40171 4.64882 9.23829 4.74355 9.14355L11.4936 2.39355C11.7436 2.14354 12.0779 2 12.43 2C12.7883 2 13.1179 2.13776 13.3614 2.38839L13.3613 2.38843L13.3664 2.39355L15.1564 4.18355L15.1564 4.18359L15.1616 4.18864C15.4122 4.43211 15.55 4.76166 15.55 5.12C15.55 5.47834 15.4122 5.80789 15.1616 6.05136ZM7.87645 11.9236L8.23 12.2771L8.58355 11.9236L11.0642 9.44293L11.4177 9.08938L11.0642 8.73582L8.80918 6.48082L8.45562 6.12727L8.10207 6.48082L5.62645 8.95645L5.48 9.10289V9.31V9.32V9.52711L5.62645 9.67355L7.87645 11.9236ZM11.4177 8.38227L11.7713 8.73582L12.1248 8.38227L14.8036 5.70355C15.1288 5.37829 15.1288 4.86171 14.8036 4.53645L13.0136 2.74645C12.8186 2.55146 12.5792 2.5 12.43 2.5H12.4134L12.3967 2.50111L12.43 3C12.3967 2.50111 12.3966 2.50112 12.3965 2.50112L12.3963 2.50114L12.3957 2.50117L12.3947 2.50125L12.3924 2.50142L12.387 2.50184L12.3732 2.50311C12.3628 2.50416 12.3498 2.50567 12.3346 2.50784C12.3049 2.51208 12.2642 2.51925 12.2178 2.53146C12.1396 2.55202 11.9797 2.60317 11.8464 2.73645L9.16273 5.42016L8.80918 5.77372L9.16273 6.12727L11.4177 8.38227ZM1.5 16H15.5V17H1.5V16Z" stroke="#15141A"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.9 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 2.75H12.5V2.25V1V0.5H12H10.358C9.91165 0.5 9.47731 0.625661 9.09989 0.860442L9.09886 0.861087L8 1.54837L6.89997 0.860979L6.89911 0.860443C6.5218 0.625734 6.08748 0.5 5.642 0.5H4H3.5V1V2.25V2.75H4H5.642C5.66478 2.75 5.6885 2.75641 5.71008 2.76968C5.71023 2.76977 5.71038 2.76986 5.71053 2.76995L6.817 3.461C6.81704 3.46103 6.81709 3.46105 6.81713 3.46108C6.81713 3.46108 6.81713 3.46108 6.81714 3.46109C6.8552 3.48494 6.876 3.52285 6.876 3.567V8V12.433C6.876 12.4771 6.85523 12.515 6.81722 12.5389C6.81715 12.5389 6.81707 12.539 6.817 12.539L5.70953 13.23C5.70941 13.2301 5.70929 13.2302 5.70917 13.2303C5.68723 13.2438 5.6644 13.25 5.641 13.25H4H3.5V13.75V15V15.5H4H5.642C6.08835 15.5 6.52269 15.3743 6.90011 15.1396L6.90086 15.1391L8 14.4526L9.10003 15.14L9.10089 15.1406C9.47831 15.3753 9.91265 15.501 10.359 15.501H12H12.5V15.001V13.751V13.251H12H10.358C10.3352 13.251 10.3115 13.2446 10.2899 13.2313C10.2897 13.2312 10.2896 13.2311 10.2895 13.231L9.183 12.54C9.18298 12.54 9.18295 12.54 9.18293 12.54C9.18291 12.5399 9.18288 12.5399 9.18286 12.5399C9.14615 12.5169 9.125 12.4797 9.125 12.434V8V3.567C9.125 3.52266 9.14603 3.48441 9.18364 3.4606C9.18377 3.46052 9.1839 3.46043 9.18404 3.46035L10.2895 2.76995C10.2896 2.76985 10.2898 2.76975 10.2899 2.76966C10.3119 2.75619 10.3346 2.75 10.358 2.75H12Z" fill="black" stroke="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,4 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0.0189877 13.6645L0.612989 10.4635C0.687989 10.0545 0.884989 9.6805 1.18099 9.3825L9.98199 0.5805C10.756 -0.1925 12.015 -0.1945 12.792 0.5805L14.42 2.2085C15.194 2.9835 15.194 4.2435 14.42 5.0185L5.61599 13.8215C5.31999 14.1165 4.94599 14.3125 4.53799 14.3875L1.33599 14.9815C1.26599 14.9935 1.19799 15.0005 1.12999 15.0005C0.832989 15.0005 0.544988 14.8835 0.330988 14.6695C0.0679874 14.4055 -0.0490122 14.0305 0.0189877 13.6645Z" fill="white"/>
|
||||
<path d="M0.0189877 13.6645L0.612989 10.4635C0.687989 10.0545 0.884989 9.6805 1.18099 9.3825L9.98199 0.5805C10.756 -0.1925 12.015 -0.1945 12.792 0.5805L14.42 2.2085C15.194 2.9835 15.194 4.2435 14.42 5.0185L5.61599 13.8215C5.31999 14.1165 4.94599 14.3125 4.53799 14.3875L1.33599 14.9815C1.26599 14.9935 1.19799 15.0005 1.12999 15.0005C0.832989 15.0005 0.544988 14.8835 0.330988 14.6695C0.0679874 14.4055 -0.0490122 14.0305 0.0189877 13.6645ZM12.472 5.1965L13.632 4.0365L13.631 3.1885L11.811 1.3675L10.963 1.3685L9.80299 2.5285L12.472 5.1965ZM4.31099 13.1585C4.47099 13.1285 4.61799 13.0515 4.73399 12.9345L11.587 6.0815L8.91899 3.4135L2.06599 10.2655C1.94899 10.3835 1.87199 10.5305 1.84099 10.6915L1.36699 13.2485L1.75199 13.6335L4.31099 13.1585Z" fill="black"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,8 @@
|
||||
<svg width="29" height="32" viewBox="0 0 29 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M28 16.75C28.2761 16.75 28.5 16.5261 28.5 16.25V15C28.5 14.7239 28.2761 14.5 28 14.5H26.358C25.9117 14.5 25.4773 14.6257 25.0999 14.8604L25.0989 14.8611L24 15.5484L22.9 14.861L22.8991 14.8604C22.5218 14.6257 22.0875 14.5 21.642 14.5H20C19.7239 14.5 19.5 14.7239 19.5 15V16.25C19.5 16.5261 19.7239 16.75 20 16.75H21.642C21.6648 16.75 21.6885 16.7564 21.7101 16.7697C21.7102 16.7698 21.7104 16.7699 21.7105 16.77L22.817 17.461C22.817 17.461 22.8171 17.4611 22.8171 17.4611C22.8171 17.4611 22.8171 17.4611 22.8171 17.4611C22.8552 17.4849 22.876 17.5229 22.876 17.567V22.625V27.683C22.876 27.7271 22.8552 27.765 22.8172 27.7889C22.8171 27.7889 22.8171 27.789 22.817 27.789L21.7095 28.48C21.7094 28.4801 21.7093 28.4802 21.7092 28.4803C21.6872 28.4938 21.6644 28.5 21.641 28.5H20C19.7239 28.5 19.5 28.7239 19.5 29V30.25C19.5 30.5261 19.7239 30.75 20 30.75H21.642C22.0883 30.75 22.5227 30.6243 22.9001 30.3896L22.9009 30.3891L24 29.7026L25.1 30.39L25.1009 30.3906C25.4783 30.6253 25.9127 30.751 26.359 30.751H28C28.2761 30.751 28.5 30.5271 28.5 30.251V29.001C28.5 28.7249 28.2761 28.501 28 28.501H26.358C26.3352 28.501 26.3115 28.4946 26.2899 28.4813C26.2897 28.4812 26.2896 28.4811 26.2895 28.481L25.183 27.79C25.183 27.79 25.183 27.79 25.1829 27.79C25.1829 27.7899 25.1829 27.7899 25.1829 27.7899C25.1462 27.7669 25.125 27.7297 25.125 27.684V22.625V17.567C25.125 17.5227 25.146 17.4844 25.1836 17.4606C25.1838 17.4605 25.1839 17.4604 25.184 17.4603L26.2895 16.77C26.2896 16.7699 26.2898 16.7698 26.2899 16.7697C26.3119 16.7562 26.3346 16.75 26.358 16.75H28Z" fill="black" stroke="#FBFBFE" stroke-linejoin="round"/>
|
||||
<path d="M24.625 17.567C24.625 17.35 24.735 17.152 24.918 17.037L26.026 16.345C26.126 16.283 26.24 16.25 26.358 16.25H28V15H26.358C26.006 15 25.663 15.099 25.364 15.285L24.256 15.978C24.161 16.037 24.081 16.113 24 16.187C23.918 16.113 23.839 16.037 23.744 15.978L22.635 15.285C22.336 15.099 21.993 15 21.642 15H20V16.25H21.642C21.759 16.25 21.874 16.283 21.974 16.345L23.082 17.037C23.266 17.152 23.376 17.35 23.376 17.567V22.625V27.683C23.376 27.9 23.266 28.098 23.082 28.213L21.973 28.905C21.873 28.967 21.759 29 21.641 29H20V30.25H21.642C21.994 30.25 22.337 30.151 22.636 29.965L23.744 29.273C23.84 29.213 23.919 29.137 24 29.064C24.081 29.137 24.161 29.213 24.256 29.273L25.365 29.966C25.664 30.152 26.007 30.251 26.359 30.251H28V29.001H26.358C26.241 29.001 26.126 28.968 26.026 28.906L24.918 28.214C24.734 28.099 24.625 27.901 24.625 27.684V22.625V17.567Z" fill="black"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M12.2 2.59C12.28 2.51 12.43 2.5 12.43 2.5C12.48 2.5 12.58 2.52 12.66 2.6L14.45 4.39C14.58 4.52 14.58 4.72 14.45 4.85L11.7713 7.52872L9.51628 5.27372L12.2 2.59ZM13.2658 4.62L11.7713 6.1145L10.9305 5.27372L12.425 3.77921L13.2658 4.62Z" fill="#FBFBFE"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.98 8.82L8.23 11.07L10.7106 8.58938L8.45562 6.33438L5.98 8.81V8.82ZM8.23 9.65579L9.29641 8.58938L8.45562 7.74859L7.38921 8.815L8.23 9.65579Z" fill="#FBFBFE"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.1526 12.6816L16.2125 6.6217C16.7576 6.08919 17.05 5.3707 17.05 4.62C17.05 3.86931 16.7576 3.15084 16.2126 2.61834L14.4317 0.837474C13.8992 0.29242 13.1807 0 12.43 0C11.6643 0 10.9529 0.312929 10.4329 0.832893L3.68289 7.58289C3.04127 8.22452 3.00459 9.25075 3.57288 9.93634L1.29187 12.2239C1.09186 12.4245 0.990263 12.6957 1.0007 12.9685L1 14C0.447715 14 0 14.4477 0 15V17C0 17.5523 0.447715 18 1 18H16C16.5523 18 17 17.5523 17 17V15C17 14.4477 16.5523 14 16 14H10.2325C9.83594 14 9.39953 13.4347 10.1526 12.6816ZM4.39 9.35L4.9807 9.9407L2.39762 12.5312H6.63877L7.10501 12.065L7.57125 12.5312H8.88875L15.51 5.91C15.86 5.57 16.05 5.11 16.05 4.62C16.05 4.13 15.86 3.67 15.51 3.33L13.72 1.54C13.38 1.19 12.92 1 12.43 1C11.94 1 11.48 1.2 11.14 1.54L4.39 8.29C4.1 8.58 4.1 9.06 4.39 9.35ZM16 17V15H1V17H16Z" fill="#FBFBFE"/>
|
||||
<path d="M15.1616 5.55136L15.1616 5.55132L15.1564 5.55645L8.40645 12.3064C8.35915 12.3537 8.29589 12.38 8.23 12.38C8.16411 12.38 8.10085 12.3537 8.05355 12.3064L7.45857 11.7115L7.10501 11.3579L6.75146 11.7115L6.03289 12.43H3.20465L5.33477 10.2937L5.6873 9.94019L5.33426 9.58715L4.74355 8.99645C4.64882 8.90171 4.64882 8.73829 4.74355 8.64355L11.4936 1.89355C11.7436 1.64354 12.0779 1.5 12.43 1.5C12.7883 1.5 13.1179 1.63776 13.3614 1.88839L13.3613 1.88843L13.3664 1.89355L15.1564 3.68355L15.1564 3.68359L15.1616 3.68864C15.4122 3.93211 15.55 4.26166 15.55 4.62C15.55 4.97834 15.4122 5.30789 15.1616 5.55136ZM5.48 8.82V9.02711L5.62645 9.17355L7.87645 11.4236L8.23 11.7771L8.58355 11.4236L11.0642 8.94293L11.4177 8.58938L11.0642 8.23582L8.80918 5.98082L8.45562 5.62727L8.10207 5.98082L5.62645 8.45645L5.48 8.60289V8.81V8.82ZM11.4177 7.88227L11.7713 8.23582L12.1248 7.88227L14.8036 5.20355C15.1288 4.87829 15.1288 4.36171 14.8036 4.03645L13.0136 2.24645C12.8186 2.05146 12.5792 2 12.43 2H12.4134L12.3967 2.00111L12.43 2.5C12.3967 2.00111 12.3966 2.00112 12.3965 2.00112L12.3963 2.00114L12.3957 2.00117L12.3947 2.00125L12.3924 2.00142L12.387 2.00184L12.3732 2.00311C12.3628 2.00416 12.3498 2.00567 12.3346 2.00784C12.3049 2.01208 12.2642 2.01925 12.2178 2.03146C12.1396 2.05202 11.9797 2.10317 11.8464 2.23645L9.16273 4.92016L8.80918 5.27372L9.16273 5.62727L11.4177 7.88227ZM1.5 16.5V15.5H15.5V16.5H1.5Z" stroke="#15141A"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.3 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||
d="M11 3H13.6C14 3 14.3 3.3 14.3 3.6C14.3 3.9 14 4.2 13.7 4.2H13.3V14C13.3 15.1 12.4 16 11.3 16H4.80005C3.70005 16 2.80005 15.1 2.80005 14V4.2H2.40005C2.00005 4.2 1.80005 4 1.80005 3.6C1.80005 3.2 2.00005 3 2.40005 3H5.00005V2C5.00005 0.9 5.90005 0 7.00005 0H9.00005C10.1 0 11 0.9 11 2V3ZM6.90005 1.2L6.30005 1.8V3H9.80005V1.8L9.20005 1.2H6.90005ZM11.4 14.7L12 14.1V4.2H4.00005V14.1L4.60005 14.7H11.4ZM7.00005 12.4C7.00005 12.7 6.70005 13 6.40005 13C6.10005 13 5.80005 12.7 5.80005 12.4V7.6C5.70005 7.3 6.00005 7 6.40005 7C6.80005 7 7.00005 7.3 7.00005 7.6V12.4ZM10.2001 12.4C10.2001 12.7 9.90006 13 9.60006 13C9.30006 13 9.00006 12.7 9.00006 12.4V7.6C9.00006 7.3 9.30006 7 9.60006 7C9.90006 7 10.2001 7.3 10.2001 7.6V12.4Z"
|
||||
fill="black" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 909 B |
|
After Width: | Height: | Size: 2.5 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M7.85822 8.84922L4.85322 11.8542C4.75891 11.9453 4.63261 11.9957 4.50151 11.9946C4.37042 11.9934 4.24501 11.9408 4.15231 11.8481C4.0596 11.7554 4.00702 11.63 4.00588 11.4989C4.00474 11.3678 4.05514 11.2415 4.14622 11.1472L7.15122 8.14222V7.85922L4.14622 4.85322C4.05514 4.75891 4.00474 4.63261 4.00588 4.50151C4.00702 4.37042 4.0596 4.24501 4.15231 4.15231C4.24501 4.0596 4.37042 4.00702 4.50151 4.00588C4.63261 4.00474 4.75891 4.05514 4.85322 4.14622L7.85822 7.15122H8.14122L11.1462 4.14622C11.2405 4.05514 11.3668 4.00474 11.4979 4.00588C11.629 4.00702 11.7544 4.0596 11.8471 4.15231C11.9398 4.24501 11.9924 4.37042 11.9936 4.50151C11.9947 4.63261 11.9443 4.75891 11.8532 4.85322L8.84822 7.85922V8.14222L11.8532 11.1472C11.9443 11.2415 11.9947 11.3678 11.9936 11.4989C11.9924 11.63 11.9398 11.7554 11.8471 11.8481C11.7544 11.9408 11.629 11.9934 11.4979 11.9946C11.3668 11.9957 11.2405 11.9453 11.1462 11.8542L8.14122 8.84922L8.14222 8.85022L7.85822 8.84922Z" fill="black"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M14.8748 12.037L9.37782 2.037C8.99682 1.346 8.31082 1 7.62482 1C6.93882 1 6.25282 1.346 5.87282 2.037L0.375823 12.037C-0.358177 13.37 0.606823 15 2.12782 15H13.1228C14.6428 15 15.6078 13.37 14.8748 12.037ZM8.24982 11.75L7.99982 12H7.24982L6.99982 11.75V11L7.24982 10.75H7.99982L8.24982 11V11.75ZM8.24982 9.062C8.24982 9.22776 8.18398 9.38673 8.06677 9.50394C7.94955 9.62115 7.79058 9.687 7.62482 9.687C7.45906 9.687 7.30009 9.62115 7.18288 9.50394C7.06567 9.38673 6.99982 9.22776 6.99982 9.062V5.625C6.99982 5.45924 7.06567 5.30027 7.18288 5.18306C7.30009 5.06585 7.45906 5 7.62482 5C7.79058 5 7.94955 5.06585 8.06677 5.18306C8.18398 5.30027 8.24982 5.45924 8.24982 5.625V9.062Z" fill="black"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 811 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8 1.5C4.41015 1.5 1.5 4.41015 1.5 8C1.5 11.5899 4.41015 14.5 8 14.5C11.5899 14.5 14.5 11.5899 14.5 8C14.5 4.41015 11.5899 1.5 8 1.5ZM0 8C0 3.58172 3.58172 0 8 0C12.4183 0 16 3.58172 16 8C16 12.4183 12.4183 16 8 16C3.58172 16 0 12.4183 0 8ZM8.75 4V5.5H7.25V4H8.75ZM8.75 12V7H7.25V12H8.75Z" fill="black"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 417 B |
@@ -0,0 +1,6 @@
|
||||
<svg width="17" height="16" viewBox="0 0 17 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.10918 11.66C7.24918 11.8 7.43918 11.88 7.63918 11.88C7.83918 11.88 8.02918 11.8 8.16918 11.66L14.9192 4.91C15.2692 4.57 15.4592 4.11 15.4592 3.62C15.4592 3.13 15.2692 2.67 14.9192 2.33L13.1292 0.54C12.7892 0.19 12.3292 0 11.8392 0C11.3492 0 10.8892 0.2 10.5492 0.54L3.79918 7.29C3.50918 7.58 3.50918 8.06 3.79918 8.35L4.38988 8.9407L1.40918 11.93H5.64918L6.51419 11.065L7.10918 11.66ZM7.63918 10.07L5.38918 7.82V7.81L7.8648 5.33438L10.1198 7.58938L7.63918 10.07ZM11.1805 6.52872L13.8592 3.85C13.9892 3.72 13.9892 3.52 13.8592 3.39L12.0692 1.6C11.9892 1.52 11.8892 1.5 11.8392 1.5C11.8392 1.5 11.6892 1.51 11.6092 1.59L8.92546 4.27372L11.1805 6.52872Z" fill="#000"/>
|
||||
<path d="M0.40918 14H15.4092V16H0.40918V14Z" fill="#000"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 910 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8.23336 10.4664L11.8474 6.85339C11.894 6.8071 11.931 6.75203 11.9563 6.69136C11.9816 6.63069 11.9946 6.56562 11.9946 6.49989C11.9946 6.43417 11.9816 6.3691 11.9563 6.30843C11.931 6.24776 11.894 6.19269 11.8474 6.14639C11.7536 6.05266 11.6264 6 11.4939 6C11.3613 6 11.2341 6.05266 11.1404 6.14639L7.99236 9.29339L4.84736 6.14739C4.75305 6.05631 4.62675 6.00592 4.49566 6.00706C4.36456 6.0082 4.23915 6.06078 4.14645 6.15348C4.05374 6.24619 4.00116 6.37159 4.00002 6.50269C3.99888 6.63379 4.04928 6.76009 4.14036 6.85439L7.75236 10.4674L8.23336 10.4664Z" fill="black"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 681 B |
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* @licstart The following is the entire license notice for the
|
||||
* JavaScript code in this page
|
||||
*
|
||||
* Copyright 2024 Mozilla Foundation
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
* @licend The above is the entire license notice for the
|
||||
* JavaScript code in this page
|
||||
*/var t={d:(e,i)=>{for(var s in i)t.o(i,s)&&!t.o(e,s)&&Object.defineProperty(e,s,{enumerable:!0,get:i[s]})},o:(t,e)=>Object.prototype.hasOwnProperty.call(t,e)},__webpack_exports__ = globalThis.pdfjsLib = {};t.d(__webpack_exports__,{AbortException:()=>AbortException,AnnotationEditorLayer:()=>AnnotationEditorLayer,AnnotationEditorParamsType:()=>m,AnnotationEditorType:()=>g,AnnotationEditorUIManager:()=>AnnotationEditorUIManager,AnnotationLayer:()=>AnnotationLayer,AnnotationMode:()=>p,ColorPicker:()=>ColorPicker,DOMSVGFactory:()=>DOMSVGFactory,DrawLayer:()=>DrawLayer,FeatureTest:()=>util_FeatureTest,GlobalWorkerOptions:()=>GlobalWorkerOptions,ImageKind:()=>_,InvalidPDFException:()=>InvalidPDFException,MissingPDFException:()=>MissingPDFException,OPS:()=>X,OutputScale:()=>OutputScale,PDFDataRangeTransport:()=>PDFDataRangeTransport,PDFDateString:()=>PDFDateString,PDFWorker:()=>PDFWorker,PasswordResponses:()=>K,PermissionFlag:()=>f,PixelsPerInch:()=>PixelsPerInch,RenderingCancelledException:()=>RenderingCancelledException,TextLayer:()=>TextLayer,TouchManager:()=>TouchManager,UnexpectedResponseException:()=>UnexpectedResponseException,Util:()=>Util,VerbosityLevel:()=>q,XfaLayer:()=>XfaLayer,build:()=>Nt,createValidAbsoluteUrl:()=>createValidAbsoluteUrl,fetchData:()=>fetchData,getDocument:()=>getDocument,getFilenameFromUrl:()=>getFilenameFromUrl,getPdfFilenameFromUrl:()=>getPdfFilenameFromUrl,getXfaPageViewport:()=>getXfaPageViewport,isDataScheme:()=>isDataScheme,isPdfFile:()=>isPdfFile,noContextMenu:()=>noContextMenu,normalizeUnicode:()=>normalizeUnicode,setLayerDimensions:()=>setLayerDimensions,shadow:()=>shadow,stopEvent:()=>stopEvent,version:()=>Ot});const e=!("object"!=typeof process||process+""!="[object process]"||process.versions.nw||process.versions.electron&&process.type&&"browser"!==process.type),i=[1,0,0,1,0,0],s=[.001,0,0,.001,0,0],n=1.35,a=1,r=2,o=4,l=16,h=32,d=64,c=128,u=256,p={DISABLE:0,ENABLE:1,ENABLE_FORMS:2,ENABLE_STORAGE:3},g={DISABLE:-1,NONE:0,FREETEXT:3,HIGHLIGHT:9,STAMP:13,INK:15},m={RESIZE:1,CREATE:2,FREETEXT_SIZE:11,FREETEXT_COLOR:12,FREETEXT_OPACITY:13,INK_COLOR:21,INK_THICKNESS:22,INK_OPACITY:23,HIGHLIGHT_COLOR:31,HIGHLIGHT_DEFAULT_COLOR:32,HIGHLIGHT_THICKNESS:33,HIGHLIGHT_FREE:34,HIGHLIGHT_SHOW_ALL:35,DRAW_STEP:41},f={PRINT:4,MODIFY_CONTENTS:8,COPY:16,MODIFY_ANNOTATIONS:32,FILL_INTERACTIVE_FORMS:256,COPY_FOR_ACCESSIBILITY:512,ASSEMBLE:1024,PRINT_HIGH_QUALITY:2048},b=0,A=1,w=2,v=3,y=3,x=4,_={GRAYSCALE_1BPP:1,RGB_24BPP:2,RGBA_32BPP:3},E=1,S=2,C=3,T=4,M=5,P=6,D=7,k=8,R=9,I=10,F=11,L=12,O=13,N=14,B=15,H=16,z=17,U=20,G=1,$=2,V=3,j=4,W=5,q={ERRORS:0,WARNINGS:1,INFOS:5},X={dependency:1,setLineWidth:2,setLineCap:3,setLineJoin:4,setMiterLimit:5,setDash:6,setRenderingIntent:7,setFlatness:8,setGState:9,save:10,restore:11,transform:12,moveTo:13,lineTo:14,curveTo:15,curveTo2:16,curveTo3:17,closePath:18,rectangle:19,stroke:20,closeStroke:21,fill:22,eoFill:23,fillStroke:24,eoFillStroke:25,closeFillStroke:26,closeEOFillStroke:27,endPath:28,clip:29,eoClip:30,beginText:31,endText:32,setCharSpacing:33,setWordSpacing:34,setHScale:35,setLeading:36,setFont:37,setTextRenderingMode:38,setTextRise:39,moveText:40,setLeadingMoveText:41,setTextMatrix:42,nextLine:43,showText:44,showSpacedText:45,nextLineShowText:46,nextLineSetSpacingShowText:47,setCharWidth:48,setCharWidthAndBounds:49,setStrokeColorSpace:50,setFillColorSpace:51,setStrokeColor:52,setStrokeColorN:53,setFillColor:54,setFillColorN:55,setStrokeGray:56,setFillGray:57,setStrokeRGBColor:58,setFillRGBColor:59,setStrokeCMYKColor:60,setFillCMYKColor:61,shadingFill:62,beginInlineImage:63,beginImageData:64,endInlineImage:65,paintXObject:66,markPoint:67,markPointProps:68,beginMarkedContent:69,beginMarkedContentProps:70,endMarkedContent:71,beginCompat:72,endCompat:73,paintFormXObjectBegin:74,paintFormXObjectEnd:75,beginGroup:76,endGroup:77,beginAnnotation:80,endAnnotation:81,paintImageMaskXObject:83,paintImageMaskXObjectGroup:84,paintImageXObject:85,paintInlineImageXObject:86,paintInlineImageXObjectGroup:87,paintImageXObjectRepeat:88,paintImageMaskXObjectRepeat:89,paintSolidColorImageMask:90,constructPath:91,setStrokeTransparent:92,setFillTransparent:93},K={NEED_PASSWORD:1,INCORRECT_PASSWORD:2};let Y=q.WARNINGS;function setVerbosityLevel(t){Number.isInteger(t)&&(Y=t)}function getVerbosityLevel(){return Y}function info(t){Y>=q.INFOS&&console.log(`Info: ${t}`)}function warn(t){Y>=q.WARNINGS&&console.log(`Warning: ${t}`)}function unreachable(t){throw new Error(t)}function assert(t,e){t||unreachable(e)}function createValidAbsoluteUrl(t,e=null,i=null){if(!t)return null;try{if(i&&"string"==typeof t){if(i.addDefaultProtocol&&t.startsWith("www.")){const e=t.match(/\./g);e?.length>=2&&(t=`http://${t}`)}if(i.tryConvertEncoding)try{t=function stringToUTF8String(t){return decodeURIComponent(escape(t))}(t)}catch{}}const s=e?new URL(t,e):new URL(t);if(function _isValidProtocol(t){switch(t?.protocol){case"http:":case"https:":case"ftp:":case"mailto:":case"tel:":return!0;default:Line truncated
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* @licstart The following is the entire license notice for the
|
||||
* JavaScript code in this page
|
||||
*
|
||||
* Copyright 2024 Mozilla Foundation
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
* @licend The above is the entire license notice for the
|
||||
* JavaScript code in this page
|
||||
*/var e={d:(t,i)=>{for(var a in i)e.o(i,a)&&!e.o(t,a)&&Object.defineProperty(t,a,{enumerable:!0,get:i[a]})},o:(e,t)=>Object.prototype.hasOwnProperty.call(e,t)},__webpack_exports__ = globalThis.pdfjsWorker = {};e.d(__webpack_exports__,{WorkerMessageHandler:()=>WorkerMessageHandler});const t=!("object"!=typeof process||process+""!="[object process]"||process.versions.nw||process.versions.electron&&process.type&&"browser"!==process.type),i=[1,0,0,1,0,0],a=[.001,0,0,.001,0,0],s=1.35,r=.35,n=.25925925925925924,g=1,o=2,c=4,C=8,h=16,l=64,Q=128,E=256,u="pdfjs_internal_editor_",d=3,f=9,p=13,m=15,y={PRINT:4,MODIFY_CONTENTS:8,COPY:16,MODIFY_ANNOTATIONS:32,FILL_INTERACTIVE_FORMS:256,COPY_FOR_ACCESSIBILITY:512,ASSEMBLE:1024,PRINT_HIGH_QUALITY:2048},w=0,D=4,b=1,F=2,S=3,k=1,R=2,N=3,G=4,M=5,U=6,x=7,L=8,H=9,J=10,Y=11,v=12,K=13,T=14,q=15,O=16,W=17,j=20,X="Group",Z="R",V=1,z=2,_=4,$=16,AA=32,eA=128,tA=512,iA=1,aA=2,sA=4096,rA=8192,nA=32768,gA=65536,oA=131072,IA=1048576,cA=2097152,CA=8388608,hA=16777216,lA=1,BA=2,QA=3,EA=4,uA=5,dA={E:"Mouse Enter",X:"Mouse Exit",D:"Mouse Down",U:"Mouse Up",Fo:"Focus",Bl:"Blur",PO:"PageOpen",PC:"PageClose",PV:"PageVisible",PI:"PageInvisible",K:"Keystroke",F:"Format",V:"Validate",C:"Calculate"},fA={WC:"WillClose",WS:"WillSave",DS:"DidSave",WP:"WillPrint",DP:"DidPrint"},pA={O:"PageOpen",C:"PageClose"},mA=1,yA=5,wA=1,DA=2,bA=3,FA=4,SA=5,kA=6,RA=7,NA=8,GA=9,MA=10,UA=11,xA=12,LA=13,HA=14,JA=15,YA=16,vA=17,KA=18,TA=19,qA=20,OA=21,PA=22,WA=23,jA=24,XA=25,ZA=26,VA=27,zA=28,_A=29,$A=30,Ae=31,ee=32,te=33,ie=34,ae=35,se=36,re=37,ne=38,ge=39,oe=40,Ie=41,ce=42,Ce=43,he=44,le=45,Be=46,Qe=47,Ee=48,ue=49,de=50,fe=51,pe=52,me=53,ye=54,we=55,De=56,be=57,Fe=58,Se=59,ke=60,Re=61,Ne=62,Ge=63,Me=64,Ue=65,xe=66,Le=67,He=68,Je=69,Ye=70,ve=71,Ke=72,Te=73,qe=74,Oe=75,Pe=76,We=77,je=80,Xe=81,Ze=83,Ve=84,ze=85,_e=86,$e=87,At=88,et=89,tt=90,it=91,at=92,st=93,rt=1,nt=2;let gt=mA;function getVerbosityLevel(){return gt}function info(e){gt>=yA&&console.log(`Info: ${e}`)}function warn(e){gt>=mA&&console.log(`Warning: ${e}`)}function unreachable(e){throw new Error(e)}function assert(e,t){e||unreachable(t)}function createValidAbsoluteUrl(e,t=null,i=null){if(!e)return null;try{if(i&&"string"==typeof e){if(i.addDefaultProtocol&&e.startsWith("www.")){const t=e.match(/\./g);t?.length>=2&&(e=`http://${e}`)}if(i.tryConvertEncoding)try{e=stringToUTF8String(e)}catch{}}const a=t?new URL(e,t):new URL(e);if(function _isValidProtocol(e){switch(e?.protocol){case"http:":case"https:":case"ftp:":case"mailto:":case"tel:":return!0;default:return!1}}(a))return a}catch{}return null}function shadow(e,t,i,a=!1){Object.defineProperty(e,t,{value:i,enumerable:!a,configurable:!0,writable:!1});return i}const ot=function BaseExceptionClosure(){function BaseException(e,t){this.message=e;this.name=t}BaseException.prototype=new Error;BaseException.constructor=BaseException;return BaseException}();class PasswordException extends ot{constructor(e,t){super(e,"PasswordException");this.code=t}}class UnknownErrorException extends ot{constructor(e,t){super(e,"UnknownErrorException");this.details=t}}class InvalidPDFException extends ot{constructor(e){super(e,"InvalidPDFException")}}class MissingPDFException extends ot{constructor(e){super(e,"MissingPDFException")}}class UnexpectedResponseException extends ot{constructor(e,t){super(e,"UnexpectedResponseException");this.status=t}}class FormatError extends ot{constructor(e){super(e,"FormatError")}}class AbortException extends ot{constructor(e){super(e,"AbortException")}}function bytesToString(e){"object"==typeof e&&void 0!==e?.length||unreachable("Invalid argument for bytesToString");const t=e.length,i=8192;if(t<i)return String.fromCharCode.apply(null,e);const a=[];for(let s=0;s<t;s+=i){const r=Math.min(s+i,t),n=e.subarray(s,r);a.push(String.fromCharCode.apply(null,n))}return a.join("")}function stringToBytes(e){"string"!=typeof e&&unreachable("Invalid argument for stringToBytes");const t=e.length,i=new Uint8Array(t);for(let a=0;a<t;++a)i[a]=255&e.charCodeAt(a);return i}function string32(e){return String.fromCharCode(e>>24&255,e>>16&255,e>>8&255,255&e)}function objectSize(e){return Object.keys(e).length}class FeatureTest{static get isLittleEndian(){return shadow(this,"isLittleEndian",function isLittleEndian(){const e=new Uint8Array(4);e[0]=1;return 1===new Uint32Array(e.buffer,0,1)[0]}())}static get isEvalSupported(){return shadow(this,"isEvalSupported",function isEvalSupported(){try{new Function("");return!0}catch{return!1}}())}static get isOffscreenCanvasSupported(){return shadow(this,"isOffscreenCanvasSupported","undefined"!=typeof OffscreenCanvas)}static get isImageDecoderSupported(){return shadow(this,"isImageDecoderSupported","undefined"!=typeof ImageDecoder)}static get platform(){return"undefined"!=typeof navigator&&"string"==typeof navigator?.platform?shadow(this,"platform",{isMac:navigator.platform.includes("Mac"),isWindows:navigator.platform.includes("Win"),isFirefox:"string"==typeof navigator?.userAgent&&navLine truncated
|
||||