Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a8bd5763fb | ||
|
|
d6ba98782e | ||
|
|
47ab97a0ac | ||
|
|
fbbf8eb87b | ||
|
|
9a760274f8 | ||
|
|
cf16809ee2 | ||
|
|
d8368fc200 | ||
|
|
e343aee026 | ||
|
|
4065b3586b |
@@ -22,3 +22,9 @@ dist/
|
|||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
.ruff_cache/
|
.ruff_cache/
|
||||||
.mypy_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
|
# TESM
|
||||||
|
|
||||||
Verwaltung von PoE-Geraeten und Aruba-Switchen: Inventar, Zustandsueberwachung,
|
Verwaltung von PoE-Geräten und Aruba-Switchen: Inventar, Zustandsüberwachung,
|
||||||
Portsteuerung, Neustart (PoE, SSH, RPC), DHCP ueber Kea, Dateifreigaben,
|
Portsteuerung, Neustart (PoE, SSH, RPC), DHCP über Kea, Dateifreigaben,
|
||||||
Wartung ueber SSH, Protokolle, Papierkorb, verschluesselte Ein- und Ausfuhr und
|
Wartung über SSH, Protokolle, Papierkorb, verschlüsselte Ein- und Ausfuhr und
|
||||||
ein SSH-Terminal im Browser.
|
ein SSH-Terminal im Browser.
|
||||||
|
|
||||||
**Dieses Repository enthaelt genau das, was zum Installieren und Betreiben von
|
**Dieses Repository enthält genau das, was zum Installieren und Betreiben von
|
||||||
TESM gebraucht wird** -- nichts darueber hinaus:
|
TESM gebraucht wird** -- nichts darüber hinaus:
|
||||||
|
|
||||||
```
|
```
|
||||||
apps/tesm die Anwendung
|
apps/tesm die Anwendung
|
||||||
@@ -16,7 +16,7 @@ deploy/ install.sh, update.sh, release.sh, systemd, privileg
|
|||||||
docs/ Betrieb und Sicherheit
|
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
|
erneuern, Antworten signieren) liegt **nicht** hier. TESM verifiziert Lizenzen
|
||||||
und stellt Anfragen; den Code zum Ausstellen soll ein Client nicht einmal
|
und stellt Anfragen; den Code zum Ausstellen soll ein Client nicht einmal
|
||||||
mitbringen.
|
mitbringen.
|
||||||
@@ -31,7 +31,7 @@ mitbringen.
|
|||||||
| Zweig | Inhalt |
|
| Zweig | Inhalt |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `main` | dieser Neubau (Version 2.0.0) |
|
| `main` | dieser Neubau (Version 2.0.0) |
|
||||||
| `SONNET5` | der Stand davor, unveraendert aufbewahrt |
|
| `SONNET5` | der Stand davor, unverändert aufbewahrt |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -43,9 +43,9 @@ sudo -u tesm tesm-admin create-admin
|
|||||||
```
|
```
|
||||||
|
|
||||||
Danach `https://<host>/`. Ohne `--domain` entsteht ein selbstsigniertes
|
Danach `https://<host>/`. Ohne `--domain` entsteht ein selbstsigniertes
|
||||||
Zertifikat; der Browser warnt einmal, die Verbindung ist verschluesselt.
|
Zertifikat; der Browser warnt einmal, die Verbindung ist verschlüsselt.
|
||||||
|
|
||||||
Mit oeffentlichem Namen und Let's Encrypt stattdessen:
|
Mit öffentlichem Namen und Let's Encrypt stattdessen:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo bash deploy/install.sh --app tesm --https --domain tesm.firma.de --acme-email it@firma.de
|
sudo bash deploy/install.sh --app tesm --https --domain tesm.firma.de --acme-email it@firma.de
|
||||||
@@ -54,16 +54,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
|
Zielzustand: **Port 443 mit TLS, Port 80 leitet dorthin um** -- ausser dem
|
||||||
ACME-Pfad, der erreichbar bleiben muss, sonst kann Let's Encrypt nie erneuern.
|
ACME-Pfad, der erreichbar bleiben muss, sonst kann Let's Encrypt nie erneuern.
|
||||||
|
|
||||||
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.
|
`--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
|
```bash
|
||||||
sudo bash deploy/install.sh --app tesm --instance test --port 5100 --http-port 8080
|
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.
|
ein eigenes Sitzungscookie.
|
||||||
|
|
||||||
### Aktualisieren
|
### Aktualisieren
|
||||||
@@ -72,9 +72,9 @@ ein eigenes Sitzungscookie.
|
|||||||
sudo bash deploy/update.sh --app tesm
|
sudo bash deploy/update.sh --app tesm
|
||||||
```
|
```
|
||||||
|
|
||||||
Das laedt ein Release, entpackt es und ruft dessen `install.sh` auf. Vor jedem
|
Das lädt ein Release, entpackt es und ruft dessen `install.sh` auf. Vor jedem
|
||||||
Update entsteht ein vollstaendiges Backup -- unabhaengig davon, ob sich das
|
Update entsteht ein vollständiges Backup -- unabhängig davon, ob sich das
|
||||||
Schema geaendert hat -- und danach laeuft ein Health-Check mit automatischem
|
Schema geändert hat -- und danach läuft ein Health-Check mit automatischem
|
||||||
Rollback.
|
Rollback.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -83,15 +83,15 @@ Rollback.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo -u tesm tesm-admin status # Zustand der Installation
|
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 create-admin # Konto anlegen oder zurücksetzen
|
||||||
sudo -u tesm tesm-admin check-now # Ueberwachungsdurchlauf sofort
|
sudo -u tesm tesm-admin check-now # Überwachungsdurchlauf sofort
|
||||||
sudo -u tesm tesm-admin housekeeping # Sitzungen und Protokolle aufraeumen
|
sudo -u tesm tesm-admin housekeeping # Sitzungen und Protokolle aufräumen
|
||||||
sudo -u tesm tesm-admin web-setup --https # nginx und TLS einrichten
|
sudo -u tesm tesm-admin web-setup --https # nginx und TLS einrichten
|
||||||
```
|
```
|
||||||
|
|
||||||
### Umzug aus dem alten TESM
|
### 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:
|
Import einlesen -- Clients, Switche, Zugangsdaten und die DHCP-Konfiguration:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -100,11 +100,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
|
`--dry-run` zeigt nur den Bericht. Ohne die Option entsteht eine Datei im
|
||||||
neuen Format, die unter *Verwaltung -> Sicherung* eingelesen wird. Der Bericht
|
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.
|
Rechte, Verzeichnisdienst, Webserver) -- das ist von Hand nachzuziehen.
|
||||||
|
|
||||||
Die Passphrase gehoert an die verdeckte Eingabeaufforderung. `--passphrase`
|
Die Passphrase gehört an die verdeckte Eingabeaufforderung. `--passphrase`
|
||||||
existiert nur fuer Automatisierung: es schreibt sie in Shell-Historie und
|
existiert nur für Automatisierung: es schreibt sie in Shell-Historie und
|
||||||
Prozessliste.
|
Prozessliste.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -135,20 +135,20 @@ Entwicklungsrepository, ebenso die Tests.
|
|||||||
|
|
||||||
## Was diese Fassung anders macht
|
## Was diese Fassung anders macht
|
||||||
|
|
||||||
Ein Neubau. Die Fehler des Vorgaengers sind bekannt; an den Stellen, an denen
|
Ein Neubau. Die Fehler des Vorgängers sind bekannt; an den Stellen, an denen
|
||||||
sie entstanden sind, steht jetzt ein Kommentar, der erklaert, warum es hier
|
sie entstanden sind, steht jetzt ein Kommentar, der erklärt, warum es hier
|
||||||
anders geloest ist. Die wichtigsten:
|
anders gelöst ist. Die wichtigsten:
|
||||||
|
|
||||||
* **Kein root fuer die Anwendung.** Ein einziger allowlisted Helfer mit
|
* **Kein root für die Anwendung.** Ein einziger allowlisted Helfer mit
|
||||||
Argumentpruefung statt eines Webprozesses als `root`.
|
Argumentprüfung statt eines Webprozesses als `root`.
|
||||||
* **CSRF-Schutz ueberhaupt.** Der Vorgaenger hatte keinen.
|
* **CSRF-Schutz überhaupt.** Der Vorgänger hatte keinen.
|
||||||
* **Rechte werden serverseitig geprueft**, nicht nur im Template ausgeblendet.
|
* **Rechte werden serverseitig geprüft**, nicht nur im Template ausgeblendet.
|
||||||
* **Die nginx-Site wird bei jedem Lauf inhaltlich verglichen und erneuert.** Ein
|
* **Die nginx-Site wird bei jedem Lauf inhaltlich verglichen und erneuert.** Ein
|
||||||
falscher `alias`-Pfad ueberlebte im Vorgaenger jahrelang jedes Update.
|
falscher `alias`-Pfad überlebte im Vorgänger jahrelang jedes Update.
|
||||||
* **Jede erzeugte Konfiguration wird vor dem Uebernehmen geprueft** -- nginx wie
|
* **Jede erzeugte Konfiguration wird vor dem Übernehmen geprüft** -- nginx wie
|
||||||
Kea. Eine fehlerhafte Datei faellt beim Schreiben auf, nicht erst beim
|
Kea. Eine fehlerhafte Datei fällt beim Schreiben auf, nicht erst beim
|
||||||
naechsten Start des Dienstes.
|
nächsten Start des Dienstes.
|
||||||
* **Kein Hostname im Lizenz-Fingerabdruck.** Umbenennen brach die Bindung.
|
* **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.
|
nginx-Site, netplan, logrotate, Zertifikate und Sitzungscookie.
|
||||||
* **Verschachtelte Datenbankverbindungen sind ein Fehler**, kein Deadlock.
|
* **Verschachtelte Datenbankverbindungen sind ein Fehler**, kein Deadlock.
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "tesm"
|
name = "tesm"
|
||||||
version = "2.0.0"
|
version = "2.0.2"
|
||||||
description = "TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten und Aruba-Switchen"
|
description = "TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten und Aruba-Switchen"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ from .permissions import PERMISSIONS, ROLE_PRESETS
|
|||||||
from .schema import TESM_MIGRATIONS
|
from .schema import TESM_MIGRATIONS
|
||||||
from .settings_spec import TESM_SETTINGS
|
from .settings_spec import TESM_SETTINGS
|
||||||
|
|
||||||
__version__ = "2.0.0"
|
__version__ = "2.0.2"
|
||||||
|
|
||||||
APP_KEY = "tesm"
|
APP_KEY = "tesm"
|
||||||
APP_NAME = "TESM"
|
APP_NAME = "TESM"
|
||||||
@@ -181,7 +181,31 @@ def _status_pills(user: Any) -> list[dict[str, str]]:
|
|||||||
extension = core()
|
extension = core()
|
||||||
pills: list[dict[str, str]] = []
|
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"]:
|
if counts["offline"]:
|
||||||
pills.append(
|
pills.append(
|
||||||
{
|
{
|
||||||
@@ -275,18 +299,61 @@ def _startup_tasks(app: Flask) -> None:
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
from .services import maintenance
|
from .services import fileshare, maintenance
|
||||||
|
|
||||||
with extension.database.session() as conn, extension.database.transaction(conn):
|
with extension.database.session() as conn, extension.database.transaction(conn):
|
||||||
orphaned = maintenance.mark_orphaned_jobs(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:
|
if orphaned:
|
||||||
logger.warning("%s unterbrochene Wartungsauftraege als unklar markiert", 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:
|
if extension.license is not None:
|
||||||
import socket
|
import socket
|
||||||
|
|
||||||
extension.license.start_heartbeat(hostname=socket.gethostname())
|
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
|
||||||
|
|
||||||
|
from .services import fileshare
|
||||||
|
|
||||||
|
def schleife() -> None:
|
||||||
|
while True:
|
||||||
|
time.sleep(FILESHARE_SWEEP_SECONDS)
|
||||||
|
try:
|
||||||
|
with app.app_context():
|
||||||
|
geloest = fileshare.sweep_expired(core(app).database)
|
||||||
|
if geloest:
|
||||||
|
logger.info("%s abgelaufene Freigabe-Einbindungen geloest", geloest)
|
||||||
|
except Exception: # noqa: BLE001 - der Webprozess darf nie sterben
|
||||||
|
logger.exception("Aufraeumen der Dateifreigaben fehlgeschlagen")
|
||||||
|
|
||||||
|
threading.Thread(target=schleife, name="fileshare-sweep", daemon=True).start()
|
||||||
|
|
||||||
|
|
||||||
# Fuer ``gunicorn tesm:app``
|
# Fuer ``gunicorn tesm:app``
|
||||||
def _lazy_app() -> Flask: # pragma: no cover - nur vom WSGI-Server benutzt
|
def _lazy_app() -> Flask: # pragma: no cover - nur vom WSGI-Server benutzt
|
||||||
|
|||||||
@@ -298,6 +298,12 @@ def _restart_as_job(extension: Any, conn: Any, device: Any, method: Any): # typ
|
|||||||
severity="notice",
|
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.start_background(
|
||||||
maintenance_service.run_reboot,
|
maintenance_service.run_reboot,
|
||||||
database=extension.database,
|
database=extension.database,
|
||||||
@@ -307,6 +313,10 @@ def _restart_as_job(extension: Any, conn: Any, device: Any, method: Any): # typ
|
|||||||
password=password,
|
password=password,
|
||||||
wait_seconds=int(extension.settings.get(conn, "maintenance_reboot_wait") or 300),
|
wait_seconds=int(extension.settings.get(conn, "maintenance_reboot_wait") or 300),
|
||||||
actor=current_user.username,
|
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(
|
flash(
|
||||||
f"{method.label} fuer {device['name']} gestartet. Der Verlauf steht unter Wartung.",
|
f"{method.label} fuer {device['name']} gestartet. Der Verlauf steht unter Wartung.",
|
||||||
|
|||||||
@@ -119,6 +119,21 @@ def preview(token: str):
|
|||||||
headers={
|
headers={
|
||||||
"Content-Disposition": f'inline; filename="{path.name}"',
|
"Content-Disposition": f'inline; filename="{path.name}"',
|
||||||
"X-Content-Type-Options": "nosniff",
|
"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",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -255,6 +270,26 @@ def mapping_add():
|
|||||||
return redirect(url_for("fileshare.mappings"))
|
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")
|
@bp.post("/zuordnungen/<int:mapping_id>/loeschen")
|
||||||
@permission_required("fileshare.edit")
|
@permission_required("fileshare.edit")
|
||||||
def mapping_delete(mapping_id: int):
|
def mapping_delete(mapping_id: int):
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ from tesm_core.auth.service import client_ip
|
|||||||
from tesm_core.db import Database
|
from tesm_core.db import Database
|
||||||
from tesm_core.extension import core, db, transaction
|
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")
|
bp = Blueprint("maintenance", __name__, url_prefix="/wartung")
|
||||||
|
|
||||||
@@ -246,6 +246,9 @@ def _start(kind: str) -> None:
|
|||||||
timeout=int(extension.settings.get(conn, "maintenance_command_timeout") or 900),
|
timeout=int(extension.settings.get(conn, "maintenance_command_timeout") or 900),
|
||||||
)
|
)
|
||||||
else:
|
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.start_background(
|
||||||
service.run_reboot,
|
service.run_reboot,
|
||||||
database=extension.database,
|
database=extension.database,
|
||||||
@@ -255,6 +258,15 @@ def _start(kind: str) -> None:
|
|||||||
password=password,
|
password=password,
|
||||||
wait_seconds=int(extension.settings.get(conn, "maintenance_reboot_wait") or 300),
|
wait_seconds=int(extension.settings.get(conn, "maintenance_reboot_wait") or 300),
|
||||||
actor=current_user.username,
|
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
|
started += 1
|
||||||
|
|
||||||
|
|||||||
@@ -54,7 +54,6 @@ def overview():
|
|||||||
"tesm/switches.html",
|
"tesm/switches.html",
|
||||||
switches=inventory.list_switches(conn),
|
switches=inventory.list_switches(conn),
|
||||||
credentials=inventory.list_credentials(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")),
|
terminal_enabled=bool(core().settings.get(conn, "terminal_enabled")),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ NAVIGATION = NavTree(
|
|||||||
),
|
),
|
||||||
NavItem(
|
NavItem(
|
||||||
key="logs_audit",
|
key="logs_audit",
|
||||||
label="Aenderungen",
|
label="Audit",
|
||||||
icon="history",
|
icon="history",
|
||||||
endpoint="logs.audit_view",
|
endpoint="logs.audit_view",
|
||||||
permissions=("logs_audit.view", "logs_audit.export"),
|
permissions=("logs_audit.view", "logs_audit.export"),
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import os
|
|||||||
import re
|
import re
|
||||||
import secrets
|
import secrets
|
||||||
import shutil
|
import shutil
|
||||||
|
import socket
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
@@ -114,6 +115,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]]:
|
def list_mappings(conn: sqlite3.Connection) -> list[dict[str, Any]]:
|
||||||
return Database.all(
|
return Database.all(
|
||||||
conn, "SELECT * FROM fileshare_mappings ORDER BY label COLLATE NOCASE"
|
conn, "SELECT * FROM fileshare_mappings ORDER BY label COLLATE NOCASE"
|
||||||
@@ -139,6 +207,52 @@ def add_mapping(
|
|||||||
return int(cursor.lastrowid or 0)
|
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:
|
def delete_mapping(conn: sqlite3.Connection, mapping_id: int) -> None:
|
||||||
conn.execute("DELETE FROM fileshare_mappings WHERE id=?", (mapping_id,))
|
conn.execute("DELETE FROM fileshare_mappings WHERE id=?", (mapping_id,))
|
||||||
|
|
||||||
@@ -259,12 +373,18 @@ def mount_for_login(
|
|||||||
root = Path(str(extension.settings.get(conn, "fileshare_mount_root") or "/mnt/tesm-shares"))
|
root = Path(str(extension.settings.get(conn, "fileshare_mount_root") or "/mnt/tesm-shares"))
|
||||||
messages: list[str] = []
|
messages: list[str] = []
|
||||||
|
|
||||||
|
suffixes = search_suffixes(conn)
|
||||||
for share in shares:
|
for share in shares:
|
||||||
token = secrets.token_urlsafe(16)
|
token = secrets.token_urlsafe(16)
|
||||||
target = root / token
|
target = root / token
|
||||||
result = sysops.mount_cifs(
|
# Kurze Hostnamen vervollstaendigen, bevor mount daran scheitert.
|
||||||
str(share["unc"]), str(target), username, password, domain=domain
|
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:
|
if not result.ok:
|
||||||
messages.append(f"{share['label']}: Einbindung fehlgeschlagen ({result.message})")
|
messages.append(f"{share['label']}: Einbindung fehlgeschlagen ({result.message})")
|
||||||
continue
|
continue
|
||||||
@@ -298,6 +418,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:
|
def release_all_for_user(conn: sqlite3.Connection, user_id: int) -> int:
|
||||||
count = 0
|
count = 0
|
||||||
for mount in active_mounts(conn, user_id):
|
for mount in active_mounts(conn, user_id):
|
||||||
@@ -315,10 +453,26 @@ def sweep_expired(database: Any) -> int:
|
|||||||
"""
|
"""
|
||||||
released = 0
|
released = 0
|
||||||
with database.session() as conn:
|
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(
|
rows = Database.all(
|
||||||
conn,
|
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:
|
if not rows:
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
@@ -500,6 +500,7 @@ def run_reboot(
|
|||||||
password: str,
|
password: str,
|
||||||
wait_seconds: int,
|
wait_seconds: int,
|
||||||
actor: str = "system",
|
actor: str = "system",
|
||||||
|
method: str = METHOD_SSH,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Loest einen Neustart aus und wartet auf die Rueckkehr.
|
"""Loest einen Neustart aus und wartet auf die Rueckkehr.
|
||||||
|
|
||||||
@@ -518,7 +519,15 @@ def run_reboot(
|
|||||||
return int((time.monotonic() - started) * 1000)
|
return int((time.monotonic() - started) * 1000)
|
||||||
|
|
||||||
host = str(device["ip"])
|
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
|
# Woran erkennt man, dass das Geraet zurueck ist? Am selben Dienst, ueber
|
||||||
# den der Neustart lief: SSH bei Linux, SMB/RPC bei Windows.
|
# 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)
|
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,
|
actor=actor,
|
||||||
duration_ms=elapsed(),
|
duration_ms=elapsed(),
|
||||||
detail=str(exc),
|
detail=str(exc),
|
||||||
|
method=method,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -607,6 +617,7 @@ def run_reboot(
|
|||||||
actor=actor,
|
actor=actor,
|
||||||
duration_ms=elapsed(),
|
duration_ms=elapsed(),
|
||||||
detail=problem,
|
detail=problem,
|
||||||
|
method=method,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -646,6 +657,7 @@ def run_reboot(
|
|||||||
actor=actor,
|
actor=actor,
|
||||||
duration_ms=elapsed(),
|
duration_ms=elapsed(),
|
||||||
detail="Neustart ueber SSH, Geraet ist zurueckgekehrt.",
|
detail="Neustart ueber SSH, Geraet ist zurueckgekehrt.",
|
||||||
|
method=method,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
time.sleep(REBOOT_POLL_SECONDS)
|
time.sleep(REBOOT_POLL_SECONDS)
|
||||||
@@ -669,7 +681,8 @@ def run_reboot(
|
|||||||
f"Neustart ausgeloest, aber {host}:{ssh_port} hat innerhalb von "
|
f"Neustart ausgeloest, aber {host}:{ssh_port} hat innerhalb von "
|
||||||
f"{wait_seconds}s nicht wieder geantwortet."
|
f"{wait_seconds}s nicht wieder geantwortet."
|
||||||
),
|
),
|
||||||
)
|
method=method,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _log_restart(
|
def _log_restart(
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ import threading
|
|||||||
import time
|
import time
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Callable
|
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]
|
command = [binary, "-c", "1", "-W", str(timeout), host]
|
||||||
try:
|
try:
|
||||||
completed = subprocess.run( # noqa: S603 - feste Argumentliste, keine Shell
|
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:
|
except subprocess.TimeoutExpired:
|
||||||
return PingResult(False, detail="Zeitlimit ueberschritten")
|
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),
|
(device_id, _now(), trigger, actor, action, result, duration_ms, detail[:8000], method),
|
||||||
)
|
)
|
||||||
if result == "ok":
|
if result == "ok":
|
||||||
# Der Zustand wird hier gesetzt, nicht in den einzelnen Neustartwegen:
|
# Hier faellt nur noch der Zaehler und der Zeitstempel an. Den Zustand
|
||||||
# PoE, SSH und RPC laufen alle durch diese Funktion. Eine zweite Stelle
|
# setzt ``mark_restarting`` schon beim Ausloesen -- dieses Ergebnis
|
||||||
# waere eine, die beim naechsten Verfahren vergessen wird.
|
# kommt bei SSH und RPC erst, wenn das Geraet zurueck ist, und bis
|
||||||
|
# dahin stand die Kachel weiter auf "online".
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE device_status SET last_restart_at=?, restart_count=restart_count+1, "
|
"UPDATE device_status SET last_restart_at=?, restart_count=restart_count+1, "
|
||||||
"state=?, last_change_at=?, fail_count=0 WHERE device_id=?",
|
"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
|
# PoE-Neustart
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
@@ -363,6 +397,13 @@ def restart_device_poe(
|
|||||||
switch_ip = str(device.get("switch_ip") or "")
|
switch_ip = str(device.get("switch_ip") or "")
|
||||||
credential_id = device.get("switch_credential_id")
|
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:
|
if not port or not switch_ip:
|
||||||
message = (
|
message = (
|
||||||
"Kein Switch oder kein Port zugeordnet -- ein PoE-Neustart ist nicht moeglich."
|
"Kein Switch oder kein Port zugeordnet -- ein PoE-Neustart ist nicht moeglich."
|
||||||
@@ -573,6 +614,28 @@ def _in_cooldown(last_restart_at: Any) -> bool:
|
|||||||
return (datetime.now(timezone.utc) - moment).total_seconds() < RESTART_COOLDOWN_SECONDS
|
return (datetime.now(timezone.utc) - moment).total_seconds() < RESTART_COOLDOWN_SECONDS
|
||||||
|
|
||||||
|
|
||||||
|
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)))
|
||||||
|
|
||||||
|
|
||||||
def dashboard_counts(conn: sqlite3.Connection) -> dict[str, int]:
|
def dashboard_counts(conn: sqlite3.Connection) -> dict[str, int]:
|
||||||
row = Database.one(
|
row = Database.one(
|
||||||
conn,
|
conn,
|
||||||
|
|||||||
@@ -45,7 +45,18 @@ class Method:
|
|||||||
return self.key == METHOD_POE
|
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] = {
|
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(
|
METHOD_POE: Method(
|
||||||
key=METHOD_POE,
|
key=METHOD_POE,
|
||||||
label="PoE-Port",
|
label="PoE-Port",
|
||||||
@@ -82,9 +93,17 @@ def available(device: Any, *, maintenance_active: bool = True) -> list[Method]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
methods: list[Method] = []
|
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])
|
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 "")
|
category = str(field("credential_category") or "")
|
||||||
has_login = bool(field("credential_id"))
|
has_login = bool(field("credential_id"))
|
||||||
if has_login and maintenance_active:
|
if has_login and maintenance_active:
|
||||||
|
|||||||
@@ -52,7 +52,8 @@ HINTS: tuple[tuple[str, str], ...] = (
|
|||||||
(
|
(
|
||||||
"NT_STATUS_LOGON_FAILURE",
|
"NT_STATUS_LOGON_FAILURE",
|
||||||
"Benutzername oder Passwort wurden vom Windows-Geraet abgewiesen. Bei einem "
|
"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",
|
"NT_STATUS_ACCESS_DENIED",
|
||||||
@@ -73,6 +74,24 @@ HINTS: tuple[tuple[str, str], ...] = (
|
|||||||
"NT_STATUS_HOST_UNREACHABLE",
|
"NT_STATUS_HOST_UNREACHABLE",
|
||||||
"Das Geraet ist nicht erreichbar.",
|
"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 ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def reboot(
|
def split_account(username: str, domain: str = "") -> tuple[str, str]:
|
||||||
host: 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,
|
username: str,
|
||||||
password: str,
|
password: str,
|
||||||
*,
|
domain: str,
|
||||||
domain: str = "",
|
timeout: int,
|
||||||
comment: str = "Neustart durch TESM-Wartung",
|
) -> RemoteResult | tuple[int, str]:
|
||||||
delay: int = 5,
|
"""Ruft ``net`` mit einer 0600-Anmeldedatei auf und raeumt sie sicher weg.
|
||||||
timeout: int = DEFAULT_TIMEOUT,
|
|
||||||
) -> RemoteResult:
|
Gemeinsam fuer Neustart und Pruefung -- die Datei traegt ein
|
||||||
"""Loest einen Neustart auf einem Windows-Geraet aus."""
|
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")
|
binary = shutil.which("net")
|
||||||
if binary is None:
|
if binary is None:
|
||||||
return RemoteResult(
|
return RemoteResult(
|
||||||
False,
|
False,
|
||||||
"",
|
"",
|
||||||
f"Auf diesem Server fehlt das Paket {REQUIRED_PACKAGE}. Ohne 'net' laesst sich "
|
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:
|
if not username:
|
||||||
return RemoteResult(False, "", "Fuer das Geraet sind keine Zugangsdaten hinterlegt.")
|
return RemoteResult(False, "", "Es 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)
|
|
||||||
|
|
||||||
|
username, domain = split_account(username, domain)
|
||||||
handle, path = tempfile.mkstemp(prefix="tesm-winauth-")
|
handle, path = tempfile.mkstemp(prefix="tesm-winauth-")
|
||||||
try:
|
try:
|
||||||
# mkstemp legt die Datei auf POSIX bereits mit 0600 an. Hier trotzdem
|
# mkstemp legt die Datei auf POSIX bereits mit 0600 an. Hier trotzdem
|
||||||
@@ -123,34 +164,22 @@ def reboot(
|
|||||||
if domain:
|
if domain:
|
||||||
stream.write(f"domain={domain}\n")
|
stream.write(f"domain={domain}\n")
|
||||||
|
|
||||||
command = [
|
command = [binary, *args, "--authentication-file", path]
|
||||||
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,
|
|
||||||
]
|
|
||||||
try:
|
try:
|
||||||
completed = subprocess.run( # noqa: S603 - feste Argumentliste, keine Shell
|
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:
|
except subprocess.TimeoutExpired:
|
||||||
return RemoteResult(False, "", f"Zeitlimit von {timeout}s ueberschritten.")
|
return RemoteResult(False, "", f"Zeitlimit von {timeout}s ueberschritten.")
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
return RemoteResult(False, "", str(exc))
|
return RemoteResult(False, "", str(exc))
|
||||||
|
return completed.returncode, (completed.stdout + completed.stderr).strip()
|
||||||
output = (completed.stdout + completed.stderr).strip()
|
|
||||||
if completed.returncode == 0:
|
|
||||||
return RemoteResult(True, output or "Neustart ausgeloest.")
|
|
||||||
return RemoteResult(False, output, hint_for(output))
|
|
||||||
finally:
|
finally:
|
||||||
# Ueberschreiben, dann loeschen -- die Datei enthaelt ein Klartextpasswort.
|
# Ueberschreiben, dann loeschen -- die Datei enthaelt ein Klartextpasswort.
|
||||||
try:
|
try:
|
||||||
@@ -166,3 +195,85 @@ def reboot(
|
|||||||
os.unlink(path)
|
os.unlink(path)
|
||||||
except OSError: # pragma: no cover - defensiv
|
except OSError: # pragma: no cover - defensiv
|
||||||
pass
|
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))
|
||||||
|
|||||||
@@ -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 %}
|
{% from "tesm_core/macros.html" import pill, empty_state %}
|
||||||
|
|
||||||
{# Beide Bloecke in einen Stapel: sonst klebt die erste Gruppe an den
|
{# Beide Blöcke in einen Stapel: sonst klebt die erste Gruppe an den
|
||||||
Filterkacheln, waehrend die Gruppen untereinander Abstand haben. #}
|
Filterkacheln, während die Gruppen untereinander Abstand haben. #}
|
||||||
<div class="stack">
|
<div class="stack">
|
||||||
<div class="grid grid--4" data-behavior="tile-filter" data-filter-scope="tile-sections">
|
<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">
|
<button type="button" class="stat" data-filter-value="" aria-pressed="false">
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
</button>
|
</button>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="stat stat--warning">
|
<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>
|
<span class="stat__value">{{ counts.unknown }}</span>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -49,9 +49,9 @@
|
|||||||
<div class="grid grid--tiles">
|
<div class="grid grid--tiles">
|
||||||
{% for tile in section.tiles %}
|
{% for tile in section.tiles %}
|
||||||
{# Kopf zuerst Kennzeichen, dann Name: das Auge findet die Kachel
|
{# 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 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
|
<article class="tile tile--{{ tile.state }}" data-filter-item
|
||||||
data-filter-text="{{ tile.name }} {{ tile.get('ip','') }} {{ tile.get('switch','') }} {{ tile.location }}">
|
data-filter-text="{{ tile.name }} {{ tile.get('ip','') }} {{ tile.get('switch','') }} {{ tile.location }}">
|
||||||
<div class="tile__head">
|
<div class="tile__head">
|
||||||
@@ -73,7 +73,7 @@
|
|||||||
<dt>Switch</dt><dd class="truncate">{{ tile.switch }} / {{ tile.port }}</dd>
|
<dt>Switch</dt><dd class="truncate">{{ tile.switch }} / {{ tile.port }}</dd>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if tile.latency_ms %}
|
{% 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 %}
|
{% endif %}
|
||||||
{% if tile.last_change_at %}
|
{% if tile.last_change_at %}
|
||||||
<dt>Seit</dt>
|
<dt>Seit</dt>
|
||||||
@@ -92,7 +92,7 @@
|
|||||||
<input type="hidden" name="method" value="{{ method.key }}">
|
<input type="hidden" name="method" value="{{ method.key }}">
|
||||||
<button class="btn btn--sm" type="submit" title="{{ method.hint }}"
|
<button class="btn btn--sm" type="submit" title="{{ method.hint }}"
|
||||||
data-busy-label="Startet ..."
|
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-title="Neustart" data-confirm-ok="Neu starten"
|
||||||
data-confirm-style="primary">
|
data-confirm-style="primary">
|
||||||
{{ icon('power', size=14) }} {{ method.label }}
|
{{ icon('power', size=14) }} {{ method.label }}
|
||||||
@@ -118,8 +118,8 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
{% if not sections | map(attribute='tiles') | select | list %}
|
{% if not sections | map(attribute='tiles') | select | list %}
|
||||||
{{ empty_state('Keine Geraete erfasst',
|
{{ empty_state('Keine Geräte erfasst',
|
||||||
'Legen Sie das erste Geraet unter Ueberwachung an.', 'cpu') }}
|
'Legen Sie das erste Gerät unter Überwachung an.', 'cpu') }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</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" %}
|
{% extends "tesm_core/base.html" %}
|
||||||
{% from "tesm_core/macros.html" import field, checkbox, alert, pill %}
|
{% from "tesm_core/macros.html" import field, checkbox, alert, pill %}
|
||||||
|
|
||||||
{% block page_title %}Sicherung und Uebertragung{% endblock %}
|
{% block page_title %}Sicherung und Übertragung{% endblock %}
|
||||||
{% block page_sub %}Verschluesselter Export · zweistufiger Import{% endblock %}
|
{% block page_sub %}Verschlüsselter Export · zweistufiger Import{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{% if license_hint %}{{ alert(license_hint, 'warning') }}{% endif %}
|
{% if license_hint %}{{ alert(license_hint, 'warning') }}{% endif %}
|
||||||
|
|
||||||
{{ alert('Der Export wird mit Argon2id aus Ihrer Passphrase verschluesselt (AES-256-GCM). Ohne
|
{{ 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 Hintertuer. Bewahren Sie
|
diese Passphrase ist die Datei nicht wiederherstellbar -- es gibt keine Hintertür. Bewahren Sie
|
||||||
sie getrennt von der Sicherung auf.', 'warning', 'Passphrase') }}
|
sie getrennt von der Sicherung auf.', 'warning', 'Passphrase') }}
|
||||||
|
|
||||||
<div class="grid grid--split">
|
<div class="grid grid--split">
|
||||||
@@ -16,7 +16,7 @@ sie getrennt von der Sicherung auf.', 'warning', 'Passphrase') }}
|
|||||||
<div class="card__header">
|
<div class="card__header">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="card__title">{{ icon('download', size=17) }} Sicherung erstellen</div>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<form method="post" action="{{ url_for('backup.export') }}" data-no-guard>
|
<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 %}
|
{% if category.admin_only %}{{ pill('nur Admin', 'accent') }}{% endif %}
|
||||||
<div class="fs-xs faint">{{ category.hint }}</div>
|
<div class="fs-xs faint">{{ category.hint }}</div>
|
||||||
{% if not allowed %}
|
{% 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 %}
|
{% endif %}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -46,9 +46,9 @@ sie getrennt von der Sicherung auf.', 'warning', 'Passphrase') }}
|
|||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
{% if can_secrets %}
|
{% if can_secrets %}
|
||||||
{{ checkbox('include_secrets', 'Passwoerter der Zugangsdaten mit exportieren',
|
{{ checkbox('include_secrets', 'Passwörter der Zugangsdaten mit exportieren',
|
||||||
hint_text='Sie liegen dann im Klartext innerhalb der verschluesselten
|
hint_text='Sie liegen dann im Klartext innerhalb der verschlüsselten
|
||||||
Datei. Ohne dieses Kennzeichen enthaelt die Sicherung nur Namen und
|
Datei. Ohne dieses Kennzeichen enthält die Sicherung nur Namen und
|
||||||
Benutzer.') }}
|
Benutzer.') }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
@@ -73,9 +73,9 @@ sie getrennt von der Sicherung auf.', 'warning', 'Passphrase') }}
|
|||||||
<section class="card card--accent">
|
<section class="card card--accent">
|
||||||
<div class="card__header">
|
<div class="card__header">
|
||||||
<div class="flex-1">
|
<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">
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -84,7 +84,7 @@ sie getrennt von der Sicherung auf.', 'warning', 'Passphrase') }}
|
|||||||
<div class="card__body stack">
|
<div class="card__body stack">
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table class="table table--compact">
|
<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>
|
<tbody>
|
||||||
{% for entry in preview.summary %}
|
{% for entry in preview.summary %}
|
||||||
<tr>
|
<tr>
|
||||||
@@ -96,7 +96,7 @@ sie getrennt von der Sicherung auf.', 'warning', 'Passphrase') }}
|
|||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{{ entry.label }}
|
{{ entry.label }}
|
||||||
{% if entry.has_secrets %}{{ pill('mit Passwoertern', 'danger') }}{% endif %}
|
{% if entry.has_secrets %}{{ pill('mit Passwörtern', 'danger') }}{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="numeric">{{ entry.count }}</td>
|
<td class="numeric">{{ entry.count }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -104,17 +104,17 @@ sie getrennt von der Sicherung auf.', 'warning', 'Passphrase') }}
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
{{ alert('Bestehende Eintraege werden nach ihrem natuerlichen Schluessel abgeglichen
|
{{ alert('Bestehende Einträge werden nach ihrem natürlichen Schlüssel abgeglichen
|
||||||
(Name, Hostname, MAC, Benutzername) und aktualisiert. Eintraege, die im Papierkorb
|
(Name, Hostname, MAC, Benutzername) und aktualisiert. Einträge, die im Papierkorb
|
||||||
liegen, werden nicht wiederbelebt.', 'info') }}
|
liegen, werden nicht wiederbelebt.', 'info') }}
|
||||||
</div>
|
</div>
|
||||||
<div class="card__footer modal__footer--split">
|
<div class="card__footer modal__footer--split">
|
||||||
<button class="btn btn--sm btn--ghost" type="submit"
|
<button class="btn btn--sm btn--ghost" type="submit"
|
||||||
formaction="{{ url_for('backup.import_discard') }}">Verwerfen</button>
|
formaction="{{ url_for('backup.import_discard') }}">Verwerfen</button>
|
||||||
<button class="btn btn--primary" type="submit"
|
<button class="btn btn--primary" type="submit"
|
||||||
data-confirm="Ausgewaehlte Kategorien jetzt uebernehmen? Bestehende Daten werden aktualisiert."
|
data-confirm="Ausgewählte Kategorien jetzt übernehmen? Bestehende Daten werden aktualisiert."
|
||||||
data-confirm-title="Sicherung uebernehmen" data-confirm-ok="Uebernehmen"
|
data-confirm-title="Sicherung übernehmen" data-confirm-ok="Übernehmen"
|
||||||
data-confirm-style="primary">Uebernehmen</button>
|
data-confirm-style="primary">Übernehmen</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
@@ -123,7 +123,7 @@ sie getrennt von der Sicherung auf.', 'warning', 'Passphrase') }}
|
|||||||
<div class="card__header">
|
<div class="card__header">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="card__title">{{ icon('upload', size=17) }} Sicherung einspielen</div>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<form method="post" enctype="multipart/form-data" data-no-guard
|
<form method="post" enctype="multipart/form-data" data-no-guard
|
||||||
@@ -135,8 +135,8 @@ sie getrennt von der Sicherung auf.', 'warning', 'Passphrase') }}
|
|||||||
autocomplete='off') }}
|
autocomplete='off') }}
|
||||||
</div>
|
</div>
|
||||||
<div class="card__footer">
|
<div class="card__footer">
|
||||||
<button class="btn btn--primary" type="submit" data-busy-label="Oeffne ...">
|
<button class="btn btn--primary" type="submit" data-busy-label="Öffne ...">
|
||||||
Datei oeffnen
|
Datei öffnen
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -150,8 +150,8 @@ sie getrennt von der Sicherung auf.', 'warning', 'Passphrase') }}
|
|||||||
<div class="card__body">
|
<div class="card__body">
|
||||||
<ul class="stack stack--tight fs-sm muted">
|
<ul class="stack stack--tight fs-sm muted">
|
||||||
<li>Verzeichniskonten werden nie exportiert -- sie entstehen am Ziel bei der ersten Anmeldung neu.</li>
|
<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>Lizenz- und Aktivierungszustand bleiben ausgenommen: sie gehören zur Maschine, nicht zur Sicherung.</li>
|
||||||
<li>Sitzungen, Ratenbegrenzung und das Aenderungsprotokoll sind nicht Teil der 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>
|
<li>Der Zwischenstand des Imports liegt in der Datenbank -- ein Dienst-Neustall verliert ihn nicht.</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,21 +18,21 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{% if not host_key_ready %}
|
{% 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.',
|
den Aufbau, bis das geschehen ist -- die Freigabe erfolgt auf der Detailseite.',
|
||||||
'danger', 'Host-Schluessel fehlt') }}
|
'danger', 'Host-Schlüssel fehlt') }}
|
||||||
<p><a class="btn btn--primary" href="{{ back_url }}">Zur Detailseite und Schluessel freigeben</a></p>
|
<p><a class="btn btn--primary" href="{{ back_url }}">Zur Detailseite und Schlüssel freigeben</a></p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{{ alert('Die Konsole ist zeilenorientiert: ein Kommando pro Zeile, Antwort darunter. Jede
|
{{ alert('Die Konsole ist zeilenorientiert: ein Kommando pro Zeile, Antwort darunter. Jede
|
||||||
Eingabe und jede Ausgabe wird auf dem Server mitgeschrieben und im Aenderungsprotokoll
|
Eingabe und jede Ausgabe wird auf dem Server mitgeschrieben und im Änderungsprotokoll
|
||||||
vermerkt. Bei Untaetigkeit trennt die Verbindung nach ' ~ idle_timeout ~ ' Sekunden.',
|
vermerkt. Bei Untätigkeit trennt die Verbindung nach ' ~ idle_timeout ~ ' Sekunden.',
|
||||||
'warning', 'Mitschrift') }}
|
'warning', 'Mitschrift') }}
|
||||||
|
|
||||||
{% if not is_switch %}
|
{% if not is_switch %}
|
||||||
{{ alert('Jedes Kommando laeuft in einer eigenen Sitzung. Das heisst: ein "cd" wirkt nicht
|
{{ alert('Jedes Kommando läuft in einer eigenen Sitzung. Das heisst: ein "cd" wirkt nicht
|
||||||
auf das naechste Kommando, und interaktive Programme (Editoren, Passwortabfragen) laufen
|
auf das nächste Kommando, und interaktive Programme (Editoren, Passwortabfragen) laufen
|
||||||
hier nicht. Fuer solche Arbeiten einen richtigen SSH-Client nehmen.', 'info',
|
hier nicht. Für solche Arbeiten einen richtigen SSH-Client nehmen.', 'info',
|
||||||
'Ein Kommando, eine Sitzung') }}
|
'Ein Kommando, eine Sitzung') }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
@@ -49,7 +49,7 @@ vermerkt. Bei Untaetigkeit trennt die Verbindung nach ' ~ idle_timeout ~ ' Sekun
|
|||||||
{{ icon('copy', size=14) }} Mitschrift kopieren
|
{{ icon('copy', size=14) }} Mitschrift kopieren
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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)">
|
<div class="card__footer" style="justify-content:stretch;gap:var(--sp-2)">
|
||||||
<select class="select" id="console-preset" style="max-width:280px">
|
<select class="select" id="console-preset" style="max-width:280px">
|
||||||
<option value="">Kommando aus der Liste ...</option>
|
<option value="">Kommando aus der Liste ...</option>
|
||||||
|
|||||||
@@ -8,8 +8,8 @@
|
|||||||
</nav>
|
</nav>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{{ alert('Dieser Aufruf steht mit Zeitpunkt, Benutzer und IP im Aenderungsprotokoll.
|
{{ alert('Dieser Aufruf steht mit Zeitpunkt, Benutzer und IP im Änderungsprotokoll.
|
||||||
Schliessen Sie die Seite, sobald Sie den Wert uebernommen haben.', 'warning') }}
|
Schliessen Sie die Seite, sobald Sie den Wert übernommen haben.', 'warning') }}
|
||||||
<section class="card" style="max-width:560px">
|
<section class="card" style="max-width:560px">
|
||||||
<div class="card__header">
|
<div class="card__header">
|
||||||
<div class="flex-1"><div class="card__title">{{ icon('key', size=17) }} {{ entry.name }}</div>
|
<div class="flex-1"><div class="card__title">{{ icon('key', size=17) }} {{ entry.name }}</div>
|
||||||
|
|||||||
@@ -5,9 +5,9 @@
|
|||||||
{% macro credential_form(entry=None) %}
|
{% macro credential_form(entry=None) %}
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
{{ field('name', 'Name', value=entry.name if entry else '', required=True,
|
{{ 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('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',
|
type='password', autocomplete='new-password',
|
||||||
required=False if entry else True) }}
|
required=False if entry else True) }}
|
||||||
<label class="field">
|
<label class="field">
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% block page_title %}Zugangsdaten{% endblock %}
|
{% 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 %}
|
{% block topbar_extra %}
|
||||||
{{ search_box('credentials-table', 'Name, Benutzer ...') }}
|
{{ search_box('credentials-table', 'Name, Benutzer ...') }}
|
||||||
@@ -36,10 +36,10 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{{ alert('Passwoerter liegen AES-256-GCM-verschluesselt und an ihren Verwendungszweck gebunden.
|
{{ 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 Passwortbestaetigung und wird
|
Sie im Klartext zu sehen braucht ein eigenes Recht, eine frische Passwortbestätigung und wird
|
||||||
protokolliert. Die Kategorie setzen Sie ausdruecklich -- sie wird bewusst nicht geraten, weil
|
protokolliert. Die Kategorie setzen Sie ausdrücklich -- sie wird bewusst nicht geraten, weil
|
||||||
davon abhaengt, welche Geraete auf der Wartungsseite erscheinen.', 'info') }}
|
davon abhängt, welche Geräte auf der Wartungsseite erscheinen.', 'info') }}
|
||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<div class="table-wrap">
|
<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) }}"
|
<form method="post" action="{{ url_for('credentials.delete', credential_id=entry.id) }}"
|
||||||
style="display:inline" data-no-guard>
|
style="display:inline" data-no-guard>
|
||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
<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"
|
||||||
{% if entry.in_use %}disabled title="Wird noch von {{ entry.in_use }} Eintraegen verwendet"{% endif %}
|
{% 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="{{ 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>
|
{{ icon('trash', size=15) }}</button>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -1,22 +1,17 @@
|
|||||||
{% extends "tesm_core/base.html" %}
|
{% extends "tesm_core/base.html" %}
|
||||||
{% from "tesm_core/macros.html" import search_box %}
|
{% from "tesm_core/macros.html" import search_box %}
|
||||||
|
|
||||||
{% block page_title %}Statusuebersicht{% endblock %}
|
{% block page_title %}Statusübersicht{% endblock %}
|
||||||
{% block page_sub %}
|
{% block page_sub %}
|
||||||
{{ counts.total }} Geraete · Pruefintervall {{ interval }} s
|
{{ counts.total }} Geräte · Prüfintervall {{ interval }} s
|
||||||
{% if not detailed %}· oeffentliche Ansicht{% endif %}
|
{% if not detailed %}· öffentliche Ansicht{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block topbar_extra %}
|
{% block topbar_extra %}
|
||||||
{{ search_box('tile-sections', 'Geraet suchen ...') }}
|
{{ search_box('tile-sections', 'Gerät suchen ...') }}
|
||||||
{% if current_user.has('settings_system.execute') %}
|
{# Der Knopf "Jetzt prüfen" sass hier ein zweites Mal: die Timer-Pille in
|
||||||
<form method="post" action="{{ url_for('settings.run_check_now') }}" data-no-guard>
|
der Topbar trägt ihn jetzt, und dort gehört er hin -- er setzt genau die
|
||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
Angabe zurück, die daneben steht. #}
|
||||||
<button class="btn btn--sm" type="submit" title="Sofort pruefen">
|
|
||||||
{{ icon('refresh', size=15) }} Jetzt pruefen
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
{% endif %}
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
<input type="hidden" name="method" value="{{ method.key }}">
|
<input type="hidden" name="method" value="{{ method.key }}">
|
||||||
<button class="btn btn--sm {{ 'btn--primary' if loop.first else 'btn--ghost' }}"
|
<button class="btn btn--sm {{ 'btn--primary' if loop.first else 'btn--ghost' }}"
|
||||||
type="submit" title="{{ method.hint }}" data-busy-label="Startet ..."
|
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-title="Neustart" data-confirm-ok="Neu starten"
|
||||||
data-confirm-style="primary">
|
data-confirm-style="primary">
|
||||||
{{ icon('power', size=15) }} {{ method.label }}
|
{{ icon('power', size=15) }} {{ method.label }}
|
||||||
@@ -36,7 +36,7 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{% if device.deleted_at %}
|
{% 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') }}
|
'warning') }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
@@ -58,8 +58,8 @@
|
|||||||
<div class="grid grid--4">
|
<div class="grid grid--4">
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
<span class="stat__label">Antwortzeit</span>
|
<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__value">{{ device.latency_ms | millis if device.latency_ms else '–' | safe }}</span>
|
||||||
<span class="stat__hint">Millisekunden</span>
|
<span class="stat__hint">letzte Messung</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat {{ 'stat--danger' if device.fail_count else '' }}">
|
<div class="stat {{ 'stat--danger' if device.fail_count else '' }}">
|
||||||
<span class="stat__label">Fehlversuche</span>
|
<span class="stat__label">Fehlversuche</span>
|
||||||
@@ -88,29 +88,33 @@
|
|||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="card__title">{{ icon('power', size=17) }} Neustarts</div>
|
<div class="card__title">{{ icon('power', size=17) }} Neustarts</div>
|
||||||
<div class="card__sub">
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% if events %}
|
{% if events %}
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table class="table table--compact">
|
<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>
|
<tbody>
|
||||||
{% for event in events %}
|
{% for event in events %}
|
||||||
<tr>
|
<tr>
|
||||||
<td class="fs-sm nowrap">{{ event.ts | dt }}</td>
|
<td class="fs-sm nowrap">{{ event.ts | dt }}</td>
|
||||||
<td class="fs-sm">
|
<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') }}
|
{% 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>
|
||||||
<td class="fs-sm">{{ event.trigger }} <span class="faint">/ {{ event.actor }}</span></td>
|
<td class="fs-sm">{{ event.trigger }} <span class="faint">/ {{ event.actor }}</span></td>
|
||||||
<td>
|
<td>
|
||||||
{% if event.result == 'ok' %}{{ pill('erfolgreich', 'online') }}
|
{% 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 %}
|
{% else %}{{ pill('fehlgeschlagen', 'danger') }}{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="numeric fs-sm">{{ event.duration_ms or 0 }} ms</td>
|
<td class="numeric fs-sm nowrap">{{ event.duration_ms | millis }}</td>
|
||||||
<td class="fs-xs muted break-all">{{ event.detail }}</td>
|
<td class="fs-xs muted break-all">{{ event.detail }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -118,7 +122,7 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% 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 %}
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -126,12 +130,12 @@
|
|||||||
<div class="card__header">
|
<div class="card__header">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="card__title">
|
<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') }}
|
{% if host_keys %}{{ pill('freigegeben', 'online') }}
|
||||||
{% else %}{{ pill('offen', 'warning') }}{% endif %}
|
{% else %}{{ pill('offen', 'warning') }}{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="card__sub">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
{% if current_user.has('devices.edit') %}
|
{% if current_user.has('devices.edit') %}
|
||||||
@@ -151,11 +155,11 @@
|
|||||||
<div class="alert alert--warning">
|
<div class="alert alert--warning">
|
||||||
{{ icon('alert', size=18) }}
|
{{ icon('alert', size=18) }}
|
||||||
<div class="alert__body">
|
<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>
|
<div class="mono fs-sm break-all">{{ probed_type }} {{ probed_fingerprint }}</div>
|
||||||
<p class="fs-sm mt-2">
|
<p class="fs-sm mt-2">
|
||||||
Vergleichen Sie den Fingerprint mit einer unabhaengigen Quelle (Konsole am
|
Vergleichen Sie den Fingerprint mit einer unabhängigen Quelle (Konsole am
|
||||||
Geraet, Inventardokumentation), bevor Sie ihn freigeben.
|
Gerät, Inventardokumentation), bevor Sie ihn freigeben.
|
||||||
</p>
|
</p>
|
||||||
<form method="post" class="mt-2"
|
<form method="post" class="mt-2"
|
||||||
action="{{ url_for('devices.trust_host_key', device_id=device.id) }}">
|
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="key_type" value="{{ probed_type }}">
|
||||||
<input type="hidden" name="fingerprint" value="{{ probed_fingerprint }}">
|
<input type="hidden" name="fingerprint" value="{{ probed_fingerprint }}">
|
||||||
<button class="btn btn--sm btn--primary" type="submit"
|
<button class="btn btn--sm btn--primary" type="submit"
|
||||||
data-confirm="Diesen Fingerprint dauerhaft als vertrauenswuerdig hinterlegen?"
|
data-confirm="Diesen Fingerprint dauerhaft als vertrauenswürdig hinterlegen?"
|
||||||
data-confirm-title="Host-Schluessel freigeben" data-confirm-ok="Freigeben"
|
data-confirm-title="Host-Schlüssel freigeben" data-confirm-ok="Freigeben"
|
||||||
data-confirm-style="primary">Freigeben</button>
|
data-confirm-style="primary">Freigeben</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -184,15 +188,15 @@
|
|||||||
data-no-guard>
|
data-no-guard>
|
||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
<button class="btn btn--sm btn--ghost" type="submit"
|
<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="Gespeicherte Host-Schlüssel dieses Geräts entfernen? Danach muss der Schlüssel erneut freigegeben werden."
|
||||||
data-confirm-title="Schluessel verwerfen" data-confirm-ok="Verwerfen">
|
data-confirm-title="Schlüssel verwerfen" data-confirm-ok="Verwerfen">
|
||||||
Verwerfen
|
Verwerfen
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="muted fs-sm">
|
<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>
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
@@ -223,7 +227,7 @@
|
|||||||
<dt>Zugangsdaten</dt><dd>{{ device.credential_name or 'keine' }}</dd>
|
<dt>Zugangsdaten</dt><dd>{{ device.credential_name or 'keine' }}</dd>
|
||||||
<dt>Automatischer Neustart</dt><dd>{{ device.auto_restart | yes_no }}</dd>
|
<dt>Automatischer Neustart</dt><dd>{{ device.auto_restart | yes_no }}</dd>
|
||||||
<dt>Angelegt</dt><dd>{{ device.created_at | dt }}</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>
|
<dd>{{ device.updated_at | dt }} <span class="faint">von {{ device.updated_by or '-' }}</span></dd>
|
||||||
</dl>
|
</dl>
|
||||||
{% if device.notes %}<p class="fs-sm muted mt-3">{{ device.notes }}</p>{% endif %}
|
{% 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="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
<input type="hidden" name="active" value="{{ '0' if device.is_active else '1' }}">
|
<input type="hidden" name="active" value="{{ '0' if device.is_active else '1' }}">
|
||||||
<button class="btn btn--sm" type="submit">
|
<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>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,10 +4,10 @@
|
|||||||
|
|
||||||
{# Neustart-Bedienelement.
|
{# Neustart-Bedienelement.
|
||||||
|
|
||||||
Ein einziger moeglicher Weg: ein Knopf, der ihn benennt. Mehrere: ein Knopf
|
Ein einziger möglicher Weg: ein Knopf, der ihn benennt. Mehrere: ein Knopf
|
||||||
fuer den bevorzugten Weg (PoE, weil er auch bei haengendem Betriebssystem
|
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
|
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) %}
|
{% macro restart_control(device, methods, compact=False) %}
|
||||||
{% if methods %}
|
{% if methods %}
|
||||||
{% set first = methods[0] %}
|
{% set first = methods[0] %}
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
<input type="hidden" name="method" value="{{ first.key }}">
|
<input type="hidden" name="method" value="{{ first.key }}">
|
||||||
<button class="btn btn--sm{{ ' btn--ghost' if compact else '' }}" type="submit"
|
<button class="btn btn--sm{{ ' btn--ghost' if compact else '' }}" type="submit"
|
||||||
title="{{ first.hint }}" data-busy-label="Startet ..."
|
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-title="Neustart" data-confirm-ok="Neu starten"
|
||||||
data-confirm-style="primary">
|
data-confirm-style="primary">
|
||||||
{{ icon('power', size=15) }}{% if not compact %} {{ first.label }}{% endif %}
|
{{ icon('power', size=15) }}{% if not compact %} {{ first.label }}{% endif %}
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
<input type="hidden" name="method" value="{{ other.key }}">
|
<input type="hidden" name="method" value="{{ other.key }}">
|
||||||
<button class="btn btn--sm btn--ghost" type="submit" title="{{ other.hint }}"
|
<button class="btn btn--sm btn--ghost" type="submit" title="{{ other.hint }}"
|
||||||
data-busy-label="Startet ..."
|
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-title="Neustart" data-confirm-ok="Neu starten"
|
||||||
data-confirm-style="primary">
|
data-confirm-style="primary">
|
||||||
{{ other.label }}
|
{{ other.label }}
|
||||||
@@ -65,7 +65,7 @@
|
|||||||
placeholder='1 oder 1/1/3',
|
placeholder='1 oder 1/1/3',
|
||||||
hint_text='Pflicht, sobald ein Switch zugeordnet ist.') }}
|
hint_text='Pflicht, sobald ein Switch zugeordnet ist.') }}
|
||||||
<label class="field">
|
<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"
|
<select class="select" name="credential_id" data-behavior="toggle-target"
|
||||||
data-toggle-value="__neu__" data-toggle-target="#new-credential-{{ scope }}">
|
data-toggle-value="__neu__" data-toggle-target="#new-credential-{{ scope }}">
|
||||||
<option value="">keine</option>
|
<option value="">keine</option>
|
||||||
@@ -85,8 +85,8 @@
|
|||||||
<legend class="field__label">Neue Zugangsdaten</legend>
|
<legend class="field__label">Neue Zugangsdaten</legend>
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
{{ field('new_credential_name', 'Bezeichnung', placeholder='z. B. Wartung Linux',
|
{{ field('new_credential_name', 'Bezeichnung', placeholder='z. B. Wartung Linux',
|
||||||
hint_text='Der Eintrag wird zusaetzlich unter Zugangsdaten gespeichert und
|
hint_text='Der Eintrag wird zusätzlich unter Zugangsdaten gespeichert und
|
||||||
ist danach fuer weitere Geraete verwendbar.') }}
|
ist danach für weitere Geräte verwendbar.') }}
|
||||||
{{ field('new_credential_username', 'Benutzer') }}
|
{{ field('new_credential_username', 'Benutzer') }}
|
||||||
{{ field('new_credential_secret', 'Passwort', type='password') }}
|
{{ field('new_credential_secret', 'Passwort', type='password') }}
|
||||||
<label class="field">
|
<label class="field">
|
||||||
@@ -101,7 +101,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
{% endif %}
|
{% 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">
|
<label class="field">
|
||||||
<span class="field__label">Kategorie</span>
|
<span class="field__label">Kategorie</span>
|
||||||
<select class="select" name="category">
|
<select class="select" name="category">
|
||||||
@@ -115,23 +115,23 @@
|
|||||||
extra_class='field--full') }}
|
extra_class='field--full') }}
|
||||||
</div>
|
</div>
|
||||||
<div class="stack stack--tight">
|
<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',
|
{{ checkbox('auto_restart', 'Automatischer PoE-Neustart bei Ausfall',
|
||||||
checked=device.auto_restart if device else True,
|
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>
|
</div>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% block page_title %}Clients{% endblock %}
|
{% block page_title %}Clients{% endblock %}
|
||||||
{% block page_sub %}
|
{% 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 %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block topbar_extra %}
|
{% block topbar_extra %}
|
||||||
{{ search_box('devices-table', 'Name, IP, MAC, Switch ...', count_id='devices-visible') }}
|
{{ search_box('devices-table', 'Name, IP, MAC, Switch ...', count_id='devices-visible') }}
|
||||||
{% if current_user.has('devices.create') %}
|
{% if current_user.has('devices.create') %}
|
||||||
<button class="btn btn--primary btn--sm" type="button" data-dialog-open="device-new">
|
<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>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -155,8 +155,8 @@
|
|||||||
<th class="sortable">MAC</th>
|
<th class="sortable">MAC</th>
|
||||||
<th class="sortable">Zugangsdaten</th>
|
<th class="sortable">Zugangsdaten</th>
|
||||||
<th class="sortable">Switch / Port</th>
|
<th class="sortable">Switch / Port</th>
|
||||||
<th class="sortable">Letzte Pruefung</th>
|
<th class="sortable">Letzte Prüfung</th>
|
||||||
<th class="sortable">Host-Schluessel</th>
|
<th class="sortable">Host-Schlüssel</th>
|
||||||
<th class="actions">Aktion</th>
|
<th class="actions">Aktion</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -167,13 +167,14 @@
|
|||||||
<a class="fw-600" href="{{ url_for('devices.detail', device_id=device.id) }}">{{ device.name }}</a>
|
<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.is_active %}{{ pill('deaktiviert', '') }}{% endif %}
|
||||||
{% if not device.auto_restart %}
|
{% 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 %}
|
{% endif %}
|
||||||
{% if device.location %}<div class="fs-xs faint">{{ device.location }}</div>{% endif %}
|
{% if device.location %}<div class="fs-xs faint">{{ device.location }}</div>{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td data-sort-value="{{ device.state }}">
|
<td data-sort-value="{{ device.state }}">
|
||||||
{% if not device.is_active %}{{ pill('aus', '') }}
|
{% if not device.is_active %}{{ pill('aus', '') }}
|
||||||
{% elif device.state == 'online' %}{{ pill('online', 'online') }}
|
{% elif device.state == 'online' %}{{ pill('online', 'online') }}
|
||||||
|
{% elif device.state == 'restarting' %}{{ pill('startet neu', 'warning') }}
|
||||||
{% elif device.state == 'offline' %}{{ pill('offline', 'offline') }}
|
{% elif device.state == 'offline' %}{{ pill('offline', 'offline') }}
|
||||||
{% else %}{{ pill('unbekannt', 'warning') }}{% endif %}
|
{% else %}{{ pill('unbekannt', 'warning') }}{% endif %}
|
||||||
{% if device.fail_count %}<span class="fs-xs faint">({{ device.fail_count }}×)</span>{% endif %}
|
{% if device.fail_count %}<span class="fs-xs faint">({{ device.fail_count }}×)</span>{% endif %}
|
||||||
@@ -200,8 +201,8 @@
|
|||||||
</td>
|
</td>
|
||||||
<td data-sort-value="{{ '1' if device.host_key_known else '0' }}">
|
<td data-sort-value="{{ '1' if device.host_key_known else '0' }}">
|
||||||
{% if device.credential_category == 'windows' %}
|
{% if device.credential_category == 'windows' %}
|
||||||
<span class="faint fs-xs" title="Windows wird nicht ueber SSH verwaltet">
|
<span class="faint fs-xs" title="Windows wird nicht über SSH verwaltet">
|
||||||
nicht noetig
|
nicht nötig
|
||||||
</span>
|
</span>
|
||||||
{% elif device.host_key_known %}{{ pill('freigegeben', 'online') }}
|
{% elif device.host_key_known %}{{ pill('freigegeben', 'online') }}
|
||||||
{% else %}
|
{% else %}
|
||||||
@@ -225,9 +226,9 @@
|
|||||||
<form method="post" action="{{ url_for('devices.delete', device_id=device.id) }}"
|
<form method="post" action="{{ url_for('devices.delete', device_id=device.id) }}"
|
||||||
style="display:inline" data-no-guard>
|
style="display:inline" data-no-guard>
|
||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
<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="{{ 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>
|
{{ icon('trash', size=15) }}</button>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -238,19 +239,19 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
{% if not devices %}
|
{% 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 %}
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{{ alert('Ein PoE-Neustart setzt Switch und Port voraus, ein Neustart ueber SSH oder
|
{{ alert('Ein PoE-Neustart setzt Switch und Port voraus, ein Neustart über SSH oder
|
||||||
RPC setzt passende Zugangsdaten voraus. Ist keine Methode moeglich, erscheint der Knopf
|
RPC setzt passende Zugangsdaten voraus. Ist keine Methode möglich, erscheint der Knopf
|
||||||
nicht -- damit kein Neustart vorgetaeuscht wird, der nichts bewirkt.', 'info') }}
|
nicht -- damit kein Neustart vorgetäuscht wird, der nichts bewirkt.', 'info') }}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|
||||||
{% block dialogs %}
|
{% block dialogs %}
|
||||||
{% if current_user.has('devices.create') %}
|
{% 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') }}">
|
<form method="post" action="{{ url_for('devices.create') }}">
|
||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
<div class="modal__body">{{ device_form(scope='neu') }}</div>
|
<div class="modal__body">{{ device_form(scope='neu') }}</div>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
{% for warning in warnings %}{{ alert(warning, 'warning') }}{% endfor %}
|
{% for warning in warnings %}{{ alert(warning, 'warning') }}{% endfor %}
|
||||||
{% if not sysops_available %}
|
{% 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') }}
|
angezeigt, nicht geschrieben werden.', 'warning') }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
@@ -27,13 +27,13 @@
|
|||||||
<div class="stat">
|
<div class="stat">
|
||||||
<span class="stat__label">Reservierungen</span>
|
<span class="stat__label">Reservierungen</span>
|
||||||
<span class="stat__value">{{ reservations | length }}</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>
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
<span class="stat__label">Leases</span>
|
<span class="stat__label">Leases</span>
|
||||||
<span class="stat__value">{{ leases | rejectattr('is_expired') | list | length }}</span>
|
<span class="stat__value">{{ leases | rejectattr('is_expired') | list | length }}</span>
|
||||||
<span class="stat__hint">
|
<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 %}
|
{{ leases | selectattr('is_expired') | list | length }} abgelaufen{% endif %}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -58,7 +58,7 @@
|
|||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
<button class="btn btn--sm {{ 'btn--' ~ style if style }}" type="submit"
|
<button class="btn btn--sm {{ 'btn--' ~ style if style }}" type="submit"
|
||||||
data-busy-label="Arbeitet ..." data-confirm="{{ question }}"
|
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>
|
data-confirm-style="{{ style or 'primary' }}">{{ label }}</button>
|
||||||
</form>
|
</form>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -164,8 +164,8 @@
|
|||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="card__title">{{ icon('puzzle', size=17) }} DHCP-Optionen</div>
|
<div class="card__title">{{ icon('puzzle', size=17) }} DHCP-Optionen</div>
|
||||||
<div class="card__sub">
|
<div class="card__sub">
|
||||||
{{ options | length }} Optionen · Standardoptionen sind nicht loeschbar, ihr Wert
|
{{ options | length }} Optionen · Standardoptionen sind nicht löschbar, ihr Wert
|
||||||
laesst sich leeren
|
lässt sich leeren
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{ search_box('options-table', 'Option suchen ...') }}
|
{{ search_box('options-table', 'Option suchen ...') }}
|
||||||
@@ -202,8 +202,8 @@
|
|||||||
data-no-guard>
|
data-no-guard>
|
||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
<button class="btn btn--sm btn--ghost danger-text" type="submit"
|
<button class="btn btn--sm btn--ghost danger-text" type="submit"
|
||||||
data-confirm="Eigene Option {{ option.name }} loeschen?"
|
data-confirm="Eigene Option {{ option.name }} löschen?"
|
||||||
data-confirm-title="Option loeschen" data-confirm-ok="Loeschen">
|
data-confirm-title="Option löschen" data-confirm-ok="Löschen">
|
||||||
{{ icon('trash', size=14) }}</button>
|
{{ icon('trash', size=14) }}</button>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -232,16 +232,16 @@
|
|||||||
<div class="card__footer"><button class="btn" type="submit">Eigene Option anlegen</button></div>
|
<div class="card__footer"><button class="btn" type="submit">Eigene Option anlegen</button></div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{# Der Wert einer Option gilt sonst fuer alle. Genau dafuer gibt es
|
{# Der Wert einer Option gilt sonst für alle. Genau dafür gibt es
|
||||||
``dhcp_option_values.device_id``: ein eigener Wert je Geraet, der in
|
``dhcp_option_values.device_id``: ein eigener Wert je Gerät, der in
|
||||||
dessen Reservierung geschrieben wird -- etwa eine Terminal-URL, die je
|
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. #}
|
es sich nicht. #}
|
||||||
<div class="card__header" style="border-top:1px solid var(--border-subtle)">
|
<div class="card__header" style="border-top:1px solid var(--border-subtle)">
|
||||||
<div class="flex-1">
|
<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">
|
<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.
|
globalen Wert. Leeres Feld entfernt ihn wieder.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -251,7 +251,7 @@
|
|||||||
<div class="card__body">
|
<div class="card__body">
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<label class="field">
|
<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>
|
<select class="select" name="device_id" required>
|
||||||
{% for reservation in reservations if reservation.device_id %}
|
{% for reservation in reservations if reservation.device_id %}
|
||||||
<option value="{{ reservation.device_id }}">
|
<option value="{{ reservation.device_id }}">
|
||||||
@@ -269,11 +269,11 @@
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
{{ field('value', 'Wert', extra_class='field--full',
|
{{ 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>
|
</div>
|
||||||
<div class="card__footer">
|
<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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -286,16 +286,16 @@
|
|||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="card__title">{{ icon('credit-card', size=17) }} Reservierungen</div>
|
<div class="card__title">{{ icon('credit-card', size=17) }} Reservierungen</div>
|
||||||
<div class="card__sub">
|
<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.
|
Nur Adressen aus eingerichteten Bereichen.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% if skipped_reservations %}
|
{% 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. #}
|
der Liste, obwohl Kea sie nie ausgeliefert hat. #}
|
||||||
<div class="card__body pt-0">
|
<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
|
Bereich und bekommen keine Reservierung. Legen Sie den passenden Bereich an, wenn sie
|
||||||
eine bekommen sollen.', 'warning') }}
|
eine bekommen sollen.', 'warning') }}
|
||||||
<details class="fs-xs faint">
|
<details class="fs-xs faint">
|
||||||
@@ -339,7 +339,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="actions">
|
<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
|
<form method="post" data-no-guard
|
||||||
action="{{ url_for('dhcp.delete_reservation', reservation_id=reservation.reservation_id) }}">
|
action="{{ url_for('dhcp.delete_reservation', reservation_id=reservation.reservation_id) }}">
|
||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
@@ -381,20 +381,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% if leases and service.get('active') != 'active' %}
|
{% 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
|
ohne laufenden Dienst wird keine Lease erneuert, und die Datei
|
||||||
veraltet still. #}
|
veraltet still. #}
|
||||||
<div class="card__body pt-0">
|
<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
|
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>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if leases %}
|
{% if leases %}
|
||||||
<div class="table-wrap" style="max-height:420px;overflow:auto">
|
<div class="table-wrap" style="max-height:420px;overflow:auto">
|
||||||
<table class="table table--compact">
|
<table class="table table--compact">
|
||||||
<thead><tr><th>IP</th><th>MAC</th><th>Hostname</th><th>Art</th><th>Zustand</th>
|
<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>
|
<tbody>
|
||||||
{% for lease in leases %}
|
{% for lease in leases %}
|
||||||
<tr>
|
<tr>
|
||||||
@@ -407,7 +407,7 @@
|
|||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{% if lease.is_expired %}{{ pill('abgelaufen', 'warning') }}
|
{% 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 %}
|
{% else %}{{ pill('inaktiv', '') }}{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="fs-sm">{{ lease.expires_text }}
|
<td class="fs-sm">{{ lease.expires_text }}
|
||||||
@@ -429,7 +429,7 @@
|
|||||||
<summary class="card__header" style="cursor:pointer">
|
<summary class="card__header" style="cursor:pointer">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="card__title">{{ icon('file-text', size=17) }} Erzeugte Kea-Konfiguration</div>
|
<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>
|
</div>
|
||||||
{{ icon('chevron-down', size=16) }}
|
{{ icon('chevron-down', size=16) }}
|
||||||
</summary>
|
</summary>
|
||||||
|
|||||||
@@ -59,8 +59,8 @@
|
|||||||
<tr>
|
<tr>
|
||||||
{% if current_user.has('fileshare.delete') %}<th style="width:40px"></th>{% endif %}
|
{% if current_user.has('fileshare.delete') %}<th style="width:40px"></th>{% endif %}
|
||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
<th class="numeric">Groesse</th>
|
<th class="numeric">Grösse</th>
|
||||||
<th>Geaendert</th>
|
<th>Geändert</th>
|
||||||
<th class="actions">Aktion</th>
|
<th class="actions">Aktion</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -69,9 +69,9 @@
|
|||||||
<tr>
|
<tr>
|
||||||
{% if current_user.has('fileshare.delete') %}<td></td>{% endif %}
|
{% if current_user.has('fileshare.delete') %}<td></td>{% endif %}
|
||||||
<td colspan="4">
|
<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 '')) }}">
|
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>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -79,7 +79,7 @@
|
|||||||
{% for entry in listing.directories %}
|
{% for entry in listing.directories %}
|
||||||
<tr>
|
<tr>
|
||||||
{% if current_user.has('fileshare.delete') %}
|
{% 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 %}
|
{% endif %}
|
||||||
<td>
|
<td>
|
||||||
{{ icon('folder', size=15) }}
|
{{ icon('folder', size=15) }}
|
||||||
@@ -93,16 +93,44 @@
|
|||||||
{% for entry in listing.files %}
|
{% for entry in listing.files %}
|
||||||
<tr>
|
<tr>
|
||||||
{% if current_user.has('fileshare.delete') %}
|
{% 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 %}
|
{% 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-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="numeric fs-sm">{{ entry.size | bytes }}</td>
|
||||||
<td class="fs-sm">{{ entry.modified | dt }}</td>
|
<td class="fs-sm">{{ entry.modified | dt }}</td>
|
||||||
<td class="actions">
|
<td class="actions">
|
||||||
{% if entry.previewable %}
|
{% if entry.previewable %}
|
||||||
<a class="btn btn--sm btn--ghost" target="_blank" rel="noopener"
|
<button class="btn btn--sm btn--ghost" type="button"
|
||||||
href="{{ url_for('fileshare.preview', token=token, pfad=entry.relative) }}"
|
data-dialog-open="file-preview"
|
||||||
aria-label="Vorschau">{{ icon('eye', size=15) }}</a>
|
data-fill-Src="{{ url_for('fileshare.preview', token=token, 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.name }}"
|
||||||
|
aria-label="Umbenennen">{{ icon('pencil', size=15) }}</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<a class="btn btn--sm btn--ghost"
|
<a class="btn btn--sm btn--ghost"
|
||||||
href="{{ url_for('fileshare.download', token=token, pfad=entry.relative) }}"
|
href="{{ url_for('fileshare.download', token=token, pfad=entry.relative) }}"
|
||||||
@@ -119,9 +147,9 @@
|
|||||||
{% if current_user.has('fileshare.delete') %}
|
{% if current_user.has('fileshare.delete') %}
|
||||||
<div class="card__footer">
|
<div class="card__footer">
|
||||||
<button class="btn btn--sm btn--ghost danger-text" type="submit"
|
<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="Die ausgewählten Einträge endgültig löschen? Auf einer Netzwerkfreigabe gibt es keinen Papierkorb."
|
||||||
data-confirm-title="Loeschen" data-confirm-ok="Loeschen">
|
data-confirm-title="Löschen" data-confirm-ok="Löschen">
|
||||||
Ausgewaehlte loeschen
|
Ausgewählte löschen
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -142,7 +170,7 @@
|
|||||||
<input type="hidden" name="pfad" value="{{ listing.relative }}">
|
<input type="hidden" name="pfad" value="{{ listing.relative }}">
|
||||||
<div class="card__body row">
|
<div class="card__body row">
|
||||||
<input class="input" type="file" name="files" multiple required style="flex:1">
|
<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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
@@ -165,4 +193,44 @@
|
|||||||
</form>
|
</form>
|
||||||
{% endcall %}
|
{% endcall %}
|
||||||
{% endif %}
|
{% 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>
|
||||||
|
<div class="modal__body modal__body--flush">
|
||||||
|
<iframe class="file-preview" title="Vorschau" data-fill-target="Src" data-fill-as="src"
|
||||||
|
src="about:blank" referrerpolicy="same-origin"></iframe>
|
||||||
|
</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>
|
||||||
|
<button class="btn btn--primary" type="button" data-dialog-close>Schliessen</button>
|
||||||
|
</div>
|
||||||
|
{% endcall %}
|
||||||
|
{% 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', required=True, attrs='data-fill-target="Name"') }}
|
||||||
|
<p class="fs-xs faint">
|
||||||
|
Umbenannt wird auf der Freigabe selbst -- es gibt dort keinen Papierkorb.
|
||||||
|
</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 %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
{% extends "tesm_core/base.html" %}
|
{% extends "tesm_core/base.html" %}
|
||||||
{% from "tesm_core/_group_picker.html" import group_picker %}
|
{% 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_title %}Freigaben einrichten{% endblock %}
|
||||||
{% block page_sub %}{{ mappings | length }} Zuordnungen · Wurzel {{ mount_root }}{% endblock %}
|
{% block page_sub %}{{ mappings | length }} Zuordnungen · Wurzel {{ mount_root }}{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
{{ alert('Eine Freigabe wird bei der Anmeldung eingebunden, wenn der Benutzer Mitglied der
|
{{ alert('Eine Freigabe wird bei der Anmeldung eingebunden, wenn der Benutzer Mitglied der
|
||||||
zugeordneten Verzeichnisgruppe ist. Eingebunden wird mit dem Konto dieses Benutzers -- deshalb
|
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') }}
|
Einbindungen.', 'info') }}
|
||||||
|
|
||||||
{% if not directory_enabled %}
|
{% if not directory_enabled %}
|
||||||
@@ -33,6 +33,11 @@ Einbindungen.', 'info') }}
|
|||||||
<td class="fs-sm">{{ entry.ad_group_name }}
|
<td class="fs-sm">{{ entry.ad_group_name }}
|
||||||
<div class="fs-xs faint break-all">{{ entry.ad_group_dn }}</div></td>
|
<div class="fs-xs faint break-all">{{ entry.ad_group_dn }}</div></td>
|
||||||
<td class="actions">
|
<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>
|
<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() }}">
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
<button class="btn btn--sm btn--ghost danger-text" type="submit"
|
<button class="btn btn--sm btn--ghost danger-text" type="submit"
|
||||||
@@ -61,7 +66,7 @@ Einbindungen.', 'info') }}
|
|||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
{{ field('label', 'Anzeigename', required=True, placeholder='Abteilung Technik') }}
|
{{ field('label', 'Anzeigename', required=True, placeholder='Abteilung Technik') }}
|
||||||
{{ field('unc', 'UNC-Pfad', required=True, placeholder='\\\\dc2022\\daten',
|
{{ 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,
|
{{ group_picker('ad_group_name', 'ad_group_dn', 'share', required=True,
|
||||||
label='Verzeichnisgruppe, die diese Freigabe bekommt') }}
|
label='Verzeichnisgruppe, die diese Freigabe bekommt') }}
|
||||||
</div>
|
</div>
|
||||||
@@ -70,3 +75,33 @@ Einbindungen.', 'info') }}
|
|||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
{% endblock %}
|
{% 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" %}
|
{% extends "tesm_core/base.html" %}
|
||||||
{% from "tesm_core/macros.html" import pill, empty_state, alert, search_box %}
|
{% 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 %}
|
{% block page_sub %}
|
||||||
{{ targets | length }} Ziele mit SSH-Zugang
|
{{ targets | length }} Ziele mit SSH-Zugang
|
||||||
{%- if open_count %} · {{ open_count }} ohne Freigabe{% endif %}
|
{%- if open_count %} · {{ open_count }} ohne Freigabe{% endif %}
|
||||||
{% endblock %}
|
{% 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 %}
|
{% block content %}
|
||||||
{% if open_count %}
|
{% if open_count %}
|
||||||
{{ alert('Fuer ' ~ open_count ~ ' Ziel(e) ist noch kein Schluessel freigegeben. Wartung,
|
{# Der PoE-Neustart hängt am Schlüssel des *Switches*, nicht am Schlüssel des
|
||||||
Konsole und PoE-Neustart brechen dort mit "Schluessel unbekannt" ab -- absichtlich: ein
|
Clients: geschaltet wird der Port auf dem Switch. Der alte Satz warf beides
|
||||||
Hintergrundauftrag darf keinen fremden Schluessel annehmen.', 'warning',
|
zusammen und liess vermuten, ein Client ohne freigegebenen Schlüssel sei
|
||||||
'Freigaben offen') }}
|
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 %}
|
{% else %}
|
||||||
{{ alert('Fuer jedes Ziel mit SSH-Zugang liegt ein freigegebener Schluessel vor.',
|
{{ alert('Für jedes Ziel mit SSH-Zugang liegt ein freigegebener Schlüssel vor.',
|
||||||
'success', 'Vollstaendig') }}
|
'success', 'Vollständig') }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{{ alert('Ein Schluessel wird in zwei Schritten uebernommen: erst auslesen, dann -- nach
|
{{ alert('Ein Schlüssel wird in zwei Schritten übernommen: erst auslesen, dann -- nach
|
||||||
Vergleich mit einer unabhaengigen Quelle -- freigeben. Gespeichert wird nach IP und Port;
|
Vergleich mit einer unabhängigen Quelle -- freigeben. Gespeichert wird nach IP und Port;
|
||||||
aendert sich die Adresse eines Geraets, ist erneut freizugeben. Ein *geaenderter* Schluessel
|
ändert sich die Adresse eines Geräts, ist erneut freizugeben. Ein *geänderter* Schlüssel
|
||||||
wird nie stillschweigend uebernommen: das ist der Fall, der bei einem Angriff auftritt.',
|
wird nie stillschweigend übernommen: das ist der Fall, der bei einem Angriff auftritt.',
|
||||||
'info', 'So funktioniert die Freigabe') }}
|
'info', 'So funktioniert die Freigabe') }}
|
||||||
|
|
||||||
{% if probed_fingerprint %}
|
{% if probed_fingerprint %}
|
||||||
<div class="alert alert--warning">
|
<div class="alert alert--warning">
|
||||||
{{ icon('alert', size=18) }}
|
{{ icon('alert', size=18) }}
|
||||||
<div class="alert__body">
|
<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>
|
<div class="mono fs-sm break-all">{{ probed_type }} {{ probed_fingerprint }}</div>
|
||||||
<p class="fs-sm mt-2">
|
<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
|
||||||
Geraet, Inventardokumentation --, bevor Sie ihn unten freigeben.
|
Gerät, Inventardokumentation --, bevor Sie ihn unten freigeben.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</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="key_type" value="{{ probed_type }}">
|
||||||
<input type="hidden" name="fingerprint" value="{{ probed_fingerprint }}">
|
<input type="hidden" name="fingerprint" value="{{ probed_fingerprint }}">
|
||||||
<button class="btn btn--sm btn--primary" type="submit"
|
<button class="btn btn--sm btn--primary" type="submit"
|
||||||
data-confirm="Fingerprint {{ probed_fingerprint }} fuer {{ row.label }} dauerhaft als vertrauenswuerdig hinterlegen?"
|
data-confirm="Fingerprint {{ probed_fingerprint }} für {{ row.label }} dauerhaft als vertrauenswürdig hinterlegen?"
|
||||||
data-confirm-title="Host-Schluessel freigeben"
|
data-confirm-title="Host-Schlüssel freigeben"
|
||||||
data-confirm-ok="Freigeben" data-confirm-style="primary">
|
data-confirm-ok="Freigeben" data-confirm-style="primary">
|
||||||
{{ icon('check', size=14) }} Freigeben
|
{{ icon('check', size=14) }} Freigeben
|
||||||
</button>
|
</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) }}">
|
action="{{ url_for('hostkeys.forget', kind=row.kind, target_id=row.id) }}">
|
||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
<button class="btn btn--sm btn--ghost" type="submit"
|
<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">
|
data-confirm-title="Freigabe verwerfen" data-confirm-ok="Verwerfen">
|
||||||
Verwerfen
|
Verwerfen
|
||||||
</button>
|
</button>
|
||||||
@@ -141,7 +145,7 @@ wird nie stillschweigend uebernommen: das ist der Fall, der bei einem Angriff au
|
|||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="card__title">{{ icon('alert', size=17) }} Verwaiste Freigaben</div>
|
<div class="card__title">{{ icon('alert', size=17) }} Verwaiste Freigaben</div>
|
||||||
<div class="card__sub">
|
<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>
|
</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="host" value="{{ row.host }}">
|
||||||
<input type="hidden" name="port" value="{{ row.port }}">
|
<input type="hidden" name="port" value="{{ row.port }}">
|
||||||
<button class="btn btn--sm btn--ghost" type="submit"
|
<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">
|
data-confirm-title="Eintrag entfernen" data-confirm-ok="Entfernen">
|
||||||
Entfernen
|
Entfernen
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
{% block page_title %}Lizenz{% endblock %}
|
{% block page_title %}Lizenz{% endblock %}
|
||||||
{% block page_sub %}
|
{% block page_sub %}
|
||||||
{% if state and state.valid and state.activated %}aktiv
|
{% if state and state.valid and state.activated %}aktiv
|
||||||
{% elif state and state.valid %}gueltig, noch nicht aktiviert
|
{% elif state and state.valid %}gültig, noch nicht aktiviert
|
||||||
{% elif state and state.present %}ungueltig
|
{% elif state and state.present %}ungültig
|
||||||
{% else %}keine Lizenz installiert{% endif %}
|
{% else %}keine Lizenz installiert{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
<dt>Lizenz-ID</dt><dd class="mono fs-xs break-all">{{ state.status.license_id }}</dd>
|
<dt>Lizenz-ID</dt><dd class="mono fs-xs break-all">{{ state.status.license_id }}</dd>
|
||||||
<dt>Revision</dt><dd>{{ state.status.seq }}</dd>
|
<dt>Revision</dt><dd>{{ state.status.seq }}</dd>
|
||||||
<dt>Ausgestellt</dt><dd>{{ state.status.issued_at | dt }}</dd>
|
<dt>Ausgestellt</dt><dd>{{ state.status.issued_at | dt }}</dd>
|
||||||
<dt>Laeuft ab</dt>
|
<dt>Läuft ab</dt>
|
||||||
<dd>
|
<dd>
|
||||||
{% if state.status.lifetime %}{{ pill('unbefristet', 'online') }}
|
{% if state.status.lifetime %}{{ pill('unbefristet', 'online') }}
|
||||||
{% else %}
|
{% else %}
|
||||||
@@ -46,7 +46,7 @@
|
|||||||
{{ pill('abgelaufen seit ' ~ state.status.days_since_expiry ~ ' Tagen', 'danger') }}
|
{{ pill('abgelaufen seit ' ~ state.status.days_since_expiry ~ ' Tagen', 'danger') }}
|
||||||
{% if state.status.grace_active %}
|
{% if state.status.grace_active %}
|
||||||
<div class="fs-xs warning-text">
|
<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>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% elif state.status.expiring_soon %}
|
{% elif state.status.expiring_soon %}
|
||||||
@@ -88,7 +88,7 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
<form method="post" action="{{ url_for('license.heartbeat') }}" data-no-guard>
|
<form method="post" action="{{ url_for('license.heartbeat') }}" data-no-guard>
|
||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
<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>
|
||||||
<form method="post" action="{{ url_for('license.deactivate') }}" data-no-guard>
|
<form method="post" action="{{ url_for('license.deactivate') }}" data-no-guard>
|
||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
@@ -118,14 +118,14 @@
|
|||||||
{% if pending.reason %}
|
{% if pending.reason %}
|
||||||
{{ alert(pending.reason, 'warning', 'Warum der Online-Weg nicht ging') }}
|
{{ alert(pending.reason, 'warning', 'Warum der Online-Weg nicht ging') }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{{ alert('Beide Codes sind signiert -- sie lassen sich unterwegs nicht veraendern.
|
{{ alert('Beide Codes sind signiert -- sie lassen sich unterwegs nicht verändern.
|
||||||
Der Anfragecode gehoert zu genau dieser Maschine und genau dieser Anfrage; eine
|
Der Anfragecode gehört zu genau dieser Maschine und genau dieser Anfrage; eine
|
||||||
Antwort auf einen anderen Code wird abgelehnt.', 'info') }}
|
Antwort auf einen anderen Code wird abgelehnt.', 'info') }}
|
||||||
|
|
||||||
{% if pending.code %}
|
{% if pending.code %}
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<span class="field__label">
|
<span class="field__label">
|
||||||
Anfragecode -- an den Anbieter uebermitteln
|
Anfragecode -- an den Anbieter übermitteln
|
||||||
</span>
|
</span>
|
||||||
<pre class="log-view" id="offline-request" style="max-height:14dvh">{{ pending.code }}</pre>
|
<pre class="log-view" id="offline-request" style="max-height:14dvh">{{ pending.code }}</pre>
|
||||||
<div class="row row--end mt-2">
|
<div class="row row--end mt-2">
|
||||||
@@ -135,8 +135,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
{{ alert('Zu dieser Anfrage liegt kein Code vor -- sie stammt aus einer aelteren
|
{{ alert('Zu dieser Anfrage liegt kein Code vor -- sie stammt aus einer älteren
|
||||||
Fassung. Loesen Sie die Aktion erneut aus, dann entsteht ein neuer Code.',
|
Fassung. Lösen Sie die Aktion erneut aus, dann entsteht ein neuer Code.',
|
||||||
'warning') }}
|
'warning') }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
@@ -145,10 +145,10 @@
|
|||||||
<label class="field">
|
<label class="field">
|
||||||
<span class="field__label">Antwortcode des Lizenzservers</span>
|
<span class="field__label">Antwortcode des Lizenzservers</span>
|
||||||
<textarea class="textarea input--mono" name="code" rows="4" required
|
<textarea class="textarea input--mono" name="code" rows="4" required
|
||||||
placeholder="Code einfuegen ..."></textarea>
|
placeholder="Code einfügen ..."></textarea>
|
||||||
</label>
|
</label>
|
||||||
<div class="row row--end mt-3">
|
<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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -161,15 +161,15 @@
|
|||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="card__title">{{ icon('shield', size=17) }} Zertifikat des Lizenzservers</div>
|
<div class="card__title">{{ icon('shield', size=17) }} Zertifikat des Lizenzservers</div>
|
||||||
<div class="card__sub">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<div class="card__body stack">
|
<div class="card__body stack">
|
||||||
{{ alert('Ein Lizenzserver im eigenen Netz hat meist ein selbstsigniertes
|
{{ alert('Ein Lizenzserver im eigenen Netz hat meist ein selbstsigniertes
|
||||||
Zertifikat. Ohne Anker scheitert die Pruefung, und jeder Versuch faellt auf den
|
Zertifikat. Ohne Anker scheitert die Prüfung, und jeder Versuch fällt auf den
|
||||||
Offline-Weg zurueck. Die Pruefung abzuschalten waere der falsche Weg -- hinterlegen
|
Offline-Weg zurück. Die Prüfung abzuschalten wäre der falsche Weg -- hinterlegen
|
||||||
Sie stattdessen das Zertifikat, dann wird gegen genau dieses geprueft.', 'info') }}
|
Sie stattdessen das Zertifikat, dann wird gegen genau dieses geprüft.', 'info') }}
|
||||||
<form method="post" action="{{ url_for('license.server_certificate') }}">
|
<form method="post" action="{{ url_for('license.server_certificate') }}">
|
||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
<label class="field">
|
<label class="field">
|
||||||
@@ -178,7 +178,7 @@
|
|||||||
placeholder="-----BEGIN CERTIFICATE----- ... -----END CERTIFICATE-----"
|
placeholder="-----BEGIN CERTIFICATE----- ... -----END CERTIFICATE-----"
|
||||||
>{{ server_certificate }}</textarea>
|
>{{ server_certificate }}</textarea>
|
||||||
<span class="field__hint">
|
<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>.
|
liegt es unter <span class="mono">/etc/tesm/certs/<name>/fullchain.pem</span>.
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -196,7 +196,7 @@
|
|||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="card__title">{{ icon('upload', size=17) }} Lizenzdatei einspielen</div>
|
<div class="card__title">{{ icon('upload', size=17) }} Lizenzdatei einspielen</div>
|
||||||
<div class="card__sub">
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -206,7 +206,7 @@
|
|||||||
<div class="card__body stack">
|
<div class="card__body stack">
|
||||||
<input class="input" type="file" name="license_file" accept=".json,.lic" required>
|
<input class="input" type="file" name="license_file" accept=".json,.lic" required>
|
||||||
{% if trusted_keys and current_user.is_admin %}
|
{% 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
|
hint_text='Nur ankreuzen, wenn die neue Lizenz bewusst von einem anderen
|
||||||
Lizenzserver kommt.') }}
|
Lizenzserver kommt.') }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -233,8 +233,8 @@
|
|||||||
<dt>Fingerprint</dt><dd class="mono fs-xs break-all">{{ fingerprint }}</dd>
|
<dt>Fingerprint</dt><dd class="mono fs-xs break-all">{{ fingerprint }}</dd>
|
||||||
</dl>
|
</dl>
|
||||||
<p class="fs-xs faint mt-3">
|
<p class="fs-xs faint mt-3">
|
||||||
Der Fingerprint stammt aus der stabilen Maschinenidentitaet. Eine Umbenennung des Hosts
|
Der Fingerprint stammt aus der stabilen Maschinenidentität. Eine Umbenennung des Hosts
|
||||||
aendert ihn <strong>nicht</strong> mehr -- im Vorgaenger fuehrte genau das zu
|
ändert ihn <strong>nicht</strong> mehr -- im Vorgänger führte genau das zu
|
||||||
„bereits anderweitig gebunden“.
|
„bereits anderweitig gebunden“.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -243,28 +243,32 @@
|
|||||||
<section class="card">
|
<section class="card">
|
||||||
<div class="card__header">
|
<div class="card__header">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="card__title">{{ icon('shield', size=17) }} Vertrauenswuerdige Aussteller</div>
|
<div class="card__title">{{ icon('shield', size=17) }} Vertrauenswürdige Aussteller</div>
|
||||||
<div class="card__sub">{{ trusted_keys | length }} gepinnte Schluessel</div>
|
<div class="card__sub">{{ trusted_keys | length }} gepinnte Schlüssel</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% if trusted_keys %}
|
{% if trusted_keys %}
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table class="table table--compact">
|
<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>
|
<tbody>
|
||||||
{% for key in trusted_keys %}
|
{% for key in trusted_keys %}
|
||||||
<tr>
|
<tr>
|
||||||
<td class="mono fs-xs">{{ key.key_id }}</td>
|
<td class="mono fs-xs">{{ key.key_id }}</td>
|
||||||
<td class="fs-sm">{{ key.label }}
|
<td class="fs-sm">{{ key.label }}
|
||||||
<div class="fs-xs faint">{{ key.added_at | dt }}</div></td>
|
<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">
|
<td class="actions">
|
||||||
{% if current_user.is_admin %}
|
{% if current_user.is_admin %}
|
||||||
<form method="post" data-no-guard
|
<form method="post" data-no-guard
|
||||||
action="{{ url_for('license.remove_trusted_key', key_id=key.key_id) }}">
|
action="{{ url_for('license.remove_trusted_key', key_id=key.key_id) }}">
|
||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
<button class="btn btn--sm btn--ghost danger-text" type="submit"
|
<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">
|
data-confirm-title="Aussteller entfernen" data-confirm-ok="Entfernen">
|
||||||
{{ icon('trash', size=14) }}</button>
|
{{ icon('trash', size=14) }}</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -277,7 +281,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
{{ empty_state('Kein Aussteller gepinnt',
|
{{ 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') }}
|
erwartet.', 'shield') }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
@@ -303,7 +307,7 @@
|
|||||||
</ul>
|
</ul>
|
||||||
<p class="fs-xs faint mt-3">
|
<p class="fs-xs faint mt-3">
|
||||||
Diese Liste ist die einzige Wahrheit: was hier steht, wird serverseitig durchgesetzt --
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -2,11 +2,11 @@
|
|||||||
{% from "tesm_core/macros.html" import pill, empty_state, alert, search_box %}
|
{% from "tesm_core/macros.html" import pill, empty_state, alert, search_box %}
|
||||||
{% block page_title %}Neustarts{% endblock %}
|
{% block page_title %}Neustarts{% endblock %}
|
||||||
{% block page_sub %}{{ rows | length }} Neustarts (neueste zuerst){% 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 %}
|
{% block content %}
|
||||||
{{ alert('Jeder Neustart wird mit Methode, Ergebnis, Dauer und Ausloeser festgehalten -- automatisch
|
{{ alert('Jeder Neustart wird mit Methode, Ergebnis, Dauer und Auslöser festgehalten -- automatisch
|
||||||
wie manuell, ueber den PoE-Port des Switches ebenso wie per Neustart-Befehl aus der Wartung.
|
wie manüll, über den PoE-Port des Switches ebenso wie per Neustart-Befehl aus der Wartung.
|
||||||
Der Vorgaenger protokollierte Neustarts ausdruecklich nicht.', 'info') }}
|
Der Vorgänger protokollierte Neustarts ausdrücklich nicht.', 'info') }}
|
||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<div class="card__header">
|
<div class="card__header">
|
||||||
@@ -25,8 +25,8 @@ Der Vorgaenger protokollierte Neustarts ausdruecklich nicht.', 'info') }}
|
|||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table class="table table--compact" id="restart-table" data-behavior="sortable">
|
<table class="table table--compact" id="restart-table" data-behavior="sortable">
|
||||||
<thead><tr>
|
<thead><tr>
|
||||||
<th class="sortable">Zeit</th><th class="sortable">Geraet</th>
|
<th class="sortable">Zeit</th><th class="sortable">Gerät</th>
|
||||||
<th class="sortable">Methode</th><th class="sortable">Ausloeser</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">Benutzer</th><th class="sortable">Ergebnis</th>
|
||||||
<th class="sortable numeric">Dauer</th><th>Detail</th>
|
<th class="sortable numeric">Dauer</th><th>Detail</th>
|
||||||
</tr></thead>
|
</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="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="fw-600 fs-sm">{{ row.device_name or '–' | safe }}</td>
|
||||||
<td class="fs-sm">
|
<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') }}
|
{% 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>
|
||||||
<td class="fs-sm">{{ row.trigger }}</td>
|
<td class="fs-sm">{{ row.trigger }}</td>
|
||||||
<td class="fs-sm">{{ row.actor }}</td>
|
<td class="fs-sm">{{ row.actor }}</td>
|
||||||
<td>
|
<td>
|
||||||
{% if row.result == 'ok' %}{{ pill('erfolgreich', 'online') }}
|
{% 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 %}
|
{% else %}{{ pill('fehlgeschlagen', 'danger') }}{% endif %}
|
||||||
</td>
|
</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>
|
<td class="fs-xs muted break-all" style="max-width:420px">{{ row.detail }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@@ -3,29 +3,29 @@
|
|||||||
|
|
||||||
{% block page_title %}Wartung{% endblock %}
|
{% block page_title %}Wartung{% endblock %}
|
||||||
{% block page_sub %}
|
{% block page_sub %}
|
||||||
{{ devices | length }} Geraete mit Zugangsdaten der Kategorie
|
{{ devices | length }} Geräte mit Zugangsdaten der Kategorie
|
||||||
„Linux-Client“ oder „Windows-Client“
|
„Linux-Client“ oder „Windows-Client“
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block topbar_extra %}{{ search_box('maintenance-table', 'Geraet suchen ...') }}{% endblock %}
|
{% block topbar_extra %}{{ search_box('maintenance-table', 'Gerät suchen ...') }}{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{% if devices | rejectattr('host_key_ready') | list %}
|
{% if devices | rejectattr('host_key_ready') | list %}
|
||||||
{{ alert('Fuer mindestens ein Geraet ist der SSH-Host-Schluessel noch nicht freigegeben.
|
{{ alert('Für mindestens ein Gerät ist der SSH-Host-Schlüssel noch nicht freigegeben.
|
||||||
Die Wartung bricht dort mit "Schluessel unbekannt" ab. Die Freigabe erfolgt auf der
|
Die Wartung bricht dort mit "Schlüssel unbekannt" ab. Die Freigabe erfolgt auf der
|
||||||
Detailseite des Geraets -- erst auslesen, Fingerprint pruefen, dann freigeben.',
|
Detailseite des Geräts -- erst auslesen, Fingerprint prüfen, dann freigeben.',
|
||||||
'warning', 'Host-Schluessel offen') }}
|
'warning', 'Host-Schlüssel offen') }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if windows_tool_missing %}
|
{% 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. #}
|
eine Sackgasse. #}
|
||||||
<div class="alert alert--warning">
|
<div class="alert alert--warning">
|
||||||
{{ icon('alert', size=18) }}
|
{{ icon('alert', size=18) }}
|
||||||
<div class="alert__body">
|
<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
|
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>
|
</div>
|
||||||
<form method="post" action="{{ url_for('maintenance.install_windows_tool') }}">
|
<form method="post" action="{{ url_for('maintenance.install_windows_tool') }}">
|
||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
@@ -38,10 +38,10 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{{ alert('Welche Geraete hier erscheinen und was mit ihnen moeglich ist, entscheidet die
|
{{ alert('Welche Geräte hier erscheinen und was mit ihnen möglich ist, entscheidet die
|
||||||
Kategorie ihrer Zugangsdaten. Linux-Clients werden ueber SSH aktualisiert und neu gestartet.
|
Kategorie ihrer Zugangsdaten. Linux-Clients werden über SSH aktualisiert und neu gestartet.
|
||||||
Windows-Clients werden nur neu gestartet, und zwar ueber RPC (wie "shutdown /m") -- sie
|
Windows-Clients werden nur neu gestartet, und zwar über RPC (wie "shutdown /m") -- sie
|
||||||
brauchen dafuer weder SSH noch einen Host-Schluessel, sondern die offene Datei- und
|
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.',
|
Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Port.',
|
||||||
'info') }}
|
'info') }}
|
||||||
|
|
||||||
@@ -51,20 +51,20 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
|
|||||||
<div class="table-toolbar">
|
<div class="table-toolbar">
|
||||||
<label class="check">
|
<label class="check">
|
||||||
<input type="checkbox" id="select-all">
|
<input type="checkbox" id="select-all">
|
||||||
<span>Alle auswaehlen</span>
|
<span>Alle auswählen</span>
|
||||||
</label>
|
</label>
|
||||||
<span class="table-toolbar__spacer"></span>
|
<span class="table-toolbar__spacer"></span>
|
||||||
{% if current_user.has('maintenance.execute') %}
|
{% if current_user.has('maintenance.execute') %}
|
||||||
<button class="btn btn--sm btn--primary" type="submit"
|
<button class="btn btn--sm btn--primary" type="submit"
|
||||||
formaction="{{ url_for('maintenance.start_update') }}"
|
formaction="{{ url_for('maintenance.start_update') }}"
|
||||||
title="Nur fuer Linux-Clients -- Windows-Geraete werden dabei uebersprungen."
|
title="Nur für Linux-Clients -- Windows-Geräte werden dabei übersprungen."
|
||||||
data-confirm="Paketaktualisierung auf den ausgewaehlten Geraeten starten?"
|
data-confirm="Paketaktualisierung auf den ausgewählten Geräten starten?"
|
||||||
data-confirm-title="Aktualisieren" data-confirm-ok="Starten" data-confirm-style="primary">
|
data-confirm-title="Aktualisieren" data-confirm-ok="Starten" data-confirm-style="primary">
|
||||||
{{ icon('download', size=15) }} Aktualisieren
|
{{ icon('download', size=15) }} Aktualisieren
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn--sm btn--danger" type="submit"
|
<button class="btn btn--sm btn--danger" type="submit"
|
||||||
formaction="{{ url_for('maintenance.start_reboot') }}"
|
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">
|
data-confirm-title="Neustarten" data-confirm-ok="Neu starten">
|
||||||
{{ icon('refresh', size=15) }} Neustarten
|
{{ icon('refresh', size=15) }} Neustarten
|
||||||
</button>
|
</button>
|
||||||
@@ -76,11 +76,11 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th style="width:44px"></th>
|
<th style="width:44px"></th>
|
||||||
<th class="sortable">Geraet</th>
|
<th class="sortable">Gerät</th>
|
||||||
<th class="sortable">IP</th>
|
<th class="sortable">IP</th>
|
||||||
<th class="sortable">Zustand</th>
|
<th class="sortable">Zustand</th>
|
||||||
<th class="sortable">Zugangsdaten</th>
|
<th class="sortable">Zugangsdaten</th>
|
||||||
<th class="sortable">Host-Schluessel</th>
|
<th class="sortable">Host-Schlüssel</th>
|
||||||
<th></th>
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</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 }}"
|
<input type="checkbox" name="device_ids" value="{{ device.id }}"
|
||||||
class="device-select" data-category="{{ device.category }}"
|
class="device-select" data-category="{{ device.category }}"
|
||||||
{% if not current_user.has('maintenance.execute') %}disabled{% endif %}>
|
{% 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>
|
</label>
|
||||||
</td>
|
</td>
|
||||||
<td class="fw-600">{{ device.name }}</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>
|
||||||
<td>
|
<td>
|
||||||
{% if not device.ssh_managed %}
|
{% if not device.ssh_managed %}
|
||||||
<span class="faint fs-xs" title="Windows wird ueber RPC verwaltet, nicht ueber SSH">
|
<span class="faint fs-xs" title="Windows wird über RPC verwaltet, nicht über SSH">
|
||||||
nicht noetig
|
nicht nötig
|
||||||
</span>
|
</span>
|
||||||
{% elif device.host_key_ready %}{{ pill('freigegeben', 'online') }}
|
{% elif device.host_key_ready %}{{ pill('freigegeben', 'online') }}
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="{{ url_for('devices.detail', device_id=device.id) }}"
|
<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') }}
|
{{ pill('offen', 'danger') }}
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -134,9 +134,9 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
{% if not devices %}
|
{% 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
|
'Setzen Sie bei den Zugangsdaten die Kategorie "Linux-Client" und ordnen Sie
|
||||||
sie einem Geraet zu.', 'wrench') }}
|
sie einem Gerät zu.', 'wrench') }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
</form>
|
</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
|
{# Kein data-refresh-url: die Schleife in app.js schreibt die Antwort in
|
||||||
innerHTML. Hier zeigte sie auf die Seite selbst -- die komplette
|
innerHTML. Hier zeigte sie auf die Seite selbst -- die komplette
|
||||||
Wartungsseite landete damit in dieser Karte, mitsamt Seitenleiste und
|
Wartungsseite landete damit in dieser Karte, mitsamt Seitenleiste und
|
||||||
Suchfeld. Aktualisiert wird ueber die Abfrage unten, die bei laufenden
|
Suchfeld. Aktualisiert wird über die Abfrage unten, die bei laufenden
|
||||||
Auftraegen die ganze Seite neu laedt. #}
|
Aufträgen die ganze Seite neu lädt. #}
|
||||||
<section class="card" id="jobs">
|
<section class="card" id="jobs">
|
||||||
<div class="card__header">
|
<div class="card__header">
|
||||||
<div class="flex-1">
|
<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">
|
<div class="card__sub">
|
||||||
Zustand liegt in der Datenbank -- ein Dienst-Neustart verliert ihn nicht mehr
|
Zustand liegt in der Datenbank -- ein Dienst-Neustart verliert ihn nicht mehr
|
||||||
</div>
|
</div>
|
||||||
@@ -158,7 +158,7 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
|
|||||||
{% if jobs %}
|
{% if jobs %}
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table class="table table--compact">
|
<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>
|
<tbody>
|
||||||
{% for job in jobs %}
|
{% for job in jobs %}
|
||||||
<tr>
|
<tr>
|
||||||
@@ -170,7 +170,7 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
|
|||||||
<td>
|
<td>
|
||||||
{% if job.state == 'succeeded' %}{{ pill('erfolgreich', 'online') }}
|
{% if job.state == 'succeeded' %}{{ pill('erfolgreich', 'online') }}
|
||||||
{% elif job.state == 'failed' %}{{ pill('fehlgeschlagen', 'danger') }}
|
{% 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') }}
|
{% elif job.state == 'unknown' %}{{ pill('unklar', 'warning') }}
|
||||||
{% else %}{{ pill('wartet', '') }}{% endif %}
|
{% else %}{{ pill('wartet', '') }}{% endif %}
|
||||||
</td>
|
</td>
|
||||||
@@ -186,16 +186,16 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% 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 %}
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script type="module" nonce="{{ csp_nonce }}">
|
<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"
|
// 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"));
|
const boxes = Array.from(document.querySelectorAll(".device-select"));
|
||||||
boxes.forEach((box) => (box.checked = false));
|
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));
|
.forEach((box) => (box.checked = all.checked));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Auftragsliste nachladen, solange etwas laeuft.
|
// Auftragsliste nachladen, solange etwas läuft.
|
||||||
async function poll() {
|
async function poll() {
|
||||||
try {
|
try {
|
||||||
const state = await window.tesm.api("{{ url_for('maintenance.status') }}");
|
const state = await window.tesm.api("{{ url_for('maintenance.status') }}");
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
{{ icon('wrench', size=17) }} Auftrag
|
{{ icon('wrench', size=17) }} Auftrag
|
||||||
{% if job.state == 'succeeded' %}{{ pill('erfolgreich', 'online') }}
|
{% if job.state == 'succeeded' %}{{ pill('erfolgreich', 'online') }}
|
||||||
{% elif job.state == 'failed' %}{{ pill('fehlgeschlagen', 'danger') }}
|
{% 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') }}
|
{% elif job.state == 'unknown' %}{{ pill('unklar', 'warning') }}
|
||||||
{% else %}{{ pill('wartet', '') }}{% endif %}
|
{% else %}{{ pill('wartet', '') }}{% endif %}
|
||||||
</div>
|
</div>
|
||||||
@@ -37,14 +37,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<div class="row row--end">
|
<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>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script type="module" nonce="{{ csp_nonce }}">
|
<script type="module" nonce="{{ csp_nonce }}">
|
||||||
// Beim Oeffnen ans Ende springen: bei einem laufenden Auftrag ist die
|
// Beim Öffnen ans Ende springen: bei einem laufenden Auftrag ist die
|
||||||
// juengste Zeile die interessante, nicht die erste. Das Nachfuehren beim
|
// jüngste Zeile die interessante, nicht die erste. Das Nachführen beim
|
||||||
// Aktualisieren erledigt die Schleife in app.js.
|
// Aktualisieren erledigt die Schleife in app.js.
|
||||||
const view = document.querySelector("#job-output .log-view");
|
const view = document.querySelector("#job-output .log-view");
|
||||||
if (view) view.scrollTop = view.scrollHeight;
|
if (view) view.scrollTop = view.scrollHeight;
|
||||||
|
|||||||
@@ -2,12 +2,12 @@
|
|||||||
{% from "tesm_core/macros.html" import field, select_field, checkbox, alert, pill %}
|
{% from "tesm_core/macros.html" import field, select_field, checkbox, alert, pill %}
|
||||||
|
|
||||||
{% block page_title %}Systemeinstellungen{% endblock %}
|
{% block page_title %}Systemeinstellungen{% endblock %}
|
||||||
{% block page_sub %}Ueberwachung, Protokolle, Host, Navigation{% endblock %}
|
{% block page_sub %}Überwachung, Protokolle, Host, Navigation{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{% if not sysops_available %}
|
{% if not sysops_available %}
|
||||||
{{ alert('Systemhelfer nicht verfuegbar -- Hostname und Zeitzone koennen hier nur angezeigt,
|
{{ alert('Systemhelfer nicht verfügbar -- Hostname und Zeitzone können hier nur angezeigt,
|
||||||
nicht geaendert werden. In einer Entwicklungsumgebung ist das der erwartete Zustand.',
|
nicht geändert werden. In einer Entwicklungsumgebung ist das der erwartete Zustand.',
|
||||||
'info') }}
|
'info') }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
@@ -16,13 +16,13 @@
|
|||||||
<section class="card">
|
<section class="card">
|
||||||
<div class="card__header">
|
<div class="card__header">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="card__title">{{ icon('activity', size=17) }} Ueberwachung</div>
|
<div class="card__title">{{ icon('activity', size=17) }} Überwachung</div>
|
||||||
<div class="card__sub">Wie oft geprueft und wann automatisch geschaltet wird</div>
|
<div class="card__sub">Wie oft geprüft und wann automatisch geschaltet wird</div>
|
||||||
</div>
|
</div>
|
||||||
{% if current_user.has('settings_system.execute') %}
|
{% if current_user.has('settings_system.execute') %}
|
||||||
<form method="post" action="{{ url_for('settings.run_check_now') }}" data-no-guard>
|
<form method="post" action="{{ url_for('settings.run_check_now') }}" data-no-guard>
|
||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
<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>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
<div class="card__body stack">
|
<div class="card__body stack">
|
||||||
{% if license_hint %}{{ alert(license_hint, 'warning') }}{% endif %}
|
{% if license_hint %}{{ alert(license_hint, 'warning') }}{% endif %}
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
{{ field('monitor_interval_seconds', 'Pruefintervall (Sekunden)',
|
{{ field('monitor_interval_seconds', 'Prüfintervall (Sekunden)',
|
||||||
value=values.monitor_interval_seconds, type='number',
|
value=values.monitor_interval_seconds, type='number',
|
||||||
disabled=bool(license_hint),
|
disabled=bool(license_hint),
|
||||||
hint_text=specs['monitor_interval_seconds'].hint) }}
|
hint_text=specs['monitor_interval_seconds'].hint) }}
|
||||||
@@ -41,9 +41,9 @@
|
|||||||
</div>
|
</div>
|
||||||
{{ checkbox('monitor_auto_restart', 'Automatischen PoE-Neustart bei Ausfall aktivieren',
|
{{ checkbox('monitor_auto_restart', 'Automatischen PoE-Neustart bei Ausfall aktivieren',
|
||||||
checked=values.monitor_auto_restart,
|
checked=values.monitor_auto_restart,
|
||||||
hint_text='Zusaetzlich lizenzpflichtig. Der manuelle Knopf im Dashboard
|
hint_text='Zusätzlich lizenzpflichtig. Der manülle Knopf im Dashboard
|
||||||
funktioniert unabhaengig davon.') }}
|
funktioniert unabhängig davon.') }}
|
||||||
{{ checkbox('dashboard_public', 'Statusuebersicht ohne Anmeldung erreichbar',
|
{{ checkbox('dashboard_public', 'Statusübersicht ohne Anmeldung erreichbar',
|
||||||
checked=values.dashboard_public,
|
checked=values.dashboard_public,
|
||||||
hint_text=specs['dashboard_public'].hint) }}
|
hint_text=specs['dashboard_public'].hint) }}
|
||||||
</div>
|
</div>
|
||||||
@@ -56,17 +56,17 @@
|
|||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="card__title">{{ icon('terminal', size=17) }} Switch-Konsole</div>
|
<div class="card__title">{{ icon('terminal', size=17) }} Switch-Konsole</div>
|
||||||
<div class="card__sub">
|
<div class="card__sub">
|
||||||
Auch bei aktiver Konsole gilt: eigenes Recht, frische Passwortbestaetigung,
|
Auch bei aktiver Konsole gilt: eigenes Recht, frische Passwortbestätigung,
|
||||||
vollstaendige Mitschrift
|
vollständige Mitschrift
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<form method="post" action="{{ url_for('settings.system_save') }}">
|
<form method="post" action="{{ url_for('settings.system_save') }}">
|
||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
<div class="card__body stack">
|
<div class="card__body stack">
|
||||||
{{ checkbox('terminal_enabled', 'Browser-Konsole fuer Switche zulassen',
|
{{ checkbox('terminal_enabled', 'Browser-Konsole für Switche zulassen',
|
||||||
checked=values.terminal_enabled) }}
|
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') }}
|
value=values.terminal_idle_timeout, type='number') }}
|
||||||
</div>
|
</div>
|
||||||
<div class="card__footer"><button class="btn btn--primary" type="submit">Speichern</button></div>
|
<div class="card__footer"><button class="btn btn--primary" type="submit">Speichern</button></div>
|
||||||
@@ -87,6 +87,9 @@
|
|||||||
specs['log_rotate_interval'].choices, selected=values.log_rotate_interval) }}
|
specs['log_rotate_interval'].choices, selected=values.log_rotate_interval) }}
|
||||||
{{ field('log_rotate_keep', 'Aufbewahrte Rotationen', value=values.log_rotate_keep,
|
{{ field('log_rotate_keep', 'Aufbewahrte Rotationen', value=values.log_rotate_keep,
|
||||||
type='number') }}
|
type='number') }}
|
||||||
|
{{ field('session_idle_minutes', 'Automatische Abmeldung (Minuten)',
|
||||||
|
value=values.session_idle_minutes, type='number',
|
||||||
|
hint_text=specs['session_idle_minutes'].hint) }}
|
||||||
{{ field('trash_retention_days', 'Papierkorb-Aufbewahrung (Tage)',
|
{{ field('trash_retention_days', 'Papierkorb-Aufbewahrung (Tage)',
|
||||||
value=values.trash_retention_days, type='number',
|
value=values.trash_retention_days, type='number',
|
||||||
hint_text=specs['trash_retention_days'].hint) }}
|
hint_text=specs['trash_retention_days'].hint) }}
|
||||||
@@ -134,7 +137,7 @@
|
|||||||
<div class="card__header">
|
<div class="card__header">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="card__title">{{ icon('sliders', size=17) }} Navigationsreihenfolge</div>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<form method="post" action="{{ url_for('admin.nav_order') }}">
|
<form method="post" action="{{ url_for('admin.nav_order') }}">
|
||||||
@@ -154,8 +157,8 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ol>
|
</ol>
|
||||||
<p class="fs-xs faint mt-3">
|
<p class="fs-xs faint mt-3">
|
||||||
Die Reihenfolge folgt der Liste. Zum Umsortieren die Eintraege in der gewuenschten
|
Die Reihenfolge folgt der Liste. Zum Umsortieren die Einträge in der gewünschten
|
||||||
Reihenfolge speichern (Drag-and-drop folgt in einer spaeteren Version).
|
Reihenfolge speichern (Drag-and-drop folgt in einer späteren Version).
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="card__footer">
|
<div class="card__footer">
|
||||||
|
|||||||
@@ -18,6 +18,6 @@
|
|||||||
<pre class="log-view" id="cmd-output">{{ output }}</pre>
|
<pre class="log-view" id="cmd-output">{{ output }}</pre>
|
||||||
</section>
|
</section>
|
||||||
<div class="row row--end">
|
<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>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -25,11 +25,11 @@
|
|||||||
<div class="card__header">
|
<div class="card__header">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="card__title">
|
<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 %}
|
{% if host_keys %}{{ pill('freigegeben', 'online') }}{% else %}{{ pill('offen', 'danger') }}{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="card__sub">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
{% if current_user.has('switches.edit') %}
|
{% if current_user.has('switches.edit') %}
|
||||||
@@ -45,12 +45,12 @@
|
|||||||
<div class="alert alert--warning">
|
<div class="alert alert--warning">
|
||||||
{{ icon('alert', size=18) }}
|
{{ icon('alert', size=18) }}
|
||||||
<div class="alert__body">
|
<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">
|
<div class="mono fs-sm break-all">
|
||||||
{{ request.args.get('probed_type') }} {{ request.args.get('probed_fingerprint') }}
|
{{ request.args.get('probed_type') }} {{ request.args.get('probed_fingerprint') }}
|
||||||
</div>
|
</div>
|
||||||
<p class="fs-sm mt-2">
|
<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.
|
Switch, Inventardokumentation), bevor Sie ihn freigeben.
|
||||||
</p>
|
</p>
|
||||||
{% if current_user.has('switches.edit') %}
|
{% 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="key_type" value="{{ request.args.get('probed_type') }}">
|
||||||
<input type="hidden" name="fingerprint" value="{{ request.args.get('probed_fingerprint') }}">
|
<input type="hidden" name="fingerprint" value="{{ request.args.get('probed_fingerprint') }}">
|
||||||
<button class="btn btn--sm btn--primary" type="submit"
|
<button class="btn btn--sm btn--primary" type="submit"
|
||||||
data-confirm="Diesen Fingerprint dauerhaft als vertrauenswuerdig hinterlegen?"
|
data-confirm="Diesen Fingerprint dauerhaft als vertrauenswürdig hinterlegen?"
|
||||||
data-confirm-title="Host-Schluessel freigeben" data-confirm-ok="Freigeben"
|
data-confirm-title="Host-Schlüssel freigeben" data-confirm-ok="Freigeben"
|
||||||
data-confirm-style="primary">Freigeben</button>
|
data-confirm-style="primary">Freigeben</button>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -83,14 +83,14 @@
|
|||||||
data-no-guard>
|
data-no-guard>
|
||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
<button class="btn btn--sm btn--ghost danger-text" type="submit"
|
<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="Gespeicherte Host-Schlüssel entfernen? Danach ist kein Zugriff mehr möglich, bis erneut freigegeben wird."
|
||||||
data-confirm-title="Schluessel verwerfen" data-confirm-ok="Verwerfen">
|
data-confirm-title="Schlüssel verwerfen" data-confirm-ok="Verwerfen">
|
||||||
Schluessel verwerfen
|
Schlüssel verwerfen
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% else %}
|
{% 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 %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -99,9 +99,9 @@
|
|||||||
<section class="card">
|
<section class="card">
|
||||||
<div class="card__header">
|
<div class="card__header">
|
||||||
<div class="flex-1">
|
<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">
|
<div class="card__sub">
|
||||||
Positivliste lesender Kommandos – verlangt eine frische Passwortbestaetigung
|
Positivliste lesender Kommandos – verlangt eine frische Passwortbestätigung
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -118,8 +118,8 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="card__footer">
|
<div class="card__footer">
|
||||||
<button class="btn btn--primary" type="submit" data-busy-label="Fuehre aus ...">
|
<button class="btn btn--primary" type="submit" data-busy-label="Führe aus ...">
|
||||||
Ausfuehren
|
Ausführen
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -140,7 +140,7 @@
|
|||||||
<dt>Zugangsdaten</dt><dd>{{ switch.credential_name or 'keine' }}</dd>
|
<dt>Zugangsdaten</dt><dd>{{ switch.credential_name or 'keine' }}</dd>
|
||||||
<dt>Standort</dt><dd>{{ switch.location or '–' | safe }}</dd>
|
<dt>Standort</dt><dd>{{ switch.location or '–' | safe }}</dd>
|
||||||
<dt>Zuletzt gesehen</dt><dd>{{ switch.last_seen_at | dt }}</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>
|
</dl>
|
||||||
{% if switch.notes %}<p class="fs-sm muted mt-3">{{ switch.notes }}</p>{% endif %}
|
{% if switch.notes %}<p class="fs-sm muted mt-3">{{ switch.notes }}</p>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
@@ -149,14 +149,14 @@
|
|||||||
<section class="card">
|
<section class="card">
|
||||||
<div class="card__header">
|
<div class="card__header">
|
||||||
<div class="flex-1">
|
<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 class="card__sub">{{ devices | length }} zugeordnet</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% if devices %}
|
{% if devices %}
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table class="table table--compact">
|
<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>
|
<tbody>
|
||||||
{% for device in devices %}
|
{% for device in devices %}
|
||||||
<tr>
|
<tr>
|
||||||
@@ -176,7 +176,7 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% 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 %}
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -39,9 +39,9 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{{ alert('Vor dem ersten Zugriff muss der SSH-Host-Schluessel eines Switches ausdruecklich
|
{{ alert('Vor dem ersten Zugriff muss der SSH-Host-Schlüssel eines Switches ausdrücklich
|
||||||
freigegeben werden. Unbekannte oder geaenderte Schluessel werden abgewiesen -- nicht
|
freigegeben werden. Unbekannte oder geänderte Schlüssel werden abgewiesen -- nicht
|
||||||
stillschweigend uebernommen.', 'info', 'Host-Schluessel') }}
|
stillschweigend übernommen.', 'info', 'Host-Schlüssel') }}
|
||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
@@ -52,8 +52,8 @@ stillschweigend uebernommen.', 'info', 'Host-Schluessel') }}
|
|||||||
<th class="sortable">IP</th>
|
<th class="sortable">IP</th>
|
||||||
<th class="sortable numeric">Port</th>
|
<th class="sortable numeric">Port</th>
|
||||||
<th class="sortable">Zugangsdaten</th>
|
<th class="sortable">Zugangsdaten</th>
|
||||||
<th class="sortable numeric">Geraete</th>
|
<th class="sortable numeric">Geräte</th>
|
||||||
<th class="sortable">Host-Schluessel</th>
|
<th class="sortable">Host-Schlüssel</th>
|
||||||
<th class="actions">Aktion</th>
|
<th class="actions">Aktion</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -99,9 +99,9 @@ stillschweigend uebernommen.', 'info', 'Host-Schluessel') }}
|
|||||||
<form method="post" action="{{ url_for('switches.delete', switch_id=switch.id) }}"
|
<form method="post" action="{{ url_for('switches.delete', switch_id=switch.id) }}"
|
||||||
style="display:inline" data-no-guard>
|
style="display:inline" data-no-guard>
|
||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
<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="{{ 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>
|
{{ icon('trash', size=15) }}</button>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -116,37 +116,15 @@ stillschweigend uebernommen.', 'info', 'Host-Schluessel') }}
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{% if host_keys %}
|
{# Die freigegebenen Host-Schlüssel standen hier ein zweites Mal. Gepflegt
|
||||||
<section class="card">
|
werden sie unter Überwachung -> Host-Schlüssel: dort mit Auslesen,
|
||||||
<div class="card__header">
|
Fingerprint-Vergleich, Freigeben, Verwerfen und Aufräumen. Eine reine
|
||||||
<div class="flex-1">
|
Zweitanzeige lädt dazu ein, sie an der falschen Stelle zu suchen. #}
|
||||||
<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 %}
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block dialogs %}
|
{% block dialogs %}
|
||||||
{% if current_user.has('switches.create') %}
|
{% 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') }}">
|
<form method="post" action="{{ url_for('switches.create') }}">
|
||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
<div class="modal__body">{{ switch_form() }}</div>
|
<div class="modal__body">{{ switch_form() }}</div>
|
||||||
|
|||||||
@@ -22,12 +22,11 @@ from types import FrameType
|
|||||||
from tesm_core.extension import core
|
from tesm_core.extension import core
|
||||||
|
|
||||||
from . import create_app
|
from . import create_app
|
||||||
from .services import fileshare, inventory, monitor
|
from .services import inventory, monitor
|
||||||
|
|
||||||
logger = logging.getLogger("tesm.monitor")
|
logger = logging.getLogger("tesm.monitor")
|
||||||
|
|
||||||
MIN_INTERVAL = 30
|
MIN_INTERVAL = 30
|
||||||
FILESHARE_SWEEP_EVERY = 30 * 60
|
|
||||||
|
|
||||||
|
|
||||||
class MonitorDaemon:
|
class MonitorDaemon:
|
||||||
@@ -35,7 +34,6 @@ class MonitorDaemon:
|
|||||||
self.app = create_app()
|
self.app = create_app()
|
||||||
self.extension = core(self.app)
|
self.extension = core(self.app)
|
||||||
self.running = True
|
self.running = True
|
||||||
self._last_fileshare_sweep = 0.0
|
|
||||||
|
|
||||||
def stop(self, signum: int, _frame: FrameType | None) -> None:
|
def stop(self, signum: int, _frame: FrameType | None) -> None:
|
||||||
logger.info("Signal %s empfangen -- beende nach dem laufenden Durchlauf.", signum)
|
logger.info("Signal %s empfangen -- beende nach dem laufenden Durchlauf.", signum)
|
||||||
@@ -100,15 +98,12 @@ class MonitorDaemon:
|
|||||||
logger.exception("Durchlauf fehlgeschlagen")
|
logger.exception("Durchlauf fehlgeschlagen")
|
||||||
live.error("monitor", "Durchlauf fehlgeschlagen -- siehe app.log")
|
live.error("monitor", "Durchlauf fehlgeschlagen -- siehe app.log")
|
||||||
|
|
||||||
if time.monotonic() - self._last_fileshare_sweep > FILESHARE_SWEEP_EVERY:
|
# Das Aufraeumen abgelaufener Freigabe-Einbindungen laeuft **nicht**
|
||||||
self._last_fileshare_sweep = time.monotonic()
|
# hier, sondern im Webprozess: eingebunden wird in dessen
|
||||||
try:
|
# Mount-Namensraum, und dieser Dienst sieht diese Einbindungen nicht
|
||||||
with self.app.app_context():
|
# einmal. Ein umount von hier waere ein Aufruf ins Leere -- die
|
||||||
released = fileshare.sweep_expired(self.extension.database)
|
# Datenbankzeile als geloest markiert, die Freigabe aber weiter
|
||||||
if released:
|
# eingebunden. Siehe tesm/__init__.py::_start_fileshare_sweep.
|
||||||
live.info("fileshare", f"{released} abgelaufene Einbindungen geloest")
|
|
||||||
except Exception: # noqa: BLE001
|
|
||||||
logger.exception("Aufraeumen der Dateifreigaben fehlgeschlagen")
|
|
||||||
|
|
||||||
elapsed = time.monotonic() - started
|
elapsed = time.monotonic() - started
|
||||||
remaining = max(1.0, interval - elapsed)
|
remaining = max(1.0, interval - elapsed)
|
||||||
|
|||||||
+16
-1
@@ -112,6 +112,21 @@ MONITOR_UNIT="$NAME-monitor.service"
|
|||||||
NGINX_SITE="/etc/nginx/sites-available/$NAME"
|
NGINX_SITE="/etc/nginx/sites-available/$NAME"
|
||||||
ENV_PREFIX="TESM_"
|
ENV_PREFIX="TESM_"
|
||||||
[[ "$APP" == "tesm-license" ]] && ENV_PREFIX="TESM_LICENSE_"
|
[[ "$APP" == "tesm-license" ]] && ENV_PREFIX="TESM_LICENSE_"
|
||||||
|
# Bei einer Aktualisierung gilt zuerst, was schon eingerichtet ist. Sonst
|
||||||
|
# verschiebt ein Update ohne Portangaben die Anwendung: eine benannte Instanz
|
||||||
|
# faellt auf 8080/8443 zurueck, auch wenn sie seit der Installation auf Port 80
|
||||||
|
# lief -- und ein Reverse Proxy davor zeigt dann ins Leere. Dieselbe Regel wie
|
||||||
|
# beim Secure-Flag weiter unten: eine Aktualisierung nimmt nichts weg, was
|
||||||
|
# nicht ausdruecklich neu gesetzt wird.
|
||||||
|
ENV_FILE="/etc/tesm/$NAME.env"
|
||||||
|
if [[ -z "$PORT" && -f "$ENV_FILE" ]]; then
|
||||||
|
PORT="$(sed -n "s/^${ENV_PREFIX}BIND=127\.0\.0\.1:\([0-9]\{1,5\}\)$/\1/p" "$ENV_FILE" | head -1)"
|
||||||
|
fi
|
||||||
|
if [[ -f "$NGINX_SITE" ]]; then
|
||||||
|
[[ -z "$HTTP_PORT" ]] && HTTP_PORT="$(sed -n 's/^[[:space:]]*listen[[:space:]]\([0-9]\{1,5\}\);.*/\1/p' "$NGINX_SITE" | head -1)"
|
||||||
|
[[ -z "$HTTPS_PORT" ]] && HTTPS_PORT="$(sed -n 's/^[[:space:]]*listen[[:space:]]\([0-9]\{1,5\}\)[[:space:]]\+ssl.*/\1/p' "$NGINX_SITE" | head -1)"
|
||||||
|
fi
|
||||||
|
|
||||||
if [[ -z "$PORT" ]]; then
|
if [[ -z "$PORT" ]]; then
|
||||||
PORT=5000
|
PORT=5000
|
||||||
[[ "$APP" == "tesm-license" ]] && PORT=5001
|
[[ "$APP" == "tesm-license" ]] && PORT=5001
|
||||||
@@ -134,7 +149,7 @@ fi
|
|||||||
# Browser sendet das Cookie dann weiter nur ueber HTTPS, die Anwendung setzt es
|
# Browser sendet das Cookie dann weiter nur ueber HTTPS, die Anwendung setzt es
|
||||||
# aber ohne das Flag neu, und beide reden aneinander vorbei.
|
# aber ohne das Flag neu, und beide reden aneinander vorbei.
|
||||||
COOKIE_SECURE=0
|
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
|
COOKIE_SECURE=1
|
||||||
fi
|
fi
|
||||||
[[ $HTTPS -eq 1 ]] && COOKIE_SECURE=1
|
[[ $HTTPS -eq 1 ]] && COOKIE_SECURE=1
|
||||||
|
|||||||
@@ -59,7 +59,20 @@ RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
|
|||||||
LockPersonality=yes
|
LockPersonality=yes
|
||||||
MemoryDenyWriteExecute=no
|
MemoryDenyWriteExecute=no
|
||||||
SystemCallArchitectures=native
|
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
|
SystemCallErrorNumber=EPERM
|
||||||
# Bewusst **kein** einschraenkendes CapabilityBoundingSet: sudo braucht
|
# Bewusst **kein** einschraenkendes CapabilityBoundingSet: sudo braucht
|
||||||
# CAP_SETUID/CAP_SETGID, und der Helfer dahinter braucht Root. Was hier fehlt,
|
# CAP_SETUID/CAP_SETGID, und der Helfer dahinter braucht Root. Was hier fehlt,
|
||||||
|
|||||||
+37
-22
@@ -258,19 +258,17 @@ case "$verb" in
|
|||||||
# Kommandozeile (Prozessliste) und nie ueber die Umgebung
|
# Kommandozeile (Prozessliste) und nie ueber die Umgebung
|
||||||
# (/proc/<pid>/environ).
|
# (/proc/<pid>/environ).
|
||||||
read_stdin_limited > "$creds"
|
read_stdin_limited > "$creds"
|
||||||
# Der aufrufende Dienst laeuft in einem eigenen Mount-Namensraum
|
# Eingebunden wird im Mount-Namensraum des aufrufenden Dienstes -- absichtlich:
|
||||||
# (ProtectSystem, PrivateTmp, ReadWritePaths). Ein Einbinden *darin*
|
# nur dieser Dienst liest die Dateien, und mit ihm verschwinden die
|
||||||
# scheitert mit "mount error(1): Operation not permitted" -- gemessen: mit
|
# Einbindungen wieder. Damit der Syscall durchkommt, braucht die Unit
|
||||||
# denselben Optionen und denselben Zugangsdaten gelingt es aus einer
|
# "@mount" im SystemCallFilter. Ohne das scheitert es mit
|
||||||
# normalen Root-Shell sofort. Deshalb wird im Namensraum des Wirts
|
# "mount error(1): Operation not permitted"; gemessen war der Syscall-Filter
|
||||||
# eingebunden; von dort wird der Mount in den Namensraum des Dienstes
|
# die Ursache, nicht der Namensraum. Ein Umweg ueber nsenter in den
|
||||||
# weitergegeben (er haengt als "slave" darunter), die Anwendung sieht ihn
|
# Namensraum des Wirts ist keine Option -- "RestrictNamespaces=yes"
|
||||||
# also. Die Zugangsdatei unter /run ist ueber die Grenze lesbar, /run ist
|
# verbietet setns, und das Recht dafuer wieder aufzugeben waere teurer als
|
||||||
# nicht privatisiert.
|
# der Gewinn.
|
||||||
im_wirt=(nsenter --mount=/proc/1/ns/mnt --)
|
mkdir -p "$target"
|
||||||
command -v nsenter >/dev/null 2>&1 || im_wirt=()
|
if mount -t cifs "$share" "$target" \
|
||||||
"${im_wirt[@]}" mkdir -p "$target"
|
|
||||||
if "${im_wirt[@]}" 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"
|
-o "credentials=$creds,uid=$(id -u tesm 2>/dev/null || echo 0),gid=$(id -g tesm 2>/dev/null || echo 0),file_mode=0640,dir_mode=0750,vers=3.1.1,seal"
|
||||||
then
|
then
|
||||||
shred -u "$creds" 2>/dev/null || rm -f "$creds"
|
shred -u "$creds" 2>/dev/null || rm -f "$creds"
|
||||||
@@ -282,13 +280,12 @@ case "$verb" in
|
|||||||
|
|
||||||
umount)
|
umount)
|
||||||
check_mount_target "${1:?Ziel fehlt}"
|
check_mount_target "${1:?Ziel fehlt}"
|
||||||
# Gegenstueck zu mount-cifs: der Mount liegt im Namensraum des Wirts, also
|
# Gegenstueck zu mount-cifs, und deshalb im selben Namensraum: loesen kann
|
||||||
# muss er auch dort geloest werden. Ein umount im Dienst-Namensraum wuerde
|
# nur, wer die Einbindung auch sieht. Ein Aufruf aus einem anderen Dienst
|
||||||
# ihn nur dort ausblenden und die Freigabe bliebe eingebunden.
|
# greift ins Leere -- genau deshalb laeuft die Aufraeumaufgabe fuer
|
||||||
im_wirt=(nsenter --mount=/proc/1/ns/mnt --)
|
# abgelaufene Einbindungen im Webprozess und nicht im Ueberwachungsdienst.
|
||||||
command -v nsenter >/dev/null 2>&1 || im_wirt=()
|
umount -l "$1" || true
|
||||||
"${im_wirt[@]}" umount -l "$1" || true
|
rmdir "$1" 2>/dev/null || true
|
||||||
"${im_wirt[@]}" rmdir "$1" 2>/dev/null || true
|
|
||||||
;;
|
;;
|
||||||
|
|
||||||
apt-install)
|
apt-install)
|
||||||
@@ -296,9 +293,27 @@ case "$verb" in
|
|||||||
for package in "$@"; do
|
for package in "$@"; do
|
||||||
check_in_list "$package" "${PACKAGES_ALLOWED[@]}" || fail "Paket nicht erlaubt: $package"
|
check_in_list "$package" "${PACKAGES_ALLOWED[@]}" || fail "Paket nicht erlaubt: $package"
|
||||||
done
|
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
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
apt-get update -qq
|
if command -v systemd-run >/dev/null 2>&1 && [[ -S /run/dbus/system_bus_socket ]]; then
|
||||||
apt-get install -y -qq "$@"
|
# 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)
|
certbot-issue)
|
||||||
|
|||||||
+41
-8
@@ -12,17 +12,47 @@
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Ein Wagenruecklauf im Paket macht jedes Shell-Skript auf dem Ziel
|
||||||
|
# unbrauchbar ("bad interpreter"). Die Pruefung darf dabei nicht selbst
|
||||||
|
# von einem Zusatzpaket abhaengen: mit "file" -- das auf einem schlanken
|
||||||
|
# Server fehlt -- endete sie in "command not found", grep bekam nichts zu
|
||||||
|
# sehen, und das Skript machte weiter, als waere alles in Ordnung.
|
||||||
|
hat_crlf() {
|
||||||
|
local cr
|
||||||
|
cr="$(printf '\r')"
|
||||||
|
# -U (binaer lesen) ist unter Linux ohne Wirkung, auf einer Windows-Buildmaschine
|
||||||
|
# aber entscheidend: dort entfernt grep den Wagenruecklauf beim Lesen und
|
||||||
|
# findet deshalb nie einen.
|
||||||
|
LC_ALL=C grep -qU "$cr" "$1"
|
||||||
|
}
|
||||||
|
|
||||||
APP="${1:-}"
|
APP="${1:-}"
|
||||||
TAG="${2:-latest}"
|
TAG="${2:-latest}"
|
||||||
BASE="${TESM_RELEASE_BASE:-https://gitea.example.invalid}"
|
|
||||||
OWNER="${TESM_RELEASE_OWNER:-wis}"
|
# Alles nach dem Tag geht unveraendert an install.sh weiter. Ohne das liesse
|
||||||
REPO="${TESM_RELEASE_REPO:-tesm}"
|
# sich eine benannte Instanz nicht aktualisieren: install.sh leitet den Namen
|
||||||
|
# aus "--instance" ab, und ohne den Schalter entstuende neben "tesm-opus" eine
|
||||||
|
# zweite Installation namens "tesm" -- mit eigener Datenbank, eigener
|
||||||
|
# nginx-Site und demselben Port.
|
||||||
|
shift $(( $# > 2 ? 2 : $# ))
|
||||||
|
EXTRA=("$@")
|
||||||
|
# Bezugsquelle. Die Vorgaben zeigen dorthin, wo die Releases tatsaechlich
|
||||||
|
# liegen -- ein Platzhalter wie "gitea.example.invalid" macht das Skript auf
|
||||||
|
# jedem Host unbrauchbar, und genau das war hier der Fall.
|
||||||
|
#
|
||||||
|
# Das Repository heisst wie die Anwendung; getrennte Repositorien, getrennte
|
||||||
|
# Releases. Der Anhang heisst "<repo>-<tag>.tar.gz", siehe unten.
|
||||||
|
BASE="${TESM_RELEASE_BASE:-https://gitea.int.eertmoed.net}"
|
||||||
|
OWNER="${TESM_RELEASE_OWNER:-alientim}"
|
||||||
|
|
||||||
[[ "$APP" == "tesm" || "$APP" == "tesm-license" ]] || {
|
[[ "$APP" == "tesm" || "$APP" == "tesm-license" ]] || {
|
||||||
echo "Verwendung: update.sh <tesm|tesm-license> [tag]" >&2
|
echo "Verwendung: update.sh <tesm|tesm-license> [tag] [install.sh-Argumente ...]" >&2
|
||||||
|
echo "Beispiel : update.sh tesm latest --instance opus" >&2
|
||||||
exit 2
|
exit 2
|
||||||
}
|
}
|
||||||
|
|
||||||
|
REPO="${TESM_RELEASE_REPO:-$APP}"
|
||||||
|
|
||||||
WORK="$(mktemp -d /tmp/tesm-update-XXXXXX)"
|
WORK="$(mktemp -d /tmp/tesm-update-XXXXXX)"
|
||||||
trap 'rm -rf "$WORK"' EXIT # anders als im Vorgaenger wird aufgeraeumt
|
trap 'rm -rf "$WORK"' EXIT # anders als im Vorgaenger wird aufgeraeumt
|
||||||
|
|
||||||
@@ -39,18 +69,21 @@ curl -fsSL -o "$WORK/$ASSET" "$BASE/$OWNER/$REPO/releases/download/$TAG/$ASSET"
|
|||||||
|
|
||||||
echo "==> Entpacke ..."
|
echo "==> Entpacke ..."
|
||||||
tar -xzf "$WORK/$ASSET" -C "$WORK"
|
tar -xzf "$WORK/$ASSET" -C "$WORK"
|
||||||
SRC="$(find "$WORK" -maxdepth 2 -name install.sh -path '*/deploy/*' -printf '%h/..\n' | head -1)"
|
# Drei Ebenen, nicht zwei: release.sh packt mit dem Praefix "<name>/", also
|
||||||
|
# liegt das Skript unter <name>/deploy/install.sh. Mit "-maxdepth 2" fand die
|
||||||
|
# Suche nie etwas -- und die Fehlermeldung schob es auf den Download.
|
||||||
|
SRC="$(find "$WORK" -maxdepth 3 -name install.sh -path '*/deploy/*' -printf '%h/..\n' | head -1)"
|
||||||
[[ -n "$SRC" ]] || { echo "Im Paket fehlt deploy/install.sh -- Download unbrauchbar." >&2; exit 1; }
|
[[ -n "$SRC" ]] || { echo "Im Paket fehlt deploy/install.sh -- Download unbrauchbar." >&2; exit 1; }
|
||||||
SRC="$(cd "$SRC" && pwd)"
|
SRC="$(cd "$SRC" && pwd)"
|
||||||
|
|
||||||
echo "==> Pruefe auf CRLF (Windows-Buildmaschinen) ..."
|
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 "Das Paket enthaelt CRLF-Zeilenenden. Beim Paketieren fehlte" >&2
|
||||||
echo "'git -c core.autocrlf=false archive'." >&2
|
echo "'git -c core.autocrlf=false archive'." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
read -r -p "Version $TAG fuer $APP installieren? [j/N] " answer
|
read -r -p "Version $TAG fuer $APP${EXTRA[0]+ (${EXTRA[*]})} installieren? [j/N] " answer
|
||||||
[[ "$answer" =~ ^[jJyY]$ ]] || exit 0
|
[[ "$answer" =~ ^[jJyY]$ ]] || exit 0
|
||||||
|
|
||||||
exec sudo bash "$SRC/deploy/install.sh" --app "$APP" --yes
|
exec sudo bash "$SRC/deploy/install.sh" --app "$APP" --yes ${EXTRA[0]+"${EXTRA[@]}"}
|
||||||
|
|||||||
+103
-65
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Dieses Dokument richtet sich an die Person, die die Anlage installiert,
|
Dieses Dokument richtet sich an die Person, die die Anlage installiert,
|
||||||
aktualisiert und im Fehlerfall wieder zum Laufen bringt. Es beschreibt, was auf
|
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 |
|
| Zweck | Pfad |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Anwendung (Code, venv, statische Dateien) | `/srv/<name>/` |
|
| 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>/` |
|
| Protokolle | `/var/log/<name>/` |
|
||||||
| Umgebungsdatei | `/etc/tesm/<name>.env` |
|
| Umgebungsdatei | `/etc/tesm/<name>.env` |
|
||||||
| Zertifikate (selbstsigniert) | `/etc/tesm/certs/<name>/` |
|
| 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` |
|
| Privilegierter Helfer | `/usr/local/lib/tesm/tesm-helper` |
|
||||||
| Verwaltungswerkzeug | `/usr/local/bin/<name>-admin` |
|
| 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
|
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
|
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.
|
wird von `install.sh` nie angefasst.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -43,10 +43,10 @@ Wichtige Optionen:
|
|||||||
| Option | Wirkung |
|
| Option | Wirkung |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `--https` | Port 443 mit TLS, Port 80 leitet dorthin um. Ohne `--domain` entsteht ein selbstsigniertes Zertifikat. |
|
| `--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. |
|
| `--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 bestaetigen. |
|
| `--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>` | Zusaetzliche Instanz neben der Hauptinstallation. |
|
| `--instance <name>` | Zusätzliche Instanz neben der Hauptinstallation. |
|
||||||
| `--port` / `--http-port` / `--https-port` | Abweichende Ports (nur fuer Testinstanzen sinnvoll). |
|
| `--port` / `--http-port` / `--https-port` | Abweichende Ports (nur für Testinstanzen sinnvoll). |
|
||||||
| `--skip-packages` | Keine Systempakete installieren. |
|
| `--skip-packages` | Keine Systempakete installieren. |
|
||||||
|
|
||||||
Danach:
|
Danach:
|
||||||
@@ -55,17 +55,17 @@ Danach:
|
|||||||
sudo -u tesm tesm-admin create-admin
|
sudo -u tesm tesm-admin create-admin
|
||||||
```
|
```
|
||||||
|
|
||||||
Beim Lizenzserver zusaetzlich, **in dieser Reihenfolge**:
|
Beim Lizenzserver zusätzlich, **in dieser Reihenfolge**:
|
||||||
|
|
||||||
```bash
|
```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 bootstrap # eigene Master-Lizenz
|
||||||
sudo -u tesm-license tesm-license-admin create-admin
|
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
|
> `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
|
### 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
|
Die Installation **ohne** Domain wird zur Vorgabe-Site (`default_server`) und
|
||||||
beantwortet alles, was zu keinem Namen passt. Zwei namenlose Sites auf
|
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.
|
sichtbar.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -90,19 +90,57 @@ sudo bash deploy/install.sh --app tesm
|
|||||||
```
|
```
|
||||||
|
|
||||||
Derselbe Befehl. Das Skript erkennt an der vorhandenen Datenbank, dass es eine
|
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>` --
|
1. **Vollständige Sicherung** nach `/srv/<name>-backup-<zeitstempel>` --
|
||||||
unabhaengig davon, ob sich das Schema geaendert hat.
|
unabhängig davon, ob sich das Schema geändert hat.
|
||||||
2. Dateiabgleich; `data/` bleibt unangetastet.
|
2. Dateiabgleich; `data/` bleibt unangetastet.
|
||||||
3. Schema-Migrationen beim Start (versioniert, mit Pruefsumme).
|
3. Schema-Migrationen beim Start (versioniert, mit Prüfsumme).
|
||||||
4. **Gesundheitspruefung.** Antwortet die Anwendung nicht, wird der alte Stand
|
4. **Gesundheitsprüfung.** Antwortet die Anwendung nicht, wird der alte Stand
|
||||||
automatisch zurueckgeholt; die fehlgeschlagene Version bleibt unter
|
automatisch zurückgeholt; die fehlgeschlagene Version bleibt unter
|
||||||
`/srv/<name>-failed-<zeitstempel>` zur Analyse liegen.
|
`/srv/<name>-failed-<zeitstempel>` zur Analyse liegen.
|
||||||
|
|
||||||
Eine bereits eingerichtete HTTPS-Konfiguration bleibt erhalten, auch ohne
|
Eine bereits eingerichtete HTTPS-Konfiguration bleibt erhalten, auch ohne
|
||||||
`--https`.
|
`--https`.
|
||||||
|
|
||||||
|
### Ohne Quellbaum: `update.sh`
|
||||||
|
|
||||||
|
Liegt kein entpacktes Release vor, holt `update.sh` es selbst -- es lädt das
|
||||||
|
Release-Asset, entpackt es und ruft dessen `install.sh` auf:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo bash /opt/tesm/deploy/update.sh tesm
|
||||||
|
sudo bash /opt/tesm-license/deploy/update.sh tesm-license
|
||||||
|
```
|
||||||
|
|
||||||
|
Alles nach dem Tag geht unverändert an `install.sh` weiter -- das ist der Weg,
|
||||||
|
eine **benannte Instanz** zu aktualisieren:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo bash /opt/tesm-opus/deploy/update.sh tesm latest --instance opus
|
||||||
|
```
|
||||||
|
|
||||||
|
Ohne `--instance` entstünde daneben eine zweite Installation namens `tesm`, mit
|
||||||
|
eigener Datenbank und eigener nginx-Site. Die Ports einer bestehenden
|
||||||
|
Installation bleiben erhalten, auch wenn sie von den Vorgaben abweichen: eine
|
||||||
|
Aktualisierung verschiebt nichts, was nicht ausdrücklich neu gesetzt wird.
|
||||||
|
|
||||||
|
Ohne Tag wird `latest` **über die API aufgelöst**, nicht in die URL geschrieben:
|
||||||
|
Gitea liefert unter einem Tag-Namen sonst ein automatisch erzeugtes
|
||||||
|
Quell-Archiv mit HTTP 200 statt des echten Anhangs -- und das enthält kein
|
||||||
|
Release, sondern den Repositoriumsstand.
|
||||||
|
|
||||||
|
Die Bezugsquelle ist voreingestellt und lässt sich über die Umgebung umlenken,
|
||||||
|
etwa auf eine Spiegelung:
|
||||||
|
|
||||||
|
| Variable | Vorgabe |
|
||||||
|
|---|---|
|
||||||
|
| `TESM_RELEASE_BASE` | `https://gitea.int.eertmoed.net` |
|
||||||
|
| `TESM_RELEASE_OWNER` | `alientim` |
|
||||||
|
| `TESM_RELEASE_REPO` | der Anwendungsname (`tesm` bzw. `tesm-license`) |
|
||||||
|
|
||||||
|
Der erwartete Anhang heisst `<repo>-<tag>.tar.gz`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. HTTPS
|
## 4. HTTPS
|
||||||
@@ -114,7 +152,7 @@ Drei Wege dorthin:
|
|||||||
|
|
||||||
**a) Bei der Installation** -- siehe oben, `--https`.
|
**a) Bei der Installation** -- siehe oben, `--https`.
|
||||||
|
|
||||||
**b) Nachtraeglich ueber die Kommandozeile:**
|
**b) Nachträglich über die Kommandozeile:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo -u tesm tesm-admin web-setup --https --self-signed --apply
|
sudo -u tesm tesm-admin web-setup --https --self-signed --apply
|
||||||
@@ -124,42 +162,42 @@ sudo -u tesm tesm-admin web-setup --https --domain tesm.firma.de \
|
|||||||
|
|
||||||
`--print-config` zeigt die Datei, ohne etwas zu schreiben.
|
`--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
|
Danach muss `COOKIE_SECURE` in `/etc/tesm/<name>.env` auf `1` stehen (alle drei
|
||||||
Wege erledigen das selbst) und der Dienst neu gestartet werden.
|
Wege erledigen das selbst) und der Dienst neu gestartet werden.
|
||||||
|
|
||||||
### Die Site-Datei wird immer neu erzeugt
|
### Die Site-Datei wird immer neu erzeugt
|
||||||
|
|
||||||
Sie ist **kein** Ort fuer Handarbeit. Jede Uebernahme schreibt sie vollstaendig
|
Sie ist **kein** Ort für Handarbeit. Jede Übernahme schreibt sie vollständig
|
||||||
aus der Vorlage neu. Was frueher von Hand hineingeschrieben wurde -- Domain,
|
aus der Vorlage neu. Was früher von Hand hineingeschrieben wurde -- Domain,
|
||||||
Ports, Zertifikatspfade, HSTS, Upload-Grenze -- ist heute Einstellung in der
|
Ports, Zertifikatspfade, HSTS, Upload-Grenze -- ist heute Einstellung in der
|
||||||
Anwendung.
|
Anwendung.
|
||||||
|
|
||||||
Der Grund steht im Vorgaengerprojekt: dort wurde die Datei nur angelegt, wenn
|
Der Grund steht im Vorgängerprojekt: dort wurde die Datei nur angelegt, wenn
|
||||||
sie fehlte. Ein einmal falscher `alias`-Pfad ueberlebte dadurch jahrelang jedes
|
sie fehlte. Ein einmal falscher `alias`-Pfad überlebte dadurch jahrelang jedes
|
||||||
Update, und die Oberflaeche kam die ganze Zeit ohne Design.
|
Update, und die Oberfläche kam die ganze Zeit ohne Design.
|
||||||
|
|
||||||
### Selbstsigniert oder Let's Encrypt?
|
### 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.
|
sinnvoll.
|
||||||
* **Geschlossenes Netz:** selbstsigniert. Browser zeigen eine Warnung, die
|
* **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
|
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`
|
Die Erneuerung von Let's Encrypt läuft über `certbot certonly --webroot`
|
||||||
(bewusst nicht ueber das nginx-Plugin, das die erzeugte Site umschreiben
|
(bewusst nicht über das nginx-Plugin, das die erzeugte Site umschreiben
|
||||||
wuerde). Der ACME-Pfad bleibt auch bei aktiver Umleitung erreichbar.
|
würde). Der ACME-Pfad bleibt auch bei aktiver Umleitung erreichbar.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. Dienste
|
## 5. Dienste
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
systemctl status tesm.service # Weboberflaeche (gunicorn)
|
systemctl status tesm.service # Weboberfläche (gunicorn)
|
||||||
systemctl status tesm-monitor.service # Ueberwachungsschleife (nur TESM)
|
systemctl status tesm-monitor.service # Überwachungsschleife (nur TESM)
|
||||||
systemctl status tesm-license.service
|
systemctl status tesm-license.service
|
||||||
journalctl -u tesm -n 100 -f
|
journalctl -u tesm -n 100 -f
|
||||||
```
|
```
|
||||||
@@ -179,8 +217,8 @@ Die Anwendung hat keine. Alles, was Root braucht, geht durch **einen** Pfad:
|
|||||||
/usr/local/lib/tesm/tesm-helper <verb> [argumente]
|
/usr/local/lib/tesm/tesm-helper <verb> [argumente]
|
||||||
```
|
```
|
||||||
|
|
||||||
Der Helfer gehoert `root`, ist per sudoers ausschliesslich fuer genau diesen
|
Der Helfer gehört `root`, ist per sudoers ausschliesslich für genau diesen
|
||||||
Pfad mit `NOPASSWD` freigegeben und prueft jedes Argument gegen eine
|
Pfad mit `NOPASSWD` freigegeben und prüft jedes Argument gegen eine
|
||||||
Positivliste. Er reicht nie etwas an eine Shell weiter (kein `eval`, kein
|
Positivliste. Er reicht nie etwas an eine Shell weiter (kein `eval`, kein
|
||||||
`sh -c`).
|
`sh -c`).
|
||||||
|
|
||||||
@@ -191,16 +229,16 @@ sudo /usr/local/lib/tesm/tesm-helper
|
|||||||
grep -n ')$' /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
|
`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
|
## 7. Sicherung und Wiederherstellung
|
||||||
|
|
||||||
In der Oberflaeche unter *Sicherung* -- verschluesselte Ausfuhr (Argon2id +
|
In der Oberfläche unter *Sicherung* -- verschlüsselte Ausfuhr (Argon2id +
|
||||||
AES-256-GCM) mit einem selbst gewaehlten Kennwort. Der Import zeigt zuerst eine
|
AES-256-GCM) mit einem selbst gewählten Kennwort. Der Import zeigt zuerst eine
|
||||||
Vorschau, bevor irgendetwas geschrieben wird.
|
Vorschau, bevor irgendetwas geschrieben wird.
|
||||||
|
|
||||||
Auf dem Host reicht:
|
Auf dem Host reicht:
|
||||||
@@ -214,10 +252,10 @@ sudo systemctl start tesm
|
|||||||
Unverzichtbar:
|
Unverzichtbar:
|
||||||
|
|
||||||
* `data/app.db` -- alle Daten
|
* `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/data.keys` -- ohne sie sind **alle gespeicherten Geheimnisse verloren**
|
||||||
* `data/license.json` und `data/license_key.json`
|
* `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 +268,17 @@ curl -s http://127.0.0.1:5000/gesundheit
|
|||||||
```
|
```
|
||||||
Antwortet gunicorn, aber der Browser nicht, liegt es an nginx.
|
Antwortet gunicorn, aber der Browser nicht, liegt es an nginx.
|
||||||
|
|
||||||
**nginx laesst sich nicht neu laden.**
|
**nginx lässt sich nicht neu laden.**
|
||||||
```bash
|
```bash
|
||||||
sudo nginx -t
|
sudo nginx -t
|
||||||
```
|
```
|
||||||
Die Uebernahme aus der Anwendung nimmt eine abgelehnte Konfiguration selbst
|
Die Übernahme aus der Anwendung nimmt eine abgelehnte Konfiguration selbst
|
||||||
zurueck. Eine von Hand bearbeitete Datei tut das nicht -- in dem Fall hilft
|
zurück. Eine von Hand bearbeitete Datei tut das nicht -- in dem Fall hilft
|
||||||
`install.sh`, das eine unbrauchbare Site durch die Vorlage ersetzt.
|
`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,
|
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
|
```bash
|
||||||
grep COOKIE_SECURE /etc/tesm/tesm.env
|
grep COOKIE_SECURE /etc/tesm/tesm.env
|
||||||
```
|
```
|
||||||
@@ -250,38 +288,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
|
`proxy_set_header Host $http_host;` stehen -- **nicht** `$host`, das verwirft
|
||||||
den Port.
|
den Port.
|
||||||
|
|
||||||
**Statische Dateien fehlen, die Oberflaeche ist unformatiert.**
|
**Statische Dateien fehlen, die Oberfläche ist unformatiert.**
|
||||||
```bash
|
```bash
|
||||||
grep alias /etc/nginx/sites-available/tesm
|
grep alias /etc/nginx/sites-available/tesm
|
||||||
ls /srv/tesm/static /srv/tesm/static-core
|
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
|
`SITE_KEY` in beiden `/etc/tesm/*.env` vergleichen -- er muss sich
|
||||||
unterscheiden. Sonst teilen sich beide nginx-Site und Sitzungscookie.
|
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
|
Kein Fehler, sondern die Absicht: ein Hintergrundauftrag darf keinen fremden
|
||||||
Schluessel akzeptieren. Die Freigabe erfolgt von Hand, in zwei Schritten, auf
|
Schlüssel akzeptieren. Die Freigabe erfolgt von Hand, in zwei Schritten, auf
|
||||||
der Detailseite des Geraets (*Clients -> Geraet -> SSH-Host-Schluessel*):
|
der Detailseite des Geräts (*Clients -> Gerät -> SSH-Host-Schlüssel*):
|
||||||
erst **Auslesen**, dann den Fingerprint mit einer unabhaengigen Quelle
|
erst **Auslesen**, dann den Fingerprint mit einer unabhängigen Quelle
|
||||||
vergleichen, dann **Freigeben**. Die Wartungsuebersicht zeigt in einer eigenen
|
vergleichen, dann **Freigeben**. Die Wartungsübersicht zeigt in einer eigenen
|
||||||
Spalte, fuer welche Geraete das noch offen ist. Fuer Switche liegt dieselbe
|
Spalte, für welche Geräte das noch offen ist. Für Switche liegt dieselbe
|
||||||
Freigabe auf der Switch-Detailseite.
|
Freigabe auf der Switch-Detailseite.
|
||||||
|
|
||||||
Der Schluessel wird nach **IP und Port** gespeichert, nicht nach Name -- TESM
|
Der Schlüssel wird nach **IP und Port** gespeichert, nicht nach Name -- TESM
|
||||||
verbindet sich immer ueber die hinterlegte IP-Adresse. Aendert sich die IP
|
verbindet sich immer über die hinterlegte IP-Adresse. Ändert sich die IP
|
||||||
eines Geraets, ist der Schluessel erneut freizugeben.
|
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,
|
Unter *Diagnose* sichtbar. Das Protokoll ist verkettet gehasht; ein Bruch heisst,
|
||||||
dass jemand direkt in die Datenbank geschrieben hat. Der Eintrag, ab dem es
|
dass jemand direkt in die Datenbank geschrieben hat. Der Eintrag, ab dem es
|
||||||
nicht mehr stimmt, wird genannt.
|
nicht mehr stimmt, wird genannt.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 9. Zurueck auf eine aeltere Version
|
## 9. Zurück auf eine ältere Version
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo systemctl stop tesm
|
sudo systemctl stop tesm
|
||||||
@@ -290,6 +328,6 @@ sudo cp -a /srv/tesm-backup-<zeitstempel> /srv/tesm
|
|||||||
sudo systemctl start tesm
|
sudo systemctl start tesm
|
||||||
```
|
```
|
||||||
|
|
||||||
Schema-Migrationen laufen nur vorwaerts. Ein Ruecksprung ueber eine
|
Schema-Migrationen laufen nur vorwärts. Ein Rücksprung über eine
|
||||||
Schema-Aenderung hinweg braucht deshalb auch die Datenbank aus der Sicherung --
|
Schema-Änderung hinweg braucht deshalb auch die Datenbank aus der Sicherung --
|
||||||
genau das liegt im Backup-Verzeichnis.
|
genau das liegt im Backup-Verzeichnis.
|
||||||
|
|||||||
+56
-56
@@ -1,6 +1,6 @@
|
|||||||
# Sicherheit
|
# 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) |
|
| Speicherung | Argon2id (argon2-cffi) |
|
||||||
| Altbestand | bcrypt wird noch geprueft und bei der naechsten erfolgreichen Anmeldung stillschweigend auf Argon2id umgestellt |
|
| Altbestand | bcrypt wird noch geprüft und bei der nächsten erfolgreichen Anmeldung stillschweigend auf Argon2id umgestellt |
|
||||||
| Zweiter Faktor | TOTP, optional erzwingbar fuer Administratoren |
|
| Zweiter Faktor | TOTP, optional erzwingbar für Administratoren |
|
||||||
| Sperre je Konto | nach `LOGIN_MAX_ATTEMPTS` Versuchen |
|
| 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 |
|
| 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 ueber TLS, Zertifikat wird geprueft. Ohne gueltiges Zertifikat keine Anmeldung -- kein Schalter, der das abschaltet |
|
| Verzeichnisdienst | LDAP nur über TLS, Zertifikat wird geprüft. Ohne gültiges Zertifikat keine Anmeldung -- kein Schalter, der das abschaltet |
|
||||||
|
|
||||||
Nach einer Anmeldung wird die Sitzungskennung neu vergeben (gegen Session
|
Nach einer Anmeldung wird die Sitzungskennung neu vergeben (gegen Session
|
||||||
Fixation) und das CSRF-Token gewechselt.
|
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,
|
verlangt eine frische Passworteingabe: Klartextanzeige von Zugangsdaten,
|
||||||
Ausfuhr, Herunterladen eines Lizenzbundles, Schluesselrotation,
|
Ausfuhr, Herunterladen eines Lizenzbundles, Schlüsselrotation,
|
||||||
2FA-Aenderungen, Deaktivierung einer Lizenz.
|
2FA-Änderungen, Deaktivierung einer Lizenz.
|
||||||
|
|
||||||
Ein uebernommenes Sitzungscookie allein reicht dafuer nicht.
|
Ein übernommenes Sitzungscookie allein reicht dafür nicht.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. Sitzungen
|
## 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.
|
Zufallskennung.
|
||||||
|
|
||||||
* `HttpOnly`, `SameSite=Lax`, `Secure` sobald HTTPS eingerichtet ist
|
* `HttpOnly`, `SameSite=Lax`, `Secure` sobald HTTPS eingerichtet ist
|
||||||
* Leerlaufzeit **und** absolute Hoechstdauer
|
* Leerlaufzeit **und** absolute Höchstdauer
|
||||||
* Der Cookiename enthaelt den `SITE_KEY` -- zwei Installationen auf einem Host
|
* Der Cookiename enthält den `SITE_KEY` -- zwei Installationen auf einem Host
|
||||||
koennen sich die Sitzung nicht gegenseitig ueberschreiben (Cookies
|
können sich die Sitzung nicht gegenseitig überschreiben (Cookies
|
||||||
unterscheiden keine Ports)
|
unterscheiden keine Ports)
|
||||||
* Aktive Sitzungen sind einsehbar und einzeln beendbar
|
* Aktive Sitzungen sind einsehbar und einzeln beendbar
|
||||||
|
|
||||||
@@ -45,22 +45,22 @@ Zufallskennung.
|
|||||||
|
|
||||||
## 3. CSRF
|
## 3. CSRF
|
||||||
|
|
||||||
Drei voneinander unabhaengige Schichten:
|
Drei voneinander unabhängige Schichten:
|
||||||
|
|
||||||
1. `SameSite=Lax` am Sitzungscookie.
|
1. `SameSite=Lax` am Sitzungscookie.
|
||||||
2. Ein Token je Sitzung, in jedem Formular, in konstanter Zeit verglichen.
|
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
|
Die dritte Schicht akzeptiert den Host mit und ohne Port sowie die
|
||||||
Standardports -- **nicht** aber beliebige Ports: ein anderer Dienst auf
|
Standardports -- **nicht** aber beliebige Ports: ein anderer Dienst auf
|
||||||
demselben Rechner ist ein anderer Ursprung.
|
demselben Rechner ist ein anderer Ursprung.
|
||||||
|
|
||||||
Der Vorgaenger hatte gar keinen CSRF-Schutz. Eine praeparierte Seite konnte im
|
Der Vorgänger hatte gar keinen CSRF-Schutz. Eine präparierte Seite konnte im
|
||||||
Namen eines angemeldeten Administrators Geraete loeschen oder Benutzer anlegen.
|
Namen eines angemeldeten Administrators Geräte löschen oder Benutzer anlegen.
|
||||||
|
|
||||||
Ausnahmen (`@csrf.exempt`) gibt es nur fuer Maschinenschnittstellen, die sich
|
Ausnahmen (`@csrf.exempt`) gibt es nur für Maschinenschnittstellen, die sich
|
||||||
**nicht** ueber Cookies autorisieren. Eine cookie-autorisierte Route ohne
|
**nicht** über Cookies autorisieren. Eine cookie-autorisierte Route ohne
|
||||||
CSRF-Schutz waere eine Luecke.
|
CSRF-Schutz wäre eine Lücke.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -69,75 +69,75 @@ CSRF-Schutz waere eine Luecke.
|
|||||||
Bei jeder Antwort:
|
Bei jeder Antwort:
|
||||||
|
|
||||||
* `Content-Security-Policy` mit einem Nonce je Anfrage. **Kein**
|
* `Content-Security-Policy` mit einem Nonce je Anfrage. **Kein**
|
||||||
`unsafe-inline` fuer Skripte -- deshalb gibt es im ganzen Projekt kein
|
`unsafe-inline` für Skripte -- deshalb gibt es im ganzen Projekt kein
|
||||||
Inline-Script ohne Nonce, und JavaScript haengt ueber
|
Inline-Script ohne Nonce, und JavaScript hängt über
|
||||||
`data-behavior`-Attribute am DOM. `style-src` erlaubt `unsafe-inline`, weil
|
`data-behavior`-Attribute am DOM. `style-src` erlaubt `unsafe-inline`, weil
|
||||||
einzelne berechnete Breiten (Fortschrittsbalken) als Style-Attribut gesetzt
|
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-Content-Type-Options: nosniff`
|
||||||
* `X-Frame-Options: DENY`, `frame-ancestors 'none'`
|
* `X-Frame-Options: DENY`, `frame-ancestors 'none'`
|
||||||
* `Referrer-Policy: same-origin`
|
* `Referrer-Policy: same-origin`
|
||||||
* `Permissions-Policy` -- Kamera, Mikrofon, Ort, Zahlung, USB abgeschaltet
|
* `Permissions-Policy` -- Kamera, Mikrofon, Ort, Zahlung, USB abgeschaltet
|
||||||
* `Cross-Origin-Opener-Policy`, `Cross-Origin-Resource-Policy`
|
* `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
|
> HSTS gehört **nicht** zu einem selbstsignierten Zertifikat. Der Browser
|
||||||
> merkt sich die Vorgabe monatelang; die Warnung laesst sich danach nicht mehr
|
> 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.
|
> wegklicken. `install.sh` setzt HSTS deshalb nur zusammen mit Let's Encrypt.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. Geheimnisse im Ruhezustand
|
## 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.
|
(`v1.<id>.<nonce>.<ct>`) und bleiben nach einer Rotation lesbar.
|
||||||
* Der Verwendungszweck geht als AAD in die Verschluesselung ein. Ein Chiffrat
|
* Der Verwendungszweck geht als AAD in die Verschlüsselung ein. Ein Chiffrat
|
||||||
aus einem Kontext laesst sich in einem anderen nicht entschluesseln.
|
aus einem Kontext lässt sich in einem anderen nicht entschlüsseln.
|
||||||
* Beim Start wird geprueft, ob die Rechte auf Schluessel- und Lizenzdateien
|
* Beim Start wird geprüft, ob die Rechte auf Schlüssel- und Lizenzdateien
|
||||||
noch stimmen; Abweichungen erscheinen unter *Diagnose*.
|
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)
|
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
|
## 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
|
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:
|
Regeln, die im Repository durch Tests erzwungen werden:
|
||||||
|
|
||||||
* Jedes Verb, das die Anwendung kennt, existiert im Helfer.
|
* Jedes Verb, das die Anwendung kennt, existiert im Helfer.
|
||||||
* Kein `eval`, kein `bash -c`, kein `sh -c`.
|
* 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 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.
|
* Der Instanzname ist auf `tesm[-<instanz>]` begrenzt; er landet in Pfaden.
|
||||||
* Keine sudoers-Regel ausser der einen. `NOPASSWD: ALL` gibt es nicht.
|
* Keine sudoers-Regel ausser der einen. `NOPASSWD: ALL` gibt es nicht.
|
||||||
|
|
||||||
Die systemd-Units haben `NoNewPrivileges=yes`, `ProtectSystem=strict`,
|
Die systemd-Units haben `NoNewPrivileges=yes`, `ProtectSystem=strict`,
|
||||||
`PrivateTmp=yes` und eine enge `RestrictAddressFamilies`-Liste.
|
`PrivateTmp=yes` und eine enge `RestrictAddressFamilies`-Liste.
|
||||||
|
|
||||||
Der Vorgaenger liess die komplette Flask-Anwendung als root laufen. Eine
|
Der Vorgänger liess die komplette Flask-Anwendung als root laufen. Eine
|
||||||
einzige Luecke in irgendeinem Pfad haette den Host bedeutet.
|
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
|
Verkettet gehasht: jeder Eintrag enthält den Hash seines Vorgängers. Ein
|
||||||
nachtraegliches Aendern oder Loeschen faellt auf und wird unter *Diagnose*
|
nachträgliches Ändern oder Löschen fällt auf und wird unter *Diagnose*
|
||||||
angezeigt, mitsamt dem Eintrag, ab dem die Kette bricht.
|
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`).
|
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:
|
Siehe [ARCHITEKTUR.md](ARCHITEKTUR.md), Abschnitt Lizenzierung. Kurz:
|
||||||
getrennte Signaturkontexte, kanonisches JSON, Nonce gegen Wiedereinspielung,
|
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.
|
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
|
selbst ein Geheimnis: eigenes Recht (`licenses.export`), frische
|
||||||
Passwortbestaetigung, `Cache-Control: no-store`, Eintrag im Zustell- und im
|
Passwortbestätigung, `Cache-Control: no-store`, Eintrag im Zustell- und im
|
||||||
Aenderungsprotokoll.
|
Änderungsprotokoll.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 9. Ein- und Ausfuhr
|
## 9. Ein- und Ausfuhr
|
||||||
|
|
||||||
Verschluesselte Umschlaege (Argon2id-Ableitung + AES-256-GCM) mit einem selbst
|
Verschlüsselte Umschläge (Argon2id-Ableitung + AES-256-GCM) mit einem selbst
|
||||||
gewaehlten Kennwort. Der Import zeigt eine Vorschau, bevor etwas geschrieben
|
gewählten Kennwort. Der Import zeigt eine Vorschau, bevor etwas geschrieben
|
||||||
wird; die Vorschau liegt in der Datenbank, nicht im Prozessspeicher -- deshalb
|
wird; die Vorschau liegt in der Datenbank, nicht im Prozessspeicher -- deshalb
|
||||||
braucht der Dienst kein `--workers 1` mehr.
|
braucht der Dienst kein `--workers 1` mehr.
|
||||||
|
|
||||||
@@ -166,11 +166,11 @@ braucht der Dienst kein `--workers 1` mehr.
|
|||||||
|
|
||||||
## 10. Bewusst nicht getan
|
## 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.
|
einmal setzt, setzt ihn dauerhaft.
|
||||||
* **Kein CSRF-Freibrief fuer cookie-autorisierte Routen.**
|
* **Kein CSRF-Freibrief für cookie-autorisierte Routen.**
|
||||||
* **Kein `unsafe-inline` fuer Skripte**, auch nicht "voruebergehend".
|
* **Kein `unsafe-inline` für Skripte**, auch nicht "vorübergehend".
|
||||||
* **Keine Rechteausweitung**, auch nicht fuer Administratoren untereinander.
|
* **Keine Rechteausweitung**, auch nicht für Administratoren untereinander.
|
||||||
* **Kein Hostname im Lizenz-Fingerabdruck** -- Umbenennen darf die Bindung
|
* **Kein Hostname im Lizenz-Fingerabdruck** -- Umbenennen darf die Bindung
|
||||||
nicht brechen.
|
nicht brechen.
|
||||||
* **Keine von Hand gepflegte nginx-Site.** Sie wird immer neu erzeugt.
|
* **Keine von Hand gepflegte nginx-Site.** Sie wird immer neu erzeugt.
|
||||||
@@ -179,5 +179,5 @@ braucht der Dienst kein `--workers 1` mehr.
|
|||||||
|
|
||||||
## 11. Wenn Sie eine Schwachstelle finden
|
## 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.
|
Lizenzservers hinterlegte Adresse.
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "tesm-core"
|
name = "tesm-core"
|
||||||
version = "2.0.0"
|
version = "2.0.2"
|
||||||
description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver"
|
description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ Design-System.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
__version__ = "2.0.0"
|
__version__ = "2.0.2"
|
||||||
|
|
||||||
from .app_factory import create_base_app
|
from .app_factory import create_base_app
|
||||||
from .config import CoreConfig, SecurityConfig, load_core_config
|
from .config import CoreConfig, SecurityConfig, load_core_config
|
||||||
|
|||||||
@@ -43,6 +43,11 @@ from .views import hostsettings as hostsettings_views
|
|||||||
from .views import logs as logs_views
|
from .views import logs as logs_views
|
||||||
from .views import trash as trash_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, ...] = (
|
CORE_SETTINGS: tuple[SettingSpec, ...] = (
|
||||||
SettingSpec("nav_order", [], "json", "Reihenfolge der Navigation", section="intern"),
|
SettingSpec("nav_order", [], "json", "Reihenfolge der Navigation", section="intern"),
|
||||||
SettingSpec("nav_child_order", {}, "json", "Reihenfolge der Unterpunkte", section="intern"),
|
SettingSpec("nav_child_order", {}, "json", "Reihenfolge der Unterpunkte", section="intern"),
|
||||||
@@ -56,6 +61,18 @@ CORE_SETTINGS: tuple[SettingSpec, ...] = (
|
|||||||
minimum=1,
|
minimum=1,
|
||||||
maximum=3650,
|
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("timezone", "", "str", "Zeitzone des Hosts", section="system"),
|
||||||
SettingSpec("hostname", "", "str", "Hostname", section="system"),
|
SettingSpec("hostname", "", "str", "Hostname", section="system"),
|
||||||
SettingSpec(
|
SettingSpec(
|
||||||
@@ -117,7 +134,7 @@ def create_base_app(
|
|||||||
#: Seitenhintergrund. Der Lizenzserver setzt sie bewusst nicht: die
|
#: Seitenhintergrund. Der Lizenzserver setzt sie bewusst nicht: die
|
||||||
#: Funktionsliste von TESM waere dort schlicht falsch.
|
#: Funktionsliste von TESM waere dort schlicht falsch.
|
||||||
auth_tagline: str = "",
|
auth_tagline: str = "",
|
||||||
app_version: str = "2.0.0",
|
app_version: str = "2.0.2",
|
||||||
built_in_trusted_keys: tuple[str, ...] = (),
|
built_in_trusted_keys: tuple[str, ...] = (),
|
||||||
enable_license: bool = True,
|
enable_license: bool = True,
|
||||||
template_folder: str | None = None,
|
template_folder: str | None = None,
|
||||||
@@ -192,11 +209,25 @@ def create_base_app(
|
|||||||
)
|
)
|
||||||
app.extensions["tesm"] = extension
|
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(
|
app.session_interface = SqliteSessionInterface(
|
||||||
database,
|
database,
|
||||||
cookie_name=app.config["SESSION_COOKIE_NAME"],
|
cookie_name=app.config["SESSION_COOKIE_NAME"],
|
||||||
idle_minutes=config.security.session_idle_minutes,
|
idle_minutes=config.security.session_idle_minutes,
|
||||||
absolute_hours=config.security.session_absolute_hours,
|
absolute_hours=config.security.session_absolute_hours,
|
||||||
|
idle_lookup=_leerlauf,
|
||||||
)
|
)
|
||||||
|
|
||||||
# -- Schema und Startzustand ------------------------------------------ #
|
# -- Schema und Startzustand ------------------------------------------ #
|
||||||
@@ -264,6 +295,7 @@ def create_base_app(
|
|||||||
"auth_tagline": auth_tagline,
|
"auth_tagline": auth_tagline,
|
||||||
"app_version": app_version,
|
"app_version": app_version,
|
||||||
"current_year": datetime.now(timezone.utc).year,
|
"current_year": datetime.now(timezone.utc).year,
|
||||||
|
"copyright_notice": COPYRIGHT_NOTICE,
|
||||||
"current_user": current_user,
|
"current_user": current_user,
|
||||||
"nav_items": nav_items,
|
"nav_items": nav_items,
|
||||||
"palette_entries": palette,
|
"palette_entries": palette,
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ AAD_LDAP_BIND = "service_accounts.ldap_bind"
|
|||||||
|
|
||||||
#: userAccountControl-Bit fuer "Konto deaktiviert".
|
#: userAccountControl-Bit fuer "Konto deaktiviert".
|
||||||
ACCOUNTDISABLE = 0x0002
|
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"
|
MATCHING_RULE_IN_CHAIN = "1.2.840.113556.1.4.1941"
|
||||||
|
|
||||||
Status = Literal["ok", "invalid", "unavailable", "disabled"]
|
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, ...]:
|
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:
|
try:
|
||||||
connection.search(
|
connection.search(
|
||||||
search_base=base,
|
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)
|
return tuple(str(entry.entry_dn) for entry in connection.entries)
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
logger.warning("Gruppenauflösung fehlgeschlagen: %s", exc)
|
logger.warning("Gruppenaufloesung fehlgeschlagen: %s", exc)
|
||||||
return ()
|
return ()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ class SecurityConfig:
|
|||||||
cookie_secure: bool = True
|
cookie_secure: bool = True
|
||||||
cookie_samesite: str = "Lax"
|
cookie_samesite: str = "Lax"
|
||||||
#: Leerlauf-Zeitfenster einer Sitzung.
|
#: Leerlauf-Zeitfenster einer Sitzung.
|
||||||
session_idle_minutes: int = 60
|
session_idle_minutes: int = 30
|
||||||
#: Harte Obergrenze unabhaengig von Aktivitaet.
|
#: Harte Obergrenze unabhaengig von Aktivitaet.
|
||||||
session_absolute_hours: int = 12
|
session_absolute_hours: int = 12
|
||||||
#: Wie lange eine erneute Passwortabfrage ("sudo-Modus") gilt.
|
#: Wie lange eine erneute Passwortabfrage ("sudo-Modus") gilt.
|
||||||
@@ -147,7 +147,7 @@ def load_core_config(
|
|||||||
security = SecurityConfig(
|
security = SecurityConfig(
|
||||||
cookie_secure=_env_bool(env_prefix, "COOKIE_SECURE", True),
|
cookie_secure=_env_bool(env_prefix, "COOKIE_SECURE", True),
|
||||||
cookie_samesite=_env(env_prefix, "COOKIE_SAMESITE") or "Lax",
|
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),
|
session_absolute_hours=_env_int(env_prefix, "SESSION_ABSOLUTE_HOURS", 12),
|
||||||
reauth_valid_minutes=_env_int(env_prefix, "REAUTH_VALID_MINUTES", 15),
|
reauth_valid_minutes=_env_int(env_prefix, "REAUTH_VALID_MINUTES", 15),
|
||||||
login_max_attempts=_env_int(env_prefix, "LOGIN_MAX_ATTEMPTS", 8),
|
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_PENDING = "license_pending_request"
|
||||||
SETTING_LAST_HEARTBEAT = "license_last_heartbeat_at"
|
SETTING_LAST_HEARTBEAT = "license_last_heartbeat_at"
|
||||||
SETTING_HEARTBEAT_ERROR = "license_heartbeat_error"
|
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"
|
SETTING_EXPORT_GRACE = "license_export_grace"
|
||||||
|
|
||||||
STATUS_INACTIVE = "inactive"
|
STATUS_INACTIVE = "inactive"
|
||||||
@@ -596,14 +600,22 @@ class LicenseManager:
|
|||||||
message = "Verbindung zum Lizenzserver bestaetigt."
|
message = "Verbindung zum Lizenzserver bestaetigt."
|
||||||
|
|
||||||
self.settings.set(conn, SETTING_PENDING, "", actor=actor)
|
self.settings.set(conn, SETTING_PENDING, "", actor=actor)
|
||||||
audit.log(
|
# Ein erfolgreicher Heartbeat laeuft alle paar Minuten und ist kein
|
||||||
conn,
|
# Ereignis, sondern der Normalzustand. Im Auditlog stand dadurch
|
||||||
actor=actor,
|
# praktisch nur noch er, und die Eintraege, um die es dort geht --
|
||||||
action=f"license.{action}",
|
# Aktivierung, Deaktivierung, Widerruf --, gingen darin unter. Der
|
||||||
target=str(request_payload["license_id"]),
|
# Zeitpunkt des letzten Heartbeats steht ohnehin in den Einstellungen
|
||||||
detail=f"Transport: {transport}",
|
# und auf der Lizenzseite. Ein *fehlgeschlagener* Heartbeat wird
|
||||||
severity="notice",
|
# weiterhin protokolliert (siehe die Fehlerpfade oben).
|
||||||
)
|
if action != "heartbeat":
|
||||||
|
audit.log(
|
||||||
|
conn,
|
||||||
|
actor=actor,
|
||||||
|
action=f"license.{action}",
|
||||||
|
target=str(request_payload["license_id"]),
|
||||||
|
detail=f"Transport: {transport}",
|
||||||
|
severity="notice",
|
||||||
|
)
|
||||||
self.reload(conn)
|
self.reload(conn)
|
||||||
return {"ok": True, "transport": transport, "code": "", "message": message}
|
return {"ok": True, "transport": transport, "code": "", "message": message}
|
||||||
|
|
||||||
@@ -704,6 +716,19 @@ class LicenseManager:
|
|||||||
except LicenseError as exc:
|
except LicenseError as exc:
|
||||||
with self.database.transaction(conn):
|
with self.database.transaction(conn):
|
||||||
self.settings.set(conn, SETTING_HEARTBEAT_ERROR, str(exc), actor="system")
|
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)}
|
return {"ok": False, "message": str(exc)}
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
@@ -761,6 +786,17 @@ class LicenseManager:
|
|||||||
"dot_class": "status-pill__dot--pulse",
|
"dot_class": "status-pill__dot--pulse",
|
||||||
"title": "Bitte rechtzeitig verlaengern.",
|
"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:
|
if state.last_heartbeat_at:
|
||||||
try:
|
try:
|
||||||
last = lic.parse_iso(state.last_heartbeat_at)
|
last = lic.parse_iso(state.last_heartbeat_at)
|
||||||
|
|||||||
@@ -88,11 +88,24 @@ class SqliteSessionInterface(SessionInterface):
|
|||||||
cookie_name: str,
|
cookie_name: str,
|
||||||
idle_minutes: int,
|
idle_minutes: int,
|
||||||
absolute_hours: int,
|
absolute_hours: int,
|
||||||
|
idle_lookup: Any = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.database = database
|
self.database = database
|
||||||
self.cookie_name = cookie_name
|
self.cookie_name = cookie_name
|
||||||
self.idle_minutes = idle_minutes
|
self.idle_minutes = idle_minutes
|
||||||
self.absolute_hours = absolute_hours
|
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 ------------------------------------------- #
|
# -- Flask-Schnittstelle ------------------------------------------- #
|
||||||
|
|
||||||
@@ -146,7 +159,7 @@ class SqliteSessionInterface(SessionInterface):
|
|||||||
with self.database.transaction(conn):
|
with self.database.transaction(conn):
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE user_sessions SET last_seen_at=?, idle_expires_at=? WHERE id=?",
|
"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
|
return session
|
||||||
|
|
||||||
@@ -190,7 +203,7 @@ class SqliteSessionInterface(SessionInterface):
|
|||||||
session.get("user_id"),
|
session.get("user_id"),
|
||||||
_iso(now),
|
_iso(now),
|
||||||
_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)),
|
_iso(now + timedelta(hours=self.absolute_hours)),
|
||||||
session.get("_ip", ""),
|
session.get("_ip", ""),
|
||||||
session.get("_ua", ""),
|
session.get("_ua", ""),
|
||||||
|
|||||||
@@ -161,6 +161,40 @@
|
|||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
box-shadow: 0 0 0 3px var(--accent-ring);
|
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] {
|
.input[readonly], .input[disabled], .select[disabled], .textarea[disabled] {
|
||||||
background: var(--surface-inset);
|
background: var(--surface-inset);
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
@@ -374,6 +408,15 @@
|
|||||||
background: var(--danger-soft);
|
background: var(--danger-soft);
|
||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
}
|
}
|
||||||
|
/* Gefuellt statt getoent: "kritisch" muss sich von "Warnung" unterscheiden,
|
||||||
|
und beide sind rot. */
|
||||||
|
.pill--critical {
|
||||||
|
background: var(--danger);
|
||||||
|
color: var(--surface-0);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.pill--critical::before { background: currentColor; }
|
||||||
|
|
||||||
.pill--warning, .pill--unknown, .pill--orange {
|
.pill--warning, .pill--unknown, .pill--orange {
|
||||||
background: var(--warning-soft);
|
background: var(--warning-soft);
|
||||||
color: var(--warning);
|
color: var(--warning);
|
||||||
@@ -402,6 +445,22 @@
|
|||||||
background: var(--success);
|
background: var(--success);
|
||||||
flex-shrink: 0;
|
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__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 { border-color: color-mix(in srgb, var(--danger) 45%, var(--border)); color: var(--danger); }
|
||||||
.status-pill--danger .status-pill__dot { background: var(--danger); }
|
.status-pill--danger .status-pill__dot { background: var(--danger); }
|
||||||
@@ -621,6 +680,37 @@
|
|||||||
}
|
}
|
||||||
.modal__title { font-size: var(--fs-md); font-weight: 640; flex: 1; }
|
.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; }
|
.modal__sub { font-size: var(--fs-xs); color: var(--text-faint); margin-top: 2px; }
|
||||||
|
/* Eine Datei-Vorschau soll die ganze Flaeche des Dialogs nutzen; der
|
||||||
|
uebliche Innenabstand des Dialogkoerpers wuerde sie einrahmen. */
|
||||||
|
.modal__body--flush { padding: 0; max-height: none; }
|
||||||
|
.modal__caption {
|
||||||
|
padding: 0 var(--sp-5) var(--sp-2);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--fs-xs);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.file-preview {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: min(70dvh, 760px);
|
||||||
|
border: 0;
|
||||||
|
background: var(--surface-0);
|
||||||
|
}
|
||||||
|
/* 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 {
|
.modal__body {
|
||||||
padding: var(--sp-5);
|
padding: var(--sp-5);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
@@ -1001,6 +1091,16 @@
|
|||||||
.warning-text { color: var(--warning); }
|
.warning-text { color: var(--warning); }
|
||||||
.accent-text { color: var(--accent); }
|
.accent-text { color: var(--accent); }
|
||||||
.mono { font-family: var(--font-mono); font-size: 0.94em; }
|
.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; }
|
.nowrap { white-space: nowrap; }
|
||||||
.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
|
.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
|
||||||
.break-all { overflow-wrap: anywhere; }
|
.break-all { overflow-wrap: anywhere; }
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ function store(key, value) {
|
|||||||
if (value === null) localStorage.removeItem(key);
|
if (value === null) localStorage.removeItem(key);
|
||||||
else localStorage.setItem(key, value);
|
else localStorage.setItem(key, value);
|
||||||
} catch {
|
} catch {
|
||||||
/* Privatmodus o. Ä. -- Einstellungen sind dann nur fluechtig. */
|
/* Privatmodus o. Ae. -- Einstellungen sind dann nur fluechtig. */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,13 +212,21 @@ function initDialogs() {
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const dialog = document.getElementById(opener.dataset.dialogOpen);
|
const dialog = document.getElementById(opener.dataset.dialogOpen);
|
||||||
if (dialog) {
|
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]) => {
|
Object.entries(opener.dataset).forEach(([key, value]) => {
|
||||||
if (!key.startsWith("fill")) return;
|
if (!key.startsWith("fill")) return;
|
||||||
const name = key.slice(4);
|
const name = key.slice(4);
|
||||||
const target = dialog.querySelector(`[data-fill-target="${name}"]`);
|
const target = dialog.querySelector(`[data-fill-target="${name}"]`);
|
||||||
if (!target) return;
|
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;
|
else target.textContent = value;
|
||||||
});
|
});
|
||||||
openDialog(dialog);
|
openDialog(dialog);
|
||||||
@@ -563,7 +571,8 @@ function initAutoRefresh() {
|
|||||||
// von innerHTML entsteht ein neues Element -- mit scrollTop 0. Genau
|
// von innerHTML entsteht ein neues Element -- mit scrollTop 0. Genau
|
||||||
// deshalb sprang ein laufendes Auftragsprotokoll bei jeder
|
// deshalb sprang ein laufendes Auftragsprotokoll bei jeder
|
||||||
// Aktualisierung an den Anfang zurueck.
|
// 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 before = scrollerOf(region);
|
||||||
const position = before.scrollTop;
|
const position = before.scrollTop;
|
||||||
// "Am Ende" grosszuegig auslegen: wer mitliest, soll mitgezogen
|
// "Am Ende" grosszuegig auslegen: wer mitliest, soll mitgezogen
|
||||||
@@ -814,6 +823,25 @@ function initTileFilters() {
|
|||||||
|
|
||||||
/* -- Start ---------------------------------------------------------------- */
|
/* -- 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() {
|
function boot() {
|
||||||
initTheme();
|
initTheme();
|
||||||
initSidebar();
|
initSidebar();
|
||||||
@@ -830,6 +858,7 @@ function boot() {
|
|||||||
initPalette();
|
initPalette();
|
||||||
initSubmitGuards();
|
initSubmitGuards();
|
||||||
initTileFilters();
|
initTileFilters();
|
||||||
|
initLogTails();
|
||||||
unsavedGuard.init();
|
unsavedGuard.init();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -166,6 +166,13 @@ class SysOps:
|
|||||||
input=stdin,
|
input=stdin,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
|
# Kodierung festnageln. "text=True" allein nimmt die Locale des
|
||||||
|
# Prozesses -- unter systemd ist die haeufig C/ASCII, und dann
|
||||||
|
# bricht das Lesen ab, sobald ein Werkzeug einen Umlaut
|
||||||
|
# ausgibt. Der Fehler entsteht im Leser-Thread von subprocess,
|
||||||
|
# also weit weg von der Zeile, die ihn ausgeloest hat.
|
||||||
|
encoding="utf-8",
|
||||||
|
errors="replace",
|
||||||
timeout=timeout or self.timeout,
|
timeout=timeout or self.timeout,
|
||||||
check=False,
|
check=False,
|
||||||
)
|
)
|
||||||
@@ -197,6 +204,8 @@ class SysOps:
|
|||||||
[binary, *command[1:]],
|
[binary, *command[1:]],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
|
encoding="utf-8",
|
||||||
|
errors="replace",
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
check=False,
|
check=False,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
{# Auswahlfeld fuer eine Verzeichnisgruppe.
|
{# Auswahlfeld für eine Verzeichnisgruppe.
|
||||||
|
|
||||||
Der DN einer AD-Gruppe ist nichts, was man abtippt: er ist lang, enthaelt
|
Der DN einer AD-Gruppe ist nichts, was man abtippt: er ist lang, enthält
|
||||||
Kommas und Leerzeichen und ein Tippfehler faellt erst bei der naechsten
|
Kommas und Leerzeichen und ein Tippfehler fällt erst bei der nächsten
|
||||||
Anmeldung auf -- als stille Nicht-Zuordnung. Deshalb gesucht statt getippt.
|
Anmeldung auf -- als stille Nicht-Zuordnung. Deshalb gesucht statt getippt.
|
||||||
|
|
||||||
Das Suchergebnis kommt aus dem Verzeichnis selbst; die Felder werden dabei
|
Das Suchergebnis kommt aus dem Verzeichnis selbst; die Felder werden dabei
|
||||||
beide gefuellt (Anzeigename und DN). Getippt werden darf trotzdem: bei einer
|
beide gefüllt (Anzeigename und DN). Getippt werden darf trotzdem: bei einer
|
||||||
Umgebung ohne Dienstkonto ist die Suche nicht moeglich, und dann ist ein
|
Umgebung ohne Dienstkonto ist die Suche nicht möglich, und dann ist ein
|
||||||
eingefuegter DN besser als gar keine Zuordnung.
|
eingefügter DN besser als gar keine Zuordnung.
|
||||||
|
|
||||||
Erwartet: name_field, dn_field (Feldnamen), scope (eindeutiges Kuerzel),
|
Erwartet: name_field, dn_field (Feldnamen), scope (eindeutiges Kürzel),
|
||||||
optional name_value / dn_value und required.
|
optional name_value / dn_value und required.
|
||||||
#}
|
#}
|
||||||
{% macro group_picker(name_field, dn_field, scope, name_value='', dn_value='',
|
{% macro group_picker(name_field, dn_field, scope, name_value='', dn_value='',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{% if lines %}
|
{% if lines %}
|
||||||
<pre class="log-view">{% for line in lines %}{{ line }}
|
<pre class="log-view" data-tail>{% for line in lines %}{{ line }}
|
||||||
{% endfor %}</pre>
|
{% endfor %}</pre>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="empty"><div class="empty__title">Keine Zeilen</div>
|
<div class="empty"><div class="empty__title">Keine Zeilen</div>
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
<div class="card__body">
|
<div class="card__body">
|
||||||
{% if current_user.is_directory_account %}
|
{% if current_user.is_directory_account %}
|
||||||
{{ alert('Ihre Stammdaten kommen aus dem Verzeichnisdienst und werden bei jeder
|
{{ alert('Ihre Stammdaten kommen aus dem Verzeichnisdienst und werden bei jeder
|
||||||
Anmeldung uebernommen.', 'info') }}
|
Anmeldung übernommen.', 'info') }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
{{ field('first_name', 'Vorname', value=profile.first_name,
|
{{ field('first_name', 'Vorname', value=profile.first_name,
|
||||||
@@ -48,7 +48,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="card__body">
|
<div class="card__body">
|
||||||
{% if current_user.is_admin %}
|
{% if current_user.is_admin %}
|
||||||
{{ alert('Sie sind Administrator und duerfen unabhaengig von Gruppen alles.', 'warning') }}
|
{{ alert('Sie sind Administrator und dürfen unabhängig von Gruppen alles.', 'warning') }}
|
||||||
{% elif permissions %}
|
{% elif permissions %}
|
||||||
<div class="row row--tight">
|
<div class="row row--tight">
|
||||||
{% for permission in permissions %}
|
{% for permission in permissions %}
|
||||||
|
|||||||
@@ -35,26 +35,26 @@
|
|||||||
<div class="stack">
|
<div class="stack">
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<div class="card__header">
|
<div class="card__header">
|
||||||
<div class="flex-1"><div class="card__title">{{ icon('lock', size=17) }} Passwort aendern</div></div>
|
<div class="flex-1"><div class="card__title">{{ icon('lock', size=17) }} Passwort ändern</div></div>
|
||||||
</div>
|
</div>
|
||||||
{% if current_user.is_directory_account %}
|
{% if current_user.is_directory_account %}
|
||||||
<div class="card__body">
|
<div class="card__body">
|
||||||
{{ alert('Ihr Passwort wird im Verzeichnisdienst gepflegt und dort geaendert.', 'info') }}
|
{{ alert('Ihr Passwort wird im Verzeichnisdienst gepflegt und dort geändert.', 'info') }}
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<form method="post" action="{{ url_for('account.change_password') }}">
|
<form method="post" action="{{ url_for('account.change_password') }}">
|
||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
<div class="card__body stack">
|
<div class="card__body stack">
|
||||||
{{ field('current_password', 'Aktuelles Passwort', type='password', required=True,
|
{{ field('current_password', 'Aktülles Passwort', type='password', required=True,
|
||||||
autocomplete='current-password') }}
|
autocomplete='current-password') }}
|
||||||
{{ field('new_password', 'Neues Passwort', type='password', required=True,
|
{{ field('new_password', 'Neues Passwort', type='password', required=True,
|
||||||
autocomplete='new-password',
|
autocomplete='new-password',
|
||||||
hint_text='Mindestens ' ~ policy.min_length ~ ' Zeichen und zwei verschiedene Zeichenarten.') }}
|
hint_text='Mindestens ' ~ policy.min_length ~ ' Zeichen und zwei verschiedene Zeichenarten.') }}
|
||||||
{{ field('repeat_password', 'Neues Passwort wiederholen', type='password', required=True,
|
{{ field('repeat_password', 'Neues Passwort wiederholen', type='password', required=True,
|
||||||
autocomplete='new-password') }}
|
autocomplete='new-password') }}
|
||||||
{{ alert('Nach der Aenderung werden alle anderen Sitzungen beendet.', 'info') }}
|
{{ alert('Nach der Änderung werden alle anderen Sitzungen beendet.', 'info') }}
|
||||||
</div>
|
</div>
|
||||||
<div class="card__footer"><button class="btn btn--primary" type="submit">Passwort aendern</button></div>
|
<div class="card__footer"><button class="btn btn--primary" type="submit">Passwort ändern</button></div>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
@@ -70,7 +70,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="card__body stack">
|
<div class="card__body stack">
|
||||||
{% if pending_secret %}
|
{% if pending_secret %}
|
||||||
{{ alert('Richten Sie den Eintrag in Ihrer App ein und bestaetigen Sie mit einem aktuellen Code. Erst dann wird die Anmeldung umgestellt.', 'warning') }}
|
{{ alert('Richten Sie den Eintrag in Ihrer App ein und bestätigen Sie mit einem aktüllen Code. Erst dann wird die Anmeldung umgestellt.', 'warning') }}
|
||||||
<dl class="kv fs-sm">
|
<dl class="kv fs-sm">
|
||||||
<dt>Geheimnis</dt>
|
<dt>Geheimnis</dt>
|
||||||
<dd><span class="mono" id="totp-secret">{{ pending_secret_grouped }}</span>
|
<dd><span class="mono" id="totp-secret">{{ pending_secret_grouped }}</span>
|
||||||
@@ -87,7 +87,7 @@
|
|||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
<input class="input input--mono" type="text" name="code" required maxlength="6"
|
<input class="input input--mono" type="text" name="code" required maxlength="6"
|
||||||
inputmode="numeric" placeholder="123456" style="max-width:130px;text-align:center">
|
inputmode="numeric" placeholder="123456" style="max-width:130px;text-align:center">
|
||||||
<button class="btn btn--primary" type="submit">Bestaetigen</button>
|
<button class="btn btn--primary" type="submit">Bestätigen</button>
|
||||||
</form>
|
</form>
|
||||||
{% elif totp_enabled %}
|
{% elif totp_enabled %}
|
||||||
<dl class="kv fs-sm">
|
<dl class="kv fs-sm">
|
||||||
@@ -100,7 +100,7 @@
|
|||||||
<form method="post" action="{{ url_for('account.recovery_codes') }}" data-no-guard>
|
<form method="post" action="{{ url_for('account.recovery_codes') }}" data-no-guard>
|
||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
<button class="btn btn--sm" type="submit"
|
<button class="btn btn--sm" type="submit"
|
||||||
data-confirm="Neue Codes erzeugen? Die bisherigen verlieren ihre Gueltigkeit."
|
data-confirm="Neue Codes erzeugen? Die bisherigen verlieren ihre Gültigkeit."
|
||||||
data-confirm-title="Codes erneuern" data-confirm-ok="Erneuern" data-confirm-style="primary">
|
data-confirm-title="Codes erneuern" data-confirm-ok="Erneuern" data-confirm-style="primary">
|
||||||
Codes erneuern</button>
|
Codes erneuern</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -114,10 +114,10 @@
|
|||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
{% if require_totp_for_admins and current_user.is_admin %}
|
{% if require_totp_for_admins and current_user.is_admin %}
|
||||||
{{ alert('In dieser Installation ist ein zweiter Faktor fuer Administratoren verpflichtend. Bitte jetzt einrichten.', 'danger') }}
|
{{ alert('In dieser Installation ist ein zweiter Faktor für Administratoren verpflichtend. Bitte jetzt einrichten.', 'danger') }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<p class="muted fs-sm">
|
<p class="muted fs-sm">
|
||||||
Ein zweiter Faktor schuetzt Ihr Konto auch dann, wenn Ihr Passwort bekannt wird.
|
Ein zweiter Faktor schützt Ihr Konto auch dann, wenn Ihr Passwort bekannt wird.
|
||||||
</p>
|
</p>
|
||||||
<form method="post" action="{{ url_for('account.totp_start') }}" data-no-guard>
|
<form method="post" action="{{ url_for('account.totp_start') }}" data-no-guard>
|
||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{# ==========================================================================
|
{# ==========================================================================
|
||||||
Grundgeruest aller angemeldeten Seiten.
|
Grundgerüst aller angemeldeten Seiten.
|
||||||
Bloecke: extra_head, page_title, page_sub, breadcrumb, topbar_extra,
|
Blöcke: extra_head, page_title, page_sub, breadcrumb, topbar_extra,
|
||||||
content, dialogs, scripts
|
content, dialogs, scripts
|
||||||
========================================================================== #}
|
========================================================================== #}
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
@@ -23,9 +23,9 @@
|
|||||||
|
|
||||||
<aside class="sidebar" aria-label="Hauptnavigation">
|
<aside class="sidebar" aria-label="Hauptnavigation">
|
||||||
<div class="sidebar__brand">
|
<div class="sidebar__brand">
|
||||||
{# Die Wortmarke traegt den Namen selbst -- ein zweiter Textblock daneben
|
{# Die Wortmarke trägt den Namen selbst -- ein zweiter Textblock daneben
|
||||||
waere eine Doppelung. Eine einzige Zeichnung, deren Farben aus den
|
wäre eine Doppelung. Eine einzige Zeichnung, deren Farben aus den
|
||||||
Themen-Token kommen: zwei per Media Query versteckte SVG waeren bei
|
Themen-Token kommen: zwei per Media Query versteckte SVG wären bei
|
||||||
"System" und beim Umschalten von Hand falsch. #}
|
"System" und beim Umschalten von Hand falsch. #}
|
||||||
<span class="sidebar__logo" role="img" aria-label="{{ app_name }}">
|
<span class="sidebar__logo" role="img" aria-label="{{ app_name }}">
|
||||||
<svg viewBox="0 0 312 112" xmlns="http://www.w3.org/2000/svg">
|
<svg viewBox="0 0 312 112" xmlns="http://www.w3.org/2000/svg">
|
||||||
@@ -126,9 +126,25 @@
|
|||||||
<div class="topbar__right">
|
<div class="topbar__right">
|
||||||
{% block topbar_extra %}{% endblock %}
|
{% block topbar_extra %}{% endblock %}
|
||||||
{% for pill in status_pills %}
|
{% for pill in status_pills %}
|
||||||
|
{# Eine Pille kann mehr als Text sein: ein Countdown (data-countdown-to
|
||||||
|
wird laufend gezählt) und eine Aktion. Der Knopf steckt bewusst in
|
||||||
|
der Pille -- er gehört zu der Angabe, die er aktualisiert. #}
|
||||||
<span class="status-pill {{ pill.css_class }}" {% if pill.title %}title="{{ pill.title }}"{% endif %}>
|
<span class="status-pill {{ pill.css_class }}" {% if pill.title %}title="{{ pill.title }}"{% endif %}>
|
||||||
{% if pill.dot %}<span class="status-pill__dot {{ pill.dot_class }}"></span>{% endif %}
|
{% if pill.dot %}<span class="status-pill__dot {{ pill.dot_class }}"></span>{% endif %}
|
||||||
{{ pill.label }}
|
{{ pill.label }}
|
||||||
|
{% if pill.countdown_to %}
|
||||||
|
<span class="mono" data-countdown-to="{{ pill.countdown_to }}"
|
||||||
|
data-countdown-done="{{ pill.countdown_done or 'jetzt' }}"></span>
|
||||||
|
{% endif %}
|
||||||
|
{% if pill.suffix %}{{ pill.suffix }}{% endif %}
|
||||||
|
{% if pill.action %}
|
||||||
|
<form method="post" action="{{ pill.action }}" data-no-guard class="status-pill__action">
|
||||||
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
|
<button type="submit" title="{{ pill.action_title or 'Jetzt ausführen' }}"
|
||||||
|
aria-label="{{ pill.action_title or 'Jetzt ausführen' }}"
|
||||||
|
data-busy-label="...">{{ icon('refresh', size=13) }}</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
</span>
|
</span>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
<button class="btn btn--icon btn--ghost hide-sm" type="button" data-behavior="open-palette"
|
<button class="btn btn--icon btn--ghost hide-sm" type="button" data-behavior="open-palette"
|
||||||
@@ -141,7 +157,7 @@
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer class="page-footer">
|
<footer class="page-footer">
|
||||||
<span>{{ app_name }} {{ app_version }} · © {{ current_year }} WiS GmbH</span>
|
<span>{{ app_name }} {{ app_version }} · © {{ current_year }} {{ copyright_notice }}</span>
|
||||||
<span class="row row--tight">
|
<span class="row row--tight">
|
||||||
{% if migration_warning %}
|
{% if migration_warning %}
|
||||||
<a class="danger-text" href="{{ url_for('diagnostics.overview') }}">Schemawarnung</a>
|
<a class="danger-text" href="{{ url_for('diagnostics.overview') }}">Schemawarnung</a>
|
||||||
@@ -163,17 +179,17 @@
|
|||||||
aria-label="Suchbegriff" autocomplete="off">
|
aria-label="Suchbegriff" autocomplete="off">
|
||||||
<div class="palette__list"></div>
|
<div class="palette__list"></div>
|
||||||
<div class="palette__hint">
|
<div class="palette__hint">
|
||||||
<span><kbd>↑</kbd><kbd>↓</kbd> waehlen</span>
|
<span><kbd>↑</kbd><kbd>↓</kbd> wählen</span>
|
||||||
<span><kbd>Enter</kbd> oeffnen</span>
|
<span><kbd>Enter</kbd> öffnen</span>
|
||||||
<span><kbd>Esc</kbd> schliessen</span>
|
<span><kbd>Esc</kbd> schliessen</span>
|
||||||
</div>
|
</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
<script type="application/json" id="palette-data">{{ palette_entries | tojson }}</script>
|
<script type="application/json" id="palette-data">{{ palette_entries | tojson }}</script>
|
||||||
|
|
||||||
{# -- Bestaetigungsdialog (ersetzt window.confirm) ------------------------- #}
|
{# -- Bestätigungsdialog (ersetzt window.confirm) ------------------------- #}
|
||||||
<dialog class="modal modal--narrow" id="confirm-dialog" aria-labelledby="confirm-title">
|
<dialog class="modal modal--narrow" id="confirm-dialog" aria-labelledby="confirm-title">
|
||||||
<div class="modal__header">
|
<div class="modal__header">
|
||||||
<div class="modal__title" id="confirm-title" data-confirm-title>Bitte bestaetigen</div>
|
<div class="modal__title" id="confirm-title" data-confirm-title>Bitte bestätigen</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal__body"><p data-confirm-text></p></div>
|
<div class="modal__body"><p data-confirm-text></p></div>
|
||||||
<div class="modal__footer">
|
<div class="modal__footer">
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
{% from "tesm_core/macros.html" import alert, pill, stat %}
|
{% from "tesm_core/macros.html" import alert, pill, stat %}
|
||||||
|
|
||||||
{% block page_title %}Diagnose{% endblock %}
|
{% block page_title %}Diagnose{% endblock %}
|
||||||
{% block page_sub %}Schemastand, Protokollintegritaet, Schluessel und Systemzugriff{% endblock %}
|
{% block page_sub %}Schemastand, Protokollintegrität, Schlüssel und Systemzugriff{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="grid grid--4">
|
<div class="grid grid--4">
|
||||||
@@ -17,17 +17,17 @@
|
|||||||
<div class="card__header">
|
<div class="card__header">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="card__title">
|
<div class="card__title">
|
||||||
{{ icon('shield', size=17) }} Aenderungsprotokoll
|
{{ icon('shield', size=17) }} Änderungsprotokoll
|
||||||
{% if chain.ok %}{{ pill('unversehrt', 'online') }}{% else %}{{ pill('Kette gebrochen', 'danger') }}{% endif %}
|
{% if chain.ok %}{{ pill('unversehrt', 'online') }}{% else %}{{ pill('Kette gebrochen', 'danger') }}{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="card__sub">{{ chain.checked }} Zeilen geprueft (Hash-Kette)</div>
|
<div class="card__sub">{{ chain.checked }} Zeilen geprüft (Hash-Kette)</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card__body">
|
<div class="card__body">
|
||||||
{% if chain.ok %}
|
{% if chain.ok %}
|
||||||
<p class="muted fs-sm">
|
<p class="muted fs-sm">
|
||||||
Jede Zeile bindet den Hash ihrer Vorgaengerin ein. Eine nachtraeglich entfernte oder
|
Jede Zeile bindet den Hash ihrer Vorgängerin ein. Eine nachträglich entfernte oder
|
||||||
geaenderte Zeile wuerde hier auffallen.
|
geänderte Zeile würde hier auffallen.
|
||||||
</p>
|
</p>
|
||||||
{% else %}
|
{% else %}
|
||||||
{{ alert(chain.reason ~ ' Erster Bruch bei Eintrag ' ~ chain.broken_at ~ '.', 'danger',
|
{{ alert(chain.reason ~ ' Erster Bruch bei Eintrag ' ~ chain.broken_at ~ '.', 'danger',
|
||||||
@@ -40,17 +40,17 @@
|
|||||||
<div class="card__header">
|
<div class="card__header">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="card__title">
|
<div class="card__title">
|
||||||
{{ icon('key', size=17) }} Schluessel und Dateirechte
|
{{ icon('key', size=17) }} Schlüssel und Dateirechte
|
||||||
{% if key_problems %}{{ pill('Problem', 'danger') }}{% else %}{{ pill('in Ordnung', 'online') }}{% endif %}
|
{% if key_problems %}{{ pill('Problem', 'danger') }}{% else %}{{ pill('in Ordnung', 'online') }}{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="card__sub">Aktiver Datenschluessel: {{ keystore_active }} (vorhanden: {{ keystore_keys | join(', ') }})</div>
|
<div class="card__sub">Aktiver Datenschlüssel: {{ keystore_active }} (vorhanden: {{ keystore_keys | join(', ') }})</div>
|
||||||
</div>
|
</div>
|
||||||
{% if current_user.is_admin %}
|
{% if current_user.is_admin %}
|
||||||
<form method="post" action="{{ url_for('diagnostics.rotate_keys') }}" data-no-guard>
|
<form method="post" action="{{ url_for('diagnostics.rotate_keys') }}" data-no-guard>
|
||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
<button class="btn btn--sm" type="submit"
|
<button class="btn btn--sm" type="submit"
|
||||||
data-confirm="Neuen Datenschluessel anlegen und gespeicherte Geheimnisse neu verschluesseln? Der alte Schluessel bleibt zum Lesen erhalten."
|
data-confirm="Neuen Datenschlüssel anlegen und gespeicherte Geheimnisse neu verschlüsseln? Der alte Schlüssel bleibt zum Lesen erhalten."
|
||||||
data-confirm-title="Schluessel rotieren" data-confirm-ok="Rotieren" data-confirm-style="primary">
|
data-confirm-title="Schlüssel rotieren" data-confirm-ok="Rotieren" data-confirm-style="primary">
|
||||||
{{ icon('refresh', size=14) }} Rotieren
|
{{ icon('refresh', size=14) }} Rotieren
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -63,8 +63,8 @@
|
|||||||
</ul>
|
</ul>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="muted fs-sm">
|
<p class="muted fs-sm">
|
||||||
Alle Schluesseldateien liegen mit Modus 0600. Feldverschluesselung ist AES-256-GCM mit
|
Alle Schlüsseldateien liegen mit Modus 0600. Feldverschlüsselung ist AES-256-GCM mit
|
||||||
rotierbarem Schluesselbund; jedes Chiffrat ist an seinen Verwendungszweck gebunden.
|
rotierbarem Schlüsselbund; jedes Chiffrat ist an seinen Verwendungszweck gebunden.
|
||||||
</p>
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
@@ -87,7 +87,7 @@
|
|||||||
<th class="sortable">Komponente</th>
|
<th class="sortable">Komponente</th>
|
||||||
<th class="sortable numeric">Version</th>
|
<th class="sortable numeric">Version</th>
|
||||||
<th class="sortable">Migration</th>
|
<th class="sortable">Migration</th>
|
||||||
<th>Pruefsumme</th>
|
<th>Prüfsumme</th>
|
||||||
<th class="sortable">Angewendet</th>
|
<th class="sortable">Angewendet</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -112,7 +112,7 @@
|
|||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="card__title">
|
<div class="card__title">
|
||||||
{{ icon('server', size=17) }} Systemzugriff
|
{{ icon('server', size=17) }} Systemzugriff
|
||||||
{% if not sysops_available %}{{ pill('nicht verfuegbar', 'warning') }}
|
{% if not sysops_available %}{{ pill('nicht verfügbar', 'warning') }}
|
||||||
{% elif sysops_selftest and sysops_selftest.ok %}{{ pill('Helfer antwortet', 'online') }}
|
{% elif sysops_selftest and sysops_selftest.ok %}{{ pill('Helfer antwortet', 'online') }}
|
||||||
{% else %}{{ pill('Helfer antwortet nicht', 'danger') }}{% endif %}
|
{% else %}{{ pill('Helfer antwortet nicht', 'danger') }}{% endif %}
|
||||||
</div>
|
</div>
|
||||||
@@ -121,7 +121,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="card__body">
|
<div class="card__body">
|
||||||
<p class="muted fs-sm">
|
<p class="muted fs-sm">
|
||||||
Der Webprozess laeuft unprivilegiert. Alles, was Root-Rechte braucht, geht durch genau
|
Der Webprozess läuft unprivilegiert. Alles, was Root-Rechte braucht, geht durch genau
|
||||||
ein Helferskript mit Positivliste – nicht durch die Anwendung selbst.
|
ein Helferskript mit Positivliste – nicht durch die Anwendung selbst.
|
||||||
</p>
|
</p>
|
||||||
{% if not sysops_available %}
|
{% if not sysops_available %}
|
||||||
@@ -129,10 +129,10 @@
|
|||||||
Alles andere funktioniert normal -- das ist der erwartete Zustand in einer
|
Alles andere funktioniert normal -- das ist der erwartete Zustand in einer
|
||||||
Entwicklungsumgebung.', 'info') }}
|
Entwicklungsumgebung.', 'info') }}
|
||||||
{% elif sysops_selftest and not sysops_selftest.ok %}
|
{% elif sysops_selftest and not sysops_selftest.ok %}
|
||||||
{# Geprueft wird ein echter Aufruf, nicht die blosse Existenz der Datei.
|
{# Geprüft wird ein echter Aufruf, nicht die blosse Existenz der Datei.
|
||||||
Genau daran lag es einmal: die Seite meldete "erreichbar", waehrend
|
Genau daran lag es einmal: die Seite meldete "erreichbar", während
|
||||||
jeder Aufruf scheiterte. #}
|
jeder Aufruf scheiterte. #}
|
||||||
{{ alert(sysops_selftest.message or 'Der Helfer laesst sich nicht aufrufen.',
|
{{ alert(sysops_selftest.message or 'Der Helfer lässt sich nicht aufrufen.',
|
||||||
'danger', 'Systemaktionen funktionieren nicht') }}
|
'danger', 'Systemaktionen funktionieren nicht') }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
@@ -144,7 +144,7 @@
|
|||||||
{% if current_user.is_admin %}
|
{% if current_user.is_admin %}
|
||||||
<form method="post" action="{{ url_for('diagnostics.housekeeping') }}" data-no-guard>
|
<form method="post" action="{{ url_for('diagnostics.housekeeping') }}" data-no-guard>
|
||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
<button class="btn btn--sm" type="submit">{{ icon('refresh', size=14) }} Aufraeumen</button>
|
<button class="btn btn--sm" type="submit">{{ icon('refresh', size=14) }} Aufräumen</button>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
@@ -167,14 +167,14 @@
|
|||||||
{{ icon('key', size=17) }} Lizenzzustand
|
{{ icon('key', size=17) }} Lizenzzustand
|
||||||
{% if license_state.valid and license_state.activated %}{{ pill('aktiv', 'online') }}
|
{% if license_state.valid and license_state.activated %}{{ pill('aktiv', 'online') }}
|
||||||
{% elif license_state.valid %}{{ pill('nicht aktiviert', 'warning') }}
|
{% elif license_state.valid %}{{ pill('nicht aktiviert', 'warning') }}
|
||||||
{% else %}{{ pill('ungueltig', 'danger') }}{% endif %}
|
{% else %}{{ pill('ungültig', 'danger') }}{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card__body">
|
<div class="card__body">
|
||||||
<dl class="kv fs-sm">
|
<dl class="kv fs-sm">
|
||||||
<dt>Datei vorhanden</dt><dd>{{ license_state.present | yes_no }}</dd>
|
<dt>Datei vorhanden</dt><dd>{{ license_state.present | yes_no }}</dd>
|
||||||
<dt>Signatur gueltig</dt><dd>{{ license_state.valid | yes_no }}</dd>
|
<dt>Signatur gültig</dt><dd>{{ license_state.valid | yes_no }}</dd>
|
||||||
<dt>Aussteller gepinnt</dt><dd>{{ license_state.issuer_pinned | yes_no }}</dd>
|
<dt>Aussteller gepinnt</dt><dd>{{ license_state.issuer_pinned | yes_no }}</dd>
|
||||||
<dt>Rolle</dt><dd>{{ license_state.role }}</dd>
|
<dt>Rolle</dt><dd>{{ license_state.role }}</dd>
|
||||||
<dt>Module</dt><dd>{{ license_state.modules | sort | join(', ') or '–' | safe }}</dd>
|
<dt>Module</dt><dd>{{ license_state.modules | sort | join(', ') or '–' | safe }}</dd>
|
||||||
|
|||||||
@@ -7,11 +7,11 @@
|
|||||||
<div class="alert__body">{{ description }}</div>
|
<div class="alert__body">{{ description }}</div>
|
||||||
</div>
|
</div>
|
||||||
{% if request_id %}
|
{% if request_id %}
|
||||||
<p class="fs-xs faint center">Vorgangsnummer fuer die Fehlersuche: <span class="mono">{{ request_id }}</span></p>
|
<p class="fs-xs faint center">Vorgangsnummer für die Fehlersuche: <span class="mono">{{ request_id }}</span></p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="row row--end">
|
<div class="row row--end">
|
||||||
<a class="btn" href="{{ url_for('auth.login') if code in (401, 403) and not current_user.is_authenticated else back_url }}">
|
<a class="btn" href="{{ url_for('auth.login') if code in (401, 403) and not current_user.is_authenticated else back_url }}">
|
||||||
{{ 'Zur Anmeldung' if code in (401, 403) and not current_user.is_authenticated else 'Zurueck' }}
|
{{ 'Zur Anmeldung' if code in (401, 403) and not current_user.is_authenticated else 'Zurück' }}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -20,8 +20,8 @@
|
|||||||
{{ icon('lock', size=18) }}
|
{{ icon('lock', size=18) }}
|
||||||
<div class="alert__body">
|
<div class="alert__body">
|
||||||
<div class="alert__title">Systemgruppe</div>
|
<div class="alert__title">Systemgruppe</div>
|
||||||
Name und Rechte sind gesperrt. Anders als im Vorgaenger werden die Rechte
|
Name und Rechte sind gesperrt. Anders als im Vorgänger werden die Rechte
|
||||||
<strong>nicht</strong> bei jedem Neustart auf die Vorlage zurueckgesetzt –
|
<strong>nicht</strong> bei jedem Neustart auf die Vorlage zurückgesetzt –
|
||||||
Anpassungen bleiben erhalten.
|
Anpassungen bleiben erhalten.
|
||||||
{% if current_user.has('groups.edit') %}
|
{% if current_user.has('groups.edit') %}
|
||||||
<a class="btn btn--sm mt-2" href="{{ url_for('admin.group_detail', group_id=group.id, freischalten=1) }}">
|
<a class="btn btn--sm mt-2" href="{{ url_for('admin.group_detail', group_id=group.id, freischalten=1) }}">
|
||||||
@@ -49,8 +49,8 @@
|
|||||||
{% if group.preset_key and current_user.is_admin %}
|
{% if group.preset_key and current_user.is_admin %}
|
||||||
<button class="btn btn--sm" type="submit"
|
<button class="btn btn--sm" type="submit"
|
||||||
formaction="{{ url_for('admin.group_apply_preset', group_id=group.id) }}"
|
formaction="{{ url_for('admin.group_apply_preset', group_id=group.id) }}"
|
||||||
data-confirm="Rechte dieser Gruppe auf die Vorlage zuruecksetzen? Bestehende Anpassungen gehen verloren."
|
data-confirm="Rechte dieser Gruppe auf die Vorlage zurücksetzen? Bestehende Anpassungen gehen verloren."
|
||||||
data-confirm-title="Vorlage anwenden" data-confirm-ok="Zuruecksetzen" data-confirm-style="primary">
|
data-confirm-title="Vorlage anwenden" data-confirm-ok="Zurücksetzen" data-confirm-style="primary">
|
||||||
{{ icon('refresh', size=14) }} Vorlage anwenden
|
{{ icon('refresh', size=14) }} Vorlage anwenden
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -58,7 +58,7 @@
|
|||||||
<div class="card__body">
|
<div class="card__body">
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
{{ field('name', 'Name', value=group.name, required=True, disabled=group.is_system,
|
{{ field('name', 'Name', value=group.name, required=True, disabled=group.is_system,
|
||||||
hint_text='Systemgruppen koennen nicht umbenannt werden.' if group.is_system else '') }}
|
hint_text='Systemgruppen können nicht umbenannt werden.' if group.is_system else '') }}
|
||||||
{{ textarea_field('description', 'Beschreibung', value=group.description, rows=2,
|
{{ textarea_field('description', 'Beschreibung', value=group.description, rows=2,
|
||||||
extra_class='field--full') }}
|
extra_class='field--full') }}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
{% from "tesm_core/macros.html" import field, textarea_field, pill, modal, alert, permission_matrix, empty_state %}
|
{% from "tesm_core/macros.html" import field, textarea_field, pill, modal, alert, permission_matrix, empty_state %}
|
||||||
|
|
||||||
{% block page_title %}Gruppen und Rechte{% endblock %}
|
{% block page_title %}Gruppen und Rechte{% endblock %}
|
||||||
{% block page_sub %}{{ groups | length }} Gruppen · Rechte addieren sich ueber alle Gruppen eines Benutzers{% endblock %}
|
{% block page_sub %}{{ groups | length }} Gruppen · Rechte addieren sich über alle Gruppen eines Benutzers{% endblock %}
|
||||||
|
|
||||||
{% block topbar_extra %}
|
{% block topbar_extra %}
|
||||||
{% if current_user.has('groups.create') %}
|
{% if current_user.has('groups.create') %}
|
||||||
@@ -13,9 +13,9 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{{ alert('Wer selbst ein Recht nicht besitzt, kann es auch nicht vergeben. Diese Regel gilt fuer
|
{{ alert('Wer selbst ein Recht nicht besitzt, kann es auch nicht vergeben. Diese Regel gilt für
|
||||||
alle ausser echten Administratoren und verhindert, dass sich ein Gruppenverwalter ueber eine
|
alle ausser echten Administratoren und verhindert, dass sich ein Gruppenverwalter über eine
|
||||||
neue Gruppe selbst hoeherstuft.', 'info', 'Eskalationsschutz') }}
|
neue Gruppe selbst höherstuft.', 'info', 'Eskalationsschutz') }}
|
||||||
|
|
||||||
<div class="grid grid--2">
|
<div class="grid grid--2">
|
||||||
{% for group in groups %}
|
{% for group in groups %}
|
||||||
@@ -37,9 +37,9 @@ neue Gruppe selbst hoeherstuft.', 'info', 'Eskalationsschutz') }}
|
|||||||
<form method="post" action="{{ url_for('admin.group_delete', group_id=group.id) }}" data-no-guard>
|
<form method="post" action="{{ url_for('admin.group_delete', group_id=group.id) }}" data-no-guard>
|
||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
<button class="btn btn--sm btn--ghost danger-text" type="submit"
|
<button class="btn btn--sm btn--ghost danger-text" type="submit"
|
||||||
data-confirm="Gruppe {{ group.name }} loeschen? Sie landet im Papierkorb; Mitglieder verlieren die zugehoerigen Rechte."
|
data-confirm="Gruppe {{ group.name }} löschen? Sie landet im Papierkorb; Mitglieder verlieren die zugehörigen Rechte."
|
||||||
data-confirm-title="Gruppe loeschen" data-confirm-ok="Loeschen"
|
data-confirm-title="Gruppe löschen" data-confirm-ok="Löschen"
|
||||||
aria-label="Loeschen">{{ icon('trash', size=15) }}</button>
|
aria-label="Löschen">{{ icon('trash', size=15) }}</button>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
@@ -67,7 +67,7 @@ neue Gruppe selbst hoeherstuft.', 'info', 'Eskalationsschutz') }}
|
|||||||
|
|
||||||
{% block dialogs %}
|
{% block dialogs %}
|
||||||
{% if current_user.has('groups.create') %}
|
{% if current_user.has('groups.create') %}
|
||||||
{% call modal('group-new', 'Gruppe anlegen', 'Rechte koennen anschliessend feiner eingestellt werden', 'wide') %}
|
{% call modal('group-new', 'Gruppe anlegen', 'Rechte können anschliessend feiner eingestellt werden', 'wide') %}
|
||||||
<form method="post" action="{{ url_for('admin.group_create') }}">
|
<form method="post" action="{{ url_for('admin.group_create') }}">
|
||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
<div class="modal__body">
|
<div class="modal__body">
|
||||||
|
|||||||
@@ -24,6 +24,6 @@
|
|||||||
</p>
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if public_dashboard_url %}
|
{% if public_dashboard_url %}
|
||||||
<p class="center fs-sm"><a href="{{ public_dashboard_url }}">Zur oeffentlichen Statusuebersicht</a></p>
|
<p class="center fs-sm"><a href="{{ public_dashboard_url }}">Zur öffentlichen Statusübersicht</a></p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{% extends "tesm_core/base.html" %}
|
{% extends "tesm_core/base.html" %}
|
||||||
{% block page_title %}Archiv {{ day }}{% endblock %}
|
{% block page_title %}Archiv {{ day }}{% endblock %}
|
||||||
{% block page_sub %}{{ rows | length }} archivierte Aenderungen{% endblock %}
|
{% block page_sub %}{{ rows | length }} archivierte Änderungen{% endblock %}
|
||||||
{% block breadcrumb %}
|
{% block breadcrumb %}
|
||||||
<nav class="breadcrumb"><span><a href="{{ url_for('logs.history') }}">Verlauf</a></span><span>{{ day }}</span></nav>
|
<nav class="breadcrumb"><span><a href="{{ url_for('logs.history') }}">Verlauf</a></span><span>{{ day }}</span></nav>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
{% extends "tesm_core/base.html" %}
|
{% extends "tesm_core/base.html" %}
|
||||||
{% from "tesm_core/macros.html" import pill, empty_state, alert %}
|
{% from "tesm_core/macros.html" import pill, empty_state, alert %}
|
||||||
|
|
||||||
{% block page_title %}Aenderungsprotokoll{% endblock %}
|
{% block page_title %}Änderungsprotokoll{% endblock %}
|
||||||
{% block page_sub %}{{ total }} Eintraege · Hash-Kette {{ 'unversehrt' if chain.ok else 'gebrochen' }}{% endblock %}
|
{% block page_sub %}{{ total }} Einträge · Hash-Kette {{ 'unversehrt' if chain.ok else 'gebrochen' }}{% endblock %}
|
||||||
|
|
||||||
{% block topbar_extra %}
|
{% block topbar_extra %}
|
||||||
{% if current_user.has('logs_audit.export') %}
|
{% if current_user.has('logs_audit.export') %}
|
||||||
@@ -13,10 +13,10 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
{% if not chain.ok %}
|
{% if not chain.ok %}
|
||||||
{{ alert(chain.reason ~ ' Erster Bruch bei Eintrag ' ~ chain.broken_at ~ '.', 'danger',
|
{{ alert(chain.reason ~ ' Erster Bruch bei Eintrag ' ~ chain.broken_at ~ '.', 'danger',
|
||||||
'Das Protokoll wurde veraendert') }}
|
'Das Protokoll wurde verändert') }}
|
||||||
{% else %}
|
{% else %}
|
||||||
{{ alert('Jeder Eintrag bindet den Hash seines Vorgaengers ein. Eine nachtraeglich entfernte
|
{{ alert('Jeder Eintrag bindet den Hash seines Vorgängers ein. Eine nachträglich entfernte
|
||||||
oder geaenderte Zeile faellt bei der Pruefung auf -- ' ~ chain.checked ~ ' Zeilen geprueft.',
|
oder geänderte Zeile fällt bei der Prüfung auf -- ' ~ chain.checked ~ ' Zeilen geprüft.',
|
||||||
'success') }}
|
'success') }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
<input class="input" type="text" name="benutzer" value="{{ filters.benutzer }}"
|
<input class="input" type="text" name="benutzer" value="{{ filters.benutzer }}"
|
||||||
placeholder="Benutzer" style="width:150px">
|
placeholder="Benutzer" style="width:150px">
|
||||||
<button class="btn btn--sm" type="submit">Filtern</button>
|
<button class="btn btn--sm" type="submit">Filtern</button>
|
||||||
<a class="btn btn--sm btn--ghost" href="{{ url_for('logs.audit_view') }}">Zuruecksetzen</a>
|
<a class="btn btn--sm btn--ghost" href="{{ url_for('logs.audit_view') }}">Zurücksetzen</a>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
@@ -66,10 +66,15 @@
|
|||||||
<td class="fs-sm">{{ row.target }}</td>
|
<td class="fs-sm">{{ row.target }}</td>
|
||||||
<td class="fs-sm muted break-all">{{ row.detail }}</td>
|
<td class="fs-sm muted break-all">{{ row.detail }}</td>
|
||||||
<td>
|
<td>
|
||||||
{% if row.severity == 'critical' %}{{ pill('kritisch', 'danger') }}
|
{# Ampel: Info grün, Hinweis gelb, Warnung rot. Vorher war Info
|
||||||
{% elif row.severity == 'warning' %}{{ pill('Warnung', 'warning') }}
|
grau und Hinweis blau -- dabei war nicht zu sehen, was harmlos
|
||||||
{% elif row.severity == 'notice' %}{{ pill('Hinweis', 'info') }}
|
ist und was Aufmerksamkeit braucht. "kritisch" bekommt eine
|
||||||
{% else %}{{ pill('Info', '') }}{% endif %}
|
eigene, gefüllte Darstellung, sonst wäre es von "Warnung"
|
||||||
|
nicht zu unterscheiden. #}
|
||||||
|
{% if row.severity == 'critical' %}{{ pill('kritisch', 'critical') }}
|
||||||
|
{% elif row.severity == 'warning' %}{{ pill('Warnung', 'danger') }}
|
||||||
|
{% elif row.severity == 'notice' %}{{ pill('Hinweis', 'warning') }}
|
||||||
|
{% else %}{{ pill('Info', 'online') }}{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="mono fs-xs faint">{{ row.ip or '–' | safe }}</td>
|
<td class="mono fs-xs faint">{{ row.ip or '–' | safe }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -77,13 +82,13 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
{% if not rows %}{{ empty_state('Keine Eintraege', 'Zu diesem Filter gibt es nichts.', 'history') }}{% endif %}
|
{% if not rows %}{{ empty_state('Keine Einträge', 'Zu diesem Filter gibt es nichts.', 'history') }}{% endif %}
|
||||||
|
|
||||||
<div class="card__footer" style="justify-content:space-between">
|
<div class="card__footer" style="justify-content:space-between">
|
||||||
<span class="fs-sm muted">Seite {{ page }}</span>
|
<span class="fs-sm muted">Seite {{ page }}</span>
|
||||||
<span class="row row--tight">
|
<span class="row row--tight">
|
||||||
{% if page > 1 %}
|
{% if page > 1 %}
|
||||||
<a class="btn btn--sm" href="{{ url_for('logs.audit_view', seite=page-1, **filters) }}">Zurueck</a>
|
<a class="btn btn--sm" href="{{ url_for('logs.audit_view', seite=page-1, **filters) }}">Zurück</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if rows | length == per_page %}
|
{% if rows | length == per_page %}
|
||||||
<a class="btn btn--sm" href="{{ url_for('logs.audit_view', seite=page+1, **filters) }}">Weiter</a>
|
<a class="btn btn--sm" href="{{ url_for('logs.audit_view', seite=page+1, **filters) }}">Weiter</a>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{{ alert('Rotiert wird per logrotate mit copytruncate -- keiner der schreibenden Prozesse
|
{{ alert('Rotiert wird per logrotate mit copytruncate -- keiner der schreibenden Prozesse
|
||||||
(Ueberwachung, Web, Kea) kann ein Reopen-Signal verarbeiten.', 'info') }}
|
(Überwachung, Web, Kea) kann ein Reopen-Signal verarbeiten.', 'info') }}
|
||||||
|
|
||||||
<div class="grid grid--2">
|
<div class="grid grid--2">
|
||||||
<section class="card">
|
<section class="card">
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
{% if rotated %}
|
{% if rotated %}
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table class="table table--compact">
|
<table class="table table--compact">
|
||||||
<thead><tr><th>Datei</th><th class="numeric">Groesse</th><th>Geaendert</th></tr></thead>
|
<thead><tr><th>Datei</th><th class="numeric">Grösse</th><th>Geändert</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for entry in rotated %}
|
{% for entry in rotated %}
|
||||||
<tr>
|
<tr>
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
<section class="card">
|
<section class="card">
|
||||||
<div class="card__header">
|
<div class="card__header">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="card__title">{{ icon('history', size=17) }} Archivierte Aenderungen</div>
|
<div class="card__title">{{ icon('history', size=17) }} Archivierte Änderungen</div>
|
||||||
<div class="card__sub">Aus der Datenbank ausgelagerte Kalendertage</div>
|
<div class="card__sub">Aus der Datenbank ausgelagerte Kalendertage</div>
|
||||||
</div>
|
</div>
|
||||||
{% if archived and current_user.has('logs_history.export') %}
|
{% if archived and current_user.has('logs_history.export') %}
|
||||||
@@ -52,7 +52,7 @@
|
|||||||
{% if archived %}
|
{% if archived %}
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table class="table table--compact">
|
<table class="table table--compact">
|
||||||
<thead><tr><th>Tag</th><th class="numeric">Groesse</th><th></th></tr></thead>
|
<thead><tr><th>Tag</th><th class="numeric">Grösse</th><th></th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for entry in archived %}
|
{% for entry in archived %}
|
||||||
<tr>
|
<tr>
|
||||||
@@ -69,8 +69,8 @@
|
|||||||
<form method="post" action="{{ url_for('logs.history_purge') }}" data-no-guard>
|
<form method="post" action="{{ url_for('logs.history_purge') }}" data-no-guard>
|
||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
<button class="btn btn--sm btn--ghost danger-text" type="submit"
|
<button class="btn btn--sm btn--ghost danger-text" type="submit"
|
||||||
data-confirm="Alle {{ archived | length }} Archivdateien endgueltig vom Server loeschen? Bitte vorher als ZIP sichern."
|
data-confirm="Alle {{ archived | length }} Archivdateien endgültig vom Server löschen? Bitte vorher als ZIP sichern."
|
||||||
data-confirm-title="Archiv loeschen" data-confirm-ok="Loeschen">
|
data-confirm-title="Archiv löschen" data-confirm-ok="Löschen">
|
||||||
Archiv vom Server entfernen
|
Archiv vom Server entfernen
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -51,8 +51,22 @@
|
|||||||
</label>
|
</label>
|
||||||
{%- endmacro %}
|
{%- endmacro %}
|
||||||
|
|
||||||
|
{# Ein nicht angehakter Haken sendet **nichts**. Wer die gesendeten Felder
|
||||||
|
auswertet, sieht ihn deshalb als "nicht angegeben" -- und liess den alten
|
||||||
|
Wert stehen: Haken raus, speichern, "keine Änderungen". Diese versteckte
|
||||||
|
Marke sagt "dieses Feld war im Formular", der Haken selbst sagt "an".
|
||||||
|
Damit sind alle drei Fälle unterscheidbar: an, aus, nicht Teil des
|
||||||
|
Formulars (Teilformulare dürfen fremde Schalter nicht löschen).
|
||||||
|
|
||||||
|
Kein ``disabled``-Fall: ein deaktivierter Haken darf auch nicht als "aus"
|
||||||
|
gespeichert werden, also fällt die Marke dann ebenfalls weg. #}
|
||||||
|
{% macro _present(name, disabled=False) -%}
|
||||||
|
{% if not disabled %}<input type="hidden" name="{{ name }}__present" value="1">{% endif %}
|
||||||
|
{%- endmacro %}
|
||||||
|
|
||||||
{% macro checkbox(name, label, checked=False, value='1', hint_text='', disabled=False) -%}
|
{% macro checkbox(name, label, checked=False, value='1', hint_text='', disabled=False) -%}
|
||||||
<label class="check {% if disabled %}check--disabled{% endif %}">
|
<label class="check {% if disabled %}check--disabled{% endif %}">
|
||||||
|
{{ _present(name, disabled) }}
|
||||||
<input type="checkbox" name="{{ name }}" value="{{ value }}"
|
<input type="checkbox" name="{{ name }}" value="{{ value }}"
|
||||||
{% if checked %}checked{% endif %} {% if disabled %}disabled{% endif %}>
|
{% if checked %}checked{% endif %} {% if disabled %}disabled{% endif %}>
|
||||||
<span>{{ label }} {{ hint(hint_text) }}</span>
|
<span>{{ label }} {{ hint(hint_text) }}</span>
|
||||||
@@ -61,6 +75,7 @@
|
|||||||
|
|
||||||
{% macro switch(name, label, checked=False, value='1', disabled=False) -%}
|
{% macro switch(name, label, checked=False, value='1', disabled=False) -%}
|
||||||
<label class="switch">
|
<label class="switch">
|
||||||
|
{{ _present(name, disabled) }}
|
||||||
<input type="checkbox" name="{{ name }}" value="{{ value }}"
|
<input type="checkbox" name="{{ name }}" value="{{ value }}"
|
||||||
{% if checked %}checked{% endif %} {% if disabled %}disabled{% endif %}>
|
{% if checked %}checked{% endif %} {% if disabled %}disabled{% endif %}>
|
||||||
<span>{{ label }}</span>
|
<span>{{ label }}</span>
|
||||||
@@ -196,12 +211,12 @@
|
|||||||
<span class="sr-only">{{ resource.label }} {{ action_meta[action][1] }}</span>
|
<span class="sr-only">{{ resource.label }} {{ action_meta[action][1] }}</span>
|
||||||
</label>
|
</label>
|
||||||
{% else %}
|
{% else %}
|
||||||
{# Bewusst *kein* Eingabefeld: ein deaktiviertes Kaestchen hat das
|
{# Bewusst *kein* Eingabefeld: ein deaktiviertes Kästchen hat das
|
||||||
JS beim Einschalten des Bereichs wieder freigegeben, es liess
|
JS beim Einschalten des Bereichs wieder freigegeben, es liess
|
||||||
sich anhaken, hatte aber kein name-Attribut -- es sah erteilt
|
sich anhaken, hatte aber kein name-Attribut -- es sah erteilt
|
||||||
aus und war nach dem Speichern weg. Was es nicht gibt, ist
|
aus und war nach dem Speichern weg. Was es nicht gibt, ist
|
||||||
hier deshalb gar kein Bedienelement. #}
|
hier deshalb gar kein Bedienelement. #}
|
||||||
<span class="matrix__na" title="Fuer diese Zeile nicht vorgesehen."></span>
|
<span class="matrix__na" title="Für diese Zeile nicht vorgesehen."></span>
|
||||||
<span class="sr-only">nicht vorgesehen</span>
|
<span class="sr-only">nicht vorgesehen</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
@@ -216,7 +231,7 @@
|
|||||||
{% for action, meta in action_meta.items() %}
|
{% for action, meta in action_meta.items() %}
|
||||||
<span><b>{{ meta[0] }}</b> {{ meta[1] }} – {{ meta[2] }}</span>
|
<span><b>{{ meta[0] }}</b> {{ meta[1] }} – {{ meta[2] }}</span>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
<span><span class="matrix__na"></span> Fuer diese Zeile nicht vorgesehen –
|
<span><span class="matrix__na"></span> Für diese Zeile nicht vorgesehen –
|
||||||
die Spalte gehoert zu einer anderen Zeile desselben Bereichs.</span>
|
die Spalte gehört zu einer anderen Zeile desselben Bereichs.</span>
|
||||||
</div>
|
</div>
|
||||||
{%- endmacro %}
|
{%- endmacro %}
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
{% extends "tesm_core/standalone.html" %}
|
{% extends "tesm_core/standalone.html" %}
|
||||||
{% block heading %}Bitte bestaetigen{% endblock %}
|
{% block heading %}Bitte bestätigen{% endblock %}
|
||||||
{% block subheading %}Diese Aktion verlangt eine erneute Eingabe Ihres Passworts{% endblock %}
|
{% block subheading %}Diese Aktion verlangt eine erneute Eingabe Ihres Passworts{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="alert alert--info">
|
<div class="alert alert--info">
|
||||||
{{ icon('info', size=18) }}
|
{{ icon('info', size=18) }}
|
||||||
<div class="alert__body">
|
<div class="alert__body">
|
||||||
Sie greifen auf Geheimnisse oder einen Datenexport zu. Ein uebernommenes
|
Sie greifen auf Geheimnisse oder einen Datenexport zu. Ein übernommenes
|
||||||
Sitzungscookie allein reicht dafuer nicht aus – die Bestaetigung gilt
|
Sitzungscookie allein reicht dafür nicht aus – die Bestätigung gilt
|
||||||
danach {{ reauth_minutes }} Minuten.
|
danach {{ reauth_minutes }} Minuten.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
</label>
|
</label>
|
||||||
<div class="row row--end">
|
<div class="row row--end">
|
||||||
<a class="btn" href="{{ cancel_url }}">Abbrechen</a>
|
<a class="btn" href="{{ cancel_url }}">Abbrechen</a>
|
||||||
<button class="btn btn--primary" type="submit">Bestaetigen</button>
|
<button class="btn btn--primary" type="submit">Bestätigen</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -11,10 +11,10 @@
|
|||||||
inputmode="numeric" autocomplete="one-time-code" maxlength="14"
|
inputmode="numeric" autocomplete="one-time-code" maxlength="14"
|
||||||
placeholder="123456" style="letter-spacing:.22em;text-align:center;font-size:1.15rem">
|
placeholder="123456" style="letter-spacing:.22em;text-align:center;font-size:1.15rem">
|
||||||
<span class="field__hint">
|
<span class="field__hint">
|
||||||
Alternativ koennen Sie einen Ihrer Wiederherstellungscodes eingeben.
|
Alternativ können Sie einen Ihrer Wiederherstellungscodes eingeben.
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<button class="btn btn--primary btn--lg btn--block" type="submit">Bestaetigen</button>
|
<button class="btn btn--primary btn--lg btn--block" type="submit">Bestätigen</button>
|
||||||
</form>
|
</form>
|
||||||
<p class="center fs-sm"><a href="{{ url_for('auth.logout_get') }}">Abbrechen und zurueck zur Anmeldung</a></p>
|
<p class="center fs-sm"><a href="{{ url_for('auth.logout_get') }}">Abbrechen und zurück zur Anmeldung</a></p>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{{ alert('Sitzungen liegen in der Datenbank, nicht im Cookie. Ein widerrufener Eintrag ist sofort
|
{{ alert('Sitzungen liegen in der Datenbank, nicht im Cookie. Ein widerrufener Eintrag ist sofort
|
||||||
ungueltig -- auch wenn das Cookie noch existiert.', 'info') }}
|
ungültig -- auch wenn das Cookie noch existiert.', 'info') }}
|
||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
@@ -17,8 +17,8 @@ ungueltig -- auch wenn das Cookie noch existiert.', 'info') }}
|
|||||||
<tr>
|
<tr>
|
||||||
<th class="sortable">Benutzer</th>
|
<th class="sortable">Benutzer</th>
|
||||||
<th class="sortable">Angemeldet</th>
|
<th class="sortable">Angemeldet</th>
|
||||||
<th class="sortable">Letzte Aktivitaet</th>
|
<th class="sortable">Letzte Aktivität</th>
|
||||||
<th class="sortable">Laeuft ab</th>
|
<th class="sortable">Läuft ab</th>
|
||||||
<th class="sortable">IP</th>
|
<th class="sortable">IP</th>
|
||||||
<th>Client</th>
|
<th>Client</th>
|
||||||
<th class="actions">Aktion</th>
|
<th class="actions">Aktion</th>
|
||||||
|
|||||||
@@ -12,16 +12,16 @@
|
|||||||
{% if license_hint %}{{ alert(license_hint, 'warning') }}{% endif %}
|
{% if license_hint %}{{ alert(license_hint, 'warning') }}{% endif %}
|
||||||
|
|
||||||
{{ alert('Same Sign-On, nicht Single Sign-On: der Benutzer tippt sein Verzeichnispasswort auf der
|
{{ alert('Same Sign-On, nicht Single Sign-On: der Benutzer tippt sein Verzeichnispasswort auf der
|
||||||
normalen Anmeldeseite ein. Es wird bei jeder Anmeldung live gegen das Verzeichnis geprueft und
|
normalen Anmeldeseite ein. Es wird bei jeder Anmeldung live gegen das Verzeichnis geprüft und
|
||||||
niemals hier gespeichert.', 'info') }}
|
niemals hier gespeichert.', 'info') }}
|
||||||
|
|
||||||
{% if values.ldap_security == 'none' %}
|
{% if values.ldap_security == 'none' %}
|
||||||
{{ alert('Die Verbindung ist unverschluesselt. Das Passwort geht dabei im Klartext ueber das
|
{{ alert('Die Verbindung ist unverschlüsselt. Das Passwort geht dabei im Klartext über das
|
||||||
Netz -- nur fuer Testumgebungen vertretbar.', 'danger', 'Unverschluesselt') }}
|
Netz -- nur für Testumgebungen vertretbar.', 'danger', 'Unverschlüsselt') }}
|
||||||
{% elif not values.ldap_verify_cert %}
|
{% elif not values.ldap_verify_cert %}
|
||||||
{{ alert('Das Serverzertifikat wird nicht geprueft. Damit schuetzt die Verschluesselung nur
|
{{ alert('Das Serverzertifikat wird nicht geprüft. Damit schützt die Verschlüsselung nur
|
||||||
gegen passives Mitlesen, nicht gegen einen Man-in-the-Middle.', 'warning',
|
gegen passives Mitlesen, nicht gegen einen Man-in-the-Middle.', 'warning',
|
||||||
'Zertifikat ungeprueft') }}
|
'Zertifikat ungeprüft') }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="grid grid--split">
|
<div class="grid grid--split">
|
||||||
@@ -48,8 +48,8 @@ niemals hier gespeichert.', 'info') }}
|
|||||||
{{ field('ldap_port', 'Port', value=values.ldap_port, type='number',
|
{{ field('ldap_port', 'Port', value=values.ldap_port, type='number',
|
||||||
disabled=bool(license_hint), attrs='id="ldap-port"') }}
|
disabled=bool(license_hint), attrs='id="ldap-port"') }}
|
||||||
{# Der Transport setzt den Port mit. Wer umstellt und den Port stehen
|
{# Der Transport setzt den Port mit. Wer umstellt und den Port stehen
|
||||||
laesst, bekommt eine Verbindung, die auf gut Glueck scheitert --
|
lässt, bekommt eine Verbindung, die auf gut Glück scheitert --
|
||||||
und die Meldung ("Zeitlimit") sagt nichts ueber die Ursache. Ein
|
und die Meldung ("Zeitlimit") sagt nichts über die Ursache. Ein
|
||||||
absichtlich abweichender Port bleibt erhalten. #}
|
absichtlich abweichender Port bleibt erhalten. #}
|
||||||
{{ select_field('ldap_security', 'Transport', specs['ldap_security'].choices,
|
{{ select_field('ldap_security', 'Transport', specs['ldap_security'].choices,
|
||||||
selected=values.ldap_security, disabled=bool(license_hint),
|
selected=values.ldap_security, disabled=bool(license_hint),
|
||||||
@@ -68,21 +68,21 @@ niemals hier gespeichert.', 'info') }}
|
|||||||
disabled=bool(license_hint)) }}
|
disabled=bool(license_hint)) }}
|
||||||
</div>
|
</div>
|
||||||
<div class="stack stack--tight">
|
<div class="stack stack--tight">
|
||||||
{{ checkbox('ldap_verify_cert', 'Serverzertifikat pruefen', checked=values.ldap_verify_cert,
|
{{ checkbox('ldap_verify_cert', 'Serverzertifikat prüfen', checked=values.ldap_verify_cert,
|
||||||
disabled=bool(license_hint)) }}
|
disabled=bool(license_hint)) }}
|
||||||
{{ checkbox('ldap_group_sync', 'Gruppen aus dem Verzeichnis uebernehmen',
|
{{ checkbox('ldap_group_sync', 'Gruppen aus dem Verzeichnis übernehmen',
|
||||||
checked=values.ldap_group_sync, disabled=bool(license_hint)) }}
|
checked=values.ldap_group_sync, disabled=bool(license_hint)) }}
|
||||||
{{ checkbox('ldap_allow_insecure', 'Unverschluesselte Verbindung ausdruecklich erlauben',
|
{{ checkbox('ldap_allow_insecure', 'Unverschlüsselte Verbindung ausdrücklich erlauben',
|
||||||
checked=values.ldap_allow_insecure, disabled=bool(license_hint),
|
checked=values.ldap_allow_insecure, disabled=bool(license_hint),
|
||||||
hint_text=specs['ldap_allow_insecure'].hint) }}
|
hint_text=specs['ldap_allow_insecure'].hint) }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<fieldset class="fieldset">
|
<fieldset class="fieldset">
|
||||||
<legend>Dienstkonto fuer die Suche (nur Lesezugriff noetig)</legend>
|
<legend>Dienstkonto für die Suche (nur Lesezugriff nötig)</legend>
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
{{ field('bind_username', 'Benutzer', value=bind_username, autocomplete='off',
|
{{ field('bind_username', 'Benutzer', value=bind_username, autocomplete='off',
|
||||||
disabled=not current_user.has('settings_directory.secrets')) }}
|
disabled=not current_user.has('settings_directory.secrets')) }}
|
||||||
{{ field('bind_password', 'Passwort (leer = unveraendert)', type='password',
|
{{ field('bind_password', 'Passwort (leer = unverändert)', type='password',
|
||||||
autocomplete='new-password',
|
autocomplete='new-password',
|
||||||
disabled=not current_user.has('settings_directory.secrets')) }}
|
disabled=not current_user.has('settings_directory.secrets')) }}
|
||||||
</div>
|
</div>
|
||||||
@@ -125,7 +125,7 @@ niemals hier gespeichert.', 'info') }}
|
|||||||
action="{{ url_for('hostsettings.directory_mapping_delete', mapping_id=entry.id) }}">
|
action="{{ url_for('hostsettings.directory_mapping_delete', mapping_id=entry.id) }}">
|
||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
<button class="btn btn--sm btn--ghost danger-text" type="submit"
|
<button class="btn btn--sm btn--ghost danger-text" type="submit"
|
||||||
data-confirm="Zuordnung entfernen? Betroffene Benutzer verlieren die Gruppe bei der naechsten Anmeldung."
|
data-confirm="Zuordnung entfernen? Betroffene Benutzer verlieren die Gruppe bei der nächsten Anmeldung."
|
||||||
data-confirm-title="Zuordnung entfernen" data-confirm-ok="Entfernen">
|
data-confirm-title="Zuordnung entfernen" data-confirm-ok="Entfernen">
|
||||||
{{ icon('trash', size=14) }}</button>
|
{{ icon('trash', size=14) }}</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -5,30 +5,30 @@
|
|||||||
{% block page_sub %}Backend: {{ backend }} · Sicherheitsnetz {{ revert_seconds }} s{% endblock %}
|
{% block page_sub %}Backend: {{ backend }} · Sicherheitsnetz {{ revert_seconds }} s{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{{ alert('Nach einer Aenderung laeuft ein Zeitgeber. Bestaetigen Sie nicht innerhalb von
|
{{ alert('Nach einer Änderung läuft ein Zeitgeber. Bestätigen Sie nicht innerhalb von
|
||||||
' ~ revert_seconds ~ ' Sekunden von der neuen Adresse aus, wird automatisch
|
' ~ revert_seconds ~ ' Sekunden von der neuen Adresse aus, wird automatisch
|
||||||
zurueckgerollt. So sperrt eine falsche Eingabe den Host nicht aus.', 'warning',
|
zurückgerollt. So sperrt eine falsche Eingabe den Host nicht aus.', 'warning',
|
||||||
'Automatischer Rueckfall') }}
|
'Automatischer Rückfall') }}
|
||||||
|
|
||||||
{% if pending %}
|
{% if pending %}
|
||||||
<section class="card card--danger">
|
<section class="card card--danger">
|
||||||
<div class="card__header">
|
<div class="card__header">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="card__title">{{ icon('alert', size=17) }} Aenderung wartet auf Bestaetigung</div>
|
<div class="card__title">{{ icon('alert', size=17) }} Änderung wartet auf Bestätigung</div>
|
||||||
<div class="card__sub">Interface {{ pending.interface }}</div>
|
<div class="card__sub">Interface {{ pending.interface }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card__body">
|
<div class="card__body">
|
||||||
<form method="post" action="{{ url_for('hostsettings.network_confirm') }}" data-no-guard>
|
<form method="post" action="{{ url_for('hostsettings.network_confirm') }}" data-no-guard>
|
||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
<button class="btn btn--primary" type="submit">Verbindung bestaetigen</button>
|
<button class="btn btn--primary" type="submit">Verbindung bestätigen</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if not sysops_available %}
|
{% if not sysops_available %}
|
||||||
{{ alert('Systemhelfer nicht verfuegbar -- die Anzeige ist rein lesend.', 'info') }}
|
{{ alert('Systemhelfer nicht verfügbar -- die Anzeige ist rein lesend.', 'info') }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
@@ -54,7 +54,7 @@ zurueckgerollt. So sperrt eine falsche Eingabe den Host nicht aus.', 'warning',
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
{{ empty_state('Keine Interfaces gelesen', 'Das System gibt hier nichts zurueck.', 'network') }}
|
{{ empty_state('Keine Interfaces gelesen', 'Das System gibt hier nichts zurück.', 'network') }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -62,11 +62,11 @@ zurueckgerollt. So sperrt eine falsche Eingabe den Host nicht aus.', 'warning',
|
|||||||
<section class="card">
|
<section class="card">
|
||||||
<div class="card__header">
|
<div class="card__header">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="card__title">{{ icon('sliders', size=17) }} Aendern</div>
|
<div class="card__title">{{ icon('sliders', size=17) }} Ändern</div>
|
||||||
<div class="card__sub">
|
<div class="card__sub">
|
||||||
Es wird nur der Block des gewaehlten Interfaces geschrieben (Datei
|
Es wird nur der Block des gewählten Interfaces geschrieben (Datei
|
||||||
<span class="mono">{{ netplan_path }}</span>) – andere Interfaces bleiben
|
<span class="mono">{{ netplan_path }}</span>) – andere Interfaces bleiben
|
||||||
unberuehrt.
|
unberührt.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -88,18 +88,18 @@ zurueckgerollt. So sperrt eine falsche Eingabe den Host nicht aus.', 'warning',
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
{{ field('address', 'IP-Adresse', placeholder='192.168.1.10') }}
|
{{ field('address', 'IP-Adresse', placeholder='192.168.1.10') }}
|
||||||
{{ field('prefix', 'Praefixlaenge', value='24', type='number') }}
|
{{ field('prefix', 'Präfixlänge', value='24', type='number') }}
|
||||||
{{ field('gateway', 'Gateway', placeholder='192.168.1.1') }}
|
{{ field('gateway', 'Gateway', placeholder='192.168.1.1') }}
|
||||||
{{ field('dns', 'DNS-Server', placeholder='192.168.1.1, 9.9.9.9') }}
|
{{ field('dns', 'DNS-Server', placeholder='192.168.1.1, 9.9.9.9') }}
|
||||||
</div>
|
</div>
|
||||||
{{ alert('Das Gateway muss im angegebenen Netz liegen -- sonst waere der Host nach der
|
{{ alert('Das Gateway muss im angegebenen Netz liegen -- sonst wäre der Host nach der
|
||||||
Aenderung nicht mehr erreichbar. Das wird vor dem Schreiben geprueft.', 'info') }}
|
Änderung nicht mehr erreichbar. Das wird vor dem Schreiben geprüft.', 'info') }}
|
||||||
</div>
|
</div>
|
||||||
<div class="card__footer">
|
<div class="card__footer">
|
||||||
<button class="btn btn--danger" type="submit"
|
<button class="btn btn--danger" type="submit"
|
||||||
data-confirm="Netzwerkeinstellungen jetzt uebernehmen? Die Verbindung kann kurz abbrechen. Ohne Bestaetigung wird nach {{ revert_seconds }} Sekunden zurueckgerollt."
|
data-confirm="Netzwerkeinstellungen jetzt übernehmen? Die Verbindung kann kurz abbrechen. Ohne Bestätigung wird nach {{ revert_seconds }} Sekunden zurückgerollt."
|
||||||
data-confirm-title="Netzwerk aendern" data-confirm-ok="Uebernehmen">
|
data-confirm-title="Netzwerk ändern" data-confirm-ok="Übernehmen">
|
||||||
Uebernehmen
|
Übernehmen
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -10,14 +10,14 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
{% if license_hint %}{{ alert(license_hint, 'warning') }}{% endif %}
|
{% if license_hint %}{{ alert(license_hint, 'warning') }}{% endif %}
|
||||||
{% if not sysops_available %}
|
{% if not sysops_available %}
|
||||||
{{ alert('Systemhelfer nicht verfuegbar -- die Konfiguration kann hier erzeugt und angesehen,
|
{{ alert('Systemhelfer nicht verfügbar -- die Konfiguration kann hier erzeugt und angesehen,
|
||||||
aber nicht angewandt werden.', 'info') }}
|
aber nicht angewandt werden.', 'info') }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{{ alert('Die Site-Datei wird bei jeder Uebernahme vollstaendig neu geschrieben.
|
{{ alert('Die Site-Datei wird bei jeder Übernahme vollständig neu geschrieben.
|
||||||
Alles, was ein Administrator sonst per Hand hineinschreiben wuerde, ist hier Einstellung. Der
|
Alles, was ein Administrator sonst per Hand hineinschreiben würde, ist hier Einstellung. Der
|
||||||
Vorgaenger schrieb die Datei nur, wenn sie noch nicht existierte -- ein einmal falscher Pfad
|
Vorgänger schrieb die Datei nur, wenn sie noch nicht existierte -- ein einmal falscher Pfad
|
||||||
blieb dadurch ueber Jahre bestehen und lieferte alle statischen Dateien als 404.', 'info',
|
blieb dadurch über Jahre bestehen und lieferte alle statischen Dateien als 404.', 'info',
|
||||||
'Warum die Datei immer neu geschrieben wird') }}
|
'Warum die Datei immer neu geschrieben wird') }}
|
||||||
|
|
||||||
<div class="grid grid--split">
|
<div class="grid grid--split">
|
||||||
@@ -32,12 +32,12 @@ blieb dadurch ueber Jahre bestehen und lieferte alle statischen Dateien als 404.
|
|||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
{{ field('web_domain', 'Domain', value=config.domain,
|
{{ field('web_domain', 'Domain', value=config.domain,
|
||||||
placeholder='tesm.firma.de', disabled=bool(license_hint),
|
placeholder='tesm.firma.de', disabled=bool(license_hint),
|
||||||
hint_text='Leer = Zugriff ueber IP-Adresse.') }}
|
hint_text='Leer = Zugriff über IP-Adresse.') }}
|
||||||
{{ field('web_acme_email', 'E-Mail fuer Let\'s Encrypt', value=acme_email, type='email',
|
{{ field('web_acme_email', 'E-Mail für Let\'s Encrypt', value=acme_email, type='email',
|
||||||
disabled=bool(license_hint)) }}
|
disabled=bool(license_hint)) }}
|
||||||
{{ field('web_cert_path', 'Zertifikat (Pfad)', value=config.cert_path,
|
{{ field('web_cert_path', 'Zertifikat (Pfad)', value=config.cert_path,
|
||||||
disabled=bool(license_hint), extra_class='field--full') }}
|
disabled=bool(license_hint), extra_class='field--full') }}
|
||||||
{{ field('web_key_path', 'Privater Schluessel (Pfad)', value=config.key_path,
|
{{ field('web_key_path', 'Privater Schlüssel (Pfad)', value=config.key_path,
|
||||||
disabled=bool(license_hint), extra_class='field--full') }}
|
disabled=bool(license_hint), extra_class='field--full') }}
|
||||||
{{ field('web_acme_webroot', 'ACME-Webroot', value=config.acme_webroot,
|
{{ field('web_acme_webroot', 'ACME-Webroot', value=config.acme_webroot,
|
||||||
disabled=bool(license_hint), extra_class='field--full') }}
|
disabled=bool(license_hint), extra_class='field--full') }}
|
||||||
@@ -47,7 +47,7 @@ blieb dadurch ueber Jahre bestehen und lieferte alle statischen Dateien als 404.
|
|||||||
checked=config.https_enabled, disabled=bool(license_hint)) }}
|
checked=config.https_enabled, disabled=bool(license_hint)) }}
|
||||||
{{ checkbox('web_redirect_http', 'HTTP (Port 80) auf HTTPS umleiten',
|
{{ checkbox('web_redirect_http', 'HTTP (Port 80) auf HTTPS umleiten',
|
||||||
checked=config.redirect_http, disabled=bool(license_hint),
|
checked=config.redirect_http, disabled=bool(license_hint),
|
||||||
hint_text='Der ACME-Pfad bleibt dabei erreichbar -- sonst koennte Let\'s
|
hint_text='Der ACME-Pfad bleibt dabei erreichbar -- sonst könnte Let\'s
|
||||||
Encrypt nie erneuern.') }}
|
Encrypt nie erneuern.') }}
|
||||||
{{ checkbox('web_hsts_enabled', 'HSTS-Header senden',
|
{{ checkbox('web_hsts_enabled', 'HSTS-Header senden',
|
||||||
checked=config.hsts_enabled, disabled=bool(license_hint),
|
checked=config.hsts_enabled, disabled=bool(license_hint),
|
||||||
@@ -67,7 +67,7 @@ blieb dadurch ueber Jahre bestehen und lieferte alle statischen Dateien als 404.
|
|||||||
<summary class="card__header" style="cursor:pointer">
|
<summary class="card__header" style="cursor:pointer">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="card__title">{{ icon('file-text', size=17) }} Erzeugte nginx-Konfiguration</div>
|
<div class="card__title">{{ icon('file-text', size=17) }} Erzeugte nginx-Konfiguration</div>
|
||||||
<div class="card__sub">Vorschau der Datei, die geschrieben wuerde</div>
|
<div class="card__sub">Vorschau der Datei, die geschrieben würde</div>
|
||||||
</div>
|
</div>
|
||||||
{{ icon('chevron-down', size=16) }}
|
{{ icon('chevron-down', size=16) }}
|
||||||
</summary>
|
</summary>
|
||||||
@@ -89,8 +89,8 @@ blieb dadurch ueber Jahre bestehen und lieferte alle statischen Dateien als 404.
|
|||||||
{% if certificate.present and not certificate.get('error') %}
|
{% if certificate.present and not certificate.get('error') %}
|
||||||
<dl class="kv fs-sm">
|
<dl class="kv fs-sm">
|
||||||
<dt>Inhaber</dt><dd class="break-all">{{ certificate.subject }}</dd>
|
<dt>Inhaber</dt><dd class="break-all">{{ certificate.subject }}</dd>
|
||||||
<dt>Aussteller</dt><dd class="break-all">{{ certificate.issuer }}</dd>
|
<dt>Aussteller</dt><dd class="break-all">{{ certificate.issür }}</dd>
|
||||||
<dt>Gueltig bis</dt><dd>{{ certificate.not_valid_after | dt }}</dd>
|
<dt>Gültig bis</dt><dd>{{ certificate.not_valid_after | dt }}</dd>
|
||||||
<dt>Seriennummer</dt><dd class="mono fs-xs break-all">{{ certificate.serial }}</dd>
|
<dt>Seriennummer</dt><dd class="mono fs-xs break-all">{{ certificate.serial }}</dd>
|
||||||
</dl>
|
</dl>
|
||||||
{% elif certificate.get('error') %}
|
{% elif certificate.get('error') %}
|
||||||
@@ -107,7 +107,7 @@ blieb dadurch ueber Jahre bestehen und lieferte alle statischen Dateien als 404.
|
|||||||
<button class="btn btn--sm" type="submit"
|
<button class="btn btn--sm" type="submit"
|
||||||
{% if license_hint or not certbot_available or not config.domain %}disabled{% endif %}
|
{% if license_hint or not certbot_available or not config.domain %}disabled{% endif %}
|
||||||
data-busy-label="Fordere an ..."
|
data-busy-label="Fordere an ..."
|
||||||
data-confirm="Zertifikat fuer {{ config.domain }} per Let's Encrypt anfordern? Die Domain muss oeffentlich auf diesen Host zeigen."
|
data-confirm="Zertifikat für {{ config.domain }} per Let's Encrypt anfordern? Die Domain muss öffentlich auf diesen Host zeigen."
|
||||||
data-confirm-title="Zertifikat anfordern" data-confirm-ok="Anfordern"
|
data-confirm-title="Zertifikat anfordern" data-confirm-ok="Anfordern"
|
||||||
data-confirm-style="primary">
|
data-confirm-style="primary">
|
||||||
{{ icon('lock', size=14) }} Let's-Encrypt-Zertifikat anfordern
|
{{ icon('lock', size=14) }} Let's-Encrypt-Zertifikat anfordern
|
||||||
@@ -117,15 +117,15 @@ blieb dadurch ueber Jahre bestehen und lieferte alle statischen Dateien als 404.
|
|||||||
<p class="fs-xs faint">certbot ist auf diesem Host nicht installiert.</p>
|
<p class="fs-xs faint">certbot ist auf diesem Host nicht installiert.</p>
|
||||||
{% elif not config.domain %}
|
{% elif not config.domain %}
|
||||||
<p class="fs-xs faint">
|
<p class="fs-xs faint">
|
||||||
Fuer Let's Encrypt wird oben eine Domain gebraucht, die oeffentlich auf
|
Für Let's Encrypt wird oben eine Domain gebraucht, die öffentlich auf
|
||||||
diesen Host zeigt.
|
diesen Host zeigt.
|
||||||
</p>
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<hr>
|
<hr>
|
||||||
<p class="muted fs-sm">
|
<p class="muted fs-sm">
|
||||||
Kein oeffentlicher Name? Dann ein eigenes Zertifikat erzeugen. Browser
|
Kein öffentlicher Name? Dann ein eigenes Zertifikat erzeugen. Browser
|
||||||
zeigen dafuer eine Warnung, die Verbindung ist aber verschluesselt --
|
zeigen dafür eine Warnung, die Verbindung ist aber verschlüsselt --
|
||||||
und das Sitzungscookie darf endlich <span class="mono">Secure</span> tragen.
|
und das Sitzungscookie darf endlich <span class="mono">Secure</span> tragen.
|
||||||
</p>
|
</p>
|
||||||
{% if local_names %}
|
{% if local_names %}
|
||||||
@@ -133,7 +133,7 @@ blieb dadurch ueber Jahre bestehen und lieferte alle statischen Dateien als 404.
|
|||||||
Wird ausgestellt auf:
|
Wird ausgestellt auf:
|
||||||
<span class="mono">{{ (config.domain or local_names[0]) }}</span>
|
<span class="mono">{{ (config.domain or local_names[0]) }}</span>
|
||||||
{% if local_names|length > 1 %}
|
{% if local_names|length > 1 %}
|
||||||
(zusaetzlich {{ local_names[1:] | join(', ') }})
|
(zusätzlich {{ local_names[1:] | join(', ') }})
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</p>
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -156,7 +156,7 @@ blieb dadurch ueber Jahre bestehen und lieferte alle statischen Dateien als 404.
|
|||||||
<section class="card card--accent">
|
<section class="card card--accent">
|
||||||
<div class="card__header">
|
<div class="card__header">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="card__title">{{ icon('upload', size=17) }} Uebernehmen</div>
|
<div class="card__title">{{ icon('upload', size=17) }} Übernehmen</div>
|
||||||
<div class="card__sub">Schreiben, <span class="mono">nginx -t</span>, dann neu laden</div>
|
<div class="card__sub">Schreiben, <span class="mono">nginx -t</span>, dann neu laden</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -165,11 +165,11 @@ blieb dadurch ueber Jahre bestehen und lieferte alle statischen Dateien als 404.
|
|||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
<button class="btn btn--primary btn--block" type="submit"
|
<button class="btn btn--primary btn--block" type="submit"
|
||||||
{% if license_hint or not sysops_available %}disabled{% endif %}
|
{% if license_hint or not sysops_available %}disabled{% endif %}
|
||||||
data-busy-label="Uebernehme ..."
|
data-busy-label="Übernehme ..."
|
||||||
data-confirm="Konfiguration schreiben, pruefen und nginx neu laden? Eine fehlerhafte Konfiguration wird vorher abgewiesen."
|
data-confirm="Konfiguration schreiben, prüfen und nginx neu laden? Eine fehlerhafte Konfiguration wird vorher abgewiesen."
|
||||||
data-confirm-title="nginx uebernehmen" data-confirm-ok="Uebernehmen"
|
data-confirm-title="nginx übernehmen" data-confirm-ok="Übernehmen"
|
||||||
data-confirm-style="primary">
|
data-confirm-style="primary">
|
||||||
Konfiguration uebernehmen
|
Konfiguration übernehmen
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
{# ==========================================================================
|
{# ==========================================================================
|
||||||
Grundgeruest fuer Seiten ohne Anwendungsrahmen: Anmeldung, zweiter Faktor,
|
Grundgerüst für Seiten ohne Anwendungsrahmen: Anmeldung, zweiter Faktor,
|
||||||
Kundenportal, Self-Service.
|
Kundenportal, Self-Service.
|
||||||
|
|
||||||
Wichtiger Unterschied zu base.html: Flash-Meldungen werden hier **sichtbar
|
Wichtiger Unterschied zu base.html: Flash-Meldungen werden hier **sichtbar
|
||||||
serverseitig** gerendert, nicht ueber die JSON-Bruecke. Der Vorgaenger hatte
|
serverseitig** gerendert, nicht über die JSON-Brücke. Der Vorgänger hatte
|
||||||
dieselbe Trennung; sie ist wichtig, weil diese Seiten auch ohne JavaScript
|
dieselbe Trennung; sie ist wichtig, weil diese Seiten auch ohne JavaScript
|
||||||
nutzbar bleiben muessen (und weil Tests hier direkt auf Text pruefen).
|
nutzbar bleiben müssen (und weil Tests hier direkt auf Text prüfen).
|
||||||
========================================================================== #}
|
========================================================================== #}
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="de">
|
<html lang="de">
|
||||||
@@ -23,11 +23,11 @@
|
|||||||
<body data-csrf="{{ csrf_token() }}">
|
<body data-csrf="{{ csrf_token() }}">
|
||||||
<div class="auth-page">
|
<div class="auth-page">
|
||||||
{% if auth_tagline %}
|
{% if auth_tagline %}
|
||||||
{# Wortmarke mit Funktionszeile als tatsaechlicher Seitenhintergrund --
|
{# Wortmarke mit Funktionszeile als tatsächlicher Seitenhintergrund --
|
||||||
uebernommen vom Vorgaenger. Bewusst inline und mit Themen-Tokens statt
|
übernommen vom Vorgänger. Bewusst inline und mit Themen-Tokens statt
|
||||||
zweier Dateien fuer hell und dunkel: bei der Einstellung "System" und
|
zweier Dateien für hell und dunkel: bei der Einstellung "System" und
|
||||||
beim Umschalten von Hand waere die versteckte Variante die falsche.
|
beim Umschalten von Hand wäre die versteckte Variante die falsche.
|
||||||
``aria-hidden``, weil der Name schon in der Ueberschrift steht. #}
|
``aria-hidden``, weil der Name schon in der Überschrift steht. #}
|
||||||
<div class="auth-backdrop" aria-hidden="true">
|
<div class="auth-backdrop" aria-hidden="true">
|
||||||
<svg viewBox="0 0 620 190" xmlns="http://www.w3.org/2000/svg">
|
<svg viewBox="0 0 620 190" xmlns="http://www.w3.org/2000/svg">
|
||||||
<g transform="translate(150,15)">
|
<g transform="translate(150,15)">
|
||||||
@@ -71,7 +71,7 @@
|
|||||||
|
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
|
|
||||||
<p class="auth-footnote">{{ app_name }} {{ app_version }} · © {{ current_year }} WiS GmbH</p>
|
<p class="auth-footnote">{{ app_name }} {{ app_version }} · © {{ current_year }} {{ copyright_notice }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script type="module" src="{{ asset_url('js/app.js') }}" nonce="{{ csp_nonce }}"></script>
|
<script type="module" src="{{ asset_url('js/app.js') }}" nonce="{{ csp_nonce }}"></script>
|
||||||
|
|||||||
@@ -2,11 +2,11 @@
|
|||||||
{% from "tesm_core/macros.html" import empty_state, alert, pill %}
|
{% from "tesm_core/macros.html" import empty_state, alert, pill %}
|
||||||
|
|
||||||
{% block page_title %}Papierkorb{% endblock %}
|
{% block page_title %}Papierkorb{% endblock %}
|
||||||
{% block page_sub %}{{ total }} Eintraege · Aufbewahrung {{ retention_days }} Tage{% endblock %}
|
{% block page_sub %}{{ total }} Einträge · Aufbewahrung {{ retention_days }} Tage{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{{ alert('Ansehen ist immer moeglich. Wiederherstellen und endgueltiges Loeschen erfordern eine
|
{{ alert('Ansehen ist immer möglich. Wiederherstellen und endgültiges Löschen erfordern eine
|
||||||
gueltige, aktivierte Lizenz. Abgelaufene Eintraege werden beim Aufruf dieser Seite entfernt.',
|
gültige, aktivierte Lizenz. Abgelaufene Einträge werden beim Aufruf dieser Seite entfernt.',
|
||||||
'info') }}
|
'info') }}
|
||||||
|
|
||||||
{% for section in sections %}
|
{% for section in sections %}
|
||||||
@@ -23,8 +23,8 @@ gueltige, aktivierte Lizenz. Abgelaufene Eintraege werden beim Aufruf dieser Sei
|
|||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
<input type="hidden" name="kind" value="{{ section.key }}">
|
<input type="hidden" name="kind" value="{{ section.key }}">
|
||||||
<button class="btn btn--sm btn--ghost danger-text" type="submit"
|
<button class="btn btn--sm btn--ghost danger-text" type="submit"
|
||||||
data-confirm="Alle {{ section.entries | length }} Eintraege unter {{ section.label }} endgueltig loeschen? Das laesst sich nicht rueckgaengig machen."
|
data-confirm="Alle {{ section.entries | length }} Einträge unter {{ section.label }} endgültig löschen? Das lässt sich nicht rückgängig machen."
|
||||||
data-confirm-title="Endgueltig loeschen" data-confirm-ok="Endgueltig loeschen">
|
data-confirm-title="Endgültig löschen" data-confirm-ok="Endgültig löschen">
|
||||||
Bereich leeren
|
Bereich leeren
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -33,7 +33,7 @@ gueltige, aktivierte Lizenz. Abgelaufene Eintraege werden beim Aufruf dieser Sei
|
|||||||
{% if section.entries %}
|
{% if section.entries %}
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table class="table table--compact">
|
<table class="table table--compact">
|
||||||
<thead><tr><th>Eintrag</th><th>Detail</th><th>Geloescht</th><th class="actions">Aktion</th></tr></thead>
|
<thead><tr><th>Eintrag</th><th>Detail</th><th>Gelöscht</th><th class="actions">Aktion</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for entry in section.entries %}
|
{% for entry in section.entries %}
|
||||||
<tr>
|
<tr>
|
||||||
@@ -53,9 +53,9 @@ gueltige, aktivierte Lizenz. Abgelaufene Eintraege werden beim Aufruf dieser Sei
|
|||||||
action="{{ url_for('trash.purge', kind=section.key, ident=entry.id) }}">
|
action="{{ url_for('trash.purge', kind=section.key, ident=entry.id) }}">
|
||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
<button class="btn btn--sm btn--ghost danger-text" type="submit"
|
<button class="btn btn--sm btn--ghost danger-text" type="submit"
|
||||||
data-confirm="{{ entry.label }} endgueltig loeschen?"
|
data-confirm="{{ entry.label }} endgültig löschen?"
|
||||||
data-confirm-title="Endgueltig loeschen" data-confirm-ok="Loeschen">
|
data-confirm-title="Endgültig löschen" data-confirm-ok="Löschen">
|
||||||
Endgueltig
|
Endgültig
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
<span class="table-toolbar__spacer"></span>
|
<span class="table-toolbar__spacer"></span>
|
||||||
{% if directory_enabled and current_user.has('users.create') %}
|
{% if directory_enabled and current_user.has('users.create') %}
|
||||||
<button class="btn btn--sm" type="button" data-dialog-open="user-directory">
|
<button class="btn btn--sm" type="button" data-dialog-open="user-directory">
|
||||||
{{ icon('users', size=15) }} Aus Verzeichnis uebernehmen
|
{{ icon('users', size=15) }} Aus Verzeichnis übernehmen
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if current_user.is_admin %}
|
{% if current_user.is_admin %}
|
||||||
@@ -90,9 +90,9 @@
|
|||||||
style="display:inline" data-no-guard>
|
style="display:inline" data-no-guard>
|
||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
<button class="btn btn--sm btn--ghost danger-text" type="submit"
|
<button class="btn btn--sm btn--ghost danger-text" type="submit"
|
||||||
data-confirm="Konto {{ user.username }} wirklich loeschen?{% if user.auth_source == 'ldap' %} Verzeichniskonten werden sofort endgueltig entfernt.{% else %} Es landet im Papierkorb.{% endif %}"
|
data-confirm="Konto {{ user.username }} wirklich löschen?{% if user.auth_source == 'ldap' %} Verzeichniskonten werden sofort endgültig entfernt.{% else %} Es landet im Papierkorb.{% endif %}"
|
||||||
data-confirm-title="Konto loeschen" data-confirm-ok="Loeschen"
|
data-confirm-title="Konto löschen" data-confirm-ok="Löschen"
|
||||||
aria-label="Loeschen">{{ icon('trash', size=15) }}</button>
|
aria-label="Löschen">{{ icon('trash', size=15) }}</button>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
@@ -106,8 +106,8 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{{ alert('Rechte werden ausschliesslich ueber Gruppen vergeben. Das Admin-Kennzeichen ist davon
|
{{ alert('Rechte werden ausschliesslich über Gruppen vergeben. Das Admin-Kennzeichen ist davon
|
||||||
unabhaengig und kann nur von Administratoren gesetzt werden; mindestens ein entsperrtes
|
unabhängig und kann nur von Administratoren gesetzt werden; mindestens ein entsperrtes
|
||||||
Administratorkonto muss immer bestehen bleiben.', 'info') }}
|
Administratorkonto muss immer bestehen bleiben.', 'info') }}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
@@ -137,7 +137,7 @@ Administratorkonto muss immer bestehen bleiben.', 'info') }}
|
|||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
{% if current_user.is_admin %}
|
{% if current_user.is_admin %}
|
||||||
{{ checkbox('is_admin', 'Administrator (darf alles, unabhaengig von Gruppen)') }}
|
{{ checkbox('is_admin', 'Administrator (darf alles, unabhängig von Gruppen)') }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{{ checkbox('must_change_password', 'Passwortwechsel bei der ersten Anmeldung erzwingen', checked=True) }}
|
{{ checkbox('must_change_password', 'Passwortwechsel bei der ersten Anmeldung erzwingen', checked=True) }}
|
||||||
</div>
|
</div>
|
||||||
@@ -149,7 +149,7 @@ Administratorkonto muss immer bestehen bleiben.', 'info') }}
|
|||||||
{% endcall %}
|
{% endcall %}
|
||||||
|
|
||||||
{% if directory_enabled %}
|
{% if directory_enabled %}
|
||||||
{% call modal('user-directory', 'Aus Verzeichnis uebernehmen',
|
{% call modal('user-directory', 'Aus Verzeichnis übernehmen',
|
||||||
'Konto vorbereiten, damit Gruppen vor der ersten Anmeldung sitzen') %}
|
'Konto vorbereiten, damit Gruppen vor der ersten Anmeldung sitzen') %}
|
||||||
<form method="post" action="{{ url_for('admin.directory_adopt') }}">
|
<form method="post" action="{{ url_for('admin.directory_adopt') }}">
|
||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
@@ -169,7 +169,7 @@ Administratorkonto muss immer bestehen bleiben.', 'info') }}
|
|||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
{{ alert('Name und E-Mail werden bei der ersten echten Anmeldung aus dem Verzeichnis
|
{{ alert('Name und E-Mail werden bei der ersten echten Anmeldung aus dem Verzeichnis
|
||||||
uebernommen und ueberschrieben.', 'info') }}
|
übernommen und überschrieben.', 'info') }}
|
||||||
</div>
|
</div>
|
||||||
<div class="modal__footer">
|
<div class="modal__footer">
|
||||||
<button class="btn" type="button" data-dialog-close>Abbrechen</button>
|
<button class="btn" type="button" data-dialog-close>Abbrechen</button>
|
||||||
@@ -198,7 +198,7 @@ Administratorkonto muss immer bestehen bleiben.', 'info') }}
|
|||||||
{{ field('email', 'E-Mail', value=user.email, type='email') }}
|
{{ field('email', 'E-Mail', value=user.email, type='email') }}
|
||||||
{{ field('new_password', 'Neues Passwort setzen (optional)', type='password',
|
{{ field('new_password', 'Neues Passwort setzen (optional)', type='password',
|
||||||
autocomplete='new-password',
|
autocomplete='new-password',
|
||||||
hint_text='Setzt das Passwort zurueck, erzwingt einen Wechsel und beendet alle Sitzungen des Kontos.') }}
|
hint_text='Setzt das Passwort zurück, erzwingt einen Wechsel und beendet alle Sitzungen des Kontos.') }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<fieldset class="fieldset">
|
<fieldset class="fieldset">
|
||||||
<legend>Gruppen</legend>
|
<legend>Gruppen</legend>
|
||||||
|
|||||||
@@ -126,6 +126,37 @@ def format_date(value: Any) -> str:
|
|||||||
return format_datetime(value, with_time=False)
|
return format_datetime(value, with_time=False)
|
||||||
|
|
||||||
|
|
||||||
|
def format_millis(value: Any) -> str:
|
||||||
|
"""Millisekunden lesbar: ms, dann Sekunden, dann Minuten:Sekunden.
|
||||||
|
|
||||||
|
Vierzehn Stellen Millisekunden liest niemand. "315895 ms" sagt nichts,
|
||||||
|
"5:15" sofort etwas. Die Grenzen liegen dort, wo die naechstgroessere
|
||||||
|
Einheit anfaengt, und die Sekunden bekommen zwei Nachkommastellen -- bei
|
||||||
|
einem Neustart ist der Unterschied zwischen 15,2 s und 15,97 s durchaus
|
||||||
|
von Interesse.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
millis = float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return "-"
|
||||||
|
if millis < 0:
|
||||||
|
return "-"
|
||||||
|
# Unter 10 ms mit einer Nachkommastelle: Antwortzeiten liegen oft bei
|
||||||
|
# 0,3 ms, und "0 ms" waere schlicht falsch.
|
||||||
|
if millis < 10:
|
||||||
|
return f"{millis:.1f} ms".replace(".", ",")
|
||||||
|
if millis < 1000:
|
||||||
|
return f"{int(round(millis))} ms"
|
||||||
|
# Erst runden, dann die Einheit waehlen: 59999 ms sind gerundet 60,00 s und
|
||||||
|
# gehoeren damit in die Minutenform, nicht in eine Sekundenangabe, die wie
|
||||||
|
# eine Minute aussieht.
|
||||||
|
hundertstel = int(round(millis / 10))
|
||||||
|
if hundertstel < 60 * 100:
|
||||||
|
return f"{hundertstel / 100:.2f} s".replace(".", ",")
|
||||||
|
minuten, rest = divmod(hundertstel, 60 * 100)
|
||||||
|
return f"{minuten}:{rest / 100:05.2f}".replace(".", ",")
|
||||||
|
|
||||||
|
|
||||||
def format_duration(seconds: Any) -> str:
|
def format_duration(seconds: Any) -> str:
|
||||||
try:
|
try:
|
||||||
total = int(seconds)
|
total = int(seconds)
|
||||||
@@ -154,6 +185,32 @@ def yes_no(value: Any) -> str:
|
|||||||
return "ja" if value else "nein"
|
return "ja" if value else "nein"
|
||||||
|
|
||||||
|
|
||||||
|
#: Woher ein gepinnter Ausstellerschluessel kommt -- in Klartext.
|
||||||
|
#:
|
||||||
|
#: In der Datenbank stehen die Kuerzel des Protokolls. "tofu" ist dabei
|
||||||
|
#: Fachjargon (Trust On First Use) und sah in der Spalte aus wie ein
|
||||||
|
#: Tippfehler. Uebersetzt wird an genau einer Stelle, damit die beiden
|
||||||
|
#: Anwendungen nicht auseinanderlaufen.
|
||||||
|
ISSUER_SOURCES: dict[str, tuple[str, str]] = {
|
||||||
|
"tofu": ("Erstkontakt", "beim ersten Einspielen automatisch uebernommen"),
|
||||||
|
"admin-confirmed": (
|
||||||
|
"Bestaetigt",
|
||||||
|
"Ausstellerwechsel von einem Administrator freigegeben",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def issuer_source(value: Any) -> str:
|
||||||
|
"""Kurzform der Herkunft. Unbekannte Werte bleiben unveraendert sichtbar."""
|
||||||
|
label, _ = ISSUER_SOURCES.get(str(value or ""), (str(value or "-"), ""))
|
||||||
|
return label
|
||||||
|
|
||||||
|
|
||||||
|
def issuer_source_hint(value: Any) -> str:
|
||||||
|
"""Erklaerung zur Herkunft, oder leer."""
|
||||||
|
return ISSUER_SOURCES.get(str(value or ""), ("", ""))[1]
|
||||||
|
|
||||||
|
|
||||||
def from_json(value: Any) -> Any:
|
def from_json(value: Any) -> Any:
|
||||||
"""Parst einen JSON-String aus der Datenbank -- fuer Modul-/Rechtelisten.
|
"""Parst einen JSON-String aus der Datenbank -- fuer Modul-/Rechtelisten.
|
||||||
|
|
||||||
@@ -230,9 +287,12 @@ def init_app(app: Flask) -> None:
|
|||||||
dt=format_datetime,
|
dt=format_datetime,
|
||||||
date=format_date,
|
date=format_date,
|
||||||
duration=format_duration,
|
duration=format_duration,
|
||||||
|
millis=format_millis,
|
||||||
number=format_number,
|
number=format_number,
|
||||||
yes_no=yes_no,
|
yes_no=yes_no,
|
||||||
from_json=from_json,
|
from_json=from_json,
|
||||||
|
issuer_source=issuer_source,
|
||||||
|
issuer_source_hint=issuer_source_hint,
|
||||||
)
|
)
|
||||||
app.jinja_env.trim_blocks = True
|
app.jinja_env.trim_blocks = True
|
||||||
app.jinja_env.lstrip_blocks = True
|
app.jinja_env.lstrip_blocks = True
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "tesm-licensing"
|
name = "tesm-licensing"
|
||||||
version = "2.0.0"
|
version = "2.0.2"
|
||||||
description = "Ed25519-basiertes Lizenz- und Aktivierungsprotokoll fuer TESM und TESM-Lizenzserver"
|
description = "Ed25519-basiertes Lizenz- und Aktivierungsprotokoll fuer TESM und TESM-Lizenzserver"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = ["cryptography>=42"]
|
dependencies = ["cryptography>=42"]
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ from .protocol import (
|
|||||||
verify_master_response,
|
verify_master_response,
|
||||||
)
|
)
|
||||||
|
|
||||||
__version__ = "2.0.0"
|
__version__ = "2.0.2"
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"ACTIONS",
|
"ACTIONS",
|
||||||
|
|||||||
Binary file not shown.
Reference in New Issue
Block a user