Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50da075c15 | ||
|
|
23980182b0 | ||
|
|
85bec34d7d | ||
|
|
ab5fda658e | ||
|
|
4e488625fe | ||
|
|
9799843cd2 | ||
|
|
8b39bfaf10 | ||
|
|
8e66d9e7bc |
@@ -22,3 +22,9 @@ dist/
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
.mypy_cache/
|
||||
|
||||
# Release-Pakete entstehen aus einem Tag und haengen am Release -- sie gehoeren
|
||||
# nicht ins Repository. Einmal ist genau das passiert: der Tarball lag im
|
||||
# Arbeitsbaum, wanderte in den Commit, und das naechste Paket enthielt das
|
||||
# vorige. Doppelte Groesse, verschachtelter Inhalt.
|
||||
*.tar.gz
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
# TESM-Lizenzserver
|
||||
|
||||
Ausgabe und Verwaltung der Lizenzen fuer TESM: Kunde -> Ticket -> Lizenz,
|
||||
Aktivierung online und offline, Kundenportal, Selbstbedienung, Versand ueber
|
||||
Ausgabe und Verwaltung der Lizenzen für TESM: Kunde -> Ticket -> Lizenz,
|
||||
Aktivierung online und offline, Kundenportal, Selbstbedienung, Versand über
|
||||
MS Graph und eine Hierarchie aus Unter-Lizenzservern.
|
||||
|
||||
**Dieses Repository enthaelt genau das, was zum Installieren und Betreiben des
|
||||
Lizenzservers gebraucht wird** -- nichts darueber hinaus:
|
||||
**Dieses Repository enthält genau das, was zum Installieren und Betreiben des
|
||||
Lizenzservers gebraucht wird** -- nichts darüber hinaus:
|
||||
|
||||
```
|
||||
apps/tesm-license die Anwendung
|
||||
packages/tesm-core gemeinsamer Kern (Anmeldung, Rechte, Sicherung, nginx, ...)
|
||||
packages/tesm-licensing Lizenzprotokoll, gemeinsame Haelfte
|
||||
packages/tesm-licensing-server Lizenzprotokoll, Serverhaelfte: ausstellen, erneuern, antworten
|
||||
packages/tesm-licensing Lizenzprotokoll, gemeinsame Hälfte
|
||||
packages/tesm-licensing-server Lizenzprotokoll, Serverhälfte: ausstellen, erneuern, antworten
|
||||
deploy/ install.sh, update.sh, release.sh, systemd, privilegierter Helfer
|
||||
docs/ Betrieb und Sicherheit
|
||||
```
|
||||
@@ -28,7 +28,7 @@ Die verwaltete Anwendung liegt hier **nicht** mit drin.
|
||||
| Zweig | Inhalt |
|
||||
|---|---|
|
||||
| `main` | dieser Neubau (Version 2.0.0) |
|
||||
| `SONNET5` | der Stand davor, unveraendert aufbewahrt |
|
||||
| `SONNET5` | der Stand davor, unverändert aufbewahrt |
|
||||
|
||||
---
|
||||
|
||||
@@ -41,18 +41,18 @@ sudo -u tesm-license tesm-license-admin bootstrap
|
||||
sudo -u tesm-license tesm-license-admin create-admin
|
||||
```
|
||||
|
||||
**`init-key` erzeugt den Signaturschluessel dieses Servers. Sichern Sie ihn
|
||||
sofort.** Ohne ihn laesst sich keine ausgestellte Lizenz mehr erneuern, und
|
||||
Kunden muessten einen Ausstellerwechsel bestaetigen.
|
||||
**`init-key` erzeugt den Signaturschlüssel dieses Servers. Sichern Sie ihn
|
||||
sofort.** Ohne ihn lässt sich keine ausgestellte Lizenz mehr erneuern, und
|
||||
Kunden müssten einen Ausstellerwechsel bestätigen.
|
||||
|
||||
Zielzustand: **Port 443 mit TLS, Port 80 leitet dorthin um** -- ausser dem
|
||||
ACME-Pfad, der erreichbar bleiben muss, sonst kann Let's Encrypt nie erneuern.
|
||||
Sitzt ein eigener Reverse Proxy davor, der TLS beendet, laeuft der Dienst ohne
|
||||
Sitzt ein eigener Reverse Proxy davor, der TLS beendet, läuft der Dienst ohne
|
||||
`--https` schlicht auf Port 80.
|
||||
|
||||
Der Lizenzserver darf neben TESM auf demselben Host laufen; nginx unterscheidet
|
||||
die beiden am Servernamen, und jede Installation hat ihren eigenen `SITE_KEY`
|
||||
fuer nginx-Site, logrotate, Zertifikate und Sitzungscookie.
|
||||
für nginx-Site, logrotate, Zertifikate und Sitzungscookie.
|
||||
|
||||
### Aktualisieren
|
||||
|
||||
@@ -60,7 +60,7 @@ fuer nginx-Site, logrotate, Zertifikate und Sitzungscookie.
|
||||
sudo bash deploy/update.sh --app tesm-license
|
||||
```
|
||||
|
||||
Vor jedem Update entsteht ein vollstaendiges Backup, danach laeuft ein
|
||||
Vor jedem Update entsteht ein vollständiges Backup, danach läuft ein
|
||||
Health-Check mit automatischem Rollback.
|
||||
|
||||
---
|
||||
@@ -69,16 +69,16 @@ Health-Check mit automatischem Rollback.
|
||||
|
||||
```bash
|
||||
sudo -u tesm-license tesm-license-admin status
|
||||
sudo -u tesm-license tesm-license-admin init-key # Signaturschluessel
|
||||
sudo -u tesm-license tesm-license-admin init-key # Signaturschlüssel
|
||||
sudo -u tesm-license tesm-license-admin bootstrap # Grunddaten
|
||||
sudo -u tesm-license tesm-license-admin create-admin
|
||||
sudo -u tesm-license tesm-license-admin migrate-legacy # Altbestand uebernehmen
|
||||
sudo -u tesm-license tesm-license-admin migrate-legacy # Altbestand übernehmen
|
||||
```
|
||||
|
||||
`migrate-legacy` liest die SQLite-Datei eines alten Lizenzservers **nur
|
||||
lesend**, uebernimmt dessen Signaturschluessel und stellt die Lizenzen mit ihrer
|
||||
restlichen Laufzeit neu aus. Wird der Schluessel nicht uebernommen, muessen
|
||||
Kunden den Ausstellerwechsel einmal bestaetigen.
|
||||
lesend**, übernimmt dessen Signaturschlüssel und stellt die Lizenzen mit ihrer
|
||||
restlichen Laufzeit neu aus. Wird der Schlüssel nicht übernommen, müssen
|
||||
Kunden den Ausstellerwechsel einmal bestätigen.
|
||||
|
||||
---
|
||||
|
||||
@@ -101,14 +101,14 @@ genau einmal.
|
||||
(`tesm/license-file/v2\0`) -- eine Signatur aus einem Kontext ist in einem
|
||||
anderen nicht verwendbar.
|
||||
* Kanonisches JSON, damit dieselbe Aussage genau eine Signatur hat.
|
||||
* Nonce gegen Wiedereinspielung, `seq` gegen das Zurueckrollen auf eine aeltere
|
||||
* Nonce gegen Wiedereinspielung, `seq` gegen das Zurückrollen auf eine ältere
|
||||
Lizenz.
|
||||
* Aussteller wird beim ersten Kontakt gemerkt (TOFU); ein Wechsel muss
|
||||
ausdruecklich bestaetigt werden.
|
||||
* Der Fingerabdruck der Maschine enthaelt **keinen** Hostnamen -- Umbenennen
|
||||
brach im Vorgaenger die Bindung.
|
||||
* Offline-Aktivierung ueber einen kurzen Code in beide Richtungen; die Lizenz
|
||||
bleibt auf dem Server als "Aktivierung offen" markiert, bis sie zurueckkommt.
|
||||
ausdrücklich bestätigt werden.
|
||||
* Der Fingerabdruck der Maschine enthält **keinen** Hostnamen -- Umbenennen
|
||||
brach im Vorgänger die Bindung.
|
||||
* Offline-Aktivierung über einen kurzen Code in beide Richtungen; die Lizenz
|
||||
bleibt auf dem Server als "Aktivierung offen" markiert, bis sie zurückkommt.
|
||||
* Keine Lizenz ohne `master_endpoint`: eine Ausstellung ohne Endpunkt wird
|
||||
abgelehnt statt eine Lizenz zu erzeugen, die sich nie aktivieren kann.
|
||||
|
||||
@@ -130,8 +130,8 @@ Entwicklungsrepository, ebenso die Tests.
|
||||
|
||||
* **Keine doppelte `licensing.py`.** Ein Paket, das beide Anwendungen
|
||||
installieren -- statt zweier Dateien, die byte-identisch bleiben sollen.
|
||||
* **Kein root fuer die Anwendung.** Ein einziger allowlisted Helfer mit
|
||||
Argumentpruefung.
|
||||
* **CSRF-Schutz ueberhaupt.** Der Vorgaenger hatte keinen.
|
||||
* **Rechte werden serverseitig geprueft**, nicht nur im Template ausgeblendet.
|
||||
* **Kein root für die Anwendung.** Ein einziger allowlisted Helfer mit
|
||||
Argumentprüfung.
|
||||
* **CSRF-Schutz überhaupt.** Der Vorgänger hatte keinen.
|
||||
* **Rechte werden serverseitig geprüft**, nicht nur im Template ausgeblendet.
|
||||
* **Verschachtelte Datenbankverbindungen sind ein Fehler**, kein Deadlock.
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "tesm-license"
|
||||
version = "2.0.0"
|
||||
version = "2.0.2"
|
||||
description = "TESM-Lizenzserver -- Kunden, Tickets, Lizenzen, Aktivierung"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -23,7 +23,7 @@ from .permissions import PERMISSIONS, ROLE_PRESETS
|
||||
from .schema import LICENSE_MIGRATIONS
|
||||
from .settings_spec import LICENSE_SERVER_SETTINGS
|
||||
|
||||
__version__ = "2.0.0"
|
||||
__version__ = "2.0.2"
|
||||
|
||||
APP_KEY = "tesm-license"
|
||||
APP_NAME = "TESM-Lizenzserver"
|
||||
|
||||
@@ -86,7 +86,7 @@ NAVIGATION = NavTree(
|
||||
),
|
||||
NavItem(
|
||||
key="logs_audit",
|
||||
label="Aenderungen",
|
||||
label="Audit",
|
||||
icon="history",
|
||||
endpoint="logs.audit_view",
|
||||
permissions=("logs_audit.view", "logs_audit.export"),
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
{% extends "tesm_core/base.html" %}
|
||||
{% from "tesm_core/macros.html" import field, alert, pill %}
|
||||
|
||||
{% block page_title %}Sicherung und Uebertragung{% endblock %}
|
||||
{% block page_sub %}Verschluesselter Export · zweistufiger Import{% endblock %}
|
||||
{% block page_title %}Sicherung und Übertragung{% endblock %}
|
||||
{% block page_sub %}Verschlüsselter Export · zweistufiger Import{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{{ alert('Der Export wird mit Argon2id aus Ihrer Passphrase verschluesselt (AES-256-GCM). Ohne
|
||||
diese Passphrase ist die Datei nicht wiederherstellbar -- es gibt keine Hintertuer.', 'warning',
|
||||
{{ alert('Der Export wird mit Argon2id aus Ihrer Passphrase verschlüsselt (AES-256-GCM). Ohne
|
||||
diese Passphrase ist die Datei nicht wiederherstellbar -- es gibt keine Hintertür.', 'warning',
|
||||
'Passphrase') }}
|
||||
|
||||
{{ alert('Der Signaturschluessel (' ~ signing_key_path ~ ') ist bewusst
|
||||
nicht Teil dieser Sicherung. Er ist der Kern des Vertrauens und gehoert in eine
|
||||
{{ alert('Der Signaturschlüssel (' ~ signing_key_path ~ ') ist bewusst
|
||||
nicht Teil dieser Sicherung. Er ist der Kern des Vertrauens und gehört in eine
|
||||
getrennte, bewusst verwahrte Sicherung -- nicht in dieselbe Datei wie die Kundendaten. Ohne ihn
|
||||
ist nach einer Wiederherstellung keine ausgestellte Lizenz mehr pruefbar.', 'danger',
|
||||
'Wichtig: Schluessel getrennt sichern') }}
|
||||
ist nach einer Wiederherstellung keine ausgestellte Lizenz mehr prüfbar.', 'danger',
|
||||
'Wichtig: Schlüssel getrennt sichern') }}
|
||||
|
||||
<div class="grid grid--split">
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('download', size=17) }} Sicherung erstellen</div>
|
||||
<div class="card__sub">Verlangt eine frische Passwortbestaetigung</div>
|
||||
<div class="card__sub">Verlangt eine frische Passwortbestätigung</div>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" action="{{ url_for('backup.export') }}" data-no-guard>
|
||||
@@ -40,7 +40,7 @@ ist nach einer Wiederherstellung keine ausgestellte Lizenz mehr pruefbar.', 'dan
|
||||
{% if category.admin_only %}{{ pill('nur Admin', 'accent') }}{% endif %}
|
||||
<div class="fs-xs faint">{{ category.hint }}</div>
|
||||
{% if not allowed %}
|
||||
<div class="fs-xs danger-text">Dafuer fehlt Ihnen das Recht.</div>
|
||||
<div class="fs-xs danger-text">Dafür fehlt Ihnen das Recht.</div>
|
||||
{% endif %}
|
||||
</span>
|
||||
</label>
|
||||
@@ -66,8 +66,8 @@ ist nach einer Wiederherstellung keine ausgestellte Lizenz mehr pruefbar.', 'dan
|
||||
<section class="card card--accent">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('upload', size=17) }} Geoeffnete Sicherung</div>
|
||||
<div class="card__sub">Gueltig fuer {{ preview_ttl }} Minuten</div>
|
||||
<div class="card__title">{{ icon('upload', size=17) }} Geöffnete Sicherung</div>
|
||||
<div class="card__sub">Gültig für {{ preview_ttl }} Minuten</div>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" action="{{ url_for('backup.import_apply') }}">
|
||||
@@ -75,7 +75,7 @@ ist nach einer Wiederherstellung keine ausgestellte Lizenz mehr pruefbar.', 'dan
|
||||
<div class="card__body">
|
||||
<div class="table-wrap">
|
||||
<table class="table table--compact">
|
||||
<thead><tr><th></th><th>Kategorie</th><th class="numeric">Eintraege</th></tr></thead>
|
||||
<thead><tr><th></th><th>Kategorie</th><th class="numeric">Einträge</th></tr></thead>
|
||||
<tbody>
|
||||
{% for entry in preview.summary %}
|
||||
<tr>
|
||||
@@ -89,17 +89,17 @@ ist nach einer Wiederherstellung keine ausgestellte Lizenz mehr pruefbar.', 'dan
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{ alert('Bereits vorhandene Lizenzen werden nie ueberschrieben -- ihr Inhalt ist
|
||||
kryptographisch festgelegt. Kunden werden ueber ihren Namen zusammengefuehrt.',
|
||||
{{ alert('Bereits vorhandene Lizenzen werden nie überschrieben -- ihr Inhalt ist
|
||||
kryptographisch festgelegt. Kunden werden über ihren Namen zusammengeführt.',
|
||||
'info') }}
|
||||
</div>
|
||||
<div class="card__footer modal__footer--split">
|
||||
<button class="btn btn--sm btn--ghost" type="submit"
|
||||
formaction="{{ url_for('backup.import_discard') }}">Verwerfen</button>
|
||||
<button class="btn btn--primary" type="submit"
|
||||
data-confirm="Ausgewaehlte Kategorien jetzt uebernehmen?"
|
||||
data-confirm-title="Sicherung uebernehmen" data-confirm-ok="Uebernehmen"
|
||||
data-confirm-style="primary">Uebernehmen</button>
|
||||
data-confirm="Ausgewählte Kategorien jetzt übernehmen?"
|
||||
data-confirm-title="Sicherung übernehmen" data-confirm-ok="Übernehmen"
|
||||
data-confirm-style="primary">Übernehmen</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
@@ -108,7 +108,7 @@ ist nach einer Wiederherstellung keine ausgestellte Lizenz mehr pruefbar.', 'dan
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('upload', size=17) }} Sicherung einspielen</div>
|
||||
<div class="card__sub">Erst oeffnen und pruefen, dann uebernehmen</div>
|
||||
<div class="card__sub">Erst öffnen und prüfen, dann übernehmen</div>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" enctype="multipart/form-data" data-no-guard
|
||||
@@ -120,8 +120,8 @@ ist nach einer Wiederherstellung keine ausgestellte Lizenz mehr pruefbar.', 'dan
|
||||
autocomplete='off') }}
|
||||
</div>
|
||||
<div class="card__footer">
|
||||
<button class="btn btn--primary" type="submit" data-busy-label="Oeffne ...">
|
||||
Datei oeffnen
|
||||
<button class="btn btn--primary" type="submit" data-busy-label="Öffne ...">
|
||||
Datei öffnen
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
{% if licenses %}
|
||||
<div class="table-wrap">
|
||||
<table class="table table--compact">
|
||||
<thead><tr><th>Lizenz</th><th>Typ</th><th>Zustand</th><th>Laeuft ab</th><th>Host</th></tr></thead>
|
||||
<thead><tr><th>Lizenz</th><th>Typ</th><th>Zustand</th><th>Läuft ab</th><th>Host</th></tr></thead>
|
||||
<tbody>
|
||||
{% for entry in licenses %}
|
||||
<tr>
|
||||
@@ -119,7 +119,7 @@
|
||||
</div>
|
||||
<div class="card__body stack">
|
||||
{{ alert('Diese Daten verwaltet der Sub-Lizenzserver selbst. Sie sind hier rein
|
||||
informativ und werden bei jedem Bericht vollstaendig ersetzt.', 'info') }}
|
||||
informativ und werden bei jedem Bericht vollständig ersetzt.', 'info') }}
|
||||
{% for entry in reported.customers %}
|
||||
<details class="card">
|
||||
<summary class="card__header" style="cursor:pointer">
|
||||
@@ -193,7 +193,7 @@
|
||||
<div class="divider"></div>
|
||||
<dl class="kv fs-xs">
|
||||
<dt>Angelegt</dt><dd>{{ customer.created_at | dt }} von {{ customer.created_by or '-' }}</dd>
|
||||
<dt>Geaendert</dt><dd>{{ customer.updated_at | dt }}</dd>
|
||||
<dt>Geändert</dt><dd>{{ customer.updated_at | dt }}</dd>
|
||||
<dt>Portal-Zugang</dt>
|
||||
<dd><span class="mono break-all">{{ portal_url }}</span>
|
||||
<button class="btn btn--sm btn--ghost" type="button" data-copy="{{ portal_url }}">
|
||||
@@ -205,9 +205,9 @@
|
||||
{% if current_user.has('customers.delete') %}
|
||||
<button class="btn btn--sm btn--ghost danger-text" type="submit"
|
||||
formaction="{{ url_for('customers.delete', customer_id=customer.id) }}"
|
||||
data-confirm="Kunde {{ customer.name }} loeschen? Das ist nur moeglich, solange kein Ticket mehr an ihm haengt."
|
||||
data-confirm-title="Kunde loeschen" data-confirm-ok="Loeschen">
|
||||
Loeschen
|
||||
data-confirm="Kunde {{ customer.name }} löschen? Das ist nur möglich, solange kein Ticket mehr an ihm hängt."
|
||||
data-confirm-title="Kunde löschen" data-confirm-ok="Löschen">
|
||||
Löschen
|
||||
</button>
|
||||
{% else %}<span></span>{% endif %}
|
||||
<button class="btn btn--primary" type="submit">Speichern</button>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
{{ field('name', 'Firmenname', value=customer.name if customer else '', required=True) }}
|
||||
{{ field('contact_email', 'E-Mail', value=customer.contact_email if customer else '',
|
||||
type='email', required=not customer,
|
||||
hint_text='Pflicht bei der Neuanlage -- ueber diese Adresse laeuft das Kundenportal.') }}
|
||||
hint_text='Pflicht bei der Neuanlage -- über diese Adresse läuft das Kundenportal.') }}
|
||||
{{ field('contact_person', 'Ansprechpartner', value=customer.contact_person if customer else '') }}
|
||||
{{ field('contact_phone', 'Telefon', value=customer.contact_phone if customer else '') }}
|
||||
{{ field('street', 'Strasse', value=customer.street if customer else '') }}
|
||||
@@ -72,7 +72,7 @@
|
||||
|
||||
{% block dialogs %}
|
||||
{% if current_user.has('customers.create') %}
|
||||
{% call modal('customer-new', 'Kunde anlegen', 'Adresse in Einzelfeldern -- fuer Rechnungen weiterverwendbar', 'wide') %}
|
||||
{% call modal('customer-new', 'Kunde anlegen', 'Adresse in Einzelfeldern -- für Rechnungen weiterverwendbar', 'wide') %}
|
||||
<form method="post" action="{{ url_for('customers.create') }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<div class="modal__body">{{ customer_form() }}</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{% extends "tesm_core/base.html" %}
|
||||
{% from "tesm_core/macros.html" import pill, empty_state, alert, stat %}
|
||||
|
||||
{% block page_title %}Uebersicht{% endblock %}
|
||||
{% block page_title %}Übersicht{% endblock %}
|
||||
{% block page_sub %}
|
||||
{% if summary %}
|
||||
{{ summary.counts.customers }} Kunden · {{ summary.counts.licenses_active }} aktive Lizenzen
|
||||
@@ -24,13 +24,13 @@
|
||||
</div>
|
||||
|
||||
{% if summary.counts.tickets_blocked %}
|
||||
{{ alert(summary.counts.tickets_blocked ~ ' Ticket(s) sind gesperrt -- fuer diese Kunden
|
||||
{{ alert(summary.counts.tickets_blocked ~ ' Ticket(s) sind gesperrt -- für diese Kunden
|
||||
lassen sich keine neuen Lizenzen ausstellen.', 'warning') }}
|
||||
{% endif %}
|
||||
|
||||
{% for key, label, kind, hint in [
|
||||
('red', 'Kritisch', 'danger', 'Weniger als 30 Tage Restlaufzeit oder bereits abgelaufen'),
|
||||
('orange', 'Bald faellig', 'warning', '30 bis 90 Tage Restlaufzeit'),
|
||||
('orange', 'Bald fällig', 'warning', '30 bis 90 Tage Restlaufzeit'),
|
||||
('green', 'In Ordnung', 'ok', 'Mehr als 90 Tage oder unbefristet')] %}
|
||||
{% set entries = summary.buckets[key] %}
|
||||
{% if entries %}
|
||||
|
||||
@@ -26,13 +26,13 @@
|
||||
{% block content %}
|
||||
{% if state.revision_pending %}
|
||||
{{ alert('Es wurde eine neue Revision signiert, die der Kunde noch nicht abgeholt hat.
|
||||
Eine erreichbare Installation holt sie beim naechsten Heartbeat; eine Installation ohne
|
||||
Verbindung braucht dafuer den Offline-Weg -- der Kunde findet ihn in seinem Portal.',
|
||||
Eine erreichbare Installation holt sie beim nächsten Heartbeat; eine Installation ohne
|
||||
Verbindung braucht dafür den Offline-Weg -- der Kunde findet ihn in seinem Portal.',
|
||||
'warning', 'Revision ausstehend') }}
|
||||
{% endif %}
|
||||
|
||||
{% if license.status == 'revoked' %}
|
||||
{{ alert('Diese Lizenz ist widerrufen. Der Client erfaehrt das beim naechsten Heartbeat und
|
||||
{{ alert('Diese Lizenz ist widerrufen. Der Client erfährt das beim nächsten Heartbeat und
|
||||
schaltet seine lizenzpflichtigen Funktionen ab.' ~ ((' Grund: ' ~ license.revoke_reason) if
|
||||
license.revoke_reason else ''), 'danger', 'Widerrufen') }}
|
||||
{% endif %}
|
||||
@@ -72,7 +72,7 @@
|
||||
{% else %}<span class="faint">keine</span>{% endif %}
|
||||
</dd>
|
||||
<dt>Ausgestellt</dt><dd>{{ license.issued_at | dt }} von {{ license.created_by or '-' }}</dd>
|
||||
<dt>Laeuft ab</dt>
|
||||
<dt>Läuft ab</dt>
|
||||
<dd>
|
||||
{% if license.lifetime %}{{ pill('unbefristet', 'online') }}
|
||||
{% else %}
|
||||
@@ -105,9 +105,9 @@
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('refresh', size=17) }} Nachtraeglich anpassen</div>
|
||||
<div class="card__title">{{ icon('refresh', size=17) }} Nachträglich anpassen</div>
|
||||
<div class="card__sub">
|
||||
Signiert eine neue Revision. Der Client erhaelt sie beim naechsten Heartbeat --
|
||||
Signiert eine neue Revision. Der Client erhält sie beim nächsten Heartbeat --
|
||||
ohne erneute Aktivierung.
|
||||
</div>
|
||||
</div>
|
||||
@@ -126,9 +126,9 @@
|
||||
</div>
|
||||
</fieldset>
|
||||
{% endif %}
|
||||
{{ field('extend_days', 'Laufzeit verlaengern (Tage)', type='number',
|
||||
{{ field('extend_days', 'Laufzeit verlängern (Tage)', type='number',
|
||||
placeholder='0',
|
||||
hint_text='Wird auf das aktuelle Ablaufdatum aufgeschlagen.') }}
|
||||
hint_text='Wird auf das aktülle Ablaufdatum aufgeschlagen.') }}
|
||||
</div>
|
||||
<div class="card__footer">
|
||||
<button class="btn btn--primary" type="submit">Neue Revision signieren</button>
|
||||
@@ -141,13 +141,13 @@
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('transfer', size=17) }} Zustellungen</div>
|
||||
<div class="card__sub">{{ deliveries | length }} Vorgaenge</div>
|
||||
<div class="card__sub">{{ deliveries | length }} Vorgänge</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if deliveries %}
|
||||
<div class="table-wrap">
|
||||
<table class="table table--compact">
|
||||
<thead><tr><th>Zeit</th><th>Weg</th><th>Empfaenger</th><th>Durch</th><th>Detail</th></tr></thead>
|
||||
<thead><tr><th>Zeit</th><th>Weg</th><th>Empfänger</th><th>Durch</th><th>Detail</th></tr></thead>
|
||||
<tbody>
|
||||
{% for entry in deliveries %}
|
||||
<tr>
|
||||
@@ -170,7 +170,7 @@
|
||||
<summary class="card__header" style="cursor:pointer">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('file-text', size=17) }} Signiertes Dokument</div>
|
||||
<div class="card__sub">Genau so, wie es signiert wurde -- jede Aenderung bricht die Signatur</div>
|
||||
<div class="card__sub">Genau so, wie es signiert wurde -- jede Änderung bricht die Signatur</div>
|
||||
</div>
|
||||
{{ icon('chevron-down', size=16) }}
|
||||
</summary>
|
||||
@@ -185,7 +185,7 @@
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('mail', size=17) }} Per E-Mail versenden</div>
|
||||
<div class="card__sub">
|
||||
{% if mail_ready %}Ueber Microsoft Graph{% else %}Nicht konfiguriert{% endif %}
|
||||
{% if mail_ready %}Über Microsoft Graph{% else %}Nicht konfiguriert{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -193,7 +193,7 @@
|
||||
data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<div class="card__body">
|
||||
{{ field('recipient', 'Empfaenger', value=license.contact_email, type='email',
|
||||
{{ field('recipient', 'Empfänger', value=license.contact_email, type='email',
|
||||
disabled=not mail_ready) }}
|
||||
</div>
|
||||
<div class="card__footer">
|
||||
@@ -215,7 +215,7 @@
|
||||
data-dialog-open="revoke-dialog">Widerrufen</button>
|
||||
<p class="fs-xs faint">
|
||||
Das Ticket bleibt offen -- eine neue Lizenz kann sofort ausgestellt werden. Genau so
|
||||
laeuft ein Systemwechsel.
|
||||
läuft ein Systemwechsel.
|
||||
</p>
|
||||
{% endif %}
|
||||
{% if current_user.has('licenses.delete') %}
|
||||
@@ -225,9 +225,9 @@
|
||||
<button class="btn btn--sm btn--ghost danger-text btn--block" type="submit"
|
||||
{% if license.status not in ('revoked', 'issued') %}disabled
|
||||
title="Eine aktive Lizenz muss erst widerrufen werden"{% endif %}
|
||||
data-confirm="Lizenz endgueltig aus der Datenbank entfernen? Die Historie geht verloren."
|
||||
data-confirm-title="Lizenz loeschen" data-confirm-ok="Loeschen">
|
||||
Endgueltig loeschen
|
||||
data-confirm="Lizenz endgültig aus der Datenbank entfernen? Die Historie geht verloren."
|
||||
data-confirm-title="Lizenz löschen" data-confirm-ok="Löschen">
|
||||
Endgültig löschen
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
@@ -244,7 +244,7 @@
|
||||
<form method="post" action="{{ url_for('licenses.revoke', license_id=license.license_id) }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<div class="modal__body">
|
||||
{{ alert('Der Client erfaehrt den Widerruf beim naechsten Heartbeat und schaltet
|
||||
{{ alert('Der Client erfährt den Widerruf beim nächsten Heartbeat und schaltet
|
||||
lizenzpflichtige Funktionen ab. Das Ticket bleibt offen.', 'warning') }}
|
||||
{{ field('reason', 'Grund (erscheint im Protokoll)', placeholder='Systemwechsel') }}
|
||||
</div>
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
{% from "tesm_core/macros.html" import alert %}
|
||||
|
||||
{% block page_title %}Offline-Aktivierung{% endblock %}
|
||||
{% block page_sub %}Code des Kunden verarbeiten und Antwortcode zurueckgeben{% endblock %}
|
||||
{% block page_sub %}Code des Kunden verarbeiten und Antwortcode zurückgeben{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{{ alert('Beide Codes sind signierte Protokollobjekte -- inhaltlich identisch mit dem
|
||||
Online-Weg, nur als kopierbarer Text. Die Pruefungen (Signatur, Frische, Nonce, Fingerprint)
|
||||
laufen exakt gleich; es gibt hier keine Abkuerzung.', 'info') }}
|
||||
Online-Weg, nur als kopierbarer Text. Die Prüfungen (Signatur, Frische, Nonce, Fingerprint)
|
||||
laufen exakt gleich; es gibt hier keine Abkürzung.', 'info') }}
|
||||
|
||||
<div class="grid grid--split">
|
||||
<section class="card">
|
||||
@@ -20,8 +20,8 @@ laufen exakt gleich; es gibt hier keine Abkuerzung.', 'info') }}
|
||||
<label class="field">
|
||||
<span class="field__label">Anfragecode</span>
|
||||
<textarea class="textarea input--mono" name="code" rows="8" required
|
||||
placeholder="Vom Kunden erhaltenen Code einfuegen ..."></textarea>
|
||||
<span class="field__hint">Umbrueche und Leerzeichen sind erlaubt.</span>
|
||||
placeholder="Vom Kunden erhaltenen Code einfügen ..."></textarea>
|
||||
<span class="field__hint">Umbrüche und Leerzeichen sind erlaubt.</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="card__footer">
|
||||
|
||||
@@ -5,14 +5,14 @@
|
||||
{% block page_sub %}
|
||||
{% if state and state.valid and state.activated %}
|
||||
aktiv · Rolle {{ role_labels.get(state.role, state.role) }}
|
||||
{% elif state and state.valid %}gueltig, nicht aktiviert
|
||||
{% elif state and state.present %}ungueltig
|
||||
{% elif state and state.valid %}gültig, nicht aktiviert
|
||||
{% elif state and state.present %}ungültig
|
||||
{% else %}keine Lizenz installiert{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block topbar_extra %}
|
||||
<a class="btn btn--sm" href="{{ url_for('own_license.signing_key_info') }}">
|
||||
{{ icon('key', size=15) }} Signaturschluessel
|
||||
{{ icon('key', size=15) }} Signaturschlüssel
|
||||
</a>
|
||||
{% endblock %}
|
||||
|
||||
@@ -23,14 +23,14 @@
|
||||
{% if state and state.error %}{{ alert(state.error, 'danger') }}{% endif %}
|
||||
|
||||
{% if state and state.role == 'master' %}
|
||||
{{ alert('Diese Instanz ist die Wurzelinstanz. Nur sie darf Lizenzen fuer
|
||||
weitere Lizenzserver ausstellen -- diese Grenze haengt an der signierten Rolle dieser
|
||||
{{ alert('Diese Instanz ist die Wurzelinstanz. Nur sie darf Lizenzen für
|
||||
weitere Lizenzserver ausstellen -- diese Grenze hängt an der signierten Rolle dieser
|
||||
Lizenzdatei, nicht an einem Gruppenrecht. Ein lokaler Administrator eines Sub-Lizenzservers
|
||||
kann sie sich damit nicht selbst einraeumen.', 'info', 'Rolle: Master') }}
|
||||
kann sie sich damit nicht selbst einräumen.', 'info', 'Rolle: Master') }}
|
||||
{% elif state and state.role == 'license_server' %}
|
||||
{{ alert('Diese Instanz ist ein Sub-Lizenzserver. Sie darf eigene Kunden,
|
||||
Tickets und Lizenzen verwalten -- aber keine weiteren Lizenzserver-Lizenzen ausstellen. Der
|
||||
eigene Bestand wird automatisch an den uebergeordneten Master gemeldet.', 'info',
|
||||
eigene Bestand wird automatisch an den übergeordneten Master gemeldet.', 'info',
|
||||
'Rolle: Lizenzserver') }}
|
||||
{% endif %}
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
<dt>Inhaber</dt><dd>{{ state.customer_name or '–' | safe }}</dd>
|
||||
<dt>Lizenz-ID</dt><dd class="mono fs-xs break-all">{{ state.status.license_id }}</dd>
|
||||
<dt>Revision</dt><dd>{{ state.status.seq }}</dd>
|
||||
<dt>Laeuft ab</dt>
|
||||
<dt>Läuft ab</dt>
|
||||
<dd>
|
||||
{% if state.status.lifetime %}{{ pill('unbefristet', 'online') }}
|
||||
{% else %}
|
||||
@@ -66,7 +66,7 @@
|
||||
{{ pill('abgelaufen', 'danger') }}
|
||||
{% if state.status.grace_active %}
|
||||
<div class="fs-xs warning-text">
|
||||
Kulanzfrist laeuft ({{ grace_days }} Tage).
|
||||
Kulanzfrist läuft ({{ grace_days }} Tage).
|
||||
</div>
|
||||
{% endif %}
|
||||
{% elif state.status.expiring_soon %}
|
||||
@@ -76,7 +76,7 @@
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</dd>
|
||||
<dt>Uebergeordneter Server</dt>
|
||||
<dt>Übergeordneter Server</dt>
|
||||
<dd class="break-all">
|
||||
{{ state.status.master_endpoint or 'keiner (Wurzelinstanz oder Offline-Betrieb)' }}
|
||||
</dd>
|
||||
@@ -116,7 +116,7 @@
|
||||
{% endif %}
|
||||
</section>
|
||||
{% else %}
|
||||
{{ empty_state('Keine gueltige Lizenz',
|
||||
{{ empty_state('Keine gültige Lizenz',
|
||||
'Spielen Sie eine Lizenzdatei ein oder erzeugen Sie mit "tesm-license-admin bootstrap"
|
||||
die eigene Lizenz der Wurzelinstanz.', 'key') }}
|
||||
{% endif %}
|
||||
@@ -133,12 +133,12 @@
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<div class="card__body">
|
||||
<label class="field">
|
||||
<span class="field__label">Antwortcode des uebergeordneten Servers</span>
|
||||
<span class="field__label">Antwortcode des übergeordneten Servers</span>
|
||||
<textarea class="textarea input--mono" name="code" rows="4" required></textarea>
|
||||
</label>
|
||||
</div>
|
||||
<div class="card__footer">
|
||||
<button class="btn btn--primary" type="submit">Antwort uebernehmen</button>
|
||||
<button class="btn btn--primary" type="submit">Antwort übernehmen</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
@@ -150,7 +150,7 @@
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('upload', size=17) }} Lizenzdatei einspielen</div>
|
||||
<div class="card__sub">
|
||||
Fuer einen Sub-Lizenzserver: die Datei, die der Master ausgestellt hat
|
||||
Für einen Sub-Lizenzserver: die Datei, die der Master ausgestellt hat
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -160,7 +160,7 @@
|
||||
<div class="card__body stack">
|
||||
<input class="input" type="file" name="license_file" accept=".json,.lic" required>
|
||||
{% if trusted_keys and current_user.is_admin %}
|
||||
{{ checkbox('allow_new_issuer', 'Wechsel des Ausstellers ausdruecklich zulassen') }}
|
||||
{{ checkbox('allow_new_issuer', 'Wechsel des Ausstellers ausdrücklich zulassen') }}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card__footer">
|
||||
@@ -182,7 +182,7 @@
|
||||
<dl class="kv fs-sm">
|
||||
<dt>Hostname</dt><dd class="mono">{{ hostname }}</dd>
|
||||
<dt>Fingerprint</dt><dd class="mono fs-xs break-all">{{ fingerprint }}</dd>
|
||||
<dt>Signaturschluessel</dt>
|
||||
<dt>Signaturschlüssel</dt>
|
||||
<dd class="mono">{{ signing_key_health.get('key_id') or 'fehlt' }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
@@ -191,8 +191,8 @@
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('shield', size=17) }} Vertrauenswuerdige Aussteller</div>
|
||||
<div class="card__sub">{{ trusted_keys | length }} gepinnte Schluessel</div>
|
||||
<div class="card__title">{{ icon('shield', size=17) }} Vertrauenswürdige Aussteller</div>
|
||||
<div class="card__sub">{{ trusted_keys | length }} gepinnte Schlüssel</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card__body">
|
||||
@@ -200,7 +200,7 @@
|
||||
<dl class="kv fs-sm">
|
||||
{% for key in trusted_keys %}
|
||||
<dt class="mono">{{ key.key_id }}</dt>
|
||||
<dd>{{ key.label }} <span class="faint">({{ key.source }}, {{ key.added_at | date }})</span></dd>
|
||||
<dd>{{ key.label }} <span class="faint">({{ key.source | issuer_source }}, {{ key.added_at | date }})</span></dd>
|
||||
{% endfor %}
|
||||
</dl>
|
||||
{% else %}
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('shield', size=17) }} Ihre Lizenzen</div>
|
||||
<div class="card__sub">{{ tickets | length }} Vertrag/Vertraege</div>
|
||||
<div class="card__sub">{{ tickets | length }} Vertrag/Verträge</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card__body stack stack--tight">
|
||||
@@ -68,7 +68,7 @@
|
||||
{{ field('contact_person', 'Ansprechpartner', value=customer.contact_person) }}
|
||||
{{ field('contact_phone', 'Telefon', value=customer.contact_phone) }}
|
||||
{{ field('contact_email', 'E-Mail', value=customer.contact_email, type='email',
|
||||
hint_text='Ueber diese Adresse laeuft der Zugang zu diesem Portal.') }}
|
||||
hint_text='Über diese Adresse läuft der Zugang zu diesem Portal.') }}
|
||||
{{ field('street', 'Strasse', value=customer.street) }}
|
||||
{{ field('house_number', 'Hausnummer', value=customer.house_number) }}
|
||||
{{ field('postal_code', 'PLZ', value=customer.postal_code) }}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
{% block content %}
|
||||
<p class="fs-sm muted center">
|
||||
Geben Sie die bei uns hinterlegte E-Mail-Adresse ein. Sie erhalten einen Link, der
|
||||
{{ minutes }} Minuten gueltig ist und sich einmal verwenden laesst.
|
||||
{{ minutes }} Minuten gültig ist und sich einmal verwenden lässt.
|
||||
</p>
|
||||
|
||||
<form method="post" class="stack" data-no-guard>
|
||||
@@ -22,7 +22,7 @@
|
||||
</form>
|
||||
|
||||
<p class="auth-footnote">
|
||||
Aus Datenschutzgruenden bekommen Sie immer dieselbe Rueckmeldung -- unabhaengig davon, ob die
|
||||
Aus Datenschutzgründen bekommen Sie immer dieselbe Rückmeldung -- unabhängig davon, ob die
|
||||
Adresse bei uns bekannt ist.
|
||||
</p>
|
||||
{% endblock %}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
{% block content %}
|
||||
{{ alert('Diese Daten verwaltet der Sub-Lizenzserver selbst. Sie sind hier rein informativ und
|
||||
werden bei jedem Bericht vollstaendig ersetzt.', 'info') }}
|
||||
werden bei jedem Bericht vollständig ersetzt.', 'info') }}
|
||||
|
||||
{% if reported.customers %}
|
||||
<div class="stack">
|
||||
@@ -71,7 +71,7 @@ werden bei jedem Bericht vollstaendig ersetzt.', 'info') }}
|
||||
</div>
|
||||
{% else %}
|
||||
{{ empty_state('Noch kein Bericht',
|
||||
'Dieser Lizenzserver hat noch nichts gemeldet. Der erste Bericht kommt mit dem naechsten
|
||||
'Dieser Lizenzserver hat noch nichts gemeldet. Der erste Bericht kommt mit dem nächsten
|
||||
Heartbeat.', 'share') }}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
|
||||
{% block content %}
|
||||
{{ alert('Ein Sub-Lizenzserver meldet seinen kompletten Bestand -- huckepack auf dem
|
||||
' ~ heartbeat_hours ~ '-Stunden-Heartbeat und zusaetzlich sofort nach jeder Aenderung. Jeder
|
||||
Bericht ist ein vollstaendiger Schnappschuss: Geloeschtes verschwindet automatisch auch hier.',
|
||||
' ~ heartbeat_hours ~ '-Stunden-Heartbeat und zusätzlich sofort nach jeder Änderung. Jeder
|
||||
Bericht ist ein vollständiger Schnappschuss: Gelöschtes verschwindet automatisch auch hier.',
|
||||
'info') }}
|
||||
|
||||
{% if sources %}
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">
|
||||
{{ icon('shield', size=17) }} Aktuelle Lizenz
|
||||
{{ icon('shield', size=17) }} Aktülle Lizenz
|
||||
{% if current.status == 'active' %}{{ pill('aktiv', 'online') }}
|
||||
{% else %}{{ pill('bereit zum Einspielen', 'info') }}{% endif %}
|
||||
</div>
|
||||
@@ -34,7 +34,7 @@
|
||||
</div>
|
||||
<div class="card__body stack">
|
||||
<dl class="kv fs-sm">
|
||||
<dt>Gueltig bis</dt>
|
||||
<dt>Gültig bis</dt>
|
||||
<dd>{% if current.lifetime %}unbefristet{% else %}{{ current.expires_at | date }}{% endif %}</dd>
|
||||
{% if current.hostname %}<dt>Installiert auf</dt><dd>{{ current.hostname }}</dd>{% endif %}
|
||||
<dt>Ausgestellt</dt><dd>{{ current.issued_at | date }}</dd>
|
||||
@@ -64,7 +64,7 @@
|
||||
action="{{ url_for('selfservice.revoke', ticket_id=ticket.id, license_id=current.license_id) }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<button class="btn btn--ghost danger-text btn--block" type="submit"
|
||||
data-confirm="Lizenz freigeben? Ihre bisherige Installation verliert damit die lizenzpflichtigen Funktionen. Danach koennen Sie sofort eine neue Lizenz fuer Ihr neues System ausstellen."
|
||||
data-confirm="Lizenz freigeben? Ihre bisherige Installation verliert damit die lizenzpflichtigen Funktionen. Danach können Sie sofort eine neue Lizenz für Ihr neues System ausstellen."
|
||||
data-confirm-title="Lizenz freigeben" data-confirm-ok="Freigeben">
|
||||
Lizenz freigeben (Systemwechsel)
|
||||
</button>
|
||||
@@ -81,7 +81,7 @@
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('shield', size=17) }} Noch keine Lizenz</div>
|
||||
<div class="card__sub">Sie koennen Ihre Lizenz jetzt erstellen</div>
|
||||
<div class="card__sub">Sie können Ihre Lizenz jetzt erstellen</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card__body">
|
||||
@@ -101,8 +101,8 @@
|
||||
<details class="card">
|
||||
<summary class="card__header" style="cursor:pointer">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('history', size=17) }} Frueher ausgestellt</div>
|
||||
<div class="card__sub">{{ history | length }} Eintraege</div>
|
||||
<div class="card__title">{{ icon('history', size=17) }} Früher ausgestellt</div>
|
||||
<div class="card__sub">{{ history | length }} Einträge</div>
|
||||
</div>
|
||||
{{ icon('chevron-down', size=16) }}
|
||||
</summary>
|
||||
@@ -115,7 +115,7 @@
|
||||
<td class="fs-sm">{{ entry.issued_at | date }}</td>
|
||||
<td class="fs-sm">
|
||||
{% if entry.status == 'revoked' %}freigegeben
|
||||
{% elif entry.status == 'deactivated' %}abgeloest
|
||||
{% elif entry.status == 'deactivated' %}abgelöst
|
||||
{% else %}{{ entry.status }}{% endif %}
|
||||
</td>
|
||||
<td class="fs-sm">{{ entry.hostname or '–' | safe }}</td>
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
<section class="card">
|
||||
<div class="card__body stack">
|
||||
{{ alert('In Ihrer TESM-Installation unter Verwaltung -- Lizenz auf "Lizenz aktivieren"
|
||||
klicken. Erscheint dort ein Anfragecode, fuegen Sie ihn hier ein. Sie bekommen einen
|
||||
Antwortcode zurueck, den Sie in TESM eintragen. Beide Codes sind signiert und lassen
|
||||
sich unterwegs nicht veraendern.', 'info', 'So geht es') }}
|
||||
klicken. Erscheint dort ein Anfragecode, fügen Sie ihn hier ein. Sie bekommen einen
|
||||
Antwortcode zurück, den Sie in TESM eintragen. Beide Codes sind signiert und lassen
|
||||
sich unterwegs nicht verändern.', 'info', 'So geht es') }}
|
||||
|
||||
{% if response_code %}
|
||||
<div class="field">
|
||||
@@ -35,7 +35,7 @@
|
||||
<label class="field">
|
||||
<span class="field__label">Anfragecode aus Ihrer TESM-Installation</span>
|
||||
<textarea class="textarea input--mono" name="code" rows="6" required
|
||||
placeholder="Code einfuegen ..."></textarea>
|
||||
placeholder="Code einfügen ..."></textarea>
|
||||
</label>
|
||||
<div class="row row--end mt-3">
|
||||
<button class="btn btn--primary" type="submit">Antwortcode erzeugen</button>
|
||||
@@ -44,7 +44,7 @@
|
||||
{% endif %}
|
||||
|
||||
<p class="fs-xs faint">
|
||||
<a href="{{ url_for('selfservice.overview', ticket_id=ticket.id) }}">Zurueck zur Uebersicht</a>
|
||||
<a href="{{ url_for('selfservice.overview', ticket_id=ticket.id) }}">Zurück zur Übersicht</a>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
{% block page_title %}E-Mail-Versand{% endblock %}
|
||||
{% block page_sub %}
|
||||
Microsoft Graph (App-only) ·
|
||||
{% if ready %}einsatzbereit{% else %}nicht vollstaendig konfiguriert{% endif %}
|
||||
{% if ready %}einsatzbereit{% else %}nicht vollständig konfiguriert{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block topbar_extra %}
|
||||
@@ -19,8 +19,8 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{{ alert('Der Versand laeuft ueber den Client-Credentials-Flow von Microsoft Graph -- kein SMTP,
|
||||
kein zusaetzliches Paket. Die Anwendung braucht in Azure die Anwendungsberechtigung
|
||||
{{ alert('Der Versand läuft über den Client-Credentials-Flow von Microsoft Graph -- kein SMTP,
|
||||
kein zusätzliches Paket. Die Anwendung braucht in Azure die Anwendungsberechtigung
|
||||
Mail.Send. Der Verbindungstest holt nur ein Token und verschickt
|
||||
bewusst keine echte Nachricht.', 'info') }}
|
||||
|
||||
@@ -46,7 +46,7 @@ bewusst keine echte Nachricht.', 'info') }}
|
||||
</div>
|
||||
<fieldset class="fieldset">
|
||||
<legend>Client-Secret {% if has_secret %}{{ pill('gesetzt', 'online') }}{% else %}{{ pill('fehlt', 'warning') }}{% endif %}</legend>
|
||||
{{ field('client_secret', 'Neues Secret (leer = unveraendert)', type='password',
|
||||
{{ field('client_secret', 'Neues Secret (leer = unverändert)', type='password',
|
||||
autocomplete='new-password', disabled=not can_secrets) }}
|
||||
{% if not can_secrets %}
|
||||
<p class="fs-xs faint mt-2">
|
||||
@@ -63,17 +63,17 @@ bewusst keine echte Nachricht.', 'info') }}
|
||||
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1"><div class="card__title">{{ icon('info', size=17) }} Wofuer der Versand genutzt wird</div></div>
|
||||
<div class="flex-1"><div class="card__title">{{ icon('info', size=17) }} Wofür der Versand genutzt wird</div></div>
|
||||
</div>
|
||||
<div class="card__body">
|
||||
<ul class="stack stack--tight fs-sm muted">
|
||||
<li>Lizenzdatei an den Kunden senden (mit Anhang).</li>
|
||||
<li>Zugangslink fuer das Kundenportal.</li>
|
||||
<li>Zugangslink für das Kundenportal.</li>
|
||||
</ul>
|
||||
<div class="divider"></div>
|
||||
<p class="fs-sm muted">
|
||||
Das Secret liegt AES-256-GCM-verschluesselt in der Datenbank, gebunden an seinen
|
||||
Verwendungszweck. Ohne die Schluesseldatei dieser Installation ist es nicht lesbar.
|
||||
Das Secret liegt AES-256-GCM-verschlüsselt in der Datenbank, gebunden an seinen
|
||||
Verwendungszweck. Ohne die Schlüsseldatei dieser Installation ist es nicht lesbar.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
|
||||
{% block content %}
|
||||
{% if not sysops_available %}
|
||||
{{ alert('Systemhelfer nicht verfuegbar -- Hostname und Zeitzone koennen hier nur angezeigt,
|
||||
nicht geaendert werden.', 'info') }}
|
||||
{{ alert('Systemhelfer nicht verfügbar -- Hostname und Zeitzone können hier nur angezeigt,
|
||||
nicht geändert werden.', 'info') }}
|
||||
{% endif %}
|
||||
|
||||
<div class="grid grid--split">
|
||||
@@ -16,7 +16,7 @@
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('users', size=17) }} Kundenportal und Self-Service</div>
|
||||
<div class="card__sub">Zwei unabhaengige Zeitfenster: Link und Sitzung</div>
|
||||
<div class="card__sub">Zwei unabhängige Zeitfenster: Link und Sitzung</div>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" action="{{ url_for('settings.system_save') }}">
|
||||
@@ -24,10 +24,10 @@
|
||||
<div class="card__body stack">
|
||||
{{ checkbox('portal_enabled', 'Kundenportal aktiv', checked=values.portal_enabled) }}
|
||||
<div class="form-grid">
|
||||
{{ field('portal_link_minutes', 'Zugangslink gueltig (Minuten)',
|
||||
{{ field('portal_link_minutes', 'Zugangslink gültig (Minuten)',
|
||||
value=values.portal_link_minutes, type='number',
|
||||
hint_text=specs['portal_link_minutes'].hint) }}
|
||||
{{ field('portal_session_minutes', 'Portalsitzung gueltig (Minuten)',
|
||||
{{ field('portal_session_minutes', 'Portalsitzung gültig (Minuten)',
|
||||
value=values.portal_session_minutes, type='number',
|
||||
hint_text=specs['portal_session_minutes'].hint) }}
|
||||
</div>
|
||||
@@ -35,7 +35,7 @@
|
||||
{{ checkbox('self_service_enabled', 'Self-Service-Link je Ticket aktiv',
|
||||
checked=values.self_service_enabled,
|
||||
hint_text=specs['self_service_enabled'].hint) }}
|
||||
{{ checkbox('allow_customer_revoke', 'Kunden duerfen ihre Lizenz selbst freigeben',
|
||||
{{ checkbox('allow_customer_revoke', 'Kunden dürfen ihre Lizenz selbst freigeben',
|
||||
checked=values.allow_customer_revoke,
|
||||
hint_text=specs['allow_customer_revoke'].hint) }}
|
||||
</div>
|
||||
@@ -60,6 +60,9 @@
|
||||
selected=values.log_rotate_interval) }}
|
||||
{{ field('log_rotate_keep', 'Aufbewahrte Rotationen', value=values.log_rotate_keep,
|
||||
type='number') }}
|
||||
{{ field('session_idle_minutes', 'Automatische Abmeldung (Minuten)',
|
||||
value=values.session_idle_minutes, type='number',
|
||||
hint_text=specs['session_idle_minutes'].hint) }}
|
||||
{{ field('trash_retention_days', 'Papierkorb-Aufbewahrung (Tage)',
|
||||
value=values.trash_retention_days, type='number') }}
|
||||
</div>
|
||||
@@ -105,7 +108,7 @@
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('sliders', size=17) }} Navigationsreihenfolge</div>
|
||||
<div class="card__sub">Gilt fuer alle Benutzer</div>
|
||||
<div class="card__sub">Gilt für alle Benutzer</div>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" action="{{ url_for('admin.nav_order') }}">
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
{% block content %}
|
||||
{{ alert('Diese Angaben werden in jede Lizenzdatei eingebettet und beim Kunden auf der
|
||||
Lizenzseite angezeigt -- inklusive Logo. Der oeffentliche Endpunkt entscheidet zusaetzlich,
|
||||
Lizenzseite angezeigt -- inklusive Logo. Der öffentliche Endpunkt entscheidet zusätzlich,
|
||||
welche Basis-URL in kundengerichteten Links steht.', 'info') }}
|
||||
|
||||
<div class="grid grid--split">
|
||||
@@ -26,18 +26,18 @@ welche Basis-URL in kundengerichteten Links steht.', 'info') }}
|
||||
disabled=not current_user.has('settings_vendor.edit')) }}
|
||||
</div>
|
||||
{{ textarea_field('vendor_address', 'Adresse', value=values.vendor_address, rows=3) }}
|
||||
{{ field('master_endpoint', 'Oeffentlicher Endpunkt', value=values.master_endpoint,
|
||||
{{ field('master_endpoint', 'Öffentlicher Endpunkt', value=values.master_endpoint,
|
||||
placeholder='https://lizenz.firma.de',
|
||||
disabled=not current_user.has('settings_vendor.edit'),
|
||||
hint_text='Basis-URL, unter der Kundeninstanzen diesen Server erreichen. Wird in
|
||||
jede Lizenz eingebettet und fuer alle kundengerichteten Links verwendet -- statt
|
||||
des Host-Headers der aktuellen Anfrage, der bei internen Aufrufen falsch waere.',
|
||||
jede Lizenz eingebettet und für alle kundengerichteten Links verwendet -- statt
|
||||
des Host-Headers der aktüllen Anfrage, der bei internen Aufrufen falsch wäre.',
|
||||
extra_class='field--full') }}
|
||||
<fieldset class="fieldset">
|
||||
<legend>Logo (maximal {{ max_logo_kb }} KB)</legend>
|
||||
<div class="stack stack--tight">
|
||||
{% if values.vendor_logo_base64 %}
|
||||
<img src="{{ values.vendor_logo_base64 }}" alt="Aktuelles Logo"
|
||||
<img src="{{ values.vendor_logo_base64 }}" alt="Aktülles Logo"
|
||||
style="max-height:64px;background:var(--surface-2);padding:var(--sp-2);border-radius:var(--r-sm)">
|
||||
{% else %}
|
||||
<p class="faint fs-sm">Kein Logo hinterlegt.</p>
|
||||
@@ -45,7 +45,7 @@ welche Basis-URL in kundengerichteten Links steht.', 'info') }}
|
||||
<input class="input" type="file" name="vendor_logo" accept="image/png,image/jpeg,image/svg+xml"
|
||||
{% if not current_user.has('settings_vendor.edit') %}disabled{% endif %}>
|
||||
<span class="field__hint">
|
||||
Ein zu grosses Logo wird abgelehnt, die uebrigen Angaben werden trotzdem gespeichert.
|
||||
Ein zu grosses Logo wird abgelehnt, die übrigen Angaben werden trotzdem gespeichert.
|
||||
</span>
|
||||
</div>
|
||||
</fieldset>
|
||||
@@ -75,7 +75,7 @@ welche Basis-URL in kundengerichteten Links steht.', 'info') }}
|
||||
<div class="divider"></div>
|
||||
<p class="fs-sm muted">
|
||||
Die Angaben werden zum Zeitpunkt der Ausstellung in die Lizenz kopiert und signiert. Eine
|
||||
Aenderung hier wirkt auf neue Lizenzen -- bestehende behalten ihren Stand, bis eine neue
|
||||
Änderung hier wirkt auf neue Lizenzen -- bestehende behalten ihren Stand, bis eine neue
|
||||
Revision signiert wird.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,34 +1,34 @@
|
||||
{% extends "tesm_core/base.html" %}
|
||||
{% from "tesm_core/macros.html" import alert %}
|
||||
{% block page_title %}Signaturschluessel{% endblock %}
|
||||
{% block page_sub %}Schluessel-ID {{ key_id }}{% endblock %}
|
||||
{% block page_title %}Signaturschlüssel{% endblock %}
|
||||
{% block page_sub %}Schlüssel-ID {{ key_id }}{% endblock %}
|
||||
{% block breadcrumb %}
|
||||
<nav class="breadcrumb">
|
||||
<span><a href="{{ url_for('own_license.overview') }}">Eigene Lizenz</a></span><span>Signaturschluessel</span>
|
||||
<span><a href="{{ url_for('own_license.overview') }}">Eigene Lizenz</a></span><span>Signaturschlüssel</span>
|
||||
</nav>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
{{ alert('Der oeffentliche Teil ist kein Geheimnis -- er reist in jeder ausgestellten Lizenz mit.
|
||||
Ihn hier anzuzeigen erlaubt es, ihn unabhaengig zu vergleichen, bevor ein Kunde ihn als
|
||||
vertrauenswuerdig hinterlegt.', 'info') }}
|
||||
{{ alert('Der öffentliche Teil ist kein Geheimnis -- er reist in jeder ausgestellten Lizenz mit.
|
||||
Ihn hier anzuzeigen erlaubt es, ihn unabhängig zu vergleichen, bevor ein Kunde ihn als
|
||||
vertrauenswürdig hinterlegt.', 'info') }}
|
||||
{% if health.problems %}
|
||||
{% for problem in health.problems %}{{ alert(problem, 'danger') }}{% endfor %}
|
||||
{% endif %}
|
||||
<section class="card" style="max-width:760px">
|
||||
<div class="card__header">
|
||||
<div class="flex-1"><div class="card__title">{{ icon('key', size=17) }} Oeffentlicher Schluessel</div>
|
||||
<div class="flex-1"><div class="card__title">{{ icon('key', size=17) }} Öffentlicher Schlüssel</div>
|
||||
<div class="card__sub">Erzeugt {{ created_at | dt }}</div></div>
|
||||
<button class="btn btn--sm" type="button" data-copy="#pubkey">{{ icon('copy', size=14) }} Kopieren</button>
|
||||
</div>
|
||||
<div class="card__body stack">
|
||||
<dl class="kv fs-sm">
|
||||
<dt>Schluessel-ID</dt><dd class="mono">{{ key_id }}</dd>
|
||||
<dt>Schlüssel-ID</dt><dd class="mono">{{ key_id }}</dd>
|
||||
<dt>Verfahren</dt><dd>Ed25519 (EdDSA)</dd>
|
||||
</dl>
|
||||
<pre class="code-block code-block--wrap" id="pubkey">{{ public_key }}</pre>
|
||||
{{ alert('Der private Teil liegt ausschliesslich auf diesem Host (Modus 0600) und ist aus dem
|
||||
Deployment-Abgleich ausgeschlossen. Ginge er verloren, waere jede
|
||||
ausgestellte Lizenz nicht mehr pruefbar -- bitte getrennt sichern.', 'warning') }}
|
||||
Deployment-Abgleich ausgeschlossen. Ginge er verloren, wäre jede
|
||||
ausgestellte Lizenz nicht mehr prüfbar -- bitte getrennt sichern.', 'warning') }}
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
@@ -17,11 +17,11 @@
|
||||
|
||||
{% block content %}
|
||||
{% if endpoint_missing %}
|
||||
{{ alert(endpoint_missing, 'danger', 'Keine Ausstellung moeglich') }}
|
||||
{{ alert(endpoint_missing, 'danger', 'Keine Ausstellung möglich') }}
|
||||
{% endif %}
|
||||
{% if ticket.status == 'blocked' %}
|
||||
{{ alert('Dieses Ticket ist gesperrt. Es lassen sich keine neuen Lizenzen ausstellen -- weder
|
||||
hier noch ueber den Self-Service des Kunden. Bereits ausgestellte Lizenzen bleiben gueltig.',
|
||||
hier noch über den Self-Service des Kunden. Bereits ausgestellte Lizenzen bleiben gültig.',
|
||||
'danger', 'Gesperrt') }}
|
||||
{% endif %}
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
<input type="hidden" name="as_trial" value="1">
|
||||
<button class="btn" type="submit"
|
||||
{% if has_open_license or ticket.status == 'blocked' %}disabled{% endif %}
|
||||
data-confirm="Testlizenz ausstellen? {{ trial_days }} Tage Laufzeit mit allen Modulen. Das Ticket selbst bleibt unveraendert."
|
||||
data-confirm="Testlizenz ausstellen? {{ trial_days }} Tage Laufzeit mit allen Modulen. Das Ticket selbst bleibt unverändert."
|
||||
data-confirm-title="Testlizenz" data-confirm-ok="Ausstellen"
|
||||
data-confirm-style="primary">
|
||||
Als Testlizenz ({{ trial_days }} Tage, alle Module)
|
||||
@@ -81,7 +81,7 @@
|
||||
{% if licenses %}
|
||||
<div class="table-wrap">
|
||||
<table class="table table--compact">
|
||||
<thead><tr><th>Lizenz</th><th>Ausgestellt</th><th>Zustand</th><th>Laeuft ab</th><th>Host</th></tr></thead>
|
||||
<thead><tr><th>Lizenz</th><th>Ausgestellt</th><th>Zustand</th><th>Läuft ab</th><th>Host</th></tr></thead>
|
||||
<tbody>
|
||||
{% for entry in licenses %}
|
||||
<tr>
|
||||
@@ -115,7 +115,7 @@
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('link', size=17) }} Self-Service fuer den Kunden</div>
|
||||
<div class="card__title">{{ icon('link', size=17) }} Self-Service für den Kunden</div>
|
||||
<div class="card__sub">
|
||||
Ohne Anmeldung nutzbar – die Berechtigung ist der Besitz dieses Links
|
||||
</div>
|
||||
@@ -128,7 +128,7 @@
|
||||
</div>
|
||||
<p class="fs-xs faint">
|
||||
Damit kann der Kunde seine Lizenz herunterladen, bei einem Systemwechsel freigeben und
|
||||
neu ausstellen. Der Link enthaelt 192 Bit Zufall und ist nicht ratbar -- behandeln Sie
|
||||
neu ausstellen. Der Link enthält 192 Bit Zufall und ist nicht ratbar -- behandeln Sie
|
||||
ihn wie ein Passwort.
|
||||
</p>
|
||||
<div class="divider"></div>
|
||||
@@ -143,7 +143,7 @@
|
||||
</div>
|
||||
</section>
|
||||
{% elif ticket.role != 'customer' %}
|
||||
{{ alert('Fuer Lizenzserver-Tickets ist der Self-Service vollstaendig gesperrt -- diese
|
||||
{{ alert('Für Lizenzserver-Tickets ist der Self-Service vollständig gesperrt -- diese
|
||||
Lizenzen werden ausschliesslich hier verwaltet.', 'info') }}
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -158,8 +158,8 @@
|
||||
<form method="post" action="{{ url_for('tickets.update', ticket_id=ticket.id) }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<div class="card__body stack">
|
||||
{{ alert('Aenderungen wirken auf die naechste Ausstellung. Kunde und Rolle sind
|
||||
unveraenderlich.', 'info') }}
|
||||
{{ alert('Änderungen wirken auf die nächste Ausstellung. Kunde und Rolle sind
|
||||
unveränderlich.', 'info') }}
|
||||
<label class="field">
|
||||
<span class="field__label">Lizenztyp</span>
|
||||
<select class="select" name="type" id="ticket-type"
|
||||
@@ -206,7 +206,7 @@
|
||||
{% if current_user.has('tickets.edit') or current_user.has('tickets.delete') %}
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1"><div class="card__title">{{ icon('lock', size=17) }} Sperre und Loeschung</div></div>
|
||||
<div class="flex-1"><div class="card__title">{{ icon('lock', size=17) }} Sperre und Löschung</div></div>
|
||||
</div>
|
||||
<div class="card__body stack stack--tight">
|
||||
{% if current_user.has('tickets.edit') %}
|
||||
@@ -216,7 +216,7 @@
|
||||
<button class="btn btn--sm btn--block {{ 'btn--danger' if ticket.status != 'blocked' }}"
|
||||
type="submit"
|
||||
data-confirm="{% if ticket.status == 'blocked' %}Ticket entsperren?{% else %}Ticket sperren? Der Kunde kann danach keine neuen Lizenzen mehr ausstellen.{% endif %}"
|
||||
data-confirm-title="Ticketsperre" data-confirm-ok="Bestaetigen"
|
||||
data-confirm-title="Ticketsperre" data-confirm-ok="Bestätigen"
|
||||
data-confirm-style="{{ 'primary' if ticket.status == 'blocked' else 'danger' }}">
|
||||
{{ 'Entsperren' if ticket.status == 'blocked' else 'Sperren' }}
|
||||
</button>
|
||||
@@ -228,9 +228,9 @@
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<button class="btn btn--sm btn--ghost danger-text btn--block" type="submit"
|
||||
{% if has_open_license %}disabled title="Erst die offene Lizenz widerrufen"{% endif %}
|
||||
data-confirm="Ticket und seine gesamte Lizenzhistorie loeschen? Das laesst sich nicht rueckgaengig machen."
|
||||
data-confirm-title="Ticket loeschen" data-confirm-ok="Loeschen">
|
||||
Ticket loeschen
|
||||
data-confirm="Ticket und seine gesamte Lizenzhistorie löschen? Das lässt sich nicht rückgängig machen."
|
||||
data-confirm-title="Ticket löschen" data-confirm-ok="Löschen">
|
||||
Ticket löschen
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
<label class="field">
|
||||
<span class="field__label">Kunde <span class="req">*</span></span>
|
||||
<select class="select" name="customer_id" required>
|
||||
<option value="">bitte waehlen</option>
|
||||
<option value="">bitte wählen</option>
|
||||
{% for customer in customers %}
|
||||
<option value="{{ customer.id }}"
|
||||
{% if preselected_customer == customer.id %}selected{% endif %}>
|
||||
@@ -32,7 +32,7 @@
|
||||
{% endfor %}
|
||||
</select>
|
||||
<span class="field__hint">
|
||||
Der Kunde ist nach dem Anlegen unveraenderlich -- der Self-Service-Link ist an ihn
|
||||
Der Kunde ist nach dem Anlegen unveränderlich -- der Self-Service-Link ist an ihn
|
||||
gebunden.
|
||||
</span>
|
||||
</label>
|
||||
@@ -47,13 +47,13 @@
|
||||
{% endfor %}
|
||||
</select>
|
||||
<span class="field__hint">
|
||||
Standard: Grundfunktionen. Individuell: Module frei waehlbar. Enterprise: alle
|
||||
Standard: Grundfunktionen. Individüll: Module frei wählbar. Enterprise: alle
|
||||
Module. Testlizenz: 30 Tage.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<fieldset class="fieldset" id="module-box">
|
||||
<legend>Module (nur bei „Individuell“)</legend>
|
||||
<legend>Module (nur bei „Individüll“)</legend>
|
||||
<div class="stack stack--tight">
|
||||
{% for module in modules %}
|
||||
{{ checkbox('modules', module_labels[module], value=module) }}
|
||||
@@ -65,7 +65,7 @@
|
||||
{{ field('valid_days', 'Laufzeit (Tage)', value=default_valid_days, type='number') }}
|
||||
</div>
|
||||
{{ checkbox('lifetime', 'Unbefristet',
|
||||
hint_text='Nicht kombinierbar mit einer Testlizenz -- das waere eine
|
||||
hint_text='Nicht kombinierbar mit einer Testlizenz -- das wäre eine
|
||||
permanente Lizenz ohne Module.') }}
|
||||
{{ textarea_field('notes', 'Interne Notiz', rows=2) }}
|
||||
</div>
|
||||
@@ -77,15 +77,15 @@
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('share', size=17) }} Lizenzserver-Lizenz</div>
|
||||
<div class="card__sub">
|
||||
Erzeugt eine neue Vertrauensgrenze -- der Empfaenger verwaltet danach eigene Kunden
|
||||
Erzeugt eine neue Vertrauensgrenze -- der Empfänger verwaltet danach eigene Kunden
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card__body stack">
|
||||
{{ alert('Ein so lizenzierter Lizenzserver darf alles, was ein Lizenzserver koennen
|
||||
{{ alert('Ein so lizenzierter Lizenzserver darf alles, was ein Lizenzserver können
|
||||
soll -- ausser selbst weitere Lizenzserver-Lizenzen auszustellen. Er meldet seinen
|
||||
Bestand automatisch hierher zurueck. Module sind fuer diese Rolle bedeutungslos und
|
||||
werden ignoriert. Self-Service und Kundenportal sind fuer solche Tickets gesperrt.',
|
||||
Bestand automatisch hierher zurück. Module sind für diese Rolle bedeutungslos und
|
||||
werden ignoriert. Self-Service und Kundenportal sind für solche Tickets gesperrt.',
|
||||
'warning') }}
|
||||
<label class="check">
|
||||
<input type="checkbox" name="role" value="license_server">
|
||||
|
||||
@@ -17,11 +17,11 @@
|
||||
|
||||
{% block content %}
|
||||
{% if endpoint_missing %}
|
||||
{{ alert(endpoint_missing, 'danger', 'Keine Ausstellung moeglich') }}
|
||||
{{ alert(endpoint_missing, 'danger', 'Keine Ausstellung möglich') }}
|
||||
{% endif %}
|
||||
{{ alert('Ein Ticket beschreibt den Anspruch (Typ, Module, Laufzeit); die Lizenz ist die daraus
|
||||
erzeugte, signierte Datei. Aenderungen am Ticket wirken auf die naechste
|
||||
Ausstellung -- bereits ausgestellte Lizenzen bleiben unveraendert.', 'info') }}
|
||||
erzeugte, signierte Datei. Änderungen am Ticket wirken auf die nächste
|
||||
Ausstellung -- bereits ausgestellte Lizenzen bleiben unverändert.', 'info') }}
|
||||
|
||||
<section class="card">
|
||||
<div class="table-wrap">
|
||||
@@ -85,7 +85,7 @@ Ausstellung -- bereits ausgestellte Lizenzen bleiben unveraendert.', 'info') }}
|
||||
</table>
|
||||
</div>
|
||||
{% if not tickets %}
|
||||
{{ empty_state('Keine Tickets', 'Legen Sie das erste Ticket fuer einen Kunden an.', 'ticket') }}
|
||||
{{ empty_state('Keine Tickets', 'Legen Sie das erste Ticket für einen Kunden an.', 'ticket') }}
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
+16
-1
@@ -112,6 +112,21 @@ MONITOR_UNIT="$NAME-monitor.service"
|
||||
NGINX_SITE="/etc/nginx/sites-available/$NAME"
|
||||
ENV_PREFIX="TESM_"
|
||||
[[ "$APP" == "tesm-license" ]] && ENV_PREFIX="TESM_LICENSE_"
|
||||
# Bei einer Aktualisierung gilt zuerst, was schon eingerichtet ist. Sonst
|
||||
# verschiebt ein Update ohne Portangaben die Anwendung: eine benannte Instanz
|
||||
# faellt auf 8080/8443 zurueck, auch wenn sie seit der Installation auf Port 80
|
||||
# lief -- und ein Reverse Proxy davor zeigt dann ins Leere. Dieselbe Regel wie
|
||||
# beim Secure-Flag weiter unten: eine Aktualisierung nimmt nichts weg, was
|
||||
# nicht ausdruecklich neu gesetzt wird.
|
||||
ENV_FILE="/etc/tesm/$NAME.env"
|
||||
if [[ -z "$PORT" && -f "$ENV_FILE" ]]; then
|
||||
PORT="$(sed -n "s/^${ENV_PREFIX}BIND=127\.0\.0\.1:\([0-9]\{1,5\}\)$/\1/p" "$ENV_FILE" | head -1)"
|
||||
fi
|
||||
if [[ -f "$NGINX_SITE" ]]; then
|
||||
[[ -z "$HTTP_PORT" ]] && HTTP_PORT="$(sed -n 's/^[[:space:]]*listen[[:space:]]\([0-9]\{1,5\}\);.*/\1/p' "$NGINX_SITE" | head -1)"
|
||||
[[ -z "$HTTPS_PORT" ]] && HTTPS_PORT="$(sed -n 's/^[[:space:]]*listen[[:space:]]\([0-9]\{1,5\}\)[[:space:]]\+ssl.*/\1/p' "$NGINX_SITE" | head -1)"
|
||||
fi
|
||||
|
||||
if [[ -z "$PORT" ]]; then
|
||||
PORT=5000
|
||||
[[ "$APP" == "tesm-license" ]] && PORT=5001
|
||||
@@ -134,7 +149,7 @@ fi
|
||||
# Browser sendet das Cookie dann weiter nur ueber HTTPS, die Anwendung setzt es
|
||||
# aber ohne das Flag neu, und beide reden aneinander vorbei.
|
||||
COOKIE_SECURE=0
|
||||
if [[ -f "/etc/tesm/$NAME.env" ]] && grep -q "^${ENV_PREFIX}COOKIE_SECURE=1" "/etc/tesm/$NAME.env"; then
|
||||
if [[ -f "$ENV_FILE" ]] && grep -q "^${ENV_PREFIX}COOKIE_SECURE=1" "$ENV_FILE"; then
|
||||
COOKIE_SECURE=1
|
||||
fi
|
||||
[[ $HTTPS -eq 1 ]] && COOKIE_SECURE=1
|
||||
|
||||
@@ -59,7 +59,20 @@ RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
|
||||
LockPersonality=yes
|
||||
MemoryDenyWriteExecute=no
|
||||
SystemCallArchitectures=native
|
||||
SystemCallFilter=@system-service
|
||||
# "@mount" zusaetzlich zu @system-service: die Dateifreigaben werden ueber
|
||||
# mount.cifs eingebunden, und @system-service enthaelt mount/umount2 nicht.
|
||||
# Ohne diese Ergaenzung scheitert jede Einbindung mit "mount error(1):
|
||||
# Operation not permitted" -- gemessen, und zwar unabhaengig von Rechten,
|
||||
# Zugangsdaten und Mount-Optionen: aus einer normalen Root-Shell gelingt
|
||||
# derselbe Aufruf sofort. Der Umweg ueber nsenter in den Namensraum des Wirts
|
||||
# ist keine Alternative, weil "RestrictNamespaces=yes" setns verbietet.
|
||||
#
|
||||
# Eingebunden wird damit im Mount-Namensraum *dieses* Dienstes. Das ist genau
|
||||
# richtig: nur er liest die Dateien, andere Prozesse haben dort nichts zu
|
||||
# suchen, und mit dem Dienst verschwinden die Einbindungen wieder. Die
|
||||
# Aufraeumaufgabe fuer abgelaufene Einbindungen laeuft deshalb im Webprozess --
|
||||
# der Ueberwachungsdienst koennte sie nicht einmal sehen.
|
||||
SystemCallFilter=@system-service @mount
|
||||
SystemCallErrorNumber=EPERM
|
||||
# Bewusst **kein** einschraenkendes CapabilityBoundingSet: sudo braucht
|
||||
# CAP_SETUID/CAP_SETGID, und der Helfer dahinter braucht Root. Was hier fehlt,
|
||||
|
||||
+35
-20
@@ -258,19 +258,17 @@ case "$verb" in
|
||||
# Kommandozeile (Prozessliste) und nie ueber die Umgebung
|
||||
# (/proc/<pid>/environ).
|
||||
read_stdin_limited > "$creds"
|
||||
# Der aufrufende Dienst laeuft in einem eigenen Mount-Namensraum
|
||||
# (ProtectSystem, PrivateTmp, ReadWritePaths). Ein Einbinden *darin*
|
||||
# scheitert mit "mount error(1): Operation not permitted" -- gemessen: mit
|
||||
# denselben Optionen und denselben Zugangsdaten gelingt es aus einer
|
||||
# normalen Root-Shell sofort. Deshalb wird im Namensraum des Wirts
|
||||
# eingebunden; von dort wird der Mount in den Namensraum des Dienstes
|
||||
# weitergegeben (er haengt als "slave" darunter), die Anwendung sieht ihn
|
||||
# also. Die Zugangsdatei unter /run ist ueber die Grenze lesbar, /run ist
|
||||
# nicht privatisiert.
|
||||
im_wirt=(nsenter --mount=/proc/1/ns/mnt --)
|
||||
command -v nsenter >/dev/null 2>&1 || im_wirt=()
|
||||
"${im_wirt[@]}" mkdir -p "$target"
|
||||
if "${im_wirt[@]}" mount -t cifs "$share" "$target" \
|
||||
# Eingebunden wird im Mount-Namensraum des aufrufenden Dienstes -- absichtlich:
|
||||
# nur dieser Dienst liest die Dateien, und mit ihm verschwinden die
|
||||
# Einbindungen wieder. Damit der Syscall durchkommt, braucht die Unit
|
||||
# "@mount" im SystemCallFilter. Ohne das scheitert es mit
|
||||
# "mount error(1): Operation not permitted"; gemessen war der Syscall-Filter
|
||||
# die Ursache, nicht der Namensraum. Ein Umweg ueber nsenter in den
|
||||
# Namensraum des Wirts ist keine Option -- "RestrictNamespaces=yes"
|
||||
# verbietet setns, und das Recht dafuer wieder aufzugeben waere teurer als
|
||||
# der Gewinn.
|
||||
mkdir -p "$target"
|
||||
if mount -t cifs "$share" "$target" \
|
||||
-o "credentials=$creds,uid=$(id -u tesm 2>/dev/null || echo 0),gid=$(id -g tesm 2>/dev/null || echo 0),file_mode=0640,dir_mode=0750,vers=3.1.1,seal"
|
||||
then
|
||||
shred -u "$creds" 2>/dev/null || rm -f "$creds"
|
||||
@@ -282,13 +280,12 @@ case "$verb" in
|
||||
|
||||
umount)
|
||||
check_mount_target "${1:?Ziel fehlt}"
|
||||
# Gegenstueck zu mount-cifs: der Mount liegt im Namensraum des Wirts, also
|
||||
# muss er auch dort geloest werden. Ein umount im Dienst-Namensraum wuerde
|
||||
# ihn nur dort ausblenden und die Freigabe bliebe eingebunden.
|
||||
im_wirt=(nsenter --mount=/proc/1/ns/mnt --)
|
||||
command -v nsenter >/dev/null 2>&1 || im_wirt=()
|
||||
"${im_wirt[@]}" umount -l "$1" || true
|
||||
"${im_wirt[@]}" rmdir "$1" 2>/dev/null || true
|
||||
# Gegenstueck zu mount-cifs, und deshalb im selben Namensraum: loesen kann
|
||||
# nur, wer die Einbindung auch sieht. Ein Aufruf aus einem anderen Dienst
|
||||
# greift ins Leere -- genau deshalb laeuft die Aufraeumaufgabe fuer
|
||||
# abgelaufene Einbindungen im Webprozess und nicht im Ueberwachungsdienst.
|
||||
umount -l "$1" || true
|
||||
rmdir "$1" 2>/dev/null || true
|
||||
;;
|
||||
|
||||
apt-install)
|
||||
@@ -296,9 +293,27 @@ case "$verb" in
|
||||
for package in "$@"; do
|
||||
check_in_list "$package" "${PACKAGES_ALLOWED[@]}" || fail "Paket nicht erlaubt: $package"
|
||||
done
|
||||
# apt laeuft ausserhalb des Sandkastens des aufrufenden Dienstes. Der wird
|
||||
# sonst an dpkg und dessen Maintainer-Skripte weitervererbt, und dort
|
||||
# scheitert etwas voellig Beilaeufiges: mit "ProtectKernelLogs=yes" gibt
|
||||
# "dmesg" ein EPERM zurueck, ein Postinst-Skript bricht daran ab, und dpkg
|
||||
# endet mit Code 1 -- die Installation schlaegt fehl, obwohl mit dem Paket
|
||||
# alles in Ordnung ist. Gemessen und mit systemd-run reproduzierbar
|
||||
# behoben: PID 1 startet den Prozess, also ohne unsere Haertung.
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
if command -v systemd-run >/dev/null 2>&1 && [[ -S /run/dbus/system_bus_socket ]]; then
|
||||
# Zwei getrennte Aufrufe, damit keine Shell dazwischen steht: die
|
||||
# Paketnamen gehen als eigene Argumente an apt-get, nicht durch eine
|
||||
# Kommandozeile, die sie erst wieder zerlegen muesste.
|
||||
ausserhalb=(systemd-run --wait --pipe --collect --quiet
|
||||
--setenv=DEBIAN_FRONTEND=noninteractive)
|
||||
"${ausserhalb[@]}" apt-get update -qq
|
||||
"${ausserhalb[@]}" apt-get install -y -qq "$@"
|
||||
else
|
||||
# Kein systemd (Container, Entwicklungsrechner): direkt, wie bisher.
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq "$@"
|
||||
fi
|
||||
;;
|
||||
|
||||
certbot-issue)
|
||||
|
||||
+41
-8
@@ -12,17 +12,47 @@
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
# Ein Wagenruecklauf im Paket macht jedes Shell-Skript auf dem Ziel
|
||||
# unbrauchbar ("bad interpreter"). Die Pruefung darf dabei nicht selbst
|
||||
# von einem Zusatzpaket abhaengen: mit "file" -- das auf einem schlanken
|
||||
# Server fehlt -- endete sie in "command not found", grep bekam nichts zu
|
||||
# sehen, und das Skript machte weiter, als waere alles in Ordnung.
|
||||
hat_crlf() {
|
||||
local cr
|
||||
cr="$(printf '\r')"
|
||||
# -U (binaer lesen) ist unter Linux ohne Wirkung, auf einer Windows-Buildmaschine
|
||||
# aber entscheidend: dort entfernt grep den Wagenruecklauf beim Lesen und
|
||||
# findet deshalb nie einen.
|
||||
LC_ALL=C grep -qU "$cr" "$1"
|
||||
}
|
||||
|
||||
APP="${1:-}"
|
||||
TAG="${2:-latest}"
|
||||
BASE="${TESM_RELEASE_BASE:-https://gitea.example.invalid}"
|
||||
OWNER="${TESM_RELEASE_OWNER:-wis}"
|
||||
REPO="${TESM_RELEASE_REPO:-tesm}"
|
||||
|
||||
# Alles nach dem Tag geht unveraendert an install.sh weiter. Ohne das liesse
|
||||
# sich eine benannte Instanz nicht aktualisieren: install.sh leitet den Namen
|
||||
# aus "--instance" ab, und ohne den Schalter entstuende neben "tesm-opus" eine
|
||||
# zweite Installation namens "tesm" -- mit eigener Datenbank, eigener
|
||||
# nginx-Site und demselben Port.
|
||||
shift $(( $# > 2 ? 2 : $# ))
|
||||
EXTRA=("$@")
|
||||
# Bezugsquelle. Die Vorgaben zeigen dorthin, wo die Releases tatsaechlich
|
||||
# liegen -- ein Platzhalter wie "gitea.example.invalid" macht das Skript auf
|
||||
# jedem Host unbrauchbar, und genau das war hier der Fall.
|
||||
#
|
||||
# Das Repository heisst wie die Anwendung; getrennte Repositorien, getrennte
|
||||
# Releases. Der Anhang heisst "<repo>-<tag>.tar.gz", siehe unten.
|
||||
BASE="${TESM_RELEASE_BASE:-https://gitea.int.eertmoed.net}"
|
||||
OWNER="${TESM_RELEASE_OWNER:-alientim}"
|
||||
|
||||
[[ "$APP" == "tesm" || "$APP" == "tesm-license" ]] || {
|
||||
echo "Verwendung: update.sh <tesm|tesm-license> [tag]" >&2
|
||||
echo "Verwendung: update.sh <tesm|tesm-license> [tag] [install.sh-Argumente ...]" >&2
|
||||
echo "Beispiel : update.sh tesm latest --instance opus" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
REPO="${TESM_RELEASE_REPO:-$APP}"
|
||||
|
||||
WORK="$(mktemp -d /tmp/tesm-update-XXXXXX)"
|
||||
trap 'rm -rf "$WORK"' EXIT # anders als im Vorgaenger wird aufgeraeumt
|
||||
|
||||
@@ -39,18 +69,21 @@ curl -fsSL -o "$WORK/$ASSET" "$BASE/$OWNER/$REPO/releases/download/$TAG/$ASSET"
|
||||
|
||||
echo "==> Entpacke ..."
|
||||
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; }
|
||||
SRC="$(cd "$SRC" && pwd)"
|
||||
|
||||
echo "==> Pruefe auf CRLF (Windows-Buildmaschinen) ..."
|
||||
if file "$SRC/deploy/install.sh" | grep -q CRLF; then
|
||||
if hat_crlf "$SRC/deploy/install.sh"; then
|
||||
echo "Das Paket enthaelt CRLF-Zeilenenden. Beim Paketieren fehlte" >&2
|
||||
echo "'git -c core.autocrlf=false archive'." >&2
|
||||
exit 1
|
||||
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
|
||||
|
||||
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,
|
||||
aktualisiert und im Fehlerfall wieder zum Laufen bringt. Es beschreibt, was auf
|
||||
dem Host tatsaechlich passiert -- nicht, wie die Oberflaeche bedient wird.
|
||||
dem Host tatsächlich passiert -- nicht, wie die Oberfläche bedient wird.
|
||||
|
||||
---
|
||||
|
||||
@@ -11,7 +11,7 @@ dem Host tatsaechlich passiert -- nicht, wie die Oberflaeche bedient wird.
|
||||
| Zweck | Pfad |
|
||||
|---|---|
|
||||
| Anwendung (Code, venv, statische Dateien) | `/srv/<name>/` |
|
||||
| Instanzdaten (Datenbank, Schluessel, Lizenz) | `/srv/<name>/data/` |
|
||||
| Instanzdaten (Datenbank, Schlüssel, Lizenz) | `/srv/<name>/data/` |
|
||||
| Protokolle | `/var/log/<name>/` |
|
||||
| Umgebungsdatei | `/etc/tesm/<name>.env` |
|
||||
| Zertifikate (selbstsigniert) | `/etc/tesm/certs/<name>/` |
|
||||
@@ -20,13 +20,13 @@ dem Host tatsaechlich passiert -- nicht, wie die Oberflaeche bedient wird.
|
||||
| Privilegierter Helfer | `/usr/local/lib/tesm/tesm-helper` |
|
||||
| Verwaltungswerkzeug | `/usr/local/bin/<name>-admin` |
|
||||
|
||||
`<name>` ist `tesm` bzw. `tesm-license` -- oder, bei einer zusaetzlichen
|
||||
`<name>` ist `tesm` bzw. `tesm-license` -- oder, bei einer zusätzlichen
|
||||
Instanz, `tesm-<instanz>`. Dieser Name (der **SITE_KEY**) benennt alles, was
|
||||
ausserhalb von `/srv/<name>` liegt. Deshalb koennen zwei Installationen
|
||||
ausserhalb von `/srv/<name>` liegt. Deshalb können zwei Installationen
|
||||
derselben Anwendung nebeneinander laufen, ohne sich gegenseitig die
|
||||
nginx-Site, die netplan-Datei oder das Sitzungscookie zu ueberschreiben.
|
||||
nginx-Site, die netplan-Datei oder das Sitzungscookie zu überschreiben.
|
||||
|
||||
Alles, was ein Mensch verlieren wuerde, liegt unter `data/`. Das Verzeichnis
|
||||
Alles, was ein Mensch verlieren würde, liegt unter `data/`. Das Verzeichnis
|
||||
wird von `install.sh` nie angefasst.
|
||||
|
||||
---
|
||||
@@ -43,10 +43,10 @@ Wichtige Optionen:
|
||||
| Option | Wirkung |
|
||||
|---|---|
|
||||
| `--https` | Port 443 mit TLS, Port 80 leitet dorthin um. Ohne `--domain` entsteht ein selbstsigniertes Zertifikat. |
|
||||
| `--domain <name>` | Oeffentlicher Name. Setzt `server_name`; die Site ist dann **nicht** mehr die Vorgabe-Site. |
|
||||
| `--acme-email <a>` | Erst zusammen mit `--domain` wird ein Let's-Encrypt-Zertifikat angefordert (und HSTS gesetzt). Ohne die Adresse bleibt es selbstsigniert -- interne Namen wie `lizenz.firma.local` kann keine CA bestaetigen. |
|
||||
| `--instance <name>` | Zusaetzliche Instanz neben der Hauptinstallation. |
|
||||
| `--port` / `--http-port` / `--https-port` | Abweichende Ports (nur fuer Testinstanzen sinnvoll). |
|
||||
| `--domain <name>` | Öffentlicher Name. Setzt `server_name`; die Site ist dann **nicht** mehr die Vorgabe-Site. |
|
||||
| `--acme-email <a>` | Erst zusammen mit `--domain` wird ein Let's-Encrypt-Zertifikat angefordert (und HSTS gesetzt). Ohne die Adresse bleibt es selbstsigniert -- interne Namen wie `lizenz.firma.local` kann keine CA bestätigen. |
|
||||
| `--instance <name>` | Zusätzliche Instanz neben der Hauptinstallation. |
|
||||
| `--port` / `--http-port` / `--https-port` | Abweichende Ports (nur für Testinstanzen sinnvoll). |
|
||||
| `--skip-packages` | Keine Systempakete installieren. |
|
||||
|
||||
Danach:
|
||||
@@ -55,17 +55,17 @@ Danach:
|
||||
sudo -u tesm tesm-admin create-admin
|
||||
```
|
||||
|
||||
Beim Lizenzserver zusaetzlich, **in dieser Reihenfolge**:
|
||||
Beim Lizenzserver zusätzlich, **in dieser Reihenfolge**:
|
||||
|
||||
```bash
|
||||
sudo -u tesm-license tesm-license-admin init-key # Signaturschluessel -- sofort sichern!
|
||||
sudo -u tesm-license tesm-license-admin init-key # Signaturschlüssel -- sofort sichern!
|
||||
sudo -u tesm-license tesm-license-admin bootstrap # eigene Master-Lizenz
|
||||
sudo -u tesm-license tesm-license-admin create-admin
|
||||
```
|
||||
|
||||
> **Der Signaturschluessel ist nicht wiederherstellbar.** Geht
|
||||
> **Der Signaturschlüssel ist nicht wiederherstellbar.** Geht
|
||||
> `data/master_signing_key.json` verloren, ist keine ausgestellte Lizenz mehr
|
||||
> pruefbar. Sichern Sie ihn getrennt vom Server.
|
||||
> prüfbar. Sichern Sie ihn getrennt vom Server.
|
||||
|
||||
### Zwei Anwendungen auf einem Host
|
||||
|
||||
@@ -78,7 +78,7 @@ sudo bash deploy/install.sh --app tesm-license --https --domain lizenz.firma.loc
|
||||
|
||||
Die Installation **ohne** Domain wird zur Vorgabe-Site (`default_server`) und
|
||||
beantwortet alles, was zu keinem Namen passt. Zwei namenlose Sites auf
|
||||
demselben Port weist nginx ab -- richtig so, und beim Uebernehmen sofort
|
||||
demselben Port weist nginx ab -- richtig so, und beim Übernehmen sofort
|
||||
sichtbar.
|
||||
|
||||
---
|
||||
@@ -90,19 +90,57 @@ sudo bash deploy/install.sh --app tesm
|
||||
```
|
||||
|
||||
Derselbe Befehl. Das Skript erkennt an der vorhandenen Datenbank, dass es eine
|
||||
Aktualisierung ist, und tut dann zusaetzlich:
|
||||
Aktualisierung ist, und tut dann zusätzlich:
|
||||
|
||||
1. **Vollstaendige Sicherung** nach `/srv/<name>-backup-<zeitstempel>` --
|
||||
unabhaengig davon, ob sich das Schema geaendert hat.
|
||||
1. **Vollständige Sicherung** nach `/srv/<name>-backup-<zeitstempel>` --
|
||||
unabhängig davon, ob sich das Schema geändert hat.
|
||||
2. Dateiabgleich; `data/` bleibt unangetastet.
|
||||
3. Schema-Migrationen beim Start (versioniert, mit Pruefsumme).
|
||||
4. **Gesundheitspruefung.** Antwortet die Anwendung nicht, wird der alte Stand
|
||||
automatisch zurueckgeholt; die fehlgeschlagene Version bleibt unter
|
||||
3. Schema-Migrationen beim Start (versioniert, mit Prüfsumme).
|
||||
4. **Gesundheitsprüfung.** Antwortet die Anwendung nicht, wird der alte Stand
|
||||
automatisch zurückgeholt; die fehlgeschlagene Version bleibt unter
|
||||
`/srv/<name>-failed-<zeitstempel>` zur Analyse liegen.
|
||||
|
||||
Eine bereits eingerichtete HTTPS-Konfiguration bleibt erhalten, auch ohne
|
||||
`--https`.
|
||||
|
||||
### Ohne Quellbaum: `update.sh`
|
||||
|
||||
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
|
||||
@@ -114,7 +152,7 @@ Drei Wege dorthin:
|
||||
|
||||
**a) Bei der Installation** -- siehe oben, `--https`.
|
||||
|
||||
**b) Nachtraeglich ueber die Kommandozeile:**
|
||||
**b) Nachträglich über die Kommandozeile:**
|
||||
|
||||
```bash
|
||||
sudo -u tesm tesm-admin web-setup --https --self-signed --apply
|
||||
@@ -124,42 +162,42 @@ sudo -u tesm tesm-admin web-setup --https --domain tesm.firma.de \
|
||||
|
||||
`--print-config` zeigt die Datei, ohne etwas zu schreiben.
|
||||
|
||||
**c) In der Oberflaeche** unter *Einstellungen -> Webserver und TLS*.
|
||||
**c) In der Oberfläche** unter *Einstellungen -> Webserver und TLS*.
|
||||
|
||||
Danach muss `COOKIE_SECURE` in `/etc/tesm/<name>.env` auf `1` stehen (alle drei
|
||||
Wege erledigen das selbst) und der Dienst neu gestartet werden.
|
||||
|
||||
### Die Site-Datei wird immer neu erzeugt
|
||||
|
||||
Sie ist **kein** Ort fuer Handarbeit. Jede Uebernahme schreibt sie vollstaendig
|
||||
aus der Vorlage neu. Was frueher von Hand hineingeschrieben wurde -- Domain,
|
||||
Sie ist **kein** Ort für Handarbeit. Jede Übernahme schreibt sie vollständig
|
||||
aus der Vorlage neu. Was früher von Hand hineingeschrieben wurde -- Domain,
|
||||
Ports, Zertifikatspfade, HSTS, Upload-Grenze -- ist heute Einstellung in der
|
||||
Anwendung.
|
||||
|
||||
Der Grund steht im Vorgaengerprojekt: dort wurde die Datei nur angelegt, wenn
|
||||
sie fehlte. Ein einmal falscher `alias`-Pfad ueberlebte dadurch jahrelang jedes
|
||||
Update, und die Oberflaeche kam die ganze Zeit ohne Design.
|
||||
Der Grund steht im Vorgängerprojekt: dort wurde die Datei nur angelegt, wenn
|
||||
sie fehlte. Ein einmal falscher `alias`-Pfad überlebte dadurch jahrelang jedes
|
||||
Update, und die Oberfläche kam die ganze Zeit ohne Design.
|
||||
|
||||
### Selbstsigniert oder Let's Encrypt?
|
||||
|
||||
* **Oeffentlich erreichbar, echter DNS-Name:** Let's Encrypt. Nur dann ist HSTS
|
||||
* **Öffentlich erreichbar, echter DNS-Name:** Let's Encrypt. Nur dann ist HSTS
|
||||
sinnvoll.
|
||||
* **Geschlossenes Netz:** selbstsigniert. Browser zeigen eine Warnung, die
|
||||
Verbindung ist trotzdem verschluesselt und das Sitzungscookie darf `Secure`
|
||||
Verbindung ist trotzdem verschlüsselt und das Sitzungscookie darf `Secure`
|
||||
tragen. **HSTS hier nicht einschalten** -- der Browser merkt sich die Vorgabe
|
||||
monatelang und die Warnung laesst sich dann nicht mehr wegklicken.
|
||||
monatelang und die Warnung lässt sich dann nicht mehr wegklicken.
|
||||
|
||||
Die Erneuerung von Let's Encrypt laeuft ueber `certbot certonly --webroot`
|
||||
(bewusst nicht ueber das nginx-Plugin, das die erzeugte Site umschreiben
|
||||
wuerde). Der ACME-Pfad bleibt auch bei aktiver Umleitung erreichbar.
|
||||
Die Erneuerung von Let's Encrypt läuft über `certbot certonly --webroot`
|
||||
(bewusst nicht über das nginx-Plugin, das die erzeugte Site umschreiben
|
||||
würde). Der ACME-Pfad bleibt auch bei aktiver Umleitung erreichbar.
|
||||
|
||||
---
|
||||
|
||||
## 5. Dienste
|
||||
|
||||
```bash
|
||||
systemctl status tesm.service # Weboberflaeche (gunicorn)
|
||||
systemctl status tesm-monitor.service # Ueberwachungsschleife (nur TESM)
|
||||
systemctl status tesm.service # Weboberfläche (gunicorn)
|
||||
systemctl status tesm-monitor.service # Überwachungsschleife (nur TESM)
|
||||
systemctl status tesm-license.service
|
||||
journalctl -u tesm -n 100 -f
|
||||
```
|
||||
@@ -179,8 +217,8 @@ Die Anwendung hat keine. Alles, was Root braucht, geht durch **einen** Pfad:
|
||||
/usr/local/lib/tesm/tesm-helper <verb> [argumente]
|
||||
```
|
||||
|
||||
Der Helfer gehoert `root`, ist per sudoers ausschliesslich fuer genau diesen
|
||||
Pfad mit `NOPASSWD` freigegeben und prueft jedes Argument gegen eine
|
||||
Der Helfer gehört `root`, ist per sudoers ausschliesslich für genau diesen
|
||||
Pfad mit `NOPASSWD` freigegeben und prüft jedes Argument gegen eine
|
||||
Positivliste. Er reicht nie etwas an eine Shell weiter (kein `eval`, kein
|
||||
`sh -c`).
|
||||
|
||||
@@ -191,16 +229,16 @@ sudo /usr/local/lib/tesm/tesm-helper
|
||||
grep -n ')$' /usr/local/lib/tesm/tesm-helper
|
||||
```
|
||||
|
||||
Regel fuer Aenderungen: **Jedes neue Verb braucht eine Argumentpruefung.**
|
||||
Regel für Änderungen: **Jedes neue Verb braucht eine Argumentprüfung.**
|
||||
`tests/test_repo_hygiene.py` erzwingt, dass jedes der Anwendung bekannte Verb
|
||||
im Helfer vorkommt und dass jede Pfadpruefung `..` abweist.
|
||||
im Helfer vorkommt und dass jede Pfadprüfung `..` abweist.
|
||||
|
||||
---
|
||||
|
||||
## 7. Sicherung und Wiederherstellung
|
||||
|
||||
In der Oberflaeche unter *Sicherung* -- verschluesselte Ausfuhr (Argon2id +
|
||||
AES-256-GCM) mit einem selbst gewaehlten Kennwort. Der Import zeigt zuerst eine
|
||||
In der Oberfläche unter *Sicherung* -- verschlüsselte Ausfuhr (Argon2id +
|
||||
AES-256-GCM) mit einem selbst gewählten Kennwort. Der Import zeigt zuerst eine
|
||||
Vorschau, bevor irgendetwas geschrieben wird.
|
||||
|
||||
Auf dem Host reicht:
|
||||
@@ -214,10 +252,10 @@ sudo systemctl start tesm
|
||||
Unverzichtbar:
|
||||
|
||||
* `data/app.db` -- alle Daten
|
||||
* `data/secret.key` -- ohne sie sind alle Sitzungen ungueltig
|
||||
* `data/secret.key` -- ohne sie sind alle Sitzungen ungültig
|
||||
* `data/data.keys` -- ohne sie sind **alle gespeicherten Geheimnisse verloren**
|
||||
* `data/license.json` und `data/license_key.json`
|
||||
* Lizenzserver zusaetzlich: `data/master_signing_key.json`
|
||||
* Lizenzserver zusätzlich: `data/master_signing_key.json`
|
||||
|
||||
---
|
||||
|
||||
@@ -230,17 +268,17 @@ curl -s http://127.0.0.1:5000/gesundheit
|
||||
```
|
||||
Antwortet gunicorn, aber der Browser nicht, liegt es an nginx.
|
||||
|
||||
**nginx laesst sich nicht neu laden.**
|
||||
**nginx lässt sich nicht neu laden.**
|
||||
```bash
|
||||
sudo nginx -t
|
||||
```
|
||||
Die Uebernahme aus der Anwendung nimmt eine abgelehnte Konfiguration selbst
|
||||
zurueck. Eine von Hand bearbeitete Datei tut das nicht -- in dem Fall hilft
|
||||
Die Übernahme aus der Anwendung nimmt eine abgelehnte Konfiguration selbst
|
||||
zurück. Eine von Hand bearbeitete Datei tut das nicht -- in dem Fall hilft
|
||||
`install.sh`, das eine unbrauchbare Site durch die Vorlage ersetzt.
|
||||
|
||||
**Anmeldung schlaegt ohne Fehlermeldung fehl.**
|
||||
**Anmeldung schlägt ohne Fehlermeldung fehl.**
|
||||
Fast immer `COOKIE_SECURE=1` ohne HTTPS. Der Browser bekommt dann ein Cookie,
|
||||
das er ueber HTTP nie zuruecksendet.
|
||||
das er über HTTP nie zurücksendet.
|
||||
```bash
|
||||
grep COOKIE_SECURE /etc/tesm/tesm.env
|
||||
```
|
||||
@@ -250,38 +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
|
||||
den Port.
|
||||
|
||||
**Statische Dateien fehlen, die Oberflaeche ist unformatiert.**
|
||||
**Statische Dateien fehlen, die Oberfläche ist unformatiert.**
|
||||
```bash
|
||||
grep alias /etc/nginx/sites-available/tesm
|
||||
ls /srv/tesm/static /srv/tesm/static-core
|
||||
```
|
||||
Beide `alias`-Pfade muessen in das Installationsverzeichnis zeigen.
|
||||
Beide `alias`-Pfade müssen in das Installationsverzeichnis zeigen.
|
||||
|
||||
**Zwei Instanzen stoeren sich.**
|
||||
**Zwei Instanzen stören sich.**
|
||||
`SITE_KEY` in beiden `/etc/tesm/*.env` vergleichen -- er muss sich
|
||||
unterscheiden. Sonst teilen sich beide nginx-Site und Sitzungscookie.
|
||||
|
||||
**Die Wartung meldet "Der SSH-Host-Schluessel ist unbekannt".**
|
||||
**Die Wartung meldet "Der SSH-Host-Schlüssel ist unbekannt".**
|
||||
Kein Fehler, sondern die Absicht: ein Hintergrundauftrag darf keinen fremden
|
||||
Schluessel akzeptieren. Die Freigabe erfolgt von Hand, in zwei Schritten, auf
|
||||
der Detailseite des Geraets (*Clients -> Geraet -> SSH-Host-Schluessel*):
|
||||
erst **Auslesen**, dann den Fingerprint mit einer unabhaengigen Quelle
|
||||
vergleichen, dann **Freigeben**. Die Wartungsuebersicht zeigt in einer eigenen
|
||||
Spalte, fuer welche Geraete das noch offen ist. Fuer Switche liegt dieselbe
|
||||
Schlüssel akzeptieren. Die Freigabe erfolgt von Hand, in zwei Schritten, auf
|
||||
der Detailseite des Geräts (*Clients -> Gerät -> SSH-Host-Schlüssel*):
|
||||
erst **Auslesen**, dann den Fingerprint mit einer unabhängigen Quelle
|
||||
vergleichen, dann **Freigeben**. Die Wartungsübersicht zeigt in einer eigenen
|
||||
Spalte, für welche Geräte das noch offen ist. Für Switche liegt dieselbe
|
||||
Freigabe auf der Switch-Detailseite.
|
||||
|
||||
Der Schluessel wird nach **IP und Port** gespeichert, nicht nach Name -- TESM
|
||||
verbindet sich immer ueber die hinterlegte IP-Adresse. Aendert sich die IP
|
||||
eines Geraets, ist der Schluessel erneut freizugeben.
|
||||
Der Schlüssel wird nach **IP und Port** gespeichert, nicht nach Name -- TESM
|
||||
verbindet sich immer über die hinterlegte IP-Adresse. Ändert sich die IP
|
||||
eines Geräts, ist der Schlüssel erneut freizugeben.
|
||||
|
||||
**Die Pruefkette des Aenderungsprotokolls ist gebrochen.**
|
||||
**Die Prüfkette des Änderungsprotokolls ist gebrochen.**
|
||||
Unter *Diagnose* sichtbar. Das Protokoll ist verkettet gehasht; ein Bruch heisst,
|
||||
dass jemand direkt in die Datenbank geschrieben hat. Der Eintrag, ab dem es
|
||||
nicht mehr stimmt, wird genannt.
|
||||
|
||||
---
|
||||
|
||||
## 9. Zurueck auf eine aeltere Version
|
||||
## 9. Zurück auf eine ältere Version
|
||||
|
||||
```bash
|
||||
sudo systemctl stop tesm
|
||||
@@ -290,6 +328,6 @@ sudo cp -a /srv/tesm-backup-<zeitstempel> /srv/tesm
|
||||
sudo systemctl start tesm
|
||||
```
|
||||
|
||||
Schema-Migrationen laufen nur vorwaerts. Ein Ruecksprung ueber eine
|
||||
Schema-Aenderung hinweg braucht deshalb auch die Datenbank aus der Sicherung --
|
||||
Schema-Migrationen laufen nur vorwärts. Ein Rücksprung über eine
|
||||
Schema-Änderung hinweg braucht deshalb auch die Datenbank aus der Sicherung --
|
||||
genau das liegt im Backup-Verzeichnis.
|
||||
|
||||
+56
-56
@@ -1,6 +1,6 @@
|
||||
# Sicherheit
|
||||
|
||||
Was geschuetzt wird, womit, und wo bewusst Grenzen gezogen sind.
|
||||
Was geschützt wird, womit, und wo bewusst Grenzen gezogen sind.
|
||||
|
||||
---
|
||||
|
||||
@@ -9,35 +9,35 @@ Was geschuetzt wird, womit, und wo bewusst Grenzen gezogen sind.
|
||||
| | |
|
||||
|---|---|
|
||||
| Speicherung | Argon2id (argon2-cffi) |
|
||||
| Altbestand | bcrypt wird noch geprueft und bei der naechsten erfolgreichen Anmeldung stillschweigend auf Argon2id umgestellt |
|
||||
| Zweiter Faktor | TOTP, optional erzwingbar fuer Administratoren |
|
||||
| Altbestand | bcrypt wird noch geprüft und bei der nächsten erfolgreichen Anmeldung stillschweigend auf Argon2id umgestellt |
|
||||
| Zweiter Faktor | TOTP, optional erzwingbar für Administratoren |
|
||||
| Sperre je Konto | nach `LOGIN_MAX_ATTEMPTS` Versuchen |
|
||||
| Sperre je Adresse | zusaetzlich `LOGIN_MAX_ATTEMPTS_PER_IP` -- sonst laesst sich die Kontosperre umgehen, indem man viele Konten je einmal probiert |
|
||||
| Verzeichnisdienst | LDAP nur ueber TLS, Zertifikat wird geprueft. Ohne gueltiges Zertifikat keine Anmeldung -- kein Schalter, der das abschaltet |
|
||||
| Sperre je Adresse | zusätzlich `LOGIN_MAX_ATTEMPTS_PER_IP` -- sonst lässt sich die Kontosperre umgehen, indem man viele Konten je einmal probiert |
|
||||
| Verzeichnisdienst | LDAP nur über TLS, Zertifikat wird geprüft. Ohne gültiges Zertifikat keine Anmeldung -- kein Schalter, der das abschaltet |
|
||||
|
||||
Nach einer Anmeldung wird die Sitzungskennung neu vergeben (gegen Session
|
||||
Fixation) und das CSRF-Token gewechselt.
|
||||
|
||||
### Erneute Bestaetigung ("sudo-Modus")
|
||||
### Erneute Bestätigung ("sudo-Modus")
|
||||
|
||||
Alles, was Geheimnisse offenlegt oder Daten aus dem System heraustraegt,
|
||||
Alles, was Geheimnisse offenlegt oder Daten aus dem System herausträgt,
|
||||
verlangt eine frische Passworteingabe: Klartextanzeige von Zugangsdaten,
|
||||
Ausfuhr, Herunterladen eines Lizenzbundles, Schluesselrotation,
|
||||
2FA-Aenderungen, Deaktivierung einer Lizenz.
|
||||
Ausfuhr, Herunterladen eines Lizenzbundles, Schlüsselrotation,
|
||||
2FA-Änderungen, Deaktivierung einer Lizenz.
|
||||
|
||||
Ein uebernommenes Sitzungscookie allein reicht dafuer nicht.
|
||||
Ein übernommenes Sitzungscookie allein reicht dafür nicht.
|
||||
|
||||
---
|
||||
|
||||
## 2. Sitzungen
|
||||
|
||||
Serverseitig in SQLite, nicht im Cookie. Das Cookie traegt nur eine
|
||||
Serverseitig in SQLite, nicht im Cookie. Das Cookie trägt nur eine
|
||||
Zufallskennung.
|
||||
|
||||
* `HttpOnly`, `SameSite=Lax`, `Secure` sobald HTTPS eingerichtet ist
|
||||
* Leerlaufzeit **und** absolute Hoechstdauer
|
||||
* Der Cookiename enthaelt den `SITE_KEY` -- zwei Installationen auf einem Host
|
||||
koennen sich die Sitzung nicht gegenseitig ueberschreiben (Cookies
|
||||
* Leerlaufzeit **und** absolute Höchstdauer
|
||||
* Der Cookiename enthält den `SITE_KEY` -- zwei Installationen auf einem Host
|
||||
können sich die Sitzung nicht gegenseitig überschreiben (Cookies
|
||||
unterscheiden keine Ports)
|
||||
* Aktive Sitzungen sind einsehbar und einzeln beendbar
|
||||
|
||||
@@ -45,22 +45,22 @@ Zufallskennung.
|
||||
|
||||
## 3. CSRF
|
||||
|
||||
Drei voneinander unabhaengige Schichten:
|
||||
Drei voneinander unabhängige Schichten:
|
||||
|
||||
1. `SameSite=Lax` am Sitzungscookie.
|
||||
2. Ein Token je Sitzung, in jedem Formular, in konstanter Zeit verglichen.
|
||||
3. Origin-/Referer-Pruefung gegen den Host der Anfrage.
|
||||
3. Origin-/Referer-Prüfung gegen den Host der Anfrage.
|
||||
|
||||
Die dritte Schicht akzeptiert den Host mit und ohne Port sowie die
|
||||
Standardports -- **nicht** aber beliebige Ports: ein anderer Dienst auf
|
||||
demselben Rechner ist ein anderer Ursprung.
|
||||
|
||||
Der Vorgaenger hatte gar keinen CSRF-Schutz. Eine praeparierte Seite konnte im
|
||||
Namen eines angemeldeten Administrators Geraete loeschen oder Benutzer anlegen.
|
||||
Der Vorgänger hatte gar keinen CSRF-Schutz. Eine präparierte Seite konnte im
|
||||
Namen eines angemeldeten Administrators Geräte löschen oder Benutzer anlegen.
|
||||
|
||||
Ausnahmen (`@csrf.exempt`) gibt es nur fuer Maschinenschnittstellen, die sich
|
||||
**nicht** ueber Cookies autorisieren. Eine cookie-autorisierte Route ohne
|
||||
CSRF-Schutz waere eine Luecke.
|
||||
Ausnahmen (`@csrf.exempt`) gibt es nur für Maschinenschnittstellen, die sich
|
||||
**nicht** über Cookies autorisieren. Eine cookie-autorisierte Route ohne
|
||||
CSRF-Schutz wäre eine Lücke.
|
||||
|
||||
---
|
||||
|
||||
@@ -69,75 +69,75 @@ CSRF-Schutz waere eine Luecke.
|
||||
Bei jeder Antwort:
|
||||
|
||||
* `Content-Security-Policy` mit einem Nonce je Anfrage. **Kein**
|
||||
`unsafe-inline` fuer Skripte -- deshalb gibt es im ganzen Projekt kein
|
||||
Inline-Script ohne Nonce, und JavaScript haengt ueber
|
||||
`unsafe-inline` für Skripte -- deshalb gibt es im ganzen Projekt kein
|
||||
Inline-Script ohne Nonce, und JavaScript hängt über
|
||||
`data-behavior`-Attribute am DOM. `style-src` erlaubt `unsafe-inline`, weil
|
||||
einzelne berechnete Breiten (Fortschrittsbalken) als Style-Attribut gesetzt
|
||||
werden; Style-Attribute fuehren keinen Code aus.
|
||||
werden; Style-Attribute führen keinen Code aus.
|
||||
* `X-Content-Type-Options: nosniff`
|
||||
* `X-Frame-Options: DENY`, `frame-ancestors 'none'`
|
||||
* `Referrer-Policy: same-origin`
|
||||
* `Permissions-Policy` -- Kamera, Mikrofon, Ort, Zahlung, USB abgeschaltet
|
||||
* `Cross-Origin-Opener-Policy`, `Cross-Origin-Resource-Policy`
|
||||
* `Strict-Transport-Security` nur, wenn TLS tatsaechlich terminiert wird
|
||||
* `Strict-Transport-Security` nur, wenn TLS tatsächlich terminiert wird
|
||||
|
||||
> HSTS gehoert **nicht** zu einem selbstsignierten Zertifikat. Der Browser
|
||||
> merkt sich die Vorgabe monatelang; die Warnung laesst sich danach nicht mehr
|
||||
> HSTS gehört **nicht** zu einem selbstsignierten Zertifikat. Der Browser
|
||||
> merkt sich die Vorgabe monatelang; die Warnung lässt sich danach nicht mehr
|
||||
> wegklicken. `install.sh` setzt HSTS deshalb nur zusammen mit Let's Encrypt.
|
||||
|
||||
---
|
||||
|
||||
## 5. Geheimnisse im Ruhezustand
|
||||
|
||||
Ein Schluesselspeicher mit AES-256-GCM (`data/data.keys`, Modus 0600):
|
||||
Ein Schlüsselspeicher mit AES-256-GCM (`data/data.keys`, Modus 0600):
|
||||
|
||||
* Versionierte Schluessel, rotierbar. Chiffrate tragen ihre Schluesselkennung
|
||||
* Versionierte Schlüssel, rotierbar. Chiffrate tragen ihre Schlüsselkennung
|
||||
(`v1.<id>.<nonce>.<ct>`) und bleiben nach einer Rotation lesbar.
|
||||
* Der Verwendungszweck geht als AAD in die Verschluesselung ein. Ein Chiffrat
|
||||
aus einem Kontext laesst sich in einem anderen nicht entschluesseln.
|
||||
* Beim Start wird geprueft, ob die Rechte auf Schluessel- und Lizenzdateien
|
||||
* Der Verwendungszweck geht als AAD in die Verschlüsselung ein. Ein Chiffrat
|
||||
aus einem Kontext lässt sich in einem anderen nicht entschlüsseln.
|
||||
* Beim Start wird geprüft, ob die Rechte auf Schlüssel- und Lizenzdateien
|
||||
noch stimmen; Abweichungen erscheinen unter *Diagnose*.
|
||||
|
||||
Passwoerter fuer Mounts gehen ueber `stdin` an den privilegierten Helfer, der
|
||||
Passwörter für Mounts gehen über `stdin` an den privilegierten Helfer, der
|
||||
sie in eine 0600-Datei schreibt -- nie als Kommandozeilenargument (Prozessliste)
|
||||
und nie ueber die Umgebung (`/proc/<pid>/environ`).
|
||||
und nie über die Umgebung (`/proc/<pid>/environ`).
|
||||
|
||||
---
|
||||
|
||||
## 6. Rechtetrennung auf dem Host
|
||||
|
||||
Der Webprozess laeuft als `tesm` bzw. `tesm-license`. Root-Aktionen gehen durch
|
||||
Der Webprozess läuft als `tesm` bzw. `tesm-license`. Root-Aktionen gehen durch
|
||||
genau einen allowlisted Helfer (`/usr/local/lib/tesm/tesm-helper`), der per
|
||||
sudoers ausschliesslich fuer diesen Pfad freigegeben ist.
|
||||
sudoers ausschliesslich für diesen Pfad freigegeben ist.
|
||||
|
||||
Regeln, die im Repository durch Tests erzwungen werden:
|
||||
|
||||
* Jedes Verb, das die Anwendung kennt, existiert im Helfer.
|
||||
* Kein `eval`, kein `bash -c`, kein `sh -c`.
|
||||
* Jede Pfadpruefung weist `..` ab -- sonst laesst sich eine Positivliste ueber
|
||||
* Jede Pfadprüfung weist `..` ab -- sonst lässt sich eine Positivliste über
|
||||
den Basisnamen unterlaufen (`/etc/nginx/sites-available/../../../root/x` hat
|
||||
den zulaessigen Basisnamen `x`).
|
||||
den zulässigen Basisnamen `x`).
|
||||
* Der Instanzname ist auf `tesm[-<instanz>]` begrenzt; er landet in Pfaden.
|
||||
* Keine sudoers-Regel ausser der einen. `NOPASSWD: ALL` gibt es nicht.
|
||||
|
||||
Die systemd-Units haben `NoNewPrivileges=yes`, `ProtectSystem=strict`,
|
||||
`PrivateTmp=yes` und eine enge `RestrictAddressFamilies`-Liste.
|
||||
|
||||
Der Vorgaenger liess die komplette Flask-Anwendung als root laufen. Eine
|
||||
einzige Luecke in irgendeinem Pfad haette den Host bedeutet.
|
||||
Der Vorgänger liess die komplette Flask-Anwendung als root laufen. Eine
|
||||
einzige Lücke in irgendeinem Pfad hätte den Host bedeutet.
|
||||
|
||||
---
|
||||
|
||||
## 7. Aenderungsprotokoll
|
||||
## 7. Änderungsprotokoll
|
||||
|
||||
Verkettet gehasht: jeder Eintrag enthaelt den Hash seines Vorgaengers. Ein
|
||||
nachtraegliches Aendern oder Loeschen faellt auf und wird unter *Diagnose*
|
||||
Verkettet gehasht: jeder Eintrag enthält den Hash seines Vorgängers. Ein
|
||||
nachträgliches Ändern oder Löschen fällt auf und wird unter *Diagnose*
|
||||
angezeigt, mitsamt dem Eintrag, ab dem die Kette bricht.
|
||||
|
||||
Protokolliert wird, wer was wann von welcher Adresse getan hat -- ausdruecklich
|
||||
Protokolliert wird, wer was wann von welcher Adresse getan hat -- ausdrücklich
|
||||
auch Lesezugriffe auf Geheimnisse (`credential.secret_revealed`).
|
||||
|
||||
Aeltere Tage wandern in JSONL-Archive und lassen sich als ZIP herunterladen.
|
||||
Ältere Tage wandern in JSONL-Archive und lassen sich als ZIP herunterladen.
|
||||
|
||||
---
|
||||
|
||||
@@ -145,20 +145,20 @@ Aeltere Tage wandern in JSONL-Archive und lassen sich als ZIP herunterladen.
|
||||
|
||||
Siehe [ARCHITEKTUR.md](ARCHITEKTUR.md), Abschnitt Lizenzierung. Kurz:
|
||||
getrennte Signaturkontexte, kanonisches JSON, Nonce gegen Wiedereinspielung,
|
||||
Frischefenster, `seq` gegen Rueckstufung, Antwort an die Anfrage gebunden,
|
||||
Frischefenster, `seq` gegen Rückstufung, Antwort an die Anfrage gebunden,
|
||||
Aussteller-Pinning.
|
||||
|
||||
Das Auslieferungsbundle enthaelt den privaten Clientschluessel und ist damit
|
||||
Das Auslieferungsbundle enthält den privaten Clientschlüssel und ist damit
|
||||
selbst ein Geheimnis: eigenes Recht (`licenses.export`), frische
|
||||
Passwortbestaetigung, `Cache-Control: no-store`, Eintrag im Zustell- und im
|
||||
Aenderungsprotokoll.
|
||||
Passwortbestätigung, `Cache-Control: no-store`, Eintrag im Zustell- und im
|
||||
Änderungsprotokoll.
|
||||
|
||||
---
|
||||
|
||||
## 9. Ein- und Ausfuhr
|
||||
|
||||
Verschluesselte Umschlaege (Argon2id-Ableitung + AES-256-GCM) mit einem selbst
|
||||
gewaehlten Kennwort. Der Import zeigt eine Vorschau, bevor etwas geschrieben
|
||||
Verschlüsselte Umschläge (Argon2id-Ableitung + AES-256-GCM) mit einem selbst
|
||||
gewählten Kennwort. Der Import zeigt eine Vorschau, bevor etwas geschrieben
|
||||
wird; die Vorschau liegt in der Datenbank, nicht im Prozessspeicher -- deshalb
|
||||
braucht der Dienst kein `--workers 1` mehr.
|
||||
|
||||
@@ -166,11 +166,11 @@ braucht der Dienst kein `--workers 1` mehr.
|
||||
|
||||
## 10. Bewusst nicht getan
|
||||
|
||||
* **Kein Schalter, der die LDAP-Zertifikatspruefung abschaltet.** Wer ihn
|
||||
* **Kein Schalter, der die LDAP-Zertifikatsprüfung abschaltet.** Wer ihn
|
||||
einmal setzt, setzt ihn dauerhaft.
|
||||
* **Kein CSRF-Freibrief fuer cookie-autorisierte Routen.**
|
||||
* **Kein `unsafe-inline` fuer Skripte**, auch nicht "voruebergehend".
|
||||
* **Keine Rechteausweitung**, auch nicht fuer Administratoren untereinander.
|
||||
* **Kein CSRF-Freibrief für cookie-autorisierte Routen.**
|
||||
* **Kein `unsafe-inline` für Skripte**, auch nicht "vorübergehend".
|
||||
* **Keine Rechteausweitung**, auch nicht für Administratoren untereinander.
|
||||
* **Kein Hostname im Lizenz-Fingerabdruck** -- Umbenennen darf die Bindung
|
||||
nicht brechen.
|
||||
* **Keine von Hand gepflegte nginx-Site.** Sie wird immer neu erzeugt.
|
||||
@@ -179,5 +179,5 @@ braucht der Dienst kein `--workers 1` mehr.
|
||||
|
||||
## 11. Wenn Sie eine Schwachstelle finden
|
||||
|
||||
Bitte nicht oeffentlich melden. Wenden Sie sich an die im Anbieterprofil des
|
||||
Bitte nicht öffentlich melden. Wenden Sie sich an die im Anbieterprofil des
|
||||
Lizenzservers hinterlegte Adresse.
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
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"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -8,7 +8,7 @@ Design-System.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
__version__ = "2.0.0"
|
||||
__version__ = "2.0.2"
|
||||
|
||||
from .app_factory import create_base_app
|
||||
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 trash as trash_views
|
||||
|
||||
#: Rechteinhaber, in dieser Reihenfolge. Steht an einer Stelle, weil die
|
||||
#: Fusszeile in zwei Vorlagen auftaucht (Anwendungsrahmen und Anmeldeseiten)
|
||||
#: und zwei Texte fuer dieselbe Aussage auseinanderlaufen.
|
||||
COPYRIGHT_NOTICE = "Tim Eertmoed · WiS IT-Solutions GmbH"
|
||||
|
||||
CORE_SETTINGS: tuple[SettingSpec, ...] = (
|
||||
SettingSpec("nav_order", [], "json", "Reihenfolge der Navigation", section="intern"),
|
||||
SettingSpec("nav_child_order", {}, "json", "Reihenfolge der Unterpunkte", section="intern"),
|
||||
@@ -56,6 +61,18 @@ CORE_SETTINGS: tuple[SettingSpec, ...] = (
|
||||
minimum=1,
|
||||
maximum=3650,
|
||||
),
|
||||
SettingSpec(
|
||||
"session_idle_minutes",
|
||||
30,
|
||||
"int",
|
||||
"Automatische Abmeldung nach Leerlauf (Minuten)",
|
||||
hint="Jede Anfrage setzt den Zaehler zurueck -- gezaehlt wird nur Untaetigkeit. "
|
||||
"Gilt auch fuer die eingebundenen Dateifreigaben: mit der Sitzung endet die "
|
||||
"Einbindung.",
|
||||
section="system",
|
||||
minimum=5,
|
||||
maximum=1440,
|
||||
),
|
||||
SettingSpec("timezone", "", "str", "Zeitzone des Hosts", section="system"),
|
||||
SettingSpec("hostname", "", "str", "Hostname", section="system"),
|
||||
SettingSpec(
|
||||
@@ -117,7 +134,7 @@ def create_base_app(
|
||||
#: Seitenhintergrund. Der Lizenzserver setzt sie bewusst nicht: die
|
||||
#: Funktionsliste von TESM waere dort schlicht falsch.
|
||||
auth_tagline: str = "",
|
||||
app_version: str = "2.0.0",
|
||||
app_version: str = "2.0.2",
|
||||
built_in_trusted_keys: tuple[str, ...] = (),
|
||||
enable_license: bool = True,
|
||||
template_folder: str | None = None,
|
||||
@@ -192,11 +209,25 @@ def create_base_app(
|
||||
)
|
||||
app.extensions["tesm"] = extension
|
||||
|
||||
def _leerlauf(conn: Any) -> int:
|
||||
"""Leerlaufgrenze aus den Einstellungen -- ueber die offene Verbindung.
|
||||
|
||||
Bewusst mit der Verbindung, die die Sitzungsverwaltung schon haelt: eine
|
||||
zweite zu oeffnen waere eine verschachtelte Datenbankverbindung, und die
|
||||
gilt hier als Fehler.
|
||||
"""
|
||||
try:
|
||||
wert = int(settings.get(conn, "session_idle_minutes") or 0)
|
||||
except (TypeError, ValueError):
|
||||
wert = 0
|
||||
return wert if wert > 0 else config.security.session_idle_minutes
|
||||
|
||||
app.session_interface = SqliteSessionInterface(
|
||||
database,
|
||||
cookie_name=app.config["SESSION_COOKIE_NAME"],
|
||||
idle_minutes=config.security.session_idle_minutes,
|
||||
absolute_hours=config.security.session_absolute_hours,
|
||||
idle_lookup=_leerlauf,
|
||||
)
|
||||
|
||||
# -- Schema und Startzustand ------------------------------------------ #
|
||||
@@ -264,6 +295,7 @@ def create_base_app(
|
||||
"auth_tagline": auth_tagline,
|
||||
"app_version": app_version,
|
||||
"current_year": datetime.now(timezone.utc).year,
|
||||
"copyright_notice": COPYRIGHT_NOTICE,
|
||||
"current_user": current_user,
|
||||
"nav_items": nav_items,
|
||||
"palette_entries": palette,
|
||||
|
||||
@@ -42,7 +42,7 @@ AAD_LDAP_BIND = "service_accounts.ldap_bind"
|
||||
|
||||
#: userAccountControl-Bit fuer "Konto deaktiviert".
|
||||
ACCOUNTDISABLE = 0x0002
|
||||
#: OID fuer rekursive Gruppenauflösung in AD.
|
||||
#: OID fuer rekursive Gruppenaufloesung in AD.
|
||||
MATCHING_RULE_IN_CHAIN = "1.2.840.113556.1.4.1941"
|
||||
|
||||
Status = Literal["ok", "invalid", "unavailable", "disabled"]
|
||||
@@ -349,7 +349,7 @@ def _attr_int(entry: Any, name: str) -> int | None:
|
||||
|
||||
|
||||
def _resolve_groups(connection: Any, base: str, user_dn: str) -> tuple[str, ...]:
|
||||
"""Rekursive Gruppenauflösung ueber LDAP_MATCHING_RULE_IN_CHAIN."""
|
||||
"""Rekursive Gruppenaufloesung ueber LDAP_MATCHING_RULE_IN_CHAIN."""
|
||||
try:
|
||||
connection.search(
|
||||
search_base=base,
|
||||
@@ -359,7 +359,7 @@ def _resolve_groups(connection: Any, base: str, user_dn: str) -> tuple[str, ...]
|
||||
)
|
||||
return tuple(str(entry.entry_dn) for entry in connection.entries)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("Gruppenauflösung fehlgeschlagen: %s", exc)
|
||||
logger.warning("Gruppenaufloesung fehlgeschlagen: %s", exc)
|
||||
return ()
|
||||
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ class SecurityConfig:
|
||||
cookie_secure: bool = True
|
||||
cookie_samesite: str = "Lax"
|
||||
#: Leerlauf-Zeitfenster einer Sitzung.
|
||||
session_idle_minutes: int = 60
|
||||
session_idle_minutes: int = 30
|
||||
#: Harte Obergrenze unabhaengig von Aktivitaet.
|
||||
session_absolute_hours: int = 12
|
||||
#: Wie lange eine erneute Passwortabfrage ("sudo-Modus") gilt.
|
||||
@@ -147,7 +147,7 @@ def load_core_config(
|
||||
security = SecurityConfig(
|
||||
cookie_secure=_env_bool(env_prefix, "COOKIE_SECURE", True),
|
||||
cookie_samesite=_env(env_prefix, "COOKIE_SAMESITE") or "Lax",
|
||||
session_idle_minutes=_env_int(env_prefix, "SESSION_IDLE_MINUTES", 60),
|
||||
session_idle_minutes=_env_int(env_prefix, "SESSION_IDLE_MINUTES", 30),
|
||||
session_absolute_hours=_env_int(env_prefix, "SESSION_ABSOLUTE_HOURS", 12),
|
||||
reauth_valid_minutes=_env_int(env_prefix, "REAUTH_VALID_MINUTES", 15),
|
||||
login_max_attempts=_env_int(env_prefix, "LOGIN_MAX_ATTEMPTS", 8),
|
||||
|
||||
@@ -48,6 +48,10 @@ SETTING_STATUS = "license_activation_status"
|
||||
SETTING_PENDING = "license_pending_request"
|
||||
SETTING_LAST_HEARTBEAT = "license_last_heartbeat_at"
|
||||
SETTING_HEARTBEAT_ERROR = "license_heartbeat_error"
|
||||
|
||||
#: Ab wann die Restlaufzeit als *Hinweis* erscheint (gruen). Unterhalb von
|
||||
#: ``EXPIRY_WARNING_DAYS`` uebernimmt die Warnung (orange), nach Ablauf Rot.
|
||||
EXPIRY_HINT_DAYS = 90
|
||||
SETTING_EXPORT_GRACE = "license_export_grace"
|
||||
|
||||
STATUS_INACTIVE = "inactive"
|
||||
@@ -596,6 +600,14 @@ class LicenseManager:
|
||||
message = "Verbindung zum Lizenzserver bestaetigt."
|
||||
|
||||
self.settings.set(conn, SETTING_PENDING, "", actor=actor)
|
||||
# Ein erfolgreicher Heartbeat laeuft alle paar Minuten und ist kein
|
||||
# Ereignis, sondern der Normalzustand. Im Auditlog stand dadurch
|
||||
# praktisch nur noch er, und die Eintraege, um die es dort geht --
|
||||
# Aktivierung, Deaktivierung, Widerruf --, gingen darin unter. Der
|
||||
# Zeitpunkt des letzten Heartbeats steht ohnehin in den Einstellungen
|
||||
# und auf der Lizenzseite. Ein *fehlgeschlagener* Heartbeat wird
|
||||
# weiterhin protokolliert (siehe die Fehlerpfade oben).
|
||||
if action != "heartbeat":
|
||||
audit.log(
|
||||
conn,
|
||||
actor=actor,
|
||||
@@ -704,6 +716,19 @@ class LicenseManager:
|
||||
except LicenseError as exc:
|
||||
with self.database.transaction(conn):
|
||||
self.settings.set(conn, SETTING_HEARTBEAT_ERROR, str(exc), actor="system")
|
||||
# Der *erfolgreiche* Heartbeat wird nicht protokolliert -- er
|
||||
# ist der Normalzustand und hat das Auditlog zugeschuettet.
|
||||
# Ein Fehlschlag dagegen ist genau das, was dort stehen soll:
|
||||
# er kann bedeuten, dass die Lizenz demnaechst in die
|
||||
# Kulanzzeit laeuft.
|
||||
audit.log(
|
||||
conn,
|
||||
actor="system",
|
||||
action="license.heartbeat_failed",
|
||||
target=self._state.license_id or "",
|
||||
detail=str(exc)[:500],
|
||||
severity="warning",
|
||||
)
|
||||
return {"ok": False, "message": str(exc)}
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
@@ -761,6 +786,17 @@ class LicenseManager:
|
||||
"dot_class": "status-pill__dot--pulse",
|
||||
"title": "Bitte rechtzeitig verlaengern.",
|
||||
}
|
||||
# Zwischen der Warnschwelle und 90 Tagen: gruen, rein als Hinweis. Wer
|
||||
# eine Verlaengerung im Voraus plant, will das Datum sehen, ohne dass es
|
||||
# nach einem Problem aussieht.
|
||||
if status.days_left is not None and 0 < status.days_left <= EXPIRY_HINT_DAYS:
|
||||
return {
|
||||
"label": f"Lizenz laeuft in {status.days_left} Tagen ab",
|
||||
"css_class": "",
|
||||
"dot": "1",
|
||||
"dot_class": "",
|
||||
"title": "Nur ein Hinweis -- es ist nichts eingeschraenkt.",
|
||||
}
|
||||
if state.last_heartbeat_at:
|
||||
try:
|
||||
last = lic.parse_iso(state.last_heartbeat_at)
|
||||
|
||||
@@ -88,11 +88,24 @@ class SqliteSessionInterface(SessionInterface):
|
||||
cookie_name: str,
|
||||
idle_minutes: int,
|
||||
absolute_hours: int,
|
||||
idle_lookup: Any = None,
|
||||
) -> None:
|
||||
self.database = database
|
||||
self.cookie_name = cookie_name
|
||||
self.idle_minutes = idle_minutes
|
||||
self.absolute_hours = absolute_hours
|
||||
#: Optionale Nachschlagefunktion fuer die Leerlaufgrenze. Damit ist die
|
||||
#: Zeit zur Laufzeit einstellbar, ohne den Dienst neu zu starten -- und
|
||||
#: ohne eine zweite Datenbankverbindung, denn sie bekommt die offene.
|
||||
self._idle_lookup = idle_lookup
|
||||
|
||||
def _idle(self, conn: Any) -> int:
|
||||
if self._idle_lookup is None:
|
||||
return self.idle_minutes
|
||||
try:
|
||||
return int(self._idle_lookup(conn)) or self.idle_minutes
|
||||
except Exception: # pragma: no cover - Einstellung unlesbar
|
||||
return self.idle_minutes
|
||||
|
||||
# -- Flask-Schnittstelle ------------------------------------------- #
|
||||
|
||||
@@ -146,7 +159,7 @@ class SqliteSessionInterface(SessionInterface):
|
||||
with self.database.transaction(conn):
|
||||
conn.execute(
|
||||
"UPDATE user_sessions SET last_seen_at=?, idle_expires_at=? WHERE id=?",
|
||||
(_iso(now), _iso(now + timedelta(minutes=self.idle_minutes)), sid),
|
||||
(_iso(now), _iso(now + timedelta(minutes=self._idle(conn))), sid),
|
||||
)
|
||||
return session
|
||||
|
||||
@@ -190,7 +203,7 @@ class SqliteSessionInterface(SessionInterface):
|
||||
session.get("user_id"),
|
||||
_iso(now),
|
||||
_iso(now),
|
||||
_iso(now + timedelta(minutes=self.idle_minutes)),
|
||||
_iso(now + timedelta(minutes=self._idle(conn))),
|
||||
_iso(now + timedelta(hours=self.absolute_hours)),
|
||||
session.get("_ip", ""),
|
||||
session.get("_ua", ""),
|
||||
|
||||
@@ -161,6 +161,40 @@
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-ring);
|
||||
}
|
||||
/* Der Knopf im Dateifeld kommt vom Browser und ignoriert jede Aussenfarbe:
|
||||
im dunklen Thema ein hellgraues Kaestchen mit dunkler Schrift, das weder
|
||||
zum Feld noch zu den uebrigen Knoepfen passt und kaum zu lesen war.
|
||||
Deshalb explizit wie ein Sekundaerknopf gestaltet -- und weil vier Felder
|
||||
betroffen sind (Sicherung, Lizenz, Profilbild, Freigaben), einmal hier. */
|
||||
.input[type="file"] {
|
||||
padding: 5px 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.input[type="file"]::file-selector-button {
|
||||
padding: 5px 12px;
|
||||
margin-right: var(--sp-1);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 570;
|
||||
cursor: pointer;
|
||||
transition: background var(--dur-fast), border-color var(--dur-fast);
|
||||
}
|
||||
.input[type="file"]:hover::file-selector-button {
|
||||
background: var(--surface-3);
|
||||
border-color: var(--text-faint);
|
||||
}
|
||||
.input[type="file"]:focus-visible::file-selector-button {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.input[readonly], .input[disabled], .select[disabled], .textarea[disabled] {
|
||||
background: var(--surface-inset);
|
||||
color: var(--text-muted);
|
||||
@@ -374,6 +408,15 @@
|
||||
background: var(--danger-soft);
|
||||
color: var(--danger);
|
||||
}
|
||||
/* Gefuellt statt getoent: "kritisch" muss sich von "Warnung" unterscheiden,
|
||||
und beide sind rot. */
|
||||
.pill--critical {
|
||||
background: var(--danger);
|
||||
color: var(--surface-0);
|
||||
font-weight: 700;
|
||||
}
|
||||
.pill--critical::before { background: currentColor; }
|
||||
|
||||
.pill--warning, .pill--unknown, .pill--orange {
|
||||
background: var(--warning-soft);
|
||||
color: var(--warning);
|
||||
@@ -402,6 +445,22 @@
|
||||
background: var(--success);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
/* Der Knopf sitzt *in* der Pille: kein Rahmen, keine Flaeche, nur das
|
||||
Symbol in der Farbe der Pille. Ein Standardknopf wuerde sie sprengen. */
|
||||
.status-pill__action { display: inline-flex; }
|
||||
.status-pill__action button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0;
|
||||
margin-left: 2px;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: inherit;
|
||||
opacity: 0.65;
|
||||
cursor: pointer;
|
||||
}
|
||||
.status-pill__action button:hover { opacity: 1; }
|
||||
|
||||
.status-pill__dot--pulse { animation: pulse-ring 2.4s var(--ease) infinite; }
|
||||
.status-pill--danger { border-color: color-mix(in srgb, var(--danger) 45%, var(--border)); color: var(--danger); }
|
||||
.status-pill--danger .status-pill__dot { background: var(--danger); }
|
||||
@@ -621,6 +680,37 @@
|
||||
}
|
||||
.modal__title { font-size: var(--fs-md); font-weight: 640; flex: 1; }
|
||||
.modal__sub { font-size: var(--fs-xs); color: var(--text-faint); margin-top: 2px; }
|
||||
/* Eine Datei-Vorschau soll die ganze Flaeche des Dialogs nutzen; der
|
||||
uebliche Innenabstand des Dialogkoerpers wuerde sie einrahmen. */
|
||||
.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 {
|
||||
padding: var(--sp-5);
|
||||
overflow-y: auto;
|
||||
@@ -1001,6 +1091,16 @@
|
||||
.warning-text { color: var(--warning); }
|
||||
.accent-text { color: var(--accent); }
|
||||
.mono { font-family: var(--font-mono); font-size: 0.94em; }
|
||||
/* Ein Inline-SVG ist im Reset display:block (base.css). In einem Knopf
|
||||
stoert das nicht -- .btn ist inline-flex. In einem einfachen Link reisst
|
||||
es die Beschriftung in die naechste Zeile. Fuer solche Links deshalb
|
||||
ausdruecklich eine Zeile. */
|
||||
.link-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.nowrap { white-space: nowrap; }
|
||||
.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
|
||||
.break-all { overflow-wrap: anywhere; }
|
||||
|
||||
@@ -28,7 +28,7 @@ function store(key, value) {
|
||||
if (value === null) localStorage.removeItem(key);
|
||||
else localStorage.setItem(key, value);
|
||||
} catch {
|
||||
/* Privatmodus o. Ä. -- Einstellungen sind dann nur fluechtig. */
|
||||
/* Privatmodus o. Ae. -- Einstellungen sind dann nur fluechtig. */
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,13 +212,21 @@ function initDialogs() {
|
||||
event.preventDefault();
|
||||
const dialog = document.getElementById(opener.dataset.dialogOpen);
|
||||
if (dialog) {
|
||||
// Felder aus data-fill-* des Auslösers in das Formular uebernehmen.
|
||||
// Felder aus data-fill-* des Ausloesers in das Formular uebernehmen.
|
||||
Object.entries(opener.dataset).forEach(([key, value]) => {
|
||||
if (!key.startsWith("fill")) return;
|
||||
const name = key.slice(4);
|
||||
const target = dialog.querySelector(`[data-fill-target="${name}"]`);
|
||||
if (!target) return;
|
||||
if ("value" in target) target.value = value;
|
||||
// Wohin der Wert gehoert, haengt am Element: ein Rahmen will die
|
||||
// Quelle, ein Link das Ziel, ein Feld einen Wert. Ohne diese
|
||||
// Unterscheidung landete die URL als *Beschriftung* im
|
||||
// Herunterladen-Knopf -- und weil das href dabei "#" blieb, lud ein
|
||||
// Klick die Seite selbst als "Herunterladen.htm" herunter.
|
||||
const als = target.dataset.fillAs;
|
||||
if (als === "src") target.src = value;
|
||||
else if (als === "href") target.href = value;
|
||||
else if ("value" in target) target.value = value;
|
||||
else target.textContent = value;
|
||||
});
|
||||
openDialog(dialog);
|
||||
@@ -563,7 +571,8 @@ function initAutoRefresh() {
|
||||
// von innerHTML entsteht ein neues Element -- mit scrollTop 0. Genau
|
||||
// deshalb sprang ein laufendes Auftragsprotokoll bei jeder
|
||||
// Aktualisierung an den Anfang zurueck.
|
||||
const scrollerOf = (el) => el.querySelector(".log-view, [data-scroll]") || el;
|
||||
const scrollerOf = (el) =>
|
||||
el.querySelector("[data-tail], .log-view, [data-scroll]") || el;
|
||||
const before = scrollerOf(region);
|
||||
const position = before.scrollTop;
|
||||
// "Am Ende" grosszuegig auslegen: wer mitliest, soll mitgezogen
|
||||
@@ -814,6 +823,25 @@ function initTileFilters() {
|
||||
|
||||
/* -- Start ---------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Protokollansichten stehen am Ende, nicht am Anfang.
|
||||
*
|
||||
* Bei einem Protokoll ist die neueste Zeile die interessante, und die steht
|
||||
* unten. Ohne diesen Anfangssprung blieb die Ansicht oben stehen -- und weil
|
||||
* die Aktualisierung nur mitzieht, wenn man ohnehin schon am Ende ist, zog
|
||||
* sie danach nie mit. Es sah aus, als waere der Autoscroll kaputt; in
|
||||
* Wirklichkeit stand man nur nie an der Stelle, an der er greift.
|
||||
*
|
||||
* Ausdruecklich per ``data-tail`` markiert: eine Konfigurationsvorschau
|
||||
* (nginx, Kea) und der Offline-Code sind auch <pre class="log-view">, sollen
|
||||
* aber oben beginnen.
|
||||
*/
|
||||
function initLogTails(root = document) {
|
||||
root.querySelectorAll("[data-tail]").forEach((view) => {
|
||||
view.scrollTop = view.scrollHeight;
|
||||
});
|
||||
}
|
||||
|
||||
function boot() {
|
||||
initTheme();
|
||||
initSidebar();
|
||||
@@ -830,6 +858,7 @@ function boot() {
|
||||
initPalette();
|
||||
initSubmitGuards();
|
||||
initTileFilters();
|
||||
initLogTails();
|
||||
unsavedGuard.init();
|
||||
}
|
||||
|
||||
|
||||
@@ -166,6 +166,13 @@ class SysOps:
|
||||
input=stdin,
|
||||
capture_output=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,
|
||||
check=False,
|
||||
)
|
||||
@@ -197,6 +204,8 @@ class SysOps:
|
||||
[binary, *command[1:]],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=timeout,
|
||||
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
|
||||
Kommas und Leerzeichen und ein Tippfehler faellt erst bei der naechsten
|
||||
Der DN einer AD-Gruppe ist nichts, was man abtippt: er ist lang, enthält
|
||||
Kommas und Leerzeichen und ein Tippfehler fällt erst bei der nächsten
|
||||
Anmeldung auf -- als stille Nicht-Zuordnung. Deshalb gesucht statt getippt.
|
||||
|
||||
Das Suchergebnis kommt aus dem Verzeichnis selbst; die Felder werden dabei
|
||||
beide gefuellt (Anzeigename und DN). Getippt werden darf trotzdem: bei einer
|
||||
Umgebung ohne Dienstkonto ist die Suche nicht moeglich, und dann ist ein
|
||||
eingefuegter DN besser als gar keine Zuordnung.
|
||||
beide gefüllt (Anzeigename und DN). Getippt werden darf trotzdem: bei einer
|
||||
Umgebung ohne Dienstkonto ist die Suche nicht möglich, und dann ist ein
|
||||
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.
|
||||
#}
|
||||
{% macro group_picker(name_field, dn_field, scope, name_value='', dn_value='',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{% if lines %}
|
||||
<pre class="log-view">{% for line in lines %}{{ line }}
|
||||
<pre class="log-view" data-tail>{% for line in lines %}{{ line }}
|
||||
{% endfor %}</pre>
|
||||
{% else %}
|
||||
<div class="empty"><div class="empty__title">Keine Zeilen</div>
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
<div class="card__body">
|
||||
{% if current_user.is_directory_account %}
|
||||
{{ alert('Ihre Stammdaten kommen aus dem Verzeichnisdienst und werden bei jeder
|
||||
Anmeldung uebernommen.', 'info') }}
|
||||
Anmeldung übernommen.', 'info') }}
|
||||
{% endif %}
|
||||
<div class="form-grid">
|
||||
{{ field('first_name', 'Vorname', value=profile.first_name,
|
||||
@@ -48,7 +48,7 @@
|
||||
</div>
|
||||
<div class="card__body">
|
||||
{% 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 %}
|
||||
<div class="row row--tight">
|
||||
{% for permission in permissions %}
|
||||
|
||||
@@ -35,26 +35,26 @@
|
||||
<div class="stack">
|
||||
<section class="card">
|
||||
<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>
|
||||
{% if current_user.is_directory_account %}
|
||||
<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>
|
||||
{% else %}
|
||||
<form method="post" action="{{ url_for('account.change_password') }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<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') }}
|
||||
{{ field('new_password', 'Neues Passwort', type='password', required=True,
|
||||
autocomplete='new-password',
|
||||
hint_text='Mindestens ' ~ policy.min_length ~ ' Zeichen und zwei verschiedene Zeichenarten.') }}
|
||||
{{ field('repeat_password', 'Neues Passwort wiederholen', type='password', required=True,
|
||||
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 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>
|
||||
{% endif %}
|
||||
</section>
|
||||
@@ -70,7 +70,7 @@
|
||||
</div>
|
||||
<div class="card__body stack">
|
||||
{% 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">
|
||||
<dt>Geheimnis</dt>
|
||||
<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 class="input input--mono" type="text" name="code" required maxlength="6"
|
||||
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>
|
||||
{% elif totp_enabled %}
|
||||
<dl class="kv fs-sm">
|
||||
@@ -100,7 +100,7 @@
|
||||
<form method="post" action="{{ url_for('account.recovery_codes') }}" data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<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">
|
||||
Codes erneuern</button>
|
||||
</form>
|
||||
@@ -114,10 +114,10 @@
|
||||
</div>
|
||||
{% else %}
|
||||
{% 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 %}
|
||||
<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>
|
||||
<form method="post" action="{{ url_for('account.totp_start') }}" data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{# ==========================================================================
|
||||
Grundgeruest aller angemeldeten Seiten.
|
||||
Bloecke: extra_head, page_title, page_sub, breadcrumb, topbar_extra,
|
||||
Grundgerüst aller angemeldeten Seiten.
|
||||
Blöcke: extra_head, page_title, page_sub, breadcrumb, topbar_extra,
|
||||
content, dialogs, scripts
|
||||
========================================================================== #}
|
||||
<!doctype html>
|
||||
@@ -23,9 +23,9 @@
|
||||
|
||||
<aside class="sidebar" aria-label="Hauptnavigation">
|
||||
<div class="sidebar__brand">
|
||||
{# Die Wortmarke traegt den Namen selbst -- ein zweiter Textblock daneben
|
||||
waere eine Doppelung. Eine einzige Zeichnung, deren Farben aus den
|
||||
Themen-Token kommen: zwei per Media Query versteckte SVG waeren bei
|
||||
{# Die Wortmarke trägt den Namen selbst -- ein zweiter Textblock daneben
|
||||
wäre eine Doppelung. Eine einzige Zeichnung, deren Farben aus den
|
||||
Themen-Token kommen: zwei per Media Query versteckte SVG wären bei
|
||||
"System" und beim Umschalten von Hand falsch. #}
|
||||
<span class="sidebar__logo" role="img" aria-label="{{ app_name }}">
|
||||
<svg viewBox="0 0 312 112" xmlns="http://www.w3.org/2000/svg">
|
||||
@@ -126,9 +126,25 @@
|
||||
<div class="topbar__right">
|
||||
{% block topbar_extra %}{% endblock %}
|
||||
{% 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 %}>
|
||||
{% if pill.dot %}<span class="status-pill__dot {{ pill.dot_class }}"></span>{% endif %}
|
||||
{{ 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>
|
||||
{% endfor %}
|
||||
<button class="btn btn--icon btn--ghost hide-sm" type="button" data-behavior="open-palette"
|
||||
@@ -141,7 +157,7 @@
|
||||
</main>
|
||||
|
||||
<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">
|
||||
{% if migration_warning %}
|
||||
<a class="danger-text" href="{{ url_for('diagnostics.overview') }}">Schemawarnung</a>
|
||||
@@ -163,17 +179,17 @@
|
||||
aria-label="Suchbegriff" autocomplete="off">
|
||||
<div class="palette__list"></div>
|
||||
<div class="palette__hint">
|
||||
<span><kbd>↑</kbd><kbd>↓</kbd> waehlen</span>
|
||||
<span><kbd>Enter</kbd> oeffnen</span>
|
||||
<span><kbd>↑</kbd><kbd>↓</kbd> wählen</span>
|
||||
<span><kbd>Enter</kbd> öffnen</span>
|
||||
<span><kbd>Esc</kbd> schliessen</span>
|
||||
</div>
|
||||
</dialog>
|
||||
<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">
|
||||
<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 class="modal__body"><p data-confirm-text></p></div>
|
||||
<div class="modal__footer">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
{% from "tesm_core/macros.html" import alert, pill, stat %}
|
||||
|
||||
{% 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 %}
|
||||
<div class="grid grid--4">
|
||||
@@ -17,17 +17,17 @@
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<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 %}
|
||||
</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 class="card__body">
|
||||
{% if chain.ok %}
|
||||
<p class="muted fs-sm">
|
||||
Jede Zeile bindet den Hash ihrer Vorgaengerin ein. Eine nachtraeglich entfernte oder
|
||||
geaenderte Zeile wuerde hier auffallen.
|
||||
Jede Zeile bindet den Hash ihrer Vorgängerin ein. Eine nachträglich entfernte oder
|
||||
geänderte Zeile würde hier auffallen.
|
||||
</p>
|
||||
{% else %}
|
||||
{{ alert(chain.reason ~ ' Erster Bruch bei Eintrag ' ~ chain.broken_at ~ '.', 'danger',
|
||||
@@ -40,17 +40,17 @@
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<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 %}
|
||||
</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>
|
||||
{% if current_user.is_admin %}
|
||||
<form method="post" action="{{ url_for('diagnostics.rotate_keys') }}" data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<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-title="Schluessel rotieren" data-confirm-ok="Rotieren" data-confirm-style="primary">
|
||||
data-confirm="Neuen Datenschlüssel anlegen und gespeicherte Geheimnisse neu verschlüsseln? Der alte Schlüssel bleibt zum Lesen erhalten."
|
||||
data-confirm-title="Schlüssel rotieren" data-confirm-ok="Rotieren" data-confirm-style="primary">
|
||||
{{ icon('refresh', size=14) }} Rotieren
|
||||
</button>
|
||||
</form>
|
||||
@@ -63,8 +63,8 @@
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="muted fs-sm">
|
||||
Alle Schluesseldateien liegen mit Modus 0600. Feldverschluesselung ist AES-256-GCM mit
|
||||
rotierbarem Schluesselbund; jedes Chiffrat ist an seinen Verwendungszweck gebunden.
|
||||
Alle Schlüsseldateien liegen mit Modus 0600. Feldverschlüsselung ist AES-256-GCM mit
|
||||
rotierbarem Schlüsselbund; jedes Chiffrat ist an seinen Verwendungszweck gebunden.
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -87,7 +87,7 @@
|
||||
<th class="sortable">Komponente</th>
|
||||
<th class="sortable numeric">Version</th>
|
||||
<th class="sortable">Migration</th>
|
||||
<th>Pruefsumme</th>
|
||||
<th>Prüfsumme</th>
|
||||
<th class="sortable">Angewendet</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -112,7 +112,7 @@
|
||||
<div class="flex-1">
|
||||
<div class="card__title">
|
||||
{{ 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') }}
|
||||
{% else %}{{ pill('Helfer antwortet nicht', 'danger') }}{% endif %}
|
||||
</div>
|
||||
@@ -121,7 +121,7 @@
|
||||
</div>
|
||||
<div class="card__body">
|
||||
<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.
|
||||
</p>
|
||||
{% if not sysops_available %}
|
||||
@@ -129,10 +129,10 @@
|
||||
Alles andere funktioniert normal -- das ist der erwartete Zustand in einer
|
||||
Entwicklungsumgebung.', 'info') }}
|
||||
{% elif sysops_selftest and not sysops_selftest.ok %}
|
||||
{# Geprueft wird ein echter Aufruf, nicht die blosse Existenz der Datei.
|
||||
Genau daran lag es einmal: die Seite meldete "erreichbar", waehrend
|
||||
{# Geprüft wird ein echter Aufruf, nicht die blosse Existenz der Datei.
|
||||
Genau daran lag es einmal: die Seite meldete "erreichbar", während
|
||||
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') }}
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -144,7 +144,7 @@
|
||||
{% if current_user.is_admin %}
|
||||
<form method="post" action="{{ url_for('diagnostics.housekeeping') }}" data-no-guard>
|
||||
<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>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -167,14 +167,14 @@
|
||||
{{ icon('key', size=17) }} Lizenzzustand
|
||||
{% if license_state.valid and license_state.activated %}{{ pill('aktiv', 'online') }}
|
||||
{% elif license_state.valid %}{{ pill('nicht aktiviert', 'warning') }}
|
||||
{% else %}{{ pill('ungueltig', 'danger') }}{% endif %}
|
||||
{% else %}{{ pill('ungültig', 'danger') }}{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card__body">
|
||||
<dl class="kv fs-sm">
|
||||
<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>Rolle</dt><dd>{{ license_state.role }}</dd>
|
||||
<dt>Module</dt><dd>{{ license_state.modules | sort | join(', ') or '–' | safe }}</dd>
|
||||
|
||||
@@ -7,11 +7,11 @@
|
||||
<div class="alert__body">{{ description }}</div>
|
||||
</div>
|
||||
{% 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 %}
|
||||
<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 }}">
|
||||
{{ '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>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -20,8 +20,8 @@
|
||||
{{ icon('lock', size=18) }}
|
||||
<div class="alert__body">
|
||||
<div class="alert__title">Systemgruppe</div>
|
||||
Name und Rechte sind gesperrt. Anders als im Vorgaenger werden die Rechte
|
||||
<strong>nicht</strong> bei jedem Neustart auf die Vorlage zurueckgesetzt –
|
||||
Name und Rechte sind gesperrt. Anders als im Vorgänger werden die Rechte
|
||||
<strong>nicht</strong> bei jedem Neustart auf die Vorlage zurückgesetzt –
|
||||
Anpassungen bleiben erhalten.
|
||||
{% 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) }}">
|
||||
@@ -49,8 +49,8 @@
|
||||
{% if group.preset_key and current_user.is_admin %}
|
||||
<button class="btn btn--sm" type="submit"
|
||||
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-title="Vorlage anwenden" data-confirm-ok="Zuruecksetzen" data-confirm-style="primary">
|
||||
data-confirm="Rechte dieser Gruppe auf die Vorlage zurücksetzen? Bestehende Anpassungen gehen verloren."
|
||||
data-confirm-title="Vorlage anwenden" data-confirm-ok="Zurücksetzen" data-confirm-style="primary">
|
||||
{{ icon('refresh', size=14) }} Vorlage anwenden
|
||||
</button>
|
||||
{% endif %}
|
||||
@@ -58,7 +58,7 @@
|
||||
<div class="card__body">
|
||||
<div class="form-grid">
|
||||
{{ 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,
|
||||
extra_class='field--full') }}
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
{% 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_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 %}
|
||||
{% if current_user.has('groups.create') %}
|
||||
@@ -13,9 +13,9 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{{ alert('Wer selbst ein Recht nicht besitzt, kann es auch nicht vergeben. Diese Regel gilt fuer
|
||||
alle ausser echten Administratoren und verhindert, dass sich ein Gruppenverwalter ueber eine
|
||||
neue Gruppe selbst hoeherstuft.', 'info', 'Eskalationsschutz') }}
|
||||
{{ 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 über eine
|
||||
neue Gruppe selbst höherstuft.', 'info', 'Eskalationsschutz') }}
|
||||
|
||||
<div class="grid grid--2">
|
||||
{% 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>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<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-title="Gruppe loeschen" data-confirm-ok="Loeschen"
|
||||
aria-label="Loeschen">{{ icon('trash', size=15) }}</button>
|
||||
data-confirm="Gruppe {{ group.name }} löschen? Sie landet im Papierkorb; Mitglieder verlieren die zugehörigen Rechte."
|
||||
data-confirm-title="Gruppe löschen" data-confirm-ok="Löschen"
|
||||
aria-label="Löschen">{{ icon('trash', size=15) }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -67,7 +67,7 @@ neue Gruppe selbst hoeherstuft.', 'info', 'Eskalationsschutz') }}
|
||||
|
||||
{% block dialogs %}
|
||||
{% 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') }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<div class="modal__body">
|
||||
|
||||
@@ -24,6 +24,6 @@
|
||||
</p>
|
||||
{% endif %}
|
||||
{% 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 %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{% extends "tesm_core/base.html" %}
|
||||
{% block page_title %}Archiv {{ day }}{% endblock %}
|
||||
{% block page_sub %}{{ rows | length }} archivierte Aenderungen{% endblock %}
|
||||
{% block page_sub %}{{ rows | length }} archivierte Änderungen{% endblock %}
|
||||
{% block breadcrumb %}
|
||||
<nav class="breadcrumb"><span><a href="{{ url_for('logs.history') }}">Verlauf</a></span><span>{{ day }}</span></nav>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{% extends "tesm_core/base.html" %}
|
||||
{% from "tesm_core/macros.html" import pill, empty_state, alert %}
|
||||
|
||||
{% block page_title %}Aenderungsprotokoll{% endblock %}
|
||||
{% block page_sub %}{{ total }} Eintraege · Hash-Kette {{ 'unversehrt' if chain.ok else 'gebrochen' }}{% endblock %}
|
||||
{% block page_title %}Änderungsprotokoll{% endblock %}
|
||||
{% block page_sub %}{{ total }} Einträge · Hash-Kette {{ 'unversehrt' if chain.ok else 'gebrochen' }}{% endblock %}
|
||||
|
||||
{% block topbar_extra %}
|
||||
{% if current_user.has('logs_audit.export') %}
|
||||
@@ -13,10 +13,10 @@
|
||||
{% block content %}
|
||||
{% if not chain.ok %}
|
||||
{{ alert(chain.reason ~ ' Erster Bruch bei Eintrag ' ~ chain.broken_at ~ '.', 'danger',
|
||||
'Das Protokoll wurde veraendert') }}
|
||||
'Das Protokoll wurde verändert') }}
|
||||
{% else %}
|
||||
{{ alert('Jeder Eintrag bindet den Hash seines Vorgaengers ein. Eine nachtraeglich entfernte
|
||||
oder geaenderte Zeile faellt bei der Pruefung auf -- ' ~ chain.checked ~ ' Zeilen geprueft.',
|
||||
{{ alert('Jeder Eintrag bindet den Hash seines Vorgängers ein. Eine nachträglich entfernte
|
||||
oder geänderte Zeile fällt bei der Prüfung auf -- ' ~ chain.checked ~ ' Zeilen geprüft.',
|
||||
'success') }}
|
||||
{% endif %}
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
<input class="input" type="text" name="benutzer" value="{{ filters.benutzer }}"
|
||||
placeholder="Benutzer" style="width:150px">
|
||||
<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>
|
||||
|
||||
<div class="table-wrap">
|
||||
@@ -66,10 +66,15 @@
|
||||
<td class="fs-sm">{{ row.target }}</td>
|
||||
<td class="fs-sm muted break-all">{{ row.detail }}</td>
|
||||
<td>
|
||||
{% if row.severity == 'critical' %}{{ pill('kritisch', 'danger') }}
|
||||
{% elif row.severity == 'warning' %}{{ pill('Warnung', 'warning') }}
|
||||
{% elif row.severity == 'notice' %}{{ pill('Hinweis', 'info') }}
|
||||
{% else %}{{ pill('Info', '') }}{% endif %}
|
||||
{# Ampel: Info grün, Hinweis gelb, Warnung rot. Vorher war Info
|
||||
grau und Hinweis blau -- dabei war nicht zu sehen, was harmlos
|
||||
ist und was Aufmerksamkeit braucht. "kritisch" bekommt eine
|
||||
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 class="mono fs-xs faint">{{ row.ip or '–' | safe }}</td>
|
||||
</tr>
|
||||
@@ -77,13 +82,13 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</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">
|
||||
<span class="fs-sm muted">Seite {{ page }}</span>
|
||||
<span class="row row--tight">
|
||||
{% 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 %}
|
||||
{% if rows | length == per_page %}
|
||||
<a class="btn btn--sm" href="{{ url_for('logs.audit_view', seite=page+1, **filters) }}">Weiter</a>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
{% block content %}
|
||||
{{ 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">
|
||||
<section class="card">
|
||||
@@ -19,7 +19,7 @@
|
||||
{% if rotated %}
|
||||
<div class="table-wrap">
|
||||
<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>
|
||||
{% for entry in rotated %}
|
||||
<tr>
|
||||
@@ -39,7 +39,7 @@
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<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>
|
||||
{% if archived and current_user.has('logs_history.export') %}
|
||||
@@ -52,7 +52,7 @@
|
||||
{% if archived %}
|
||||
<div class="table-wrap">
|
||||
<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>
|
||||
{% for entry in archived %}
|
||||
<tr>
|
||||
@@ -69,8 +69,8 @@
|
||||
<form method="post" action="{{ url_for('logs.history_purge') }}" data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<button class="btn btn--sm btn--ghost danger-text" type="submit"
|
||||
data-confirm="Alle {{ archived | length }} Archivdateien endgueltig vom Server loeschen? Bitte vorher als ZIP sichern."
|
||||
data-confirm-title="Archiv loeschen" data-confirm-ok="Loeschen">
|
||||
data-confirm="Alle {{ archived | length }} Archivdateien endgültig vom Server löschen? Bitte vorher als ZIP sichern."
|
||||
data-confirm-title="Archiv löschen" data-confirm-ok="Löschen">
|
||||
Archiv vom Server entfernen
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@@ -51,8 +51,22 @@
|
||||
</label>
|
||||
{%- 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) -%}
|
||||
<label class="check {% if disabled %}check--disabled{% endif %}">
|
||||
{{ _present(name, disabled) }}
|
||||
<input type="checkbox" name="{{ name }}" value="{{ value }}"
|
||||
{% if checked %}checked{% endif %} {% if disabled %}disabled{% endif %}>
|
||||
<span>{{ label }} {{ hint(hint_text) }}</span>
|
||||
@@ -61,6 +75,7 @@
|
||||
|
||||
{% macro switch(name, label, checked=False, value='1', disabled=False) -%}
|
||||
<label class="switch">
|
||||
{{ _present(name, disabled) }}
|
||||
<input type="checkbox" name="{{ name }}" value="{{ value }}"
|
||||
{% if checked %}checked{% endif %} {% if disabled %}disabled{% endif %}>
|
||||
<span>{{ label }}</span>
|
||||
@@ -196,12 +211,12 @@
|
||||
<span class="sr-only">{{ resource.label }} {{ action_meta[action][1] }}</span>
|
||||
</label>
|
||||
{% 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
|
||||
sich anhaken, hatte aber kein name-Attribut -- es sah erteilt
|
||||
aus und war nach dem Speichern weg. Was es nicht gibt, ist
|
||||
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>
|
||||
{% endif %}
|
||||
</td>
|
||||
@@ -216,7 +231,7 @@
|
||||
{% for action, meta in action_meta.items() %}
|
||||
<span><b>{{ meta[0] }}</b> {{ meta[1] }} – {{ meta[2] }}</span>
|
||||
{% endfor %}
|
||||
<span><span class="matrix__na"></span> Fuer diese Zeile nicht vorgesehen –
|
||||
die Spalte gehoert zu einer anderen Zeile desselben Bereichs.</span>
|
||||
<span><span class="matrix__na"></span> Für diese Zeile nicht vorgesehen –
|
||||
die Spalte gehört zu einer anderen Zeile desselben Bereichs.</span>
|
||||
</div>
|
||||
{%- endmacro %}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
{% 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 content %}
|
||||
<div class="alert alert--info">
|
||||
{{ icon('info', size=18) }}
|
||||
<div class="alert__body">
|
||||
Sie greifen auf Geheimnisse oder einen Datenexport zu. Ein uebernommenes
|
||||
Sitzungscookie allein reicht dafuer nicht aus – die Bestaetigung gilt
|
||||
Sie greifen auf Geheimnisse oder einen Datenexport zu. Ein übernommenes
|
||||
Sitzungscookie allein reicht dafür nicht aus – die Bestätigung gilt
|
||||
danach {{ reauth_minutes }} Minuten.
|
||||
</div>
|
||||
</div>
|
||||
@@ -19,7 +19,7 @@
|
||||
</label>
|
||||
<div class="row row--end">
|
||||
<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>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
@@ -11,10 +11,10 @@
|
||||
inputmode="numeric" autocomplete="one-time-code" maxlength="14"
|
||||
placeholder="123456" style="letter-spacing:.22em;text-align:center;font-size:1.15rem">
|
||||
<span class="field__hint">
|
||||
Alternativ koennen Sie einen Ihrer Wiederherstellungscodes eingeben.
|
||||
Alternativ können Sie einen Ihrer Wiederherstellungscodes eingeben.
|
||||
</span>
|
||||
</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>
|
||||
<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 %}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
{% block content %}
|
||||
{{ 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">
|
||||
<div class="table-wrap">
|
||||
@@ -17,8 +17,8 @@ ungueltig -- auch wenn das Cookie noch existiert.', 'info') }}
|
||||
<tr>
|
||||
<th class="sortable">Benutzer</th>
|
||||
<th class="sortable">Angemeldet</th>
|
||||
<th class="sortable">Letzte Aktivitaet</th>
|
||||
<th class="sortable">Laeuft ab</th>
|
||||
<th class="sortable">Letzte Aktivität</th>
|
||||
<th class="sortable">Läuft ab</th>
|
||||
<th class="sortable">IP</th>
|
||||
<th>Client</th>
|
||||
<th class="actions">Aktion</th>
|
||||
|
||||
@@ -12,16 +12,16 @@
|
||||
{% if license_hint %}{{ alert(license_hint, 'warning') }}{% endif %}
|
||||
|
||||
{{ 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') }}
|
||||
|
||||
{% if values.ldap_security == 'none' %}
|
||||
{{ alert('Die Verbindung ist unverschluesselt. Das Passwort geht dabei im Klartext ueber das
|
||||
Netz -- nur fuer Testumgebungen vertretbar.', 'danger', 'Unverschluesselt') }}
|
||||
{{ alert('Die Verbindung ist unverschlüsselt. Das Passwort geht dabei im Klartext über das
|
||||
Netz -- nur für Testumgebungen vertretbar.', 'danger', 'Unverschlüsselt') }}
|
||||
{% 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',
|
||||
'Zertifikat ungeprueft') }}
|
||||
'Zertifikat ungeprüft') }}
|
||||
{% endif %}
|
||||
|
||||
<div class="grid grid--split">
|
||||
@@ -48,8 +48,8 @@ niemals hier gespeichert.', 'info') }}
|
||||
{{ field('ldap_port', 'Port', value=values.ldap_port, type='number',
|
||||
disabled=bool(license_hint), attrs='id="ldap-port"') }}
|
||||
{# Der Transport setzt den Port mit. Wer umstellt und den Port stehen
|
||||
laesst, bekommt eine Verbindung, die auf gut Glueck scheitert --
|
||||
und die Meldung ("Zeitlimit") sagt nichts ueber die Ursache. Ein
|
||||
lässt, bekommt eine Verbindung, die auf gut Glück scheitert --
|
||||
und die Meldung ("Zeitlimit") sagt nichts über die Ursache. Ein
|
||||
absichtlich abweichender Port bleibt erhalten. #}
|
||||
{{ select_field('ldap_security', 'Transport', specs['ldap_security'].choices,
|
||||
selected=values.ldap_security, disabled=bool(license_hint),
|
||||
@@ -68,21 +68,21 @@ niemals hier gespeichert.', 'info') }}
|
||||
disabled=bool(license_hint)) }}
|
||||
</div>
|
||||
<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)) }}
|
||||
{{ 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)) }}
|
||||
{{ 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),
|
||||
hint_text=specs['ldap_allow_insecure'].hint) }}
|
||||
</div>
|
||||
|
||||
<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">
|
||||
{{ field('bind_username', 'Benutzer', value=bind_username, autocomplete='off',
|
||||
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',
|
||||
disabled=not current_user.has('settings_directory.secrets')) }}
|
||||
</div>
|
||||
@@ -125,7 +125,7 @@ niemals hier gespeichert.', 'info') }}
|
||||
action="{{ url_for('hostsettings.directory_mapping_delete', mapping_id=entry.id) }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<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">
|
||||
{{ icon('trash', size=14) }}</button>
|
||||
</form>
|
||||
|
||||
@@ -5,30 +5,30 @@
|
||||
{% block page_sub %}Backend: {{ backend }} · Sicherheitsnetz {{ revert_seconds }} s{% endblock %}
|
||||
|
||||
{% 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
|
||||
zurueckgerollt. So sperrt eine falsche Eingabe den Host nicht aus.', 'warning',
|
||||
'Automatischer Rueckfall') }}
|
||||
zurückgerollt. So sperrt eine falsche Eingabe den Host nicht aus.', 'warning',
|
||||
'Automatischer Rückfall') }}
|
||||
|
||||
{% if pending %}
|
||||
<section class="card card--danger">
|
||||
<div class="card__header">
|
||||
<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>
|
||||
</div>
|
||||
<div class="card__body">
|
||||
<form method="post" action="{{ url_for('hostsettings.network_confirm') }}" data-no-guard>
|
||||
<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>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% 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 %}
|
||||
|
||||
<section class="card">
|
||||
@@ -54,7 +54,7 @@ zurueckgerollt. So sperrt eine falsche Eingabe den Host nicht aus.', 'warning',
|
||||
</table>
|
||||
</div>
|
||||
{% 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 %}
|
||||
</section>
|
||||
|
||||
@@ -62,11 +62,11 @@ zurueckgerollt. So sperrt eine falsche Eingabe den Host nicht aus.', 'warning',
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<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">
|
||||
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
|
||||
unberuehrt.
|
||||
unberührt.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -88,18 +88,18 @@ zurueckgerollt. So sperrt eine falsche Eingabe den Host nicht aus.', 'warning',
|
||||
</select>
|
||||
</label>
|
||||
{{ 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('dns', 'DNS-Server', placeholder='192.168.1.1, 9.9.9.9') }}
|
||||
</div>
|
||||
{{ alert('Das Gateway muss im angegebenen Netz liegen -- sonst waere der Host nach der
|
||||
Aenderung nicht mehr erreichbar. Das wird vor dem Schreiben geprueft.', 'info') }}
|
||||
{{ alert('Das Gateway muss im angegebenen Netz liegen -- sonst wäre der Host nach der
|
||||
Änderung nicht mehr erreichbar. Das wird vor dem Schreiben geprüft.', 'info') }}
|
||||
</div>
|
||||
<div class="card__footer">
|
||||
<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-title="Netzwerk aendern" data-confirm-ok="Uebernehmen">
|
||||
Uebernehmen
|
||||
data-confirm="Netzwerkeinstellungen jetzt übernehmen? Die Verbindung kann kurz abbrechen. Ohne Bestätigung wird nach {{ revert_seconds }} Sekunden zurückgerollt."
|
||||
data-confirm-title="Netzwerk ändern" data-confirm-ok="Übernehmen">
|
||||
Übernehmen
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -10,14 +10,14 @@
|
||||
{% block content %}
|
||||
{% if license_hint %}{{ alert(license_hint, 'warning') }}{% endif %}
|
||||
{% 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') }}
|
||||
{% endif %}
|
||||
|
||||
{{ alert('Die Site-Datei wird bei jeder Uebernahme vollstaendig neu geschrieben.
|
||||
Alles, was ein Administrator sonst per Hand hineinschreiben wuerde, ist hier Einstellung. Der
|
||||
Vorgaenger 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',
|
||||
{{ alert('Die Site-Datei wird bei jeder Übernahme vollständig neu geschrieben.
|
||||
Alles, was ein Administrator sonst per Hand hineinschreiben würde, ist hier Einstellung. Der
|
||||
Vorgänger schrieb die Datei nur, wenn sie noch nicht existierte -- ein einmal falscher Pfad
|
||||
blieb dadurch über Jahre bestehen und lieferte alle statischen Dateien als 404.', 'info',
|
||||
'Warum die Datei immer neu geschrieben wird') }}
|
||||
|
||||
<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">
|
||||
{{ field('web_domain', 'Domain', value=config.domain,
|
||||
placeholder='tesm.firma.de', disabled=bool(license_hint),
|
||||
hint_text='Leer = Zugriff ueber IP-Adresse.') }}
|
||||
{{ field('web_acme_email', 'E-Mail fuer Let\'s Encrypt', value=acme_email, type='email',
|
||||
hint_text='Leer = Zugriff über IP-Adresse.') }}
|
||||
{{ field('web_acme_email', 'E-Mail für Let\'s Encrypt', value=acme_email, type='email',
|
||||
disabled=bool(license_hint)) }}
|
||||
{{ field('web_cert_path', 'Zertifikat (Pfad)', value=config.cert_path,
|
||||
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') }}
|
||||
{{ field('web_acme_webroot', 'ACME-Webroot', value=config.acme_webroot,
|
||||
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)) }}
|
||||
{{ checkbox('web_redirect_http', 'HTTP (Port 80) auf HTTPS umleiten',
|
||||
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.') }}
|
||||
{{ checkbox('web_hsts_enabled', 'HSTS-Header senden',
|
||||
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">
|
||||
<div class="flex-1">
|
||||
<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>
|
||||
{{ icon('chevron-down', size=16) }}
|
||||
</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') %}
|
||||
<dl class="kv fs-sm">
|
||||
<dt>Inhaber</dt><dd class="break-all">{{ certificate.subject }}</dd>
|
||||
<dt>Aussteller</dt><dd class="break-all">{{ certificate.issuer }}</dd>
|
||||
<dt>Gueltig bis</dt><dd>{{ certificate.not_valid_after | dt }}</dd>
|
||||
<dt>Aussteller</dt><dd class="break-all">{{ certificate.issür }}</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>
|
||||
</dl>
|
||||
{% 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"
|
||||
{% if license_hint or not certbot_available or not config.domain %}disabled{% endif %}
|
||||
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-style="primary">
|
||||
{{ 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>
|
||||
{% elif not config.domain %}
|
||||
<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.
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<hr>
|
||||
<p class="muted fs-sm">
|
||||
Kein oeffentlicher Name? Dann ein eigenes Zertifikat erzeugen. Browser
|
||||
zeigen dafuer eine Warnung, die Verbindung ist aber verschluesselt --
|
||||
Kein öffentlicher Name? Dann ein eigenes Zertifikat erzeugen. Browser
|
||||
zeigen dafür eine Warnung, die Verbindung ist aber verschlüsselt --
|
||||
und das Sitzungscookie darf endlich <span class="mono">Secure</span> tragen.
|
||||
</p>
|
||||
{% if local_names %}
|
||||
@@ -133,7 +133,7 @@ blieb dadurch ueber Jahre bestehen und lieferte alle statischen Dateien als 404.
|
||||
Wird ausgestellt auf:
|
||||
<span class="mono">{{ (config.domain or local_names[0]) }}</span>
|
||||
{% if local_names|length > 1 %}
|
||||
(zusaetzlich {{ local_names[1:] | join(', ') }})
|
||||
(zusätzlich {{ local_names[1:] | join(', ') }})
|
||||
{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
@@ -156,7 +156,7 @@ blieb dadurch ueber Jahre bestehen und lieferte alle statischen Dateien als 404.
|
||||
<section class="card card--accent">
|
||||
<div class="card__header">
|
||||
<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>
|
||||
</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() }}">
|
||||
<button class="btn btn--primary btn--block" type="submit"
|
||||
{% if license_hint or not sysops_available %}disabled{% endif %}
|
||||
data-busy-label="Uebernehme ..."
|
||||
data-confirm="Konfiguration schreiben, pruefen und nginx neu laden? Eine fehlerhafte Konfiguration wird vorher abgewiesen."
|
||||
data-confirm-title="nginx uebernehmen" data-confirm-ok="Uebernehmen"
|
||||
data-busy-label="Übernehme ..."
|
||||
data-confirm="Konfiguration schreiben, prüfen und nginx neu laden? Eine fehlerhafte Konfiguration wird vorher abgewiesen."
|
||||
data-confirm-title="nginx übernehmen" data-confirm-ok="Übernehmen"
|
||||
data-confirm-style="primary">
|
||||
Konfiguration uebernehmen
|
||||
Konfiguration übernehmen
|
||||
</button>
|
||||
</form>
|
||||
</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.
|
||||
|
||||
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
|
||||
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>
|
||||
<html lang="de">
|
||||
@@ -23,11 +23,11 @@
|
||||
<body data-csrf="{{ csrf_token() }}">
|
||||
<div class="auth-page">
|
||||
{% if auth_tagline %}
|
||||
{# Wortmarke mit Funktionszeile als tatsaechlicher Seitenhintergrund --
|
||||
uebernommen vom Vorgaenger. Bewusst inline und mit Themen-Tokens statt
|
||||
zweier Dateien fuer hell und dunkel: bei der Einstellung "System" und
|
||||
beim Umschalten von Hand waere die versteckte Variante die falsche.
|
||||
``aria-hidden``, weil der Name schon in der Ueberschrift steht. #}
|
||||
{# Wortmarke mit Funktionszeile als tatsächlicher Seitenhintergrund --
|
||||
übernommen vom Vorgänger. Bewusst inline und mit Themen-Tokens statt
|
||||
zweier Dateien für hell und dunkel: bei der Einstellung "System" und
|
||||
beim Umschalten von Hand wäre die versteckte Variante die falsche.
|
||||
``aria-hidden``, weil der Name schon in der Überschrift steht. #}
|
||||
<div class="auth-backdrop" aria-hidden="true">
|
||||
<svg viewBox="0 0 620 190" xmlns="http://www.w3.org/2000/svg">
|
||||
<g transform="translate(150,15)">
|
||||
@@ -71,7 +71,7 @@
|
||||
|
||||
{% 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>
|
||||
<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 %}
|
||||
|
||||
{% 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 %}
|
||||
{{ alert('Ansehen ist immer moeglich. Wiederherstellen und endgueltiges Loeschen erfordern eine
|
||||
gueltige, aktivierte Lizenz. Abgelaufene Eintraege werden beim Aufruf dieser Seite entfernt.',
|
||||
{{ alert('Ansehen ist immer möglich. Wiederherstellen und endgültiges Löschen erfordern eine
|
||||
gültige, aktivierte Lizenz. Abgelaufene Einträge werden beim Aufruf dieser Seite entfernt.',
|
||||
'info') }}
|
||||
|
||||
{% 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="kind" value="{{ section.key }}">
|
||||
<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-title="Endgueltig loeschen" data-confirm-ok="Endgueltig loeschen">
|
||||
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="Endgültig löschen" data-confirm-ok="Endgültig löschen">
|
||||
Bereich leeren
|
||||
</button>
|
||||
</form>
|
||||
@@ -33,7 +33,7 @@ gueltige, aktivierte Lizenz. Abgelaufene Eintraege werden beim Aufruf dieser Sei
|
||||
{% if section.entries %}
|
||||
<div class="table-wrap">
|
||||
<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>
|
||||
{% for entry in section.entries %}
|
||||
<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) }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<button class="btn btn--sm btn--ghost danger-text" type="submit"
|
||||
data-confirm="{{ entry.label }} endgueltig loeschen?"
|
||||
data-confirm-title="Endgueltig loeschen" data-confirm-ok="Loeschen">
|
||||
Endgueltig
|
||||
data-confirm="{{ entry.label }} endgültig löschen?"
|
||||
data-confirm-title="Endgültig löschen" data-confirm-ok="Löschen">
|
||||
Endgültig
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
<span class="table-toolbar__spacer"></span>
|
||||
{% if directory_enabled and current_user.has('users.create') %}
|
||||
<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>
|
||||
{% endif %}
|
||||
{% if current_user.is_admin %}
|
||||
@@ -90,9 +90,9 @@
|
||||
style="display:inline" data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<button class="btn btn--sm btn--ghost danger-text" type="submit"
|
||||
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-title="Konto loeschen" data-confirm-ok="Loeschen"
|
||||
aria-label="Loeschen">{{ icon('trash', size=15) }}</button>
|
||||
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 löschen" data-confirm-ok="Löschen"
|
||||
aria-label="Löschen">{{ icon('trash', size=15) }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
@@ -106,8 +106,8 @@
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
{{ alert('Rechte werden ausschliesslich ueber Gruppen vergeben. Das Admin-Kennzeichen ist davon
|
||||
unabhaengig und kann nur von Administratoren gesetzt werden; mindestens ein entsperrtes
|
||||
{{ alert('Rechte werden ausschliesslich über Gruppen vergeben. Das Admin-Kennzeichen ist davon
|
||||
unabhängig und kann nur von Administratoren gesetzt werden; mindestens ein entsperrtes
|
||||
Administratorkonto muss immer bestehen bleiben.', 'info') }}
|
||||
{% endblock %}
|
||||
|
||||
@@ -137,7 +137,7 @@ Administratorkonto muss immer bestehen bleiben.', 'info') }}
|
||||
</div>
|
||||
</fieldset>
|
||||
{% 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 %}
|
||||
{{ checkbox('must_change_password', 'Passwortwechsel bei der ersten Anmeldung erzwingen', checked=True) }}
|
||||
</div>
|
||||
@@ -149,7 +149,7 @@ Administratorkonto muss immer bestehen bleiben.', 'info') }}
|
||||
{% endcall %}
|
||||
|
||||
{% 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') %}
|
||||
<form method="post" action="{{ url_for('admin.directory_adopt') }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
@@ -169,7 +169,7 @@ Administratorkonto muss immer bestehen bleiben.', 'info') }}
|
||||
</div>
|
||||
</fieldset>
|
||||
{{ alert('Name und E-Mail werden bei der ersten echten Anmeldung aus dem Verzeichnis
|
||||
uebernommen und ueberschrieben.', 'info') }}
|
||||
übernommen und überschrieben.', 'info') }}
|
||||
</div>
|
||||
<div class="modal__footer">
|
||||
<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('new_password', 'Neues Passwort setzen (optional)', type='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 %}
|
||||
<fieldset class="fieldset">
|
||||
<legend>Gruppen</legend>
|
||||
|
||||
@@ -126,6 +126,37 @@ def format_date(value: Any) -> str:
|
||||
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:
|
||||
try:
|
||||
total = int(seconds)
|
||||
@@ -154,6 +185,32 @@ def yes_no(value: Any) -> str:
|
||||
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:
|
||||
"""Parst einen JSON-String aus der Datenbank -- fuer Modul-/Rechtelisten.
|
||||
|
||||
@@ -230,9 +287,12 @@ def init_app(app: Flask) -> None:
|
||||
dt=format_datetime,
|
||||
date=format_date,
|
||||
duration=format_duration,
|
||||
millis=format_millis,
|
||||
number=format_number,
|
||||
yes_no=yes_no,
|
||||
from_json=from_json,
|
||||
issuer_source=issuer_source,
|
||||
issuer_source_hint=issuer_source_hint,
|
||||
)
|
||||
app.jinja_env.trim_blocks = True
|
||||
app.jinja_env.lstrip_blocks = True
|
||||
|
||||
@@ -4,10 +4,10 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "tesm-licensing-server"
|
||||
version = "2.0.0"
|
||||
version = "2.0.2"
|
||||
description = "Serverhaelfte des TESM-Lizenzprotokolls: ausstellen, erneuern, antworten"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = ["tesm-licensing==2.0.0", "cryptography>=41"]
|
||||
dependencies = ["tesm-licensing==2.0.2", "cryptography>=41"]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "tesm-licensing"
|
||||
version = "2.0.0"
|
||||
version = "2.0.2"
|
||||
description = "Ed25519-basiertes Lizenz- und Aktivierungsprotokoll fuer TESM und TESM-Lizenzserver"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = ["cryptography>=42"]
|
||||
|
||||
@@ -62,7 +62,7 @@ from .protocol import (
|
||||
verify_master_response,
|
||||
)
|
||||
|
||||
__version__ = "2.0.0"
|
||||
__version__ = "2.0.2"
|
||||
|
||||
__all__ = [
|
||||
"ACTIONS",
|
||||
|
||||
Binary file not shown.
Reference in New Issue
Block a user