Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d34baa67be | ||
|
|
491724beb5 | ||
|
|
b54b87ca6f | ||
|
|
270ba455a8 | ||
|
|
f3b312046f | ||
|
|
436b080deb | ||
|
|
85c40ccfbd | ||
|
|
0ac651a1b9 | ||
|
|
4275c84a6c | ||
|
|
4f4310bfe4 | ||
|
|
1ad2f3f9a5 | ||
|
|
9d5eac1db2 | ||
|
|
2fd78ac26b | ||
|
|
86c09d8a0f | ||
|
|
70f6b005a9 | ||
|
|
57eb7764cc | ||
|
|
30c2c56656 | ||
|
|
292ea622a7 | ||
|
|
0d99760d89 | ||
|
|
713ff12db2 | ||
|
|
2db7e03154 | ||
|
|
5dcb87d6ec | ||
|
|
dc94a8e666 | ||
|
|
c639f4f507 | ||
|
|
ad0195bab0 | ||
|
|
ce22239d78 | ||
|
|
92c8abb79b | ||
|
|
dcb4f57eeb | ||
|
|
11a55b20f8 | ||
|
|
ed17bce40a | ||
|
|
5282cd8f2d | ||
|
|
754cbea6bb | ||
|
|
07ae69e3b7 | ||
|
|
d3c700283a | ||
|
|
61a47debdc | ||
|
|
bec74e6936 | ||
|
|
d2679361bc | ||
|
|
20c7fd8813 | ||
|
|
eaca6f7990 | ||
|
|
4eb1c0c2d8 | ||
|
|
c0a6dd057c | ||
|
|
8c2cd10ee4 | ||
|
|
2c3ca4acc1 | ||
|
|
f16ba7d875 | ||
|
|
7bfe328e4b | ||
|
|
5b9373bb24 | ||
|
|
f77c8b221c | ||
|
|
985deb7a24 | ||
|
|
258e90bf4f | ||
|
|
337a52cc38 | ||
|
|
160675e368 | ||
|
|
3bbddd7d29 | ||
|
|
7407f4bc00 | ||
|
|
635722089e | ||
|
|
6dff1ed137 | ||
|
|
b4529a0e31 | ||
|
|
313eb4bbf9 | ||
|
|
51ae39dd53 |
No files matched your search
@@ -23,7 +23,8 @@ mitbringen.
|
|||||||
|
|
||||||
| Wo sonst noch | |
|
| Wo sonst noch | |
|
||||||
|---|---|
|
|---|---|
|
||||||
| [alientim/tesm-license](https://gitea.int.eertmoed.net/alientim/tesm-license) | der Lizenzserver |
|
| [alientim/tesm-license](https://gitea.int.eertmoed.net/alientim/tesm-license) | der Lizenzserver (Sub-Instanzen) |
|
||||||
|
| [alientim/tesm-license-master](https://gitea.int.eertmoed.net/alientim/tesm-license-master) | Lizenzserver, Master-Ausgabe (privat) |
|
||||||
| [alientim/TESM-DEV](https://gitea.int.eertmoed.net/alientim/TESM-DEV) | Entwicklung: beide Anwendungen, Tests, Architekturdoku |
|
| [alientim/TESM-DEV](https://gitea.int.eertmoed.net/alientim/TESM-DEV) | Entwicklung: beide Anwendungen, Tests, Architekturdoku |
|
||||||
|
|
||||||
## Zweige
|
## Zweige
|
||||||
@@ -38,10 +39,18 @@ mitbringen.
|
|||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo bash deploy/install.sh --app tesm --https
|
sudo bash install.sh --app tesm --https
|
||||||
sudo -u tesm tesm-admin create-admin
|
sudo -u tesm tesm-admin create-admin
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`install.sh` liegt oben im Paket und genügt auch **allein**: ohne Paket
|
||||||
|
drumherum lädt es sich das aktuelle Release selbst.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSLO https://gitea.int.eertmoed.net/alientim/tesm/raw/branch/main/install.sh
|
||||||
|
sudo bash install.sh --app tesm --https
|
||||||
|
```
|
||||||
|
|
||||||
Danach `https://<host>/`. Ohne `--domain` entsteht ein selbstsigniertes
|
Danach `https://<host>/`. Ohne `--domain` entsteht ein selbstsigniertes
|
||||||
Zertifikat; der Browser warnt einmal, die Verbindung ist verschlüsselt.
|
Zertifikat; der Browser warnt einmal, die Verbindung ist verschlüsselt.
|
||||||
|
|
||||||
@@ -152,3 +161,19 @@ anders gelöst ist. Die wichtigsten:
|
|||||||
* **Zwei Installationen stören sich nicht** -- eigener `SITE_KEY` für
|
* **Zwei Installationen stören sich nicht** -- eigener `SITE_KEY` für
|
||||||
nginx-Site, netplan, logrotate, Zertifikate und Sitzungscookie.
|
nginx-Site, netplan, logrotate, Zertifikate und Sitzungscookie.
|
||||||
* **Verschachtelte Datenbankverbindungen sind ein Fehler**, kein Deadlock.
|
* **Verschachtelte Datenbankverbindungen sind ein Fehler**, kein Deadlock.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Von einer Installation der Fassung 1.x
|
||||||
|
|
||||||
|
Eine einzelne Datei genügt -- sie lädt das Release selbst:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSLO https://gitea.int.eertmoed.net/alientim/tesm/raw/branch/main/deploy/upgrade.sh
|
||||||
|
sudo bash upgrade.sh --app tesm
|
||||||
|
```
|
||||||
|
|
||||||
|
Sichert die alte Installation, schaltet sie ab, installiert diese Fassung und
|
||||||
|
übernimmt die Daten. Einzelheiten und was **nicht** mitkommt:
|
||||||
|
[docs/BETRIEB.md](docs/BETRIEB.md).
|
||||||
|
|
||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "tesm"
|
name = "tesm"
|
||||||
version = "2.0.2"
|
version = "2.8.18"
|
||||||
description = "TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten und Aruba-Switchen"
|
description = "TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten und Aruba-Switchen"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -19,11 +19,24 @@ from tesm_core.extension import core
|
|||||||
from tesm_core.views import trash as trash_views
|
from tesm_core.views import trash as trash_views
|
||||||
|
|
||||||
from .navigation import NAVIGATION
|
from .navigation import NAVIGATION
|
||||||
from .permissions import PERMISSIONS, ROLE_PRESETS
|
from .permissions import DHCP_PAGE, PERMISSIONS, ROLE_PRESETS
|
||||||
from .schema import TESM_MIGRATIONS
|
from .schema import TESM_MIGRATIONS
|
||||||
from .settings_spec import TESM_SETTINGS
|
from .settings_spec import TESM_SETTINGS
|
||||||
|
|
||||||
__version__ = "2.0.2"
|
#: Version aus den Metadaten des installierten Pakets -- die einzige Quelle
|
||||||
|
#: ist ``pyproject.toml``. Vorher stand hier eine feste Zahl, und die
|
||||||
|
#: Fusszeile zeigte vier Releases lang 2.0.2.
|
||||||
|
#:
|
||||||
|
#: Im Quellbaum ohne ``pip install`` gibt es keine Metadaten; dann steht dort
|
||||||
|
#: "unbekannt" statt einer erfundenen Nummer. In einem editierbaren Install
|
||||||
|
#: zeigt sie den Stand des letzten ``pip install -e`` -- also die Version, die
|
||||||
|
#: tatsaechlich installiert ist.
|
||||||
|
try: # pragma: no cover - haengt an der Installationsart
|
||||||
|
from importlib.metadata import PackageNotFoundError, version as _paketversion
|
||||||
|
|
||||||
|
__version__ = _paketversion("tesm")
|
||||||
|
except (ImportError, PackageNotFoundError): # pragma: no cover
|
||||||
|
__version__ = "unbekannt"
|
||||||
|
|
||||||
APP_KEY = "tesm"
|
APP_KEY = "tesm"
|
||||||
APP_NAME = "TESM"
|
APP_NAME = "TESM"
|
||||||
@@ -32,10 +45,12 @@ APP_EDITION = "Geraeteverwaltung"
|
|||||||
#: Funktionszeile unter der Wortmarke auf der Anmeldeseite. Uebernommen vom
|
#: Funktionszeile unter der Wortmarke auf der Anmeldeseite. Uebernommen vom
|
||||||
#: Vorgaenger, wo sie Teil des Hintergrundmotivs war -- sie sagt in einer Zeile,
|
#: Vorgaenger, wo sie Teil des Hintergrundmotivs war -- sie sagt in einer Zeile,
|
||||||
#: was die Anwendung tut, und das gehoert auf die erste Seite.
|
#: was die Anwendung tut, und das gehoert auf die erste Seite.
|
||||||
AUTH_TAGLINE = (
|
#: Zeile unter der Wortmarke auf den Anmeldeseiten. Kurz gehalten: sie ist
|
||||||
"UEBERWACHEN · BOOTEN · ANBINDEN (DHCP) · "
|
#: eine Marke, keine Funktionsliste. "(PXE)" stand ausserdem fuer etwas, das
|
||||||
"BETRIEBSSYSTEM GEBEN (PXE)"
|
#: es noch nicht gibt -- das Anmeldebanner hat es aus demselben Grund nie
|
||||||
)
|
#: gezeigt. Wortgleich in ``deploy/tesm-motd``; ein Hygienetest haelt beides
|
||||||
|
#: zusammen.
|
||||||
|
AUTH_TAGLINE = "ÜBERWACHEN · BOOTEN · ANBINDEN"
|
||||||
ENV_PREFIX = "TESM_"
|
ENV_PREFIX = "TESM_"
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -85,6 +100,7 @@ def _register_blueprints(app: Flask) -> None:
|
|||||||
settings,
|
settings,
|
||||||
switches,
|
switches,
|
||||||
hostkeys,
|
hostkeys,
|
||||||
|
screen,
|
||||||
terminal,
|
terminal,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -101,6 +117,7 @@ def _register_blueprints(app: Flask) -> None:
|
|||||||
app.register_blueprint(license_bp.bp)
|
app.register_blueprint(license_bp.bp)
|
||||||
app.register_blueprint(backup.bp)
|
app.register_blueprint(backup.bp)
|
||||||
terminal.init_app(app)
|
terminal.init_app(app)
|
||||||
|
screen.init_app(app)
|
||||||
|
|
||||||
# Die Statusuebersicht ist die Startseite; ``index`` bleibt als Endpunkt
|
# Die Statusuebersicht ist die Startseite; ``index`` bleibt als Endpunkt
|
||||||
# bestehen, weil der Kern (Weiterleitungen, Fehlerseiten) darauf verweist.
|
# bestehen, weil der Kern (Weiterleitungen, Fehlerseiten) darauf verweist.
|
||||||
@@ -149,7 +166,7 @@ def _mount_shares_after_login(conn: Any, user: Any, secret: str, group_dns: tupl
|
|||||||
group_dns=group_dns,
|
group_dns=group_dns,
|
||||||
)
|
)
|
||||||
for message in messages:
|
for message in messages:
|
||||||
current_app.logger.info("Dateifreigabe fuer %s -- %s", user.username, message)
|
current_app.logger.info("Dateifreigabe für %s | %s", user.username, message)
|
||||||
|
|
||||||
|
|
||||||
def _release_shares_on_logout(conn: Any, user: Any) -> None:
|
def _release_shares_on_logout(conn: Any, user: Any) -> None:
|
||||||
@@ -159,14 +176,19 @@ def _release_shares_on_logout(conn: Any, user: Any) -> None:
|
|||||||
dem Abmelden bestehen, waere sein Zugriff weiter offen -- bis irgendwann
|
dem Abmelden bestehen, waere sein Zugriff weiter offen -- bis irgendwann
|
||||||
die Zeitgrenze greift. Das ist zu lange.
|
die Zeitgrenze greift. Das ist zu lange.
|
||||||
"""
|
"""
|
||||||
|
from flask import session
|
||||||
|
|
||||||
from .services import fileshare
|
from .services import fileshare
|
||||||
|
|
||||||
extension = core()
|
extension = core()
|
||||||
|
# Nur die Einbindungen dieser Sitzung: wer an zwei Geraeten angemeldet ist,
|
||||||
|
# soll am zweiten weiterarbeiten koennen.
|
||||||
|
sitzung = str(getattr(session, "sid", "") or "")
|
||||||
with extension.database.transaction(conn):
|
with extension.database.transaction(conn):
|
||||||
released = fileshare.release_all_for_user(conn, user.id)
|
released = fileshare.release_all_for_user(conn, user.id, sitzung)
|
||||||
if released:
|
if released:
|
||||||
current_app.logger.info(
|
current_app.logger.info(
|
||||||
"Dateifreigaben fuer %s geloest: %d", user.username, released
|
"Dateifreigaben für %s gelöst: %d", user.username, released
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -185,24 +207,24 @@ def _status_pills(user: Any) -> list[dict[str, str]]:
|
|||||||
|
|
||||||
# Timer bis zum naechsten Durchlauf, mit Knopf fuer "sofort pruefen". Der
|
# Timer bis zum naechsten Durchlauf, mit Knopf fuer "sofort pruefen". Der
|
||||||
# Knopf gehoert in die Pille, weil er genau die Angabe daneben zuruecksetzt.
|
# Knopf gehoert in die Pille, weil er genau die Angabe daneben zuruecksetzt.
|
||||||
if user.has_any(("devices.view", "monitoring.view")):
|
if user.has_any(("devices.read", "monitoring.read")):
|
||||||
intervall = int(extension.settings.get(verbindung, "monitor_interval_seconds") or 300)
|
intervall = int(extension.settings.get(verbindung, "monitor_interval_seconds") or 300)
|
||||||
faellig = monitor.next_check_at(verbindung, intervall)
|
faellig = monitor.next_check_at(verbindung, intervall)
|
||||||
if faellig is not None:
|
if faellig is not None:
|
||||||
eintrag = {
|
eintrag = {
|
||||||
"label": "Naechste Pruefung in",
|
"label": "Nächste Prüfung in",
|
||||||
"countdown_to": faellig.isoformat(),
|
"countdown_to": faellig.isoformat(),
|
||||||
"countdown_done": "jetzt",
|
"countdown_done": "jetzt",
|
||||||
"css_class": "",
|
"css_class": "",
|
||||||
"dot": "1",
|
"dot": "1",
|
||||||
"dot_class": "",
|
"dot_class": "",
|
||||||
"title": f"Der Ueberwachungsdienst prueft alle {intervall} Sekunden.",
|
"title": f"Der Überwachungsdienst prüft alle {intervall} Sekunden.",
|
||||||
}
|
}
|
||||||
if user.has("settings_system.execute"):
|
if user.has("settings_system.execute"):
|
||||||
from flask import url_for
|
from flask import url_for
|
||||||
|
|
||||||
eintrag["action"] = url_for("settings.run_check_now")
|
eintrag["action"] = url_for("settings.run_check_now")
|
||||||
eintrag["action_title"] = "Alle Clients sofort pruefen"
|
eintrag["action_title"] = "Alle Clients sofort prüfen"
|
||||||
pills.append(eintrag)
|
pills.append(eintrag)
|
||||||
|
|
||||||
counts = monitor.dashboard_counts(verbindung)
|
counts = monitor.dashboard_counts(verbindung)
|
||||||
@@ -213,11 +235,11 @@ def _status_pills(user: Any) -> list[dict[str, str]]:
|
|||||||
"css_class": "status-pill--danger",
|
"css_class": "status-pill--danger",
|
||||||
"dot": "1",
|
"dot": "1",
|
||||||
"dot_class": "status-pill__dot--pulse",
|
"dot_class": "status-pill__dot--pulse",
|
||||||
"title": "Geraete, die beim letzten Durchlauf nicht geantwortet haben.",
|
"title": "Geräte, die beim letzten Durchlauf nicht geantwortet haben.",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
if user.has_any(("dhcp.view", "dhcp.edit")) and extension.license is not None:
|
if user.has_any(DHCP_PAGE) and extension.license is not None:
|
||||||
if extension.license.module_active("dhcp") and extension.sysops is not None:
|
if extension.license.module_active("dhcp") and extension.sysops is not None:
|
||||||
state = extension.sysops.service_state(kea.SERVICE_UNIT)
|
state = extension.sysops.service_state(kea.SERVICE_UNIT)
|
||||||
if state["installed"] == "ja":
|
if state["installed"] == "ja":
|
||||||
@@ -250,7 +272,7 @@ def _register_trash_providers(app: Flask) -> None:
|
|||||||
conn, "devices", int(ident), unique_column="mac"
|
conn, "devices", int(ident), unique_column="mac"
|
||||||
),
|
),
|
||||||
purge_fn=lambda conn, ident: inventory.purge_row(conn, "devices", int(ident)),
|
purge_fn=lambda conn, ident: inventory.purge_row(conn, "devices", int(ident)),
|
||||||
hint="Die MAC-Adresse bleibt reserviert, solange das Geraet hier liegt.",
|
hint="Die MAC-Adresse bleibt reserviert, solange das Gerät hier liegt.",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
trash_views.register(
|
trash_views.register(
|
||||||
@@ -279,7 +301,7 @@ def _register_trash_providers(app: Flask) -> None:
|
|||||||
conn, "credentials", int(ident), unique_column="name"
|
conn, "credentials", int(ident), unique_column="name"
|
||||||
),
|
),
|
||||||
purge_fn=lambda conn, ident: inventory.purge_row(conn, "credentials", int(ident)),
|
purge_fn=lambda conn, ident: inventory.purge_row(conn, "credentials", int(ident)),
|
||||||
hint="Endgueltiges Loeschen entfernt auch das gespeicherte Passwort.",
|
hint="Endgültiges Löschen entfernt auch das gespeicherte Passwort.",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -295,7 +317,7 @@ def _startup_tasks(app: Flask) -> None:
|
|||||||
extension = core(app)
|
extension = core(app)
|
||||||
if not extension.config.web_process:
|
if not extension.config.web_process:
|
||||||
logger.info(
|
logger.info(
|
||||||
"Startaufgaben uebersprungen -- dieser Prozess ist nicht als Webprozess markiert."
|
"Startaufgaben übersprungen – dieser Prozess ist nicht als Webprozess markiert."
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -308,7 +330,7 @@ def _startup_tasks(app: Flask) -> None:
|
|||||||
# die Oberflaeche Freigaben, die es nicht gibt.
|
# die Oberflaeche Freigaben, die es nicht gibt.
|
||||||
vergessen = fileshare.forget_all_mounts(conn)
|
vergessen = fileshare.forget_all_mounts(conn)
|
||||||
if orphaned:
|
if orphaned:
|
||||||
logger.warning("%s unterbrochene Wartungsauftraege als unklar markiert", orphaned)
|
logger.warning("%s unterbrochene Wartungsaufträge als unklar markiert", orphaned)
|
||||||
if vergessen:
|
if vergessen:
|
||||||
logger.info(
|
logger.info(
|
||||||
"%s Freigabe-Einbindungen eines vorherigen Prozesses vergessen "
|
"%s Freigabe-Einbindungen eines vorherigen Prozesses vergessen "
|
||||||
@@ -338,19 +360,32 @@ def _start_fileshare_sweep(app: Flask) -> None:
|
|||||||
weiter eingebunden.
|
weiter eingebunden.
|
||||||
"""
|
"""
|
||||||
import threading
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
from .services import fileshare
|
from .services import fileshare
|
||||||
|
|
||||||
def schleife() -> None:
|
def einmal() -> None:
|
||||||
while True:
|
|
||||||
time.sleep(FILESHARE_SWEEP_SECONDS)
|
|
||||||
try:
|
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
geloest = fileshare.sweep_expired(core(app).database)
|
geloest = fileshare.sweep_expired(core(app).database)
|
||||||
if geloest:
|
if geloest:
|
||||||
logger.info("%s abgelaufene Freigabe-Einbindungen geloest", geloest)
|
logger.info("%s abgelaufene Freigabe-Einbindungen gelöst", geloest)
|
||||||
|
|
||||||
|
def schleife() -> None:
|
||||||
|
# Zwei Netze: das innere haelt den Faden am Leben, wenn ein einzelner
|
||||||
|
# Durchlauf scheitert; das aeussere sorgt dafuer, dass sein Ende
|
||||||
|
# ueberhaupt auffaellt. Vorher fehlte der Import von "time" -- der
|
||||||
|
# NameError kam beim ersten sleep, also vor jedem try, und der Faden
|
||||||
|
# war weg, ohne eine Zeile zu hinterlassen.
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
time.sleep(FILESHARE_SWEEP_SECONDS)
|
||||||
|
try:
|
||||||
|
einmal()
|
||||||
except Exception: # noqa: BLE001 - der Webprozess darf nie sterben
|
except Exception: # noqa: BLE001 - der Webprozess darf nie sterben
|
||||||
logger.exception("Aufraeumen der Dateifreigaben fehlgeschlagen")
|
logger.exception("Aufräumen der Dateifreigaben fehlgeschlagen")
|
||||||
|
except BaseException: # noqa: BLE001 - auch ein Abbruch gehoert protokolliert
|
||||||
|
logger.exception("Der Aufräumfaden der Dateifreigaben ist beendet")
|
||||||
|
raise
|
||||||
|
|
||||||
threading.Thread(target=schleife, name="fileshare-sweep", daemon=True).start()
|
threading.Thread(target=schleife, name="fileshare-sweep", daemon=True).start()
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ from tesm_core.licensing_client import LicenseError
|
|||||||
|
|
||||||
from ..services import transfer
|
from ..services import transfer
|
||||||
|
|
||||||
bp = Blueprint("backup", __name__, url_prefix="/sicherung")
|
bp = Blueprint("backup", __name__, url_prefix="/administration/backup")
|
||||||
|
|
||||||
MAX_IMPORT_BYTES = 64 * 1024 * 1024
|
MAX_IMPORT_BYTES = 64 * 1024 * 1024
|
||||||
PREVIEW_SESSION_KEY = "_import_token"
|
PREVIEW_SESSION_KEY = "_import_token"
|
||||||
@@ -37,14 +37,14 @@ def _allowed_categories() -> list[transfer.Category]:
|
|||||||
for category in transfer.CATEGORIES:
|
for category in transfer.CATEGORIES:
|
||||||
if category.admin_only and not current_user.is_admin:
|
if category.admin_only and not current_user.is_admin:
|
||||||
continue
|
continue
|
||||||
if category.sensitive and not current_user.has("backup.secrets"):
|
if category.sensitive and not current_user.has("backup_export.secrets"):
|
||||||
continue
|
continue
|
||||||
allowed.append(category)
|
allowed.append(category)
|
||||||
return allowed
|
return allowed
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/")
|
@bp.get("/")
|
||||||
@permission_required("backup.view", "backup.export", "backup.execute", mode="any")
|
@permission_required("backup_export.read", "backup_import.read", mode="any")
|
||||||
def overview():
|
def overview():
|
||||||
extension = core()
|
extension = core()
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -72,13 +72,13 @@ def overview():
|
|||||||
min_passphrase=transfer.MIN_PASSPHRASE_LENGTH,
|
min_passphrase=transfer.MIN_PASSPHRASE_LENGTH,
|
||||||
preview_ttl=transfer.PREVIEW_TTL_MINUTES,
|
preview_ttl=transfer.PREVIEW_TTL_MINUTES,
|
||||||
license_hint=license_hint,
|
license_hint=license_hint,
|
||||||
can_secrets=current_user.has("backup.secrets"),
|
can_secrets=current_user.has("backup_export.secrets"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/export")
|
@bp.post("/export")
|
||||||
@permission_required("backup.export")
|
@permission_required("backup_export.export")
|
||||||
@reauth_required
|
# Die frische Bestaetigung verlangt das Recht selbst (SENSITIVE_ACTIONS).
|
||||||
def export():
|
def export():
|
||||||
extension = core()
|
extension = core()
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -92,16 +92,16 @@ def export():
|
|||||||
allowed = {category.key for category in _allowed_categories()}
|
allowed = {category.key for category in _allowed_categories()}
|
||||||
chosen = [key for key in request.form.getlist("categories") if key in allowed]
|
chosen = [key for key in request.form.getlist("categories") if key in allowed]
|
||||||
if not chosen:
|
if not chosen:
|
||||||
flash("Es wurde keine Kategorie ausgewaehlt.", "warning")
|
flash("Es wurde keine Kategorie ausgewählt.", "warning")
|
||||||
return redirect(url_for("backup.overview"))
|
return redirect(url_for("backup.overview"))
|
||||||
|
|
||||||
include_secrets = (
|
include_secrets = (
|
||||||
request.form.get("include_secrets") == "1" and current_user.has("backup.secrets")
|
request.form.get("include_secrets") == "1" and current_user.has("backup_export.secrets")
|
||||||
)
|
)
|
||||||
passphrase = request.form.get("passphrase") or ""
|
passphrase = request.form.get("passphrase") or ""
|
||||||
repeat = request.form.get("passphrase_repeat") or ""
|
repeat = request.form.get("passphrase_repeat") or ""
|
||||||
if passphrase != repeat:
|
if passphrase != repeat:
|
||||||
flash("Die beiden Passphrasen stimmen nicht ueberein.", "danger")
|
flash("Die beiden Passphrasen stimmen nicht überein.", "danger")
|
||||||
return redirect(url_for("backup.overview"))
|
return redirect(url_for("backup.overview"))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -129,18 +129,18 @@ def export():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/import/vorschau")
|
@bp.post("/import/preview")
|
||||||
@permission_required("backup.execute")
|
@permission_required("backup_import.execute")
|
||||||
@reauth_required
|
@reauth_required
|
||||||
def import_preview():
|
def import_preview():
|
||||||
conn = db()
|
conn = db()
|
||||||
upload = request.files.get("backup_file")
|
upload = request.files.get("backup_file")
|
||||||
if upload is None or not upload.filename:
|
if upload is None or not upload.filename:
|
||||||
flash("Keine Datei ausgewaehlt.", "danger")
|
flash("Keine Datei ausgewählt.", "danger")
|
||||||
return redirect(url_for("backup.overview"))
|
return redirect(url_for("backup.overview"))
|
||||||
raw = upload.stream.read(MAX_IMPORT_BYTES + 1)
|
raw = upload.stream.read(MAX_IMPORT_BYTES + 1)
|
||||||
if len(raw) > MAX_IMPORT_BYTES:
|
if len(raw) > MAX_IMPORT_BYTES:
|
||||||
flash("Die Datei ist zu gross.", "danger")
|
flash("Die Datei ist zu groß.", "danger")
|
||||||
return redirect(url_for("backup.overview"))
|
return redirect(url_for("backup.overview"))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -154,28 +154,28 @@ def import_preview():
|
|||||||
session[PREVIEW_SESSION_KEY] = token
|
session[PREVIEW_SESSION_KEY] = token
|
||||||
_audit("backup.import_previewed", f"Erstellt {header.get('created_at')}", severity="notice")
|
_audit("backup.import_previewed", f"Erstellt {header.get('created_at')}", severity="notice")
|
||||||
flash(
|
flash(
|
||||||
f"Datei geoeffnet. Sie haben {transfer.PREVIEW_TTL_MINUTES} Minuten Zeit, die "
|
f"Datei geöffnet. Sie haben {transfer.PREVIEW_TTL_MINUTES} Minuten Zeit, die "
|
||||||
"Uebernahme zu bestaetigen.",
|
"Übernahme zu bestätigen.",
|
||||||
"success",
|
"success",
|
||||||
)
|
)
|
||||||
return redirect(url_for("backup.overview"))
|
return redirect(url_for("backup.overview"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/import/uebernehmen")
|
@bp.post("/import/apply")
|
||||||
@permission_required("backup.execute")
|
@permission_required("backup_import.execute")
|
||||||
@reauth_required
|
@reauth_required
|
||||||
def import_apply():
|
def import_apply():
|
||||||
extension = core()
|
extension = core()
|
||||||
conn = db()
|
conn = db()
|
||||||
token = session.get(PREVIEW_SESSION_KEY, "")
|
token = session.get(PREVIEW_SESSION_KEY, "")
|
||||||
if not token:
|
if not token:
|
||||||
flash("Es liegt keine geoeffnete Sicherung vor.", "danger")
|
flash("Es liegt keine geöffnete Sicherung vor.", "danger")
|
||||||
return redirect(url_for("backup.overview"))
|
return redirect(url_for("backup.overview"))
|
||||||
|
|
||||||
allowed = {category.key for category in _allowed_categories()}
|
allowed = {category.key for category in _allowed_categories()}
|
||||||
chosen = [key for key in request.form.getlist("categories") if key in allowed]
|
chosen = [key for key in request.form.getlist("categories") if key in allowed]
|
||||||
if not chosen:
|
if not chosen:
|
||||||
flash("Es wurde keine Kategorie ausgewaehlt.", "warning")
|
flash("Es wurde keine Kategorie ausgewählt.", "warning")
|
||||||
return redirect(url_for("backup.overview"))
|
return redirect(url_for("backup.overview"))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -206,7 +206,7 @@ def import_apply():
|
|||||||
action="backup.imported",
|
action="backup.imported",
|
||||||
detail="; ".join(
|
detail="; ".join(
|
||||||
f"{key}: {counts['created']} neu, {counts['updated']} aktualisiert, "
|
f"{key}: {counts['created']} neu, {counts['updated']} aktualisiert, "
|
||||||
f"{counts['skipped']} uebersprungen"
|
f"{counts['skipped']} übersprungen"
|
||||||
for key, counts in report.items()
|
for key, counts in report.items()
|
||||||
),
|
),
|
||||||
ip=client_ip(),
|
ip=client_ip(),
|
||||||
@@ -217,16 +217,16 @@ def import_apply():
|
|||||||
lines = [
|
lines = [
|
||||||
f"{transfer.CATEGORY_BY_KEY[key].label}: {counts['created']} neu, "
|
f"{transfer.CATEGORY_BY_KEY[key].label}: {counts['created']} neu, "
|
||||||
f"{counts['updated']} aktualisiert"
|
f"{counts['updated']} aktualisiert"
|
||||||
+ (f", {counts['skipped']} uebersprungen" if counts["skipped"] else "")
|
+ (f", {counts['skipped']} übersprungen" if counts["skipped"] else "")
|
||||||
for key, counts in report.items()
|
for key, counts in report.items()
|
||||||
if key in transfer.CATEGORY_BY_KEY
|
if key in transfer.CATEGORY_BY_KEY
|
||||||
]
|
]
|
||||||
flash("Uebernahme abgeschlossen. " + " | ".join(lines), "success")
|
flash("Übernahme abgeschlossen. " + " – ".join(lines), "success")
|
||||||
return redirect(url_for("backup.overview"))
|
return redirect(url_for("backup.overview"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/import/verwerfen")
|
@bp.post("/import/discard")
|
||||||
@permission_required("backup.execute")
|
@permission_required("backup_import.execute")
|
||||||
def import_discard():
|
def import_discard():
|
||||||
token = session.pop(PREVIEW_SESSION_KEY, "")
|
token = session.pop(PREVIEW_SESSION_KEY, "")
|
||||||
if token:
|
if token:
|
||||||
|
|||||||
@@ -6,13 +6,13 @@ from typing import Any
|
|||||||
|
|
||||||
from flask import Blueprint, abort, flash, redirect, render_template, request, url_for
|
from flask import Blueprint, abort, flash, redirect, render_template, request, url_for
|
||||||
from tesm_core import audit
|
from tesm_core import audit
|
||||||
from tesm_core.auth.models import current_user, permission_required, reauth_required
|
from tesm_core.auth.models import current_user, permission_required
|
||||||
from tesm_core.auth.service import client_ip
|
from tesm_core.auth.service import client_ip
|
||||||
from tesm_core.extension import core, db, transaction
|
from tesm_core.extension import core, db, transaction
|
||||||
|
|
||||||
from ..services import inventory
|
from ..services import inventory
|
||||||
|
|
||||||
bp = Blueprint("credentials", __name__, url_prefix="/zugangsdaten")
|
bp = Blueprint("credentials", __name__, url_prefix="/monitoring/credentials")
|
||||||
|
|
||||||
|
|
||||||
def _audit(conn: Any, action: str, target: str, detail: str = "", severity: str = "info") -> None:
|
def _audit(conn: Any, action: str, target: str, detail: str = "", severity: str = "info") -> None:
|
||||||
@@ -29,7 +29,7 @@ def _audit(conn: Any, action: str, target: str, detail: str = "", severity: str
|
|||||||
|
|
||||||
|
|
||||||
@bp.get("/")
|
@bp.get("/")
|
||||||
@permission_required("credentials.view")
|
@permission_required("credentials.read")
|
||||||
def overview():
|
def overview():
|
||||||
conn = db()
|
conn = db()
|
||||||
return render_template(
|
return render_template(
|
||||||
@@ -39,7 +39,7 @@ def overview():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/neu")
|
@bp.post("/new")
|
||||||
@permission_required("credentials.create")
|
@permission_required("credentials.create")
|
||||||
def create():
|
def create():
|
||||||
try:
|
try:
|
||||||
@@ -62,7 +62,7 @@ def create():
|
|||||||
|
|
||||||
|
|
||||||
@bp.post("/<int:credential_id>")
|
@bp.post("/<int:credential_id>")
|
||||||
@permission_required("credentials.edit")
|
@permission_required("credentials.write")
|
||||||
def update(credential_id: int):
|
def update(credential_id: int):
|
||||||
try:
|
try:
|
||||||
with transaction() as conn:
|
with transaction() as conn:
|
||||||
@@ -85,13 +85,13 @@ def update(credential_id: int):
|
|||||||
"; ".join(changes),
|
"; ".join(changes),
|
||||||
severity="notice",
|
severity="notice",
|
||||||
)
|
)
|
||||||
flash("Aenderungen gespeichert." if changes else "Keine Aenderungen.", "success" if changes else "info")
|
flash("Änderungen gespeichert." if changes else "Keine Änderungen.", "success" if changes else "info")
|
||||||
except inventory.ValidationError as exc:
|
except inventory.ValidationError as exc:
|
||||||
flash(str(exc), "danger")
|
flash(str(exc), "danger")
|
||||||
return redirect(url_for("credentials.overview"))
|
return redirect(url_for("credentials.overview"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/<int:credential_id>/loeschen")
|
@bp.post("/<int:credential_id>/delete")
|
||||||
@permission_required("credentials.delete")
|
@permission_required("credentials.delete")
|
||||||
def delete(credential_id: int):
|
def delete(credential_id: int):
|
||||||
try:
|
try:
|
||||||
@@ -109,9 +109,9 @@ def delete(credential_id: int):
|
|||||||
return redirect(url_for("credentials.overview"))
|
return redirect(url_for("credentials.overview"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/<int:credential_id>/anzeigen")
|
@bp.post("/<int:credential_id>/reveal")
|
||||||
@permission_required("credentials.secrets")
|
@permission_required("credentials.secrets")
|
||||||
@reauth_required
|
# Die frische Bestaetigung verlangt das Recht selbst (SENSITIVE_ACTIONS).
|
||||||
def reveal(credential_id: int):
|
def reveal(credential_id: int):
|
||||||
"""Zeigt ein gespeichertes Passwort im Klartext.
|
"""Zeigt ein gespeichertes Passwort im Klartext.
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from __future__ import annotations
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from flask import Blueprint, abort, render_template, request
|
from flask import Blueprint, abort, render_template, request
|
||||||
|
from tesm_core.security.sessions import fragment
|
||||||
from tesm_core.extension import core, db
|
from tesm_core.extension import core, db
|
||||||
|
|
||||||
from ..services import monitor
|
from ..services import monitor
|
||||||
@@ -38,11 +39,21 @@ def _tiles(*, authenticated: bool) -> dict[str, Any]:
|
|||||||
|
|
||||||
from ..services import inventory, restart
|
from ..services import inventory, restart
|
||||||
|
|
||||||
|
from ..services import vnc as vnc_dienst
|
||||||
|
|
||||||
conn = db()
|
conn = db()
|
||||||
counts = monitor.dashboard_counts(conn)
|
counts = monitor.dashboard_counts(conn)
|
||||||
rows = inventory.list_devices(conn, only_active=not authenticated)
|
rows = inventory.list_devices(conn, only_active=not authenticated)
|
||||||
|
|
||||||
detailed = authenticated and current_user.has("devices.view")
|
detailed = authenticated and current_user.has("devices.read")
|
||||||
|
# Einmal je Seitenaufbau, nicht je Kachel: der Port ist fuer alle derselbe,
|
||||||
|
# und ohne das Recht wird gar nicht erst nachgesehen.
|
||||||
|
vnc_erlaubt = detailed and current_user.has("device_vnc.execute")
|
||||||
|
vnc_port = (
|
||||||
|
int(core().settings.get(conn, "vnc_port") or vnc_dienst.STANDARD_PORT)
|
||||||
|
if vnc_erlaubt and core().settings.get(conn, "vnc_enabled")
|
||||||
|
else 0
|
||||||
|
)
|
||||||
tiles: list[dict[str, Any]] = []
|
tiles: list[dict[str, Any]] = []
|
||||||
for row in rows:
|
for row in rows:
|
||||||
entry: dict[str, Any] = {
|
entry: dict[str, Any] = {
|
||||||
@@ -66,9 +77,24 @@ def _tiles(*, authenticated: bool) -> dict[str, Any]:
|
|||||||
"credential_category": str(row["credential_category"] or ""),
|
"credential_category": str(row["credential_category"] or ""),
|
||||||
# Nicht nur PoE: ein Geraet kann auch ueber SSH oder RPC
|
# Nicht nur PoE: ein Geraet kann auch ueber SSH oder RPC
|
||||||
# neu startbar sein. Die Kachel zeigt den bevorzugten Weg.
|
# neu startbar sein. Die Kachel zeigt den bevorzugten Weg.
|
||||||
|
# Mit Rechtefilter: die Kachel zeigt nur Wege, die
|
||||||
|
# dieser Benutzer auch ausloesen darf.
|
||||||
"restart_methods": restart.available(
|
"restart_methods": restart.available(
|
||||||
row, maintenance_active=_maintenance_active()
|
row,
|
||||||
|
maintenance_active=_maintenance_active(),
|
||||||
|
darf=current_user.has,
|
||||||
),
|
),
|
||||||
|
# Der Knopf oeffnet die Sitzung in TESM selbst. Ein
|
||||||
|
# vnc://-Link setzte einen eingerichteten Viewer auf dem
|
||||||
|
# Arbeitsplatz voraus -- und tat dort, wo keiner
|
||||||
|
# registriert ist, schlicht nichts.
|
||||||
|
#
|
||||||
|
# Ob dort ein Server antwortet, steht aus dem letzten
|
||||||
|
# Durchlauf in der Datenbank. Die Uebersicht zeigt viele
|
||||||
|
# Kacheln und laedt sich selbst nach; ein
|
||||||
|
# Verbindungsversuch je Kachel und Aufbau waere genau die
|
||||||
|
# Art Hintergrundlast, die niemand bestellt hat.
|
||||||
|
"vnc": bool(vnc_port and row["vnc_open"]),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
tiles.append(entry)
|
tiles.append(entry)
|
||||||
@@ -79,7 +105,7 @@ def _tiles(*, authenticated: bool) -> dict[str, Any]:
|
|||||||
# erwartbar weg. Wer die Uebersicht ansieht, will genau das zuerst
|
# erwartbar weg. Wer die Uebersicht ansieht, will genau das zuerst
|
||||||
# wissen -- sonst haelt er einen laufenden Neustart fuer einen Ausfall.
|
# wissen -- sonst haelt er einen laufenden Neustart fuer einen Ausfall.
|
||||||
"key": "restarting",
|
"key": "restarting",
|
||||||
"label": "Neustart laeuft",
|
"label": "Neustart läuft",
|
||||||
"kind": "warning",
|
"kind": "warning",
|
||||||
"tiles": [tile for tile in tiles if tile["state"] == "restarting"],
|
"tiles": [tile for tile in tiles if tile["state"] == "restarting"],
|
||||||
},
|
},
|
||||||
@@ -97,7 +123,7 @@ def _tiles(*, authenticated: bool) -> dict[str, Any]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "unknown",
|
"key": "unknown",
|
||||||
"label": "Noch nicht geprueft",
|
"label": "Noch nicht geprüft",
|
||||||
"kind": "warning",
|
"kind": "warning",
|
||||||
"tiles": [tile for tile in tiles if tile["state"] == "unknown"],
|
"tiles": [tile for tile in tiles if tile["state"] == "unknown"],
|
||||||
},
|
},
|
||||||
@@ -120,7 +146,7 @@ def _tiles(*, authenticated: bool) -> dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/uebersicht")
|
@bp.get("/overview")
|
||||||
def index():
|
def index():
|
||||||
from tesm_core.auth.models import current_user
|
from tesm_core.auth.models import current_user
|
||||||
|
|
||||||
@@ -137,7 +163,8 @@ def index_view():
|
|||||||
return index()
|
return index()
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/uebersicht/kacheln")
|
@bp.get("/overview/tiles")
|
||||||
|
@fragment
|
||||||
def tiles():
|
def tiles():
|
||||||
"""Teilbereich fuer die automatische Aktualisierung -- liefert nur HTML-Fragment."""
|
"""Teilbereich fuer die automatische Aktualisierung -- liefert nur HTML-Fragment."""
|
||||||
from tesm_core.auth.models import current_user
|
from tesm_core.auth.models import current_user
|
||||||
|
|||||||
@@ -11,16 +11,25 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from flask import Blueprint, abort, flash, redirect, render_template, request, url_for
|
from flask import (
|
||||||
|
Blueprint,
|
||||||
|
Response,
|
||||||
|
abort,
|
||||||
|
flash,
|
||||||
|
redirect,
|
||||||
|
render_template,
|
||||||
|
request,
|
||||||
|
url_for,
|
||||||
|
)
|
||||||
from tesm_core import audit
|
from tesm_core import audit
|
||||||
from tesm_core.auth.models import current_user, permission_required, reauth_required
|
from tesm_core.auth.models import current_user, permission_required, reauth_required
|
||||||
from tesm_core.auth.service import client_ip
|
from tesm_core.auth.service import client_ip
|
||||||
from tesm_core.db import Database
|
from tesm_core.db import Database
|
||||||
from tesm_core.extension import core, db, transaction
|
from tesm_core.extension import core, db, transaction
|
||||||
|
|
||||||
from ..services import inventory, monitor, restart, ssh
|
from ..services import inventory, monitor, restart, ssh, vnc
|
||||||
|
|
||||||
bp = Blueprint("devices", __name__, url_prefix="/clients")
|
bp = Blueprint("devices", __name__, url_prefix="/monitoring/clients")
|
||||||
|
|
||||||
|
|
||||||
def _audit(conn: Any, action: str, target: str, detail: str = "", severity: str = "info") -> None:
|
def _audit(conn: Any, action: str, target: str, detail: str = "", severity: str = "info") -> None:
|
||||||
@@ -60,7 +69,7 @@ def _form_values() -> dict[str, Any]:
|
|||||||
|
|
||||||
|
|
||||||
@bp.get("/")
|
@bp.get("/")
|
||||||
@permission_required("devices.view")
|
@permission_required("devices.read")
|
||||||
def overview():
|
def overview():
|
||||||
conn = db()
|
conn = db()
|
||||||
devices = inventory.list_devices(conn)
|
devices = inventory.list_devices(conn)
|
||||||
@@ -100,7 +109,7 @@ def _credential_from_form(conn: Any) -> int | None:
|
|||||||
)
|
)
|
||||||
name = (request.form.get("new_credential_name") or "").strip()
|
name = (request.form.get("new_credential_name") or "").strip()
|
||||||
if not name:
|
if not name:
|
||||||
raise inventory.ValidationError("Fuer neue Zugangsdaten wird eine Bezeichnung gebraucht.")
|
raise inventory.ValidationError("Für neue Zugangsdaten wird eine Bezeichnung gebraucht.")
|
||||||
return inventory.create_credential(
|
return inventory.create_credential(
|
||||||
conn,
|
conn,
|
||||||
core().keystore,
|
core().keystore,
|
||||||
@@ -108,12 +117,12 @@ def _credential_from_form(conn: Any) -> int | None:
|
|||||||
username=(request.form.get("new_credential_username") or "").strip(),
|
username=(request.form.get("new_credential_username") or "").strip(),
|
||||||
secret=request.form.get("new_credential_secret") or "",
|
secret=request.form.get("new_credential_secret") or "",
|
||||||
category=request.form.get("new_credential_category", "linux"),
|
category=request.form.get("new_credential_category", "linux"),
|
||||||
description="Beim Anlegen eines Geraets erfasst.",
|
description="Beim Anlegen eines Geräts erfasst.",
|
||||||
actor=current_user.username,
|
actor=current_user.username,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/neu")
|
@bp.post("/new")
|
||||||
@permission_required("devices.create")
|
@permission_required("devices.create")
|
||||||
def create():
|
def create():
|
||||||
try:
|
try:
|
||||||
@@ -130,7 +139,7 @@ def create():
|
|||||||
f"MAC {request.form.get('mac')}, IP {request.form.get('ip')}",
|
f"MAC {request.form.get('mac')}, IP {request.form.get('ip')}",
|
||||||
severity="notice",
|
severity="notice",
|
||||||
)
|
)
|
||||||
flash("Geraet angelegt.", "success")
|
flash("Gerät angelegt.", "success")
|
||||||
return redirect(url_for("devices.overview", highlight=device_id))
|
return redirect(url_for("devices.overview", highlight=device_id))
|
||||||
except inventory.ValidationError as exc:
|
except inventory.ValidationError as exc:
|
||||||
flash(str(exc), "danger")
|
flash(str(exc), "danger")
|
||||||
@@ -138,7 +147,7 @@ def create():
|
|||||||
|
|
||||||
|
|
||||||
@bp.post("/<int:device_id>")
|
@bp.post("/<int:device_id>")
|
||||||
@permission_required("devices.edit")
|
@permission_required("devices.write")
|
||||||
def update(device_id: int):
|
def update(device_id: int):
|
||||||
try:
|
try:
|
||||||
with transaction() as conn:
|
with transaction() as conn:
|
||||||
@@ -155,14 +164,14 @@ def update(device_id: int):
|
|||||||
"; ".join(changes),
|
"; ".join(changes),
|
||||||
severity="notice",
|
severity="notice",
|
||||||
)
|
)
|
||||||
flash("Aenderungen gespeichert." if changes else "Keine Aenderungen.", "success" if changes else "info")
|
flash("Änderungen gespeichert." if changes else "Keine Änderungen.", "success" if changes else "info")
|
||||||
except inventory.ValidationError as exc:
|
except inventory.ValidationError as exc:
|
||||||
flash(str(exc), "danger")
|
flash(str(exc), "danger")
|
||||||
return redirect(url_for("devices.overview"))
|
return redirect(url_for("devices.overview"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/<int:device_id>/aktiv")
|
@bp.post("/<int:device_id>/active")
|
||||||
@permission_required("devices.edit")
|
@permission_required("devices.write")
|
||||||
def toggle_active(device_id: int):
|
def toggle_active(device_id: int):
|
||||||
active = request.form.get("active") == "1"
|
active = request.form.get("active") == "1"
|
||||||
with transaction() as conn:
|
with transaction() as conn:
|
||||||
@@ -176,11 +185,11 @@ def toggle_active(device_id: int):
|
|||||||
str(device["name"]),
|
str(device["name"]),
|
||||||
severity="notice",
|
severity="notice",
|
||||||
)
|
)
|
||||||
flash("Geraet aktiviert." if active else "Geraet deaktiviert.", "success")
|
flash("Gerät aktiviert." if active else "Gerät deaktiviert.", "success")
|
||||||
return redirect(request.referrer or url_for("devices.overview"))
|
return redirect(request.referrer or url_for("devices.overview"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/<int:device_id>/loeschen")
|
@bp.post("/<int:device_id>/delete")
|
||||||
@permission_required("devices.delete")
|
@permission_required("devices.delete")
|
||||||
def delete(device_id: int):
|
def delete(device_id: int):
|
||||||
with transaction() as conn:
|
with transaction() as conn:
|
||||||
@@ -189,12 +198,17 @@ def delete(device_id: int):
|
|||||||
abort(404)
|
abort(404)
|
||||||
inventory.delete_device(conn, device_id)
|
inventory.delete_device(conn, device_id)
|
||||||
_audit(conn, "device.deleted", str(device["name"]), severity="warning")
|
_audit(conn, "device.deleted", str(device["name"]), severity="warning")
|
||||||
flash("Geraet in den Papierkorb verschoben.", "success")
|
flash("Gerät in den Papierkorb verschoben.", "success")
|
||||||
return redirect(url_for("devices.overview"))
|
return redirect(url_for("devices.overview"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/<int:device_id>/neustart")
|
@bp.post("/<int:device_id>/restart")
|
||||||
@permission_required("devices.execute")
|
# Die Seite darf sehen, wer Clients sehen darf; welcher Weg offensteht,
|
||||||
|
# entscheidet das Recht des Weges -- geprueft in restart.resolve() ueber
|
||||||
|
# "darf". Ein Weg, den der Benutzer nicht ausloesen darf, existiert fuer ihn
|
||||||
|
# nicht, und die Antwort ist dieselbe wie bei einem technisch unmoeglichen:
|
||||||
|
# "dieser Weg steht nicht zur Verfuegung".
|
||||||
|
@permission_required("devices.read")
|
||||||
def restart_device(device_id: int):
|
def restart_device(device_id: int):
|
||||||
"""Startet ein Geraet neu -- ueber den Weg, der dafuer gewaehlt wurde.
|
"""Startet ein Geraet neu -- ueber den Weg, der dafuer gewaehlt wurde.
|
||||||
|
|
||||||
@@ -214,19 +228,22 @@ def restart_device(device_id: int):
|
|||||||
dict(device),
|
dict(device),
|
||||||
(request.form.get("method") or "").strip(),
|
(request.form.get("method") or "").strip(),
|
||||||
maintenance_active=maintenance_active,
|
maintenance_active=maintenance_active,
|
||||||
|
darf=current_user.has,
|
||||||
)
|
)
|
||||||
if method is None:
|
if method is None:
|
||||||
options = restart.available(dict(device), maintenance_active=maintenance_active)
|
options = restart.available(
|
||||||
|
dict(device), maintenance_active=maintenance_active, darf=current_user.has
|
||||||
|
)
|
||||||
if options:
|
if options:
|
||||||
flash(
|
flash(
|
||||||
"Der gewaehlte Weg steht fuer dieses Geraet nicht zur Verfuegung. "
|
"Der gewählte Weg steht für dieses Gerät nicht zur Verfügung. "
|
||||||
"Moeglich waere: " + ", ".join(entry.label for entry in options),
|
"Möglich wäre: " + ", ".join(entry.label for entry in options),
|
||||||
"warning",
|
"warning",
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
flash(
|
flash(
|
||||||
f"Fuer {device['name']} ist kein Neustart moeglich: es fehlen Switch und Port "
|
f"Für {device['name']} ist kein Neustart möglich: es fehlen Switch und Port "
|
||||||
"fuer den PoE-Weg und passende Zugangsdaten fuer SSH oder RPC.",
|
"für den PoE-Weg und passende Zugangsdaten für SSH oder RPC.",
|
||||||
"warning",
|
"warning",
|
||||||
)
|
)
|
||||||
return redirect(request.referrer or url_for("devices.overview"))
|
return redirect(request.referrer or url_for("devices.overview"))
|
||||||
@@ -262,7 +279,7 @@ def _restart_over_poe(extension: Any, conn: Any, device: Any): # type: ignore[n
|
|||||||
return redirect(request.referrer or url_for("devices.overview"))
|
return redirect(request.referrer or url_for("devices.overview"))
|
||||||
|
|
||||||
flash(
|
flash(
|
||||||
f"PoE-Neustart fuer {device['name']} durchgefuehrt."
|
f"PoE-Neustart für {device['name']} durchgeführt."
|
||||||
if outcome["ok"]
|
if outcome["ok"]
|
||||||
else f"PoE-Neustart fehlgeschlagen: {outcome['message']}",
|
else f"PoE-Neustart fehlgeschlagen: {outcome['message']}",
|
||||||
"success" if outcome["ok"] else "danger",
|
"success" if outcome["ok"] else "danger",
|
||||||
@@ -319,14 +336,14 @@ def _restart_as_job(extension: Any, conn: Any, device: Any, method: Any): # typ
|
|||||||
method=method.key,
|
method=method.key,
|
||||||
)
|
)
|
||||||
flash(
|
flash(
|
||||||
f"{method.label} fuer {device['name']} gestartet. Der Verlauf steht unter Wartung.",
|
f"{method.label} für {device['name']} gestartet. Der Verlauf steht unter Wartung.",
|
||||||
"success",
|
"success",
|
||||||
)
|
)
|
||||||
return redirect(request.referrer or url_for("devices.overview"))
|
return redirect(request.referrer or url_for("devices.overview"))
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/<int:device_id>")
|
@bp.get("/<int:device_id>")
|
||||||
@permission_required("devices.view")
|
@permission_required("devices.read")
|
||||||
def detail(device_id: int):
|
def detail(device_id: int):
|
||||||
conn = db()
|
conn = db()
|
||||||
device = inventory.get_device(conn, device_id)
|
device = inventory.get_device(conn, device_id)
|
||||||
@@ -351,7 +368,69 @@ def detail(device_id: int):
|
|||||||
categories=inventory.DEVICE_CATEGORIES,
|
categories=inventory.DEVICE_CATEGORIES,
|
||||||
host_keys=host_keys,
|
host_keys=host_keys,
|
||||||
method_labels=monitor.METHOD_LABELS,
|
method_labels=monitor.METHOD_LABELS,
|
||||||
terminal_enabled=bool(extension.settings.get(conn, "terminal_enabled")),
|
terminal_enabled=bool(extension.settings.get(conn, "terminal_clients_enabled")),
|
||||||
|
vnc=_vnc_zugang(conn, device),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _vnc_zugang(conn: Any, device: dict[str, Any]) -> vnc.Zugang | None:
|
||||||
|
"""Der Bildschirmzugriff dieses Clients -- aus dem, was bekannt ist.
|
||||||
|
|
||||||
|
Diese Seite fragt das Geraet **nicht**. Erreichbarkeit kommt aus dem
|
||||||
|
Ueberwachungsdurchlauf, die Anmeldeverfahren aus der letzten
|
||||||
|
ausdruecklichen Pruefung. Der Grund steht in :func:`vnc.zugang_gemerkt`:
|
||||||
|
jede Messung ist eine angefangene Protokollverhandlung, und ein RealVNC
|
||||||
|
Server zaehlt so etwas mit.
|
||||||
|
"""
|
||||||
|
extension = core()
|
||||||
|
if not current_user.has("device_vnc.execute"):
|
||||||
|
return None
|
||||||
|
if not extension.settings.get(conn, "vnc_enabled"):
|
||||||
|
return None
|
||||||
|
port = int(extension.settings.get(conn, "vnc_port") or vnc.STANDARD_PORT)
|
||||||
|
return vnc.zugang_gemerkt(device, port=port)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("/<int:device_id>/vnc")
|
||||||
|
@permission_required("device_vnc.execute")
|
||||||
|
def vnc_connection(device_id: int):
|
||||||
|
"""Die Verbindungsdatei -- drei Zeilen, sonst nichts.
|
||||||
|
|
||||||
|
Kein Passwort, kein Benutzername, keine Verschluesselungsvorgabe: was beim
|
||||||
|
Verbinden verlangt wird, entscheidet der VNC-Server auf dem Geraet. Die
|
||||||
|
Datei traegt nur, wohin es geht und wie die Verbindung im Viewer heisst.
|
||||||
|
"""
|
||||||
|
conn = db()
|
||||||
|
device = inventory.get_device(conn, device_id)
|
||||||
|
if device is None:
|
||||||
|
abort(404)
|
||||||
|
extension = core()
|
||||||
|
if not extension.settings.get(conn, "vnc_enabled"):
|
||||||
|
abort(404)
|
||||||
|
port = int(extension.settings.get(conn, "vnc_port") or vnc.STANDARD_PORT)
|
||||||
|
ziel = vnc.ziel(device)
|
||||||
|
if not ziel:
|
||||||
|
abort(404)
|
||||||
|
with transaction():
|
||||||
|
_audit(
|
||||||
|
conn,
|
||||||
|
"device.vnc_opened",
|
||||||
|
str(device["name"]),
|
||||||
|
f"Verbindungsdatei für {ziel}:{port}",
|
||||||
|
)
|
||||||
|
return Response(
|
||||||
|
vnc.verbindungsdatei(
|
||||||
|
device,
|
||||||
|
port=port,
|
||||||
|
warnung_aus=bool(extension.settings.get(conn, "vnc_warn_unencrypted_off")),
|
||||||
|
),
|
||||||
|
mimetype="application/octet-stream",
|
||||||
|
headers={
|
||||||
|
"Content-Disposition": f'attachment; filename="{vnc.dateiname(device)}"',
|
||||||
|
# Eine Verbindungsdatei ist kein Dokument, das ein Proxy aufheben
|
||||||
|
# soll -- und sie ist billig genug, sie jedes Mal neu zu erzeugen.
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -360,8 +439,8 @@ def detail(device_id: int):
|
|||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/<int:device_id>/hostkey/pruefen")
|
@bp.post("/<int:device_id>/host-key/probe")
|
||||||
@permission_required("devices.edit")
|
@permission_required("devices.write")
|
||||||
def probe_host_key(device_id: int):
|
def probe_host_key(device_id: int):
|
||||||
"""Liest den Host-Schluessel des Clients, ohne sich anzumelden.
|
"""Liest den Host-Schluessel des Clients, ohne sich anzumelden.
|
||||||
|
|
||||||
@@ -390,8 +469,8 @@ def probe_host_key(device_id: int):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/<int:device_id>/hostkey/freigeben")
|
@bp.post("/<int:device_id>/host-key/trust")
|
||||||
@permission_required("devices.edit")
|
@permission_required("devices.write")
|
||||||
@reauth_required
|
@reauth_required
|
||||||
def trust_host_key(device_id: int):
|
def trust_host_key(device_id: int):
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -401,7 +480,7 @@ def trust_host_key(device_id: int):
|
|||||||
key_type = (request.form.get("key_type") or "").strip()
|
key_type = (request.form.get("key_type") or "").strip()
|
||||||
fingerprint = (request.form.get("fingerprint") or "").strip()
|
fingerprint = (request.form.get("fingerprint") or "").strip()
|
||||||
if not key_type or not ssh.valid_fingerprint(fingerprint):
|
if not key_type or not ssh.valid_fingerprint(fingerprint):
|
||||||
flash("Es wurde kein gueltiger Fingerprint uebergeben.", "danger")
|
flash("Es wurde kein gültiger Fingerprint übergeben.", "danger")
|
||||||
return redirect(url_for("devices.detail", device_id=device_id))
|
return redirect(url_for("devices.detail", device_id=device_id))
|
||||||
|
|
||||||
with transaction():
|
with transaction():
|
||||||
@@ -420,12 +499,12 @@ def trust_host_key(device_id: int):
|
|||||||
f"{key_type} {fingerprint}",
|
f"{key_type} {fingerprint}",
|
||||||
severity="notice",
|
severity="notice",
|
||||||
)
|
)
|
||||||
flash("Host-Schluessel freigegeben.", "success")
|
flash("Host-Schlüssel freigegeben.", "success")
|
||||||
return redirect(url_for("devices.detail", device_id=device_id))
|
return redirect(url_for("devices.detail", device_id=device_id))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/<int:device_id>/hostkey/verwerfen")
|
@bp.post("/<int:device_id>/host-key/forget")
|
||||||
@permission_required("devices.edit")
|
@permission_required("devices.write")
|
||||||
def forget_host_key(device_id: int):
|
def forget_host_key(device_id: int):
|
||||||
conn = db()
|
conn = db()
|
||||||
device = inventory.get_device(conn, device_id)
|
device = inventory.get_device(conn, device_id)
|
||||||
@@ -434,5 +513,5 @@ def forget_host_key(device_id: int):
|
|||||||
with transaction():
|
with transaction():
|
||||||
ssh.forget_key(conn, str(device["ip"]), int(device["ssh_port"] or 22))
|
ssh.forget_key(conn, str(device["ip"]), int(device["ssh_port"] or 22))
|
||||||
_audit(conn, "device.host_key_forgotten", str(device["name"]), severity="warning")
|
_audit(conn, "device.host_key_forgotten", str(device["name"]), severity="warning")
|
||||||
flash("Gespeicherte Host-Schluessel entfernt.", "info")
|
flash("Gespeicherte Host-Schlüssel entfernt.", "info")
|
||||||
return redirect(url_for("devices.detail", device_id=device_id))
|
return redirect(url_for("devices.detail", device_id=device_id))
|
||||||
@@ -21,6 +21,7 @@ from tesm_core.db import Database
|
|||||||
from tesm_core.extension import core, db, transaction
|
from tesm_core.extension import core, db, transaction
|
||||||
|
|
||||||
from ..services import kea
|
from ..services import kea
|
||||||
|
from ..permissions import DHCP_PAGE
|
||||||
|
|
||||||
bp = Blueprint("dhcp", __name__, url_prefix="/dhcp")
|
bp = Blueprint("dhcp", __name__, url_prefix="/dhcp")
|
||||||
|
|
||||||
@@ -53,7 +54,7 @@ def _settings_values() -> dict[str, Any]:
|
|||||||
|
|
||||||
|
|
||||||
@bp.get("/")
|
@bp.get("/")
|
||||||
@permission_required("dhcp.view", "dhcp.edit", "dhcp.execute", mode="any")
|
@permission_required(*DHCP_PAGE, mode="any")
|
||||||
def overview():
|
def overview():
|
||||||
extension = core()
|
extension = core()
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -105,8 +106,8 @@ def overview():
|
|||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/einstellungen")
|
@bp.post("/settings")
|
||||||
@permission_required("dhcp.edit")
|
@permission_required("dhcp_subnets.write")
|
||||||
def save_settings():
|
def save_settings():
|
||||||
extension = core()
|
extension = core()
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -118,12 +119,12 @@ def save_settings():
|
|||||||
with transaction():
|
with transaction():
|
||||||
changed = extension.settings.set_many(conn, values, actor=current_user.username)
|
changed = extension.settings.set_many(conn, values, actor=current_user.username)
|
||||||
_audit("dhcp.settings_saved", detail=", ".join(changed), severity="notice")
|
_audit("dhcp.settings_saved", detail=", ".join(changed), severity="notice")
|
||||||
flash("Einstellungen gespeichert." if changed else "Keine Aenderungen.", "success" if changed else "info")
|
flash("Einstellungen gespeichert." if changed else "Keine Änderungen.", "success" if changed else "info")
|
||||||
return _write_and_redirect()
|
return _write_and_redirect()
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/subnetz")
|
@bp.post("/subnet")
|
||||||
@permission_required("dhcp.edit")
|
@permission_required("dhcp_subnets.create")
|
||||||
def add_subnet():
|
def add_subnet():
|
||||||
extension = core()
|
extension = core()
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -158,8 +159,8 @@ def add_subnet():
|
|||||||
return _write_and_redirect()
|
return _write_and_redirect()
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/subnetz/<int:subnet_id>")
|
@bp.post("/subnet/<int:subnet_id>")
|
||||||
@permission_required("dhcp.edit")
|
@permission_required("dhcp_subnets.write")
|
||||||
def update_subnet(subnet_id: int):
|
def update_subnet(subnet_id: int):
|
||||||
extension = core()
|
extension = core()
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -173,6 +174,9 @@ def update_subnet(subnet_id: int):
|
|||||||
flash(problem, "danger")
|
flash(problem, "danger")
|
||||||
return redirect(url_for("dhcp.overview"))
|
return redirect(url_for("dhcp.overview"))
|
||||||
|
|
||||||
|
# Vor der Aenderung lesen -- danach ist der alte Stand weg, und "Subnetz
|
||||||
|
# gespeichert" allein sagt nicht, was vorher eingestellt war.
|
||||||
|
vorher = Database.one(conn, "SELECT * FROM dhcp_subnets WHERE id=?", (subnet_id,))
|
||||||
with transaction():
|
with transaction():
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE dhcp_subnets SET interface=?, range_start=?, range_end=?, gateway=?, dns=?, "
|
"UPDATE dhcp_subnets SET interface=?, range_start=?, range_end=?, gateway=?, dns=?, "
|
||||||
@@ -189,13 +193,32 @@ def update_subnet(subnet_id: int):
|
|||||||
subnet_id,
|
subnet_id,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
_audit("dhcp.subnet_updated", str(subnet_id), severity="notice")
|
nachher = Database.one(conn, "SELECT * FROM dhcp_subnets WHERE id=?", (subnet_id,))
|
||||||
|
_audit(
|
||||||
|
"dhcp.subnet_updated",
|
||||||
|
f"{interface} {start}-{end}",
|
||||||
|
audit.diff(
|
||||||
|
vorher,
|
||||||
|
nachher,
|
||||||
|
felder=(
|
||||||
|
("interface", "Schnittstelle"),
|
||||||
|
("range_start", "Von"),
|
||||||
|
("range_end", "Bis"),
|
||||||
|
("gateway", "Gateway"),
|
||||||
|
("dns", "DNS"),
|
||||||
|
("comment", "Kommentar"),
|
||||||
|
("enabled", "Aktiv"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
or "keine inhaltliche Änderung",
|
||||||
|
severity="notice",
|
||||||
|
)
|
||||||
flash("Subnetz gespeichert.", "success")
|
flash("Subnetz gespeichert.", "success")
|
||||||
return _write_and_redirect()
|
return _write_and_redirect()
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/subnetz/<int:subnet_id>/loeschen")
|
@bp.post("/subnet/<int:subnet_id>/delete")
|
||||||
@permission_required("dhcp.edit")
|
@permission_required("dhcp_subnets.delete")
|
||||||
def delete_subnet(subnet_id: int):
|
def delete_subnet(subnet_id: int):
|
||||||
with transaction() as conn:
|
with transaction() as conn:
|
||||||
conn.execute("DELETE FROM dhcp_subnets WHERE id=?", (subnet_id,))
|
conn.execute("DELETE FROM dhcp_subnets WHERE id=?", (subnet_id,))
|
||||||
@@ -209,8 +232,8 @@ def delete_subnet(subnet_id: int):
|
|||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/option/wert")
|
@bp.post("/option/value")
|
||||||
@permission_required("dhcp.edit")
|
@permission_required("dhcp_options.write")
|
||||||
def set_option_value():
|
def set_option_value():
|
||||||
conn = db()
|
conn = db()
|
||||||
option_id = int(request.form.get("option_id") or 0)
|
option_id = int(request.form.get("option_id") or 0)
|
||||||
@@ -248,8 +271,8 @@ def _device_option_values(conn) -> dict[int, list[dict[str, Any]]]: # type: ign
|
|||||||
return gruppen
|
return gruppen
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/option/geraet")
|
@bp.post("/option/device")
|
||||||
@permission_required("dhcp.edit")
|
@permission_required("dhcp_options.write")
|
||||||
def set_device_option_value():
|
def set_device_option_value():
|
||||||
"""Setzt eine Option fuer ein einzelnes Geraet.
|
"""Setzt eine Option fuer ein einzelnes Geraet.
|
||||||
|
|
||||||
@@ -289,15 +312,15 @@ def set_device_option_value():
|
|||||||
value or "(geleert)",
|
value or "(geleert)",
|
||||||
)
|
)
|
||||||
flash(
|
flash(
|
||||||
f"Option {option['name']} fuer {device['name']} "
|
f"Option {option['name']} für {device['name']} "
|
||||||
f"{'gespeichert' if value else 'entfernt'}.",
|
f"{'gespeichert' if value else 'entfernt'}.",
|
||||||
"success",
|
"success",
|
||||||
)
|
)
|
||||||
return _write_and_redirect()
|
return _write_and_redirect()
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/option/neu")
|
@bp.post("/option/new")
|
||||||
@permission_required("dhcp.edit")
|
@permission_required("dhcp_options.write")
|
||||||
def add_custom_option():
|
def add_custom_option():
|
||||||
conn = db()
|
conn = db()
|
||||||
try:
|
try:
|
||||||
@@ -327,8 +350,8 @@ def add_custom_option():
|
|||||||
return redirect(url_for("dhcp.overview"))
|
return redirect(url_for("dhcp.overview"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/option/<int:option_id>/loeschen")
|
@bp.post("/option/<int:option_id>/delete")
|
||||||
@permission_required("dhcp.edit")
|
@permission_required("dhcp_options.write")
|
||||||
def delete_custom_option(option_id: int):
|
def delete_custom_option(option_id: int):
|
||||||
conn = db()
|
conn = db()
|
||||||
option = Database.one(conn, "SELECT * FROM dhcp_options WHERE id=?", (option_id,))
|
option = Database.one(conn, "SELECT * FROM dhcp_options WHERE id=?", (option_id,))
|
||||||
@@ -336,7 +359,7 @@ def delete_custom_option(option_id: int):
|
|||||||
abort(404)
|
abort(404)
|
||||||
if int(option["is_standard"] or 0):
|
if int(option["is_standard"] or 0):
|
||||||
flash(
|
flash(
|
||||||
"Standardoptionen koennen nicht geloescht werden -- nur ihr Wert lasst sich leeren.",
|
"Standardoptionen können nicht gelöscht werden – nur ihr Wert lässt sich leeren.",
|
||||||
"danger",
|
"danger",
|
||||||
)
|
)
|
||||||
return redirect(url_for("dhcp.overview"))
|
return redirect(url_for("dhcp.overview"))
|
||||||
@@ -352,14 +375,14 @@ def delete_custom_option(option_id: int):
|
|||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/reservierung")
|
@bp.post("/reservation")
|
||||||
@permission_required("dhcp.edit")
|
@permission_required("dhcp_reservations.create")
|
||||||
def add_reservation():
|
def add_reservation():
|
||||||
conn = db()
|
conn = db()
|
||||||
mac = kea.normalize_mac(request.form.get("mac", ""))
|
mac = kea.normalize_mac(request.form.get("mac", ""))
|
||||||
ip = (request.form.get("ip") or "").strip()
|
ip = (request.form.get("ip") or "").strip()
|
||||||
if not mac:
|
if not mac:
|
||||||
flash("Die MAC-Adresse ist ungueltig.", "danger")
|
flash("Die MAC-Adresse ist ungültig.", "danger")
|
||||||
return redirect(url_for("dhcp.overview"))
|
return redirect(url_for("dhcp.overview"))
|
||||||
|
|
||||||
# Eine Reservierung ausserhalb aller eingerichteten Bereiche liefert Kea nie
|
# Eine Reservierung ausserhalb aller eingerichteten Bereiche liefert Kea nie
|
||||||
@@ -367,7 +390,7 @@ def add_reservation():
|
|||||||
# schlechteste der drei Moeglichkeiten -- deshalb hier ablehnen.
|
# schlechteste der drei Moeglichkeiten -- deshalb hier ablehnen.
|
||||||
if not Database.value(conn, "SELECT COUNT(*) FROM dhcp_subnets WHERE enabled=1"):
|
if not Database.value(conn, "SELECT COUNT(*) FROM dhcp_subnets WHERE enabled=1"):
|
||||||
flash(
|
flash(
|
||||||
"Es ist noch kein Bereich eingerichtet. Legen Sie zuerst ein Subnetz an -- "
|
"Es ist noch kein Bereich eingerichtet. Legen Sie zuerst ein Subnetz an – "
|
||||||
"ohne eines wird keine Reservierung ausgeliefert.",
|
"ohne eines wird keine Reservierung ausgeliefert.",
|
||||||
"danger",
|
"danger",
|
||||||
)
|
)
|
||||||
@@ -380,7 +403,7 @@ def add_reservation():
|
|||||||
# keine Reservierung mehr moeglich. Stattdessen benennen, was ungeprueft
|
# keine Reservierung mehr moeglich. Stattdessen benennen, was ungeprueft
|
||||||
# bleibt.
|
# bleibt.
|
||||||
flash(
|
flash(
|
||||||
"Der Bereich konnte nicht geprueft werden -- ohne Systemhelfer ist das Netz "
|
"Der Bereich konnte nicht geprüft werden – ohne Systemhelfer ist das Netz "
|
||||||
"dieses Servers nicht feststellbar.",
|
"dieses Servers nicht feststellbar.",
|
||||||
"warning",
|
"warning",
|
||||||
)
|
)
|
||||||
@@ -390,11 +413,12 @@ def add_reservation():
|
|||||||
bereiche = ", ".join(str(netz) for netz in netze)
|
bereiche = ", ".join(str(netz) for netz in netze)
|
||||||
flash(
|
flash(
|
||||||
f"{ip or 'Diese Adresse'} liegt in keinem eingerichteten Bereich "
|
f"{ip or 'Diese Adresse'} liegt in keinem eingerichteten Bereich "
|
||||||
f"({bereiche}). Die Reservierung waere ohne Wirkung.",
|
f"({bereiche}). Die Reservierung wäre ohne Wirkung.",
|
||||||
"danger",
|
"danger",
|
||||||
)
|
)
|
||||||
return redirect(url_for("dhcp.overview"))
|
return redirect(url_for("dhcp.overview"))
|
||||||
|
|
||||||
|
vorher = Database.one(conn, "SELECT * FROM dhcp_reservations WHERE mac=?", (mac,))
|
||||||
with transaction():
|
with transaction():
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO dhcp_reservations (mac, ip, hostname, comment, created_at) "
|
"INSERT INTO dhcp_reservations (mac, ip, hostname, comment, created_at) "
|
||||||
@@ -407,13 +431,24 @@ def add_reservation():
|
|||||||
(request.form.get("comment") or "").strip(),
|
(request.form.get("comment") or "").strip(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
_audit("dhcp.reservation_saved", f"{mac} -> {ip}", severity="notice")
|
nachher = Database.one(conn, "SELECT * FROM dhcp_reservations WHERE mac=?", (mac,))
|
||||||
|
_audit(
|
||||||
|
"dhcp.reservation_saved",
|
||||||
|
mac,
|
||||||
|
audit.diff(
|
||||||
|
vorher,
|
||||||
|
nachher,
|
||||||
|
felder=(("ip", "IP"), ("hostname", "Hostname"), ("comment", "Kommentar")),
|
||||||
|
)
|
||||||
|
or f"neu angelegt: {ip}",
|
||||||
|
severity="notice",
|
||||||
|
)
|
||||||
flash("Reservierung gespeichert.", "success")
|
flash("Reservierung gespeichert.", "success")
|
||||||
return _write_and_redirect()
|
return _write_and_redirect()
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/reservierung/<int:reservation_id>/loeschen")
|
@bp.post("/reservation/<int:reservation_id>/delete")
|
||||||
@permission_required("dhcp.edit")
|
@permission_required("dhcp_reservations.delete")
|
||||||
def delete_reservation(reservation_id: int):
|
def delete_reservation(reservation_id: int):
|
||||||
with transaction() as conn:
|
with transaction() as conn:
|
||||||
conn.execute("DELETE FROM dhcp_reservations WHERE id=?", (reservation_id,))
|
conn.execute("DELETE FROM dhcp_reservations WHERE id=?", (reservation_id,))
|
||||||
@@ -437,14 +472,14 @@ def _write_config() -> dict[str, Any]:
|
|||||||
sysops = extension.sysops
|
sysops = extension.sysops
|
||||||
conn = db()
|
conn = db()
|
||||||
if sysops is None or not sysops.available:
|
if sysops is None or not sysops.available:
|
||||||
return {"ok": False, "message": "Systemhelfer nicht verfuegbar."}
|
return {"ok": False, "message": "Systemhelfer nicht verfügbar."}
|
||||||
|
|
||||||
networks = kea.detect_networks(sysops)
|
networks = kea.detect_networks(sysops)
|
||||||
config, warnings = kea.render_config(conn, networks=networks, settings=_settings_values())
|
config, warnings = kea.render_config(conn, networks=networks, settings=_settings_values())
|
||||||
if not config["Dhcp4"]["subnet4"]:
|
if not config["Dhcp4"]["subnet4"]:
|
||||||
return {
|
return {
|
||||||
"ok": False,
|
"ok": False,
|
||||||
"message": "Es ist kein gueltiges Subnetz konfiguriert -- die Datei wurde nicht geschrieben.",
|
"message": "Es ist kein gültiges Subnetz konfiguriert – die Datei wurde nicht geschrieben.",
|
||||||
"warnings": warnings,
|
"warnings": warnings,
|
||||||
}
|
}
|
||||||
result = sysops.write_kea_config(kea.config_to_text(config))
|
result = sysops.write_kea_config(kea.config_to_text(config))
|
||||||
@@ -480,14 +515,14 @@ def _write_and_redirect():
|
|||||||
return redirect(url_for("dhcp.overview"))
|
return redirect(url_for("dhcp.overview"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/schreiben")
|
@bp.post("/write")
|
||||||
@permission_required("dhcp.edit")
|
@permission_required("dhcp_config.execute")
|
||||||
def write_config():
|
def write_config():
|
||||||
return _write_and_redirect()
|
return _write_and_redirect()
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/dienst/<action>")
|
@bp.post("/service/<action>")
|
||||||
@permission_required("dhcp.execute")
|
@permission_required("dhcp_service.execute")
|
||||||
def service_action(action: str):
|
def service_action(action: str):
|
||||||
allowed = {"install", "start", "stop"}
|
allowed = {"install", "start", "stop"}
|
||||||
if action not in allowed:
|
if action not in allowed:
|
||||||
@@ -495,7 +530,7 @@ def service_action(action: str):
|
|||||||
extension = core()
|
extension = core()
|
||||||
sysops = extension.sysops
|
sysops = extension.sysops
|
||||||
if sysops is None or not sysops.available:
|
if sysops is None or not sysops.available:
|
||||||
flash("Systemaktionen sind in dieser Umgebung nicht verfuegbar.", "warning")
|
flash("Systemaktionen sind in dieser Umgebung nicht verfügbar.", "warning")
|
||||||
return redirect(url_for("dhcp.overview"))
|
return redirect(url_for("dhcp.overview"))
|
||||||
|
|
||||||
if action == "install":
|
if action == "install":
|
||||||
|
|||||||
@@ -2,7 +2,10 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
import io
|
import io
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from flask import (
|
from flask import (
|
||||||
Blueprint,
|
Blueprint,
|
||||||
@@ -18,11 +21,12 @@ from flask import (
|
|||||||
from tesm_core import audit
|
from tesm_core import audit
|
||||||
from tesm_core.auth.models import current_user, permission_required
|
from tesm_core.auth.models import current_user, permission_required
|
||||||
from tesm_core.auth.service import client_ip
|
from tesm_core.auth.service import client_ip
|
||||||
|
from tesm_core import uploads
|
||||||
from tesm_core.extension import core, db, transaction
|
from tesm_core.extension import core, db, transaction
|
||||||
|
|
||||||
from ..services import fileshare as service
|
from ..services import fileshare as service
|
||||||
|
|
||||||
bp = Blueprint("fileshare", __name__, url_prefix="/dateifreigaben")
|
bp = Blueprint("fileshare", __name__, url_prefix="/fileshares")
|
||||||
|
|
||||||
|
|
||||||
@bp.before_request
|
@bp.before_request
|
||||||
@@ -46,8 +50,33 @@ def _audit(action: str, target: str, detail: str = "", severity: str = "info") -
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _unterschriften(roh: str) -> list[str]:
|
||||||
|
"""Die Namen aus dem Unterschriftsfeld -- Text, keine Struktur.
|
||||||
|
|
||||||
|
Was hier ankommt, hat der Browser geschickt und damit der Mensch davor.
|
||||||
|
Es wird protokolliert, nicht geglaubt: laenge begrenzt, Zeilenumbrueche
|
||||||
|
raus, mehr passiert damit nicht.
|
||||||
|
"""
|
||||||
|
namen: list[str] = []
|
||||||
|
try:
|
||||||
|
eintraege = json.loads(roh) if roh else []
|
||||||
|
except ValueError:
|
||||||
|
return []
|
||||||
|
if not isinstance(eintraege, list):
|
||||||
|
return []
|
||||||
|
for eintrag in eintraege[:20]:
|
||||||
|
name = str(eintrag if isinstance(eintrag, str) else eintrag.get("name", ""))
|
||||||
|
name = " ".join(name.split())[:120]
|
||||||
|
if name:
|
||||||
|
namen.append(name)
|
||||||
|
return namen
|
||||||
|
|
||||||
|
|
||||||
def _mount_or_404(token: str) -> service.Mount:
|
def _mount_or_404(token: str) -> service.Mount:
|
||||||
mounts = service.active_mounts(db(), current_user.id)
|
conn = db()
|
||||||
|
mounts = service.doppelte_zusammenfassen(
|
||||||
|
conn, service.active_mounts(conn, current_user.id), service.aktuelle_sitzung()
|
||||||
|
)
|
||||||
for mount in mounts:
|
for mount in mounts:
|
||||||
if mount.token == token:
|
if mount.token == token:
|
||||||
return mount
|
return mount
|
||||||
@@ -55,12 +84,14 @@ def _mount_or_404(token: str) -> service.Mount:
|
|||||||
|
|
||||||
|
|
||||||
@bp.get("/")
|
@bp.get("/")
|
||||||
@permission_required("fileshare.view")
|
@permission_required("fileshare.read")
|
||||||
def browse():
|
def browse():
|
||||||
conn = db()
|
conn = db()
|
||||||
mounts = service.active_mounts(conn, current_user.id)
|
mounts = service.doppelte_zusammenfassen(
|
||||||
token = request.args.get("freigabe") or (mounts[0].token if mounts else "")
|
conn, service.active_mounts(conn, current_user.id), service.aktuelle_sitzung()
|
||||||
relative = request.args.get("pfad") or ""
|
)
|
||||||
|
token = request.args.get("share") or (mounts[0].token if mounts else "")
|
||||||
|
relative = request.args.get("path") or ""
|
||||||
listing = None
|
listing = None
|
||||||
error = ""
|
error = ""
|
||||||
if token:
|
if token:
|
||||||
@@ -75,15 +106,27 @@ def browse():
|
|||||||
token=token,
|
token=token,
|
||||||
listing=listing,
|
listing=listing,
|
||||||
error=error,
|
error=error,
|
||||||
max_upload_mb=int(core().settings.get(conn, "fileshare_upload_max_mb") or 512),
|
# Dieselbe Zahl, die auch die Anfrage begrenzt und in nginx steht --
|
||||||
|
# vorher versprach diese Seite 512 MB, waehrend bei 64 MB abgebrochen
|
||||||
|
# wurde.
|
||||||
|
max_upload_mb=uploads.limit_mb(conn),
|
||||||
|
# Vorschlagsliste fuer "Ausgefuellt speichern": wohin darf es gehen?
|
||||||
|
verzeichnisse=(service.all_directories(mount) if listing else []),
|
||||||
|
signature_witness=bool(core().settings.get(conn, "signature_witness_enabled")),
|
||||||
|
# Die Uhr des Servers. Das Datum unter einer Unterschrift soll nicht
|
||||||
|
# davon abhaengen, wie das Tablet gestellt ist.
|
||||||
|
serverzeit=datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/<token>/herunterladen")
|
@bp.get("/<token>/download")
|
||||||
@permission_required("fileshare.view")
|
# Herunterladen traegt Daten aus dem System heraus -- in der Rechtematrix ist
|
||||||
|
# das "Exportieren", nicht "Ansehen". Eine Vorschau bleibt im Haus, eine
|
||||||
|
# heruntergeladene Datei nicht.
|
||||||
|
@permission_required("fileshare.export")
|
||||||
def download(token: str):
|
def download(token: str):
|
||||||
mount = _mount_or_404(token)
|
mount = _mount_or_404(token)
|
||||||
relative = request.args.get("pfad") or ""
|
relative = request.args.get("path") or ""
|
||||||
try:
|
try:
|
||||||
path = service.resolve_path(mount, relative)
|
path = service.resolve_path(mount, relative)
|
||||||
except service.FileshareError as exc:
|
except service.FileshareError as exc:
|
||||||
@@ -95,15 +138,20 @@ def download(token: str):
|
|||||||
path,
|
path,
|
||||||
as_attachment=True,
|
as_attachment=True,
|
||||||
download_name=path.name,
|
download_name=path.name,
|
||||||
mimetype=service.download_mime(path.name),
|
# Bewusst der neutrale Typ und nicht der erkannte: iOS Safari
|
||||||
|
# entscheidet nach dem MIME-Typ und zeigt eine "application/pdf" im
|
||||||
|
# Browser an, auch wenn sie als Anhang ausgeliefert wird. Wer hier
|
||||||
|
# klickt, will die Datei aber ablegen -- angesehen wird sie in der
|
||||||
|
# Vorschau. Fuer alle anderen Browser aendert sich damit nichts.
|
||||||
|
mimetype="application/octet-stream",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/<token>/vorschau")
|
@bp.get("/<token>/preview")
|
||||||
@permission_required("fileshare.view")
|
@permission_required("fileshare.read")
|
||||||
def preview(token: str):
|
def preview(token: str):
|
||||||
mount = _mount_or_404(token)
|
mount = _mount_or_404(token)
|
||||||
relative = request.args.get("pfad") or ""
|
relative = request.args.get("path") or ""
|
||||||
try:
|
try:
|
||||||
path = service.resolve_path(mount, relative)
|
path = service.resolve_path(mount, relative)
|
||||||
mimetype = service.preview_mime(path.name)
|
mimetype = service.preview_mime(path.name)
|
||||||
@@ -138,17 +186,17 @@ def preview(token: str):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/<token>/hochladen")
|
@bp.post("/<token>/upload")
|
||||||
@permission_required("fileshare.create")
|
@permission_required("fileshare.create")
|
||||||
def upload(token: str):
|
def upload(token: str):
|
||||||
mount = _mount_or_404(token)
|
mount = _mount_or_404(token)
|
||||||
relative = request.form.get("pfad") or ""
|
relative = request.form.get("path") or ""
|
||||||
files = request.files.getlist("files")
|
files = request.files.getlist("files")
|
||||||
if not files:
|
if not files:
|
||||||
flash("Keine Datei ausgewaehlt.", "danger")
|
flash("Keine Datei ausgewählt.", "danger")
|
||||||
return redirect(url_for("fileshare.browse", freigabe=token, pfad=relative))
|
return redirect(url_for("fileshare.browse", share=token, path=relative))
|
||||||
|
|
||||||
limit = int(core().settings.get(db(), "fileshare_upload_max_mb") or 512) * 1024 * 1024
|
limit = uploads.limit_bytes(db())
|
||||||
stored = 0
|
stored = 0
|
||||||
try:
|
try:
|
||||||
target_dir = service.resolve_path(mount, relative)
|
target_dir = service.resolve_path(mount, relative)
|
||||||
@@ -158,58 +206,239 @@ def upload(token: str):
|
|||||||
name = service.safe_filename(upload_file.filename)
|
name = service.safe_filename(upload_file.filename)
|
||||||
data = upload_file.stream.read(limit + 1)
|
data = upload_file.stream.read(limit + 1)
|
||||||
if len(data) > limit:
|
if len(data) > limit:
|
||||||
flash(f"{name}: zu gross (Grenze {limit // 1024 // 1024} MB).", "danger")
|
flash(f"{name}: zu groß (Grenze {limit // 1024 // 1024} MB).", "danger")
|
||||||
continue
|
continue
|
||||||
(target_dir / name).write_bytes(data)
|
(target_dir / name).write_bytes(data)
|
||||||
stored += 1
|
stored += 1
|
||||||
except service.FileshareError as exc:
|
except service.FileshareError as exc:
|
||||||
flash(str(exc), "danger")
|
flash(str(exc), "danger")
|
||||||
return redirect(url_for("fileshare.browse", freigabe=token, pfad=relative))
|
return redirect(url_for("fileshare.browse", share=token, path=relative))
|
||||||
|
|
||||||
if stored:
|
if stored:
|
||||||
_audit("fileshare.uploaded", f"{mount.label}:{relative}", f"{stored} Datei(en)")
|
_audit("fileshare.uploaded", f"{mount.label}:{relative}", f"{stored} Datei(en)")
|
||||||
flash(f"{stored} Datei(en) hochgeladen.", "success")
|
flash(f"{stored} Datei(en) hochgeladen.", "success")
|
||||||
return redirect(url_for("fileshare.browse", freigabe=token, pfad=relative))
|
return redirect(url_for("fileshare.browse", share=token, path=relative))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/<token>/ordner")
|
@bp.get("/<token>/signature")
|
||||||
|
@permission_required("fileshare.read")
|
||||||
|
def signature_state(token: str):
|
||||||
|
"""Passt die Datei noch zu ihrem Eintrag im Aenderungsprotokoll?
|
||||||
|
|
||||||
|
Die Vorschau fragt das beim Oeffnen. Eine unterschriebene Fassung, die
|
||||||
|
seither veraendert wurde, sieht man einer PDF sonst nicht an -- und genau
|
||||||
|
das ist die Frage, die man sich vor einer Unterschrift stellt.
|
||||||
|
"""
|
||||||
|
mount = _mount_or_404(token)
|
||||||
|
try:
|
||||||
|
return service.signaturstand(db(), mount, request.args.get("path") or "")
|
||||||
|
except service.FileshareError as exc:
|
||||||
|
return {"error": str(exc)}, 400
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("/<token>/folders")
|
||||||
|
@permission_required("fileshare.read")
|
||||||
|
def folders(token: str):
|
||||||
|
"""Die Ordner **dieser** Freigabe als Vorschlagsliste.
|
||||||
|
|
||||||
|
Gebraucht, seit das Speichern in eine andere Freigabe gehen darf: die
|
||||||
|
Vorschlaege muessen der gewaehlten Freigabe folgen. Alle Listen beim
|
||||||
|
Seitenaufbau mitzuschicken waere die Alternative gewesen -- bei vier
|
||||||
|
eingebundenen Freigaben mit je ein paar hundert Ordnern ist das eine
|
||||||
|
viertel Megabyte, die fast nie gebraucht wird.
|
||||||
|
"""
|
||||||
|
mount = _mount_or_404(token)
|
||||||
|
return {"folders": service.all_directories(mount)}
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/<token>/form")
|
||||||
|
@permission_required("fileshare_pdf.create")
|
||||||
|
def save_form(token: str):
|
||||||
|
"""Nimmt ein in der Vorschau ausgefuelltes PDF und legt es daneben ab.
|
||||||
|
|
||||||
|
Als **neue** Datei: auf einer Freigabe liegt in der Regel die leere
|
||||||
|
Vorlage, und die soll leer bleiben. Wer das Original ersetzen will, kann
|
||||||
|
die alte Datei danach loeschen -- das ist eine bewusste Handlung.
|
||||||
|
"""
|
||||||
|
mount = _mount_or_404(token)
|
||||||
|
# Das Ziel darf eine andere Freigabe sein: die Vorlage liegt oft in einer
|
||||||
|
# Vorlagenfreigabe, die ausgefuellte Fassung gehoert in die Ablage des
|
||||||
|
# Bereichs. Geprueft wird es wie jede andere Einbindung -- nur eine, die
|
||||||
|
# diesem Benutzer gerade gehoert, kommt in Frage.
|
||||||
|
ziel_token = (request.form.get("ziel") or "").strip()
|
||||||
|
ziel_mount = _mount_or_404(ziel_token) if ziel_token else mount
|
||||||
|
relative = request.form.get("path") or ""
|
||||||
|
upload_file = request.files.get("datei")
|
||||||
|
if upload_file is None or not upload_file.filename:
|
||||||
|
return {"error": "Es kam keine Datei an."}, 400
|
||||||
|
|
||||||
|
# Ein unterschriebenes Dokument ist abgeschlossen. Es als Vorlage fuer eine
|
||||||
|
# neue Fassung zu benutzen hiesse, eine Unterschrift unter einen anderen
|
||||||
|
# Text zu stellen -- die Oberflaeche blendet das aus, hier wird es
|
||||||
|
# abgewiesen.
|
||||||
|
if service.signaturstand(db(), mount, relative).get("status") in {
|
||||||
|
"unveraendert",
|
||||||
|
"veraendert",
|
||||||
|
}:
|
||||||
|
return {
|
||||||
|
"error": "Dieses Dokument ist unterschrieben und damit abgeschlossen. "
|
||||||
|
"Legen Sie eine neue Fassung aus der ursprünglichen Vorlage an."
|
||||||
|
}, 409
|
||||||
|
|
||||||
|
grenze = uploads.limit_bytes(db())
|
||||||
|
daten = upload_file.stream.read(grenze + 1)
|
||||||
|
if len(daten) > grenze:
|
||||||
|
return {"error": f"Zu groß (Grenze {grenze // 1024 // 1024} MB)."}, 413
|
||||||
|
if not daten.startswith(b"%PDF-"):
|
||||||
|
# Diese Route nimmt nur, was pdf.js hier ablegt. Ein anderer Inhalt
|
||||||
|
# waere ein Umweg, um beliebige Dateien in die Freigabe zu bekommen.
|
||||||
|
return {"error": "Das ist keine PDF-Datei."}, 415
|
||||||
|
|
||||||
|
# Geprueft wird die **Datei**, nicht ein mitgeschicktes Auswahlfeld: wer nur
|
||||||
|
# anlegen darf, legt endgueltige Fassungen ab. Traegt die Datei noch
|
||||||
|
# Formularfelder, ist sie weiter veraenderbar -- dafuer braucht es
|
||||||
|
# "PDF-Formulare: Bearbeiten". Ein umgebogenes Feld im Browser hilft damit
|
||||||
|
# niemandem weiter.
|
||||||
|
if service.hat_formularfelder(daten) and not current_user.has("fileshare_pdf.write"):
|
||||||
|
return {
|
||||||
|
"error": "Sie dürfen ausgefüllte Formulare nur abgeschlossen speichern "
|
||||||
|
"(Felder gesperrt). Bitte im Auswahlfeld 'Abgeschlossen' wählen."
|
||||||
|
}, 403
|
||||||
|
|
||||||
|
# Wohin und unter welchem Namen -- beides darf der Benutzer bestimmen.
|
||||||
|
ordner = (request.form.get("ordner") or "").strip().strip("/")
|
||||||
|
wunschname = (request.form.get("name") or "").strip()
|
||||||
|
ersetzen = request.form.get("ersetzen") == "1"
|
||||||
|
|
||||||
|
try:
|
||||||
|
original = service.resolve_path(mount, relative)
|
||||||
|
# Ohne Namen landet die Datei neben dem Original -- das geht nur in
|
||||||
|
# derselben Freigabe.
|
||||||
|
if ziel_mount.token != mount.token and not wunschname:
|
||||||
|
return {"error": "Für eine andere Freigabe braucht es einen Dateinamen."}, 400
|
||||||
|
# Mit Namen entscheidet das Ordnerfeld -- leer heisst Wurzelverzeichnis,
|
||||||
|
# so wie es im Feld steht. Ohne Namen (Aufruf ohne Oberflaeche) bleibt
|
||||||
|
# es beim Ordner des Originals.
|
||||||
|
if wunschname:
|
||||||
|
zielordner = service.resolve_path(ziel_mount, ordner)
|
||||||
|
else:
|
||||||
|
zielordner = service.resolve_path(mount, ordner) if ordner else original.parent
|
||||||
|
if not zielordner.is_dir():
|
||||||
|
return {
|
||||||
|
"error": f"Den Ordner '{ordner}' gibt es in der Freigabe "
|
||||||
|
f"'{ziel_mount.label}' nicht."
|
||||||
|
}, 400
|
||||||
|
if wunschname:
|
||||||
|
# Eingegeben wird der Name ohne Endung; die kommt von hier. Wer
|
||||||
|
# sie mittippt, bekommt sie nicht doppelt -- und wer eine falsche
|
||||||
|
# tippt, bekommt trotzdem eine Datei, die sich oeffnen laesst.
|
||||||
|
name = service.safe_filename(wunschname)
|
||||||
|
if not name.lower().endswith(".pdf"):
|
||||||
|
name += ".pdf"
|
||||||
|
ziel = zielordner / name
|
||||||
|
if ziel.exists() and not ersetzen:
|
||||||
|
return {
|
||||||
|
"error": f"In '{ziel_mount.label}' gibt es '{name}' schon. Anderen "
|
||||||
|
"Namen wählen oder 'Vorhandene ersetzen' ankreuzen."
|
||||||
|
}, 409
|
||||||
|
else:
|
||||||
|
# Ohne Angabe wie bisher: neben das Original, ohne etwas zu ueberschreiben.
|
||||||
|
ziel = service.free_name(original.parent, original.stem, "-ausgefuellt", ".pdf")
|
||||||
|
ziel.write_bytes(daten)
|
||||||
|
except service.FileshareError as exc:
|
||||||
|
return {"error": str(exc)}, 400
|
||||||
|
|
||||||
|
wohin = ziel.relative_to(ziel_mount.path.resolve()).as_posix()
|
||||||
|
pruefsumme = hashlib.sha256(daten).hexdigest()
|
||||||
|
unterschriften = _unterschriften(request.form.get("unterschriften") or "")
|
||||||
|
# Unterschreiben ist kein Aendern: am Ende steht eine Fassung, die niemand
|
||||||
|
# mehr bearbeiten kann, mit Namen im Dokument und Pruefsumme im Protokoll.
|
||||||
|
# Wer Dateien ablegen darf, darf das nicht automatisch.
|
||||||
|
if unterschriften and not current_user.has("fileshare_signature.execute"):
|
||||||
|
return {"error": "Zum Unterschreiben fehlt die Berechtigung."}, 403
|
||||||
|
|
||||||
|
# Zwei Angaben, die man nicht verwechseln darf, und deshalb stehen beide
|
||||||
|
# da: der Name **in** der Unterschrift ist eine Angabe des
|
||||||
|
# Unterschreibenden -- wie auf Papier. Wer die Datei abgelegt hat, steht
|
||||||
|
# ohnehin als Handelnder in der Zeile und ist belegt.
|
||||||
|
# Zwei Zeilen: oben, was ein Mensch liest; unten die Pruefsumme, die
|
||||||
|
# niemand liest, aber jeder vergleichen koennen muss. In einer einzigen
|
||||||
|
# Zeile drueckte der Hash den Rest aus der Spalte.
|
||||||
|
kopf = f"{'Signiert' if unterschriften else 'Gespeichert'}: {wohin}"
|
||||||
|
if unterschriften:
|
||||||
|
kopf += f" von: {', '.join(unterschriften)}"
|
||||||
|
beschreibung = f"{kopf}{chr(10)}sha256={pruefsumme}"
|
||||||
|
_audit(
|
||||||
|
"fileshare.form_signed" if unterschriften else "fileshare.form_saved",
|
||||||
|
f"{mount.label}:{relative}",
|
||||||
|
beschreibung,
|
||||||
|
severity="notice",
|
||||||
|
)
|
||||||
|
if unterschriften:
|
||||||
|
# Zusaetzlich ins Signaturverzeichnis: das Protokoll wird irgendwann
|
||||||
|
# archiviert, die Frage "ist diese Datei noch die unterschriebene?"
|
||||||
|
# stellt sich aber noch Jahre spaeter. Und mit fsync -- die Datei liegt
|
||||||
|
# schon auf der Freigabe; ein Stromausfall soll nicht den Nachweis
|
||||||
|
# dazu verschlucken.
|
||||||
|
verbindung = db()
|
||||||
|
with core().database.dauerhaft(verbindung), transaction():
|
||||||
|
service.signatur_merken(
|
||||||
|
verbindung,
|
||||||
|
sha256=pruefsumme,
|
||||||
|
label=ziel_mount.label,
|
||||||
|
pfad=wohin,
|
||||||
|
actor=current_user.username,
|
||||||
|
namen=unterschriften,
|
||||||
|
)
|
||||||
|
# Zweites Exemplar ausserhalb der Datenbank.
|
||||||
|
service.signatur_journalisieren(
|
||||||
|
sha256=pruefsumme,
|
||||||
|
label=ziel_mount.label,
|
||||||
|
pfad=wohin,
|
||||||
|
actor=current_user.username,
|
||||||
|
namen=unterschriften,
|
||||||
|
)
|
||||||
|
return {"message": f"Gespeichert als {wohin}.", "path": wohin, "sha256": pruefsumme}
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/<token>/folder")
|
||||||
@permission_required("fileshare.create")
|
@permission_required("fileshare.create")
|
||||||
def make_directory(token: str):
|
def make_directory(token: str):
|
||||||
mount = _mount_or_404(token)
|
mount = _mount_or_404(token)
|
||||||
relative = request.form.get("pfad") or ""
|
relative = request.form.get("path") or ""
|
||||||
try:
|
try:
|
||||||
service.make_directory(mount, relative, request.form.get("name", ""))
|
service.make_directory(mount, relative, request.form.get("name", ""))
|
||||||
except (service.FileshareError, OSError) as exc:
|
except (service.FileshareError, OSError) as exc:
|
||||||
flash(str(exc), "danger")
|
flash(str(exc), "danger")
|
||||||
return redirect(url_for("fileshare.browse", freigabe=token, pfad=relative))
|
return redirect(url_for("fileshare.browse", share=token, path=relative))
|
||||||
_audit("fileshare.directory_created", f"{mount.label}:{relative}/{request.form.get('name')}")
|
_audit("fileshare.directory_created", f"{mount.label}:{relative}/{request.form.get('name')}")
|
||||||
flash("Ordner angelegt.", "success")
|
flash("Ordner angelegt.", "success")
|
||||||
return redirect(url_for("fileshare.browse", freigabe=token, pfad=relative))
|
return redirect(url_for("fileshare.browse", share=token, path=relative))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/<token>/umbenennen")
|
@bp.post("/<token>/rename")
|
||||||
@permission_required("fileshare.edit")
|
@permission_required("fileshare.write")
|
||||||
def rename(token: str):
|
def rename(token: str):
|
||||||
mount = _mount_or_404(token)
|
mount = _mount_or_404(token)
|
||||||
relative = request.form.get("pfad") or ""
|
relative = request.form.get("path") or ""
|
||||||
parent = request.form.get("parent") or ""
|
parent = request.form.get("parent") or ""
|
||||||
try:
|
try:
|
||||||
service.rename_entry(mount, relative, request.form.get("name", ""))
|
service.rename_entry(mount, relative, request.form.get("name", ""))
|
||||||
except (service.FileshareError, OSError) as exc:
|
except (service.FileshareError, OSError) as exc:
|
||||||
flash(str(exc), "danger")
|
flash(str(exc), "danger")
|
||||||
return redirect(url_for("fileshare.browse", freigabe=token, pfad=parent))
|
return redirect(url_for("fileshare.browse", share=token, path=parent))
|
||||||
_audit("fileshare.renamed", f"{mount.label}:{relative}")
|
_audit("fileshare.renamed", f"{mount.label}:{relative}")
|
||||||
flash("Umbenannt.", "success")
|
flash("Umbenannt.", "success")
|
||||||
return redirect(url_for("fileshare.browse", freigabe=token, pfad=parent))
|
return redirect(url_for("fileshare.browse", share=token, path=parent))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/<token>/loeschen")
|
@bp.post("/<token>/delete")
|
||||||
@permission_required("fileshare.delete")
|
@permission_required("fileshare.delete")
|
||||||
def delete(token: str):
|
def delete(token: str):
|
||||||
mount = _mount_or_404(token)
|
mount = _mount_or_404(token)
|
||||||
parent = request.form.get("parent") or ""
|
parent = request.form.get("parent") or ""
|
||||||
removed = 0
|
removed = 0
|
||||||
for relative in request.form.getlist("pfade"):
|
for relative in request.form.getlist("paths"):
|
||||||
try:
|
try:
|
||||||
kind = service.delete_entry(mount, relative)
|
kind = service.delete_entry(mount, relative)
|
||||||
_audit("fileshare.deleted", f"{mount.label}:{relative}", kind, severity="warning")
|
_audit("fileshare.deleted", f"{mount.label}:{relative}", kind, severity="warning")
|
||||||
@@ -217,18 +446,18 @@ def delete(token: str):
|
|||||||
except (service.FileshareError, OSError) as exc:
|
except (service.FileshareError, OSError) as exc:
|
||||||
flash(str(exc), "danger")
|
flash(str(exc), "danger")
|
||||||
if removed:
|
if removed:
|
||||||
flash(f"{removed} Eintrag/Eintraege geloescht.", "success")
|
flash(f"{removed} Eintrag/Einträge gelöscht.", "success")
|
||||||
return redirect(url_for("fileshare.browse", freigabe=token, pfad=parent))
|
return redirect(url_for("fileshare.browse", share=token, path=parent))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/<token>/freigeben")
|
@bp.post("/<token>/release")
|
||||||
@permission_required("fileshare.view")
|
@permission_required("fileshare.read")
|
||||||
def release(token: str):
|
def release(token: str):
|
||||||
mount = _mount_or_404(token)
|
mount = _mount_or_404(token)
|
||||||
with transaction() as conn:
|
with transaction() as conn:
|
||||||
service.release_mount(conn, mount.id)
|
service.release_mount(conn, mount.id)
|
||||||
_audit("fileshare.released", mount.label)
|
_audit("fileshare.released", mount.label)
|
||||||
flash("Einbindung geloest.", "success")
|
flash("Einbindung gelöst.", "success")
|
||||||
return redirect(url_for("fileshare.browse"))
|
return redirect(url_for("fileshare.browse"))
|
||||||
|
|
||||||
|
|
||||||
@@ -237,8 +466,8 @@ def release(token: str):
|
|||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/zuordnungen")
|
@bp.get("/mappings")
|
||||||
@permission_required("fileshare.edit")
|
@permission_required("fileshare_mappings.read")
|
||||||
def mappings():
|
def mappings():
|
||||||
from tesm_core.auth import directory
|
from tesm_core.auth import directory
|
||||||
|
|
||||||
@@ -251,8 +480,8 @@ def mappings():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/zuordnungen")
|
@bp.post("/mappings")
|
||||||
@permission_required("fileshare.edit")
|
@permission_required("fileshare_mappings.create")
|
||||||
def mapping_add():
|
def mapping_add():
|
||||||
try:
|
try:
|
||||||
with transaction() as conn:
|
with transaction() as conn:
|
||||||
@@ -263,18 +492,33 @@ def mapping_add():
|
|||||||
label=request.form.get("label", ""),
|
label=request.form.get("label", ""),
|
||||||
unc=request.form.get("unc", ""),
|
unc=request.form.get("unc", ""),
|
||||||
)
|
)
|
||||||
_audit("fileshare.mapping_added", request.form.get("label", ""), severity="notice")
|
_audit(
|
||||||
|
"fileshare.mapping_added",
|
||||||
|
request.form.get("label", ""),
|
||||||
|
service.describe_mapping(
|
||||||
|
{
|
||||||
|
"unc": request.form.get("unc", ""),
|
||||||
|
"ad_group_name": request.form.get("ad_group_name", ""),
|
||||||
|
"ad_group_dn": request.form.get("ad_group_dn", ""),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
severity="notice",
|
||||||
|
)
|
||||||
flash("Zuordnung gespeichert.", "success")
|
flash("Zuordnung gespeichert.", "success")
|
||||||
except service.FileshareError as exc:
|
except service.FileshareError as exc:
|
||||||
flash(str(exc), "danger")
|
flash(str(exc), "danger")
|
||||||
return redirect(url_for("fileshare.mappings"))
|
return redirect(url_for("fileshare.mappings"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/zuordnungen/<int:mapping_id>")
|
@bp.post("/mappings/<int:mapping_id>")
|
||||||
@permission_required("fileshare.edit")
|
@permission_required("fileshare_mappings.write")
|
||||||
def mapping_update(mapping_id: int):
|
def mapping_update(mapping_id: int):
|
||||||
try:
|
try:
|
||||||
with transaction() as conn:
|
with transaction() as conn:
|
||||||
|
# Den Stand vor der Aenderung merken -- danach ist er weg, und
|
||||||
|
# "geaendert" ohne "was" beantwortet keine einzige Frage, die man
|
||||||
|
# spaeter an ein Protokoll stellt.
|
||||||
|
vorher = service.get_mapping(conn, mapping_id)
|
||||||
service.update_mapping(
|
service.update_mapping(
|
||||||
conn,
|
conn,
|
||||||
mapping_id,
|
mapping_id,
|
||||||
@@ -283,18 +527,32 @@ def mapping_update(mapping_id: int):
|
|||||||
label=request.form.get("label", ""),
|
label=request.form.get("label", ""),
|
||||||
unc=request.form.get("unc", ""),
|
unc=request.form.get("unc", ""),
|
||||||
)
|
)
|
||||||
_audit("fileshare.mapping_updated", request.form.get("label", ""), severity="notice")
|
nachher = service.get_mapping(conn, mapping_id)
|
||||||
flash("Zuordnung geaendert.", "success")
|
_audit(
|
||||||
|
"fileshare.mapping_updated",
|
||||||
|
str((nachher or {}).get("label") or request.form.get("label", "")),
|
||||||
|
service.mapping_changes(vorher, nachher) or "keine inhaltliche Änderung",
|
||||||
|
severity="notice",
|
||||||
|
)
|
||||||
|
flash("Zuordnung geändert.", "success")
|
||||||
except service.FileshareError as exc:
|
except service.FileshareError as exc:
|
||||||
flash(str(exc), "danger")
|
flash(str(exc), "danger")
|
||||||
return redirect(url_for("fileshare.mappings"))
|
return redirect(url_for("fileshare.mappings"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/zuordnungen/<int:mapping_id>/loeschen")
|
@bp.post("/mappings/<int:mapping_id>/delete")
|
||||||
@permission_required("fileshare.edit")
|
@permission_required("fileshare_mappings.delete")
|
||||||
def mapping_delete(mapping_id: int):
|
def mapping_delete(mapping_id: int):
|
||||||
with transaction() as conn:
|
with transaction() as conn:
|
||||||
|
entfernt = service.get_mapping(conn, mapping_id)
|
||||||
service.delete_mapping(conn, mapping_id)
|
service.delete_mapping(conn, mapping_id)
|
||||||
_audit("fileshare.mapping_deleted", str(mapping_id), severity="notice")
|
# Eine entfernte Zuordnung nimmt allen Mitgliedern der Gruppe den Zugang
|
||||||
|
# zu dieser Freigabe -- das ist keine Notiz am Rande, sondern eine Warnung.
|
||||||
|
_audit(
|
||||||
|
"fileshare.mapping_deleted",
|
||||||
|
str((entfernt or {}).get("label") or mapping_id),
|
||||||
|
service.describe_mapping(entfernt),
|
||||||
|
severity="warning",
|
||||||
|
)
|
||||||
flash("Zuordnung entfernt.", "success")
|
flash("Zuordnung entfernt.", "success")
|
||||||
return redirect(url_for("fileshare.mappings"))
|
return redirect(url_for("fileshare.mappings"))
|
||||||
@@ -31,13 +31,17 @@ from tesm_core.extension import core, db, transaction
|
|||||||
|
|
||||||
from ..services import inventory, ssh
|
from ..services import inventory, ssh
|
||||||
|
|
||||||
bp = Blueprint("hostkeys", __name__, url_prefix="/hostschluessel")
|
bp = Blueprint("hostkeys", __name__, url_prefix="/monitoring/host-keys")
|
||||||
|
|
||||||
KIND_DEVICE = "client"
|
KIND_DEVICE = "client"
|
||||||
KIND_SWITCH = "switch"
|
KIND_SWITCH = "switch"
|
||||||
|
|
||||||
#: Welches Recht welche Art von Ziel freigibt.
|
#: Freigeben ist eine eigene Zeile: es war das Recht fuer Stammdaten
|
||||||
EDIT_RIGHT = {KIND_DEVICE: "devices.edit", KIND_SWITCH: "switches.edit"}
|
#: ("Gerät ändern"), obwohl es der Vorgang ist, mit dem man bestaetigt, dass
|
||||||
|
#: ein Geraet wirklich das ist, fuer das es sich ausgibt. Die Art des Ziels
|
||||||
|
#: entscheidet nicht mehr mit -- Schluessel ist Schluessel.
|
||||||
|
FREIGABE_RECHT = "hostkeys.execute"
|
||||||
|
EDIT_RIGHT = {KIND_DEVICE: FREIGABE_RECHT, KIND_SWITCH: FREIGABE_RECHT}
|
||||||
|
|
||||||
|
|
||||||
def _audit(action: str, target: str, detail: str = "", severity: str = "info") -> None:
|
def _audit(action: str, target: str, detail: str = "", severity: str = "info") -> None:
|
||||||
@@ -112,7 +116,7 @@ def _orphans(conn: Any, targets: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|||||||
|
|
||||||
|
|
||||||
@bp.get("/")
|
@bp.get("/")
|
||||||
@permission_required("devices.view", "switches.view", mode="any")
|
@permission_required("hostkeys.read")
|
||||||
def overview():
|
def overview():
|
||||||
conn = db()
|
conn = db()
|
||||||
targets = _targets(conn)
|
targets = _targets(conn)
|
||||||
@@ -142,8 +146,8 @@ def _require(kind: str) -> None:
|
|||||||
abort(403)
|
abort(403)
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/<kind>/<int:target_id>/pruefen")
|
@bp.post("/<kind>/<int:target_id>/probe")
|
||||||
@permission_required("devices.view", "switches.view", mode="any")
|
@permission_required("hostkeys.read")
|
||||||
def probe(kind: str, target_id: int):
|
def probe(kind: str, target_id: int):
|
||||||
conn = db()
|
conn = db()
|
||||||
_require(kind)
|
_require(kind)
|
||||||
@@ -169,8 +173,8 @@ def probe(kind: str, target_id: int):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/<kind>/<int:target_id>/freigeben")
|
@bp.post("/<kind>/<int:target_id>/trust")
|
||||||
@permission_required("devices.view", "switches.view", mode="any")
|
@permission_required("hostkeys.read")
|
||||||
@reauth_required
|
@reauth_required
|
||||||
def trust(kind: str, target_id: int):
|
def trust(kind: str, target_id: int):
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -181,7 +185,7 @@ def trust(kind: str, target_id: int):
|
|||||||
key_type = (request.form.get("key_type") or "").strip()
|
key_type = (request.form.get("key_type") or "").strip()
|
||||||
fingerprint = (request.form.get("fingerprint") or "").strip()
|
fingerprint = (request.form.get("fingerprint") or "").strip()
|
||||||
if not key_type or not ssh.valid_fingerprint(fingerprint):
|
if not key_type or not ssh.valid_fingerprint(fingerprint):
|
||||||
flash("Es wurde kein gueltiger Fingerprint uebergeben.", "danger")
|
flash("Es wurde kein gültiger Fingerprint übergeben.", "danger")
|
||||||
return redirect(url_for("hostkeys.overview"))
|
return redirect(url_for("hostkeys.overview"))
|
||||||
|
|
||||||
with transaction():
|
with transaction():
|
||||||
@@ -199,12 +203,12 @@ def trust(kind: str, target_id: int):
|
|||||||
f"{key_type} {fingerprint}",
|
f"{key_type} {fingerprint}",
|
||||||
severity="notice",
|
severity="notice",
|
||||||
)
|
)
|
||||||
flash(f"Host-Schluessel von {target['label']} freigegeben.", "success")
|
flash(f"Host-Schlüssel von {target['label']} freigegeben.", "success")
|
||||||
return redirect(url_for("hostkeys.overview"))
|
return redirect(url_for("hostkeys.overview"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/<kind>/<int:target_id>/verwerfen")
|
@bp.post("/<kind>/<int:target_id>/forget")
|
||||||
@permission_required("devices.view", "switches.view", mode="any")
|
@permission_required("hostkeys.read")
|
||||||
def forget(kind: str, target_id: int):
|
def forget(kind: str, target_id: int):
|
||||||
conn = db()
|
conn = db()
|
||||||
_require(kind)
|
_require(kind)
|
||||||
@@ -218,12 +222,12 @@ def forget(kind: str, target_id: int):
|
|||||||
target["label"],
|
target["label"],
|
||||||
severity="warning",
|
severity="warning",
|
||||||
)
|
)
|
||||||
flash(f"Gespeicherte Schluessel von {target['label']} entfernt.", "info")
|
flash(f"Gespeicherte Schlüssel von {target['label']} entfernt.", "info")
|
||||||
return redirect(url_for("hostkeys.overview"))
|
return redirect(url_for("hostkeys.overview"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/verwaist/verwerfen")
|
@bp.post("/orphaned/forget")
|
||||||
@permission_required("devices.edit", "switches.edit", mode="any")
|
@permission_required("hostkeys.execute")
|
||||||
def forget_orphan():
|
def forget_orphan():
|
||||||
"""Entfernt eine Freigabe, zu der es kein Ziel mehr gibt."""
|
"""Entfernt eine Freigabe, zu der es kein Ziel mehr gibt."""
|
||||||
host = (request.form.get("host") or "").strip()
|
host = (request.form.get("host") or "").strip()
|
||||||
@@ -232,18 +236,18 @@ def forget_orphan():
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
port = 0
|
port = 0
|
||||||
if not host or not 1 <= port <= 65535:
|
if not host or not 1 <= port <= 65535:
|
||||||
flash("Ungueltiges Ziel.", "danger")
|
flash("Ungültiges Ziel.", "danger")
|
||||||
return redirect(url_for("hostkeys.overview"))
|
return redirect(url_for("hostkeys.overview"))
|
||||||
|
|
||||||
conn = db()
|
conn = db()
|
||||||
if (host, port) in {(row["host"], row["port"]) for row in _targets(conn)}:
|
if (host, port) in {(row["host"], row["port"]) for row in _targets(conn)}:
|
||||||
# Ein Ziel, das es noch gibt, wird ueber seine eigene Zeile verworfen --
|
# Ein Ziel, das es noch gibt, wird ueber seine eigene Zeile verworfen --
|
||||||
# sonst umginge dieser Weg die Rechtepruefung je Objektart.
|
# sonst umginge dieser Weg die Rechtepruefung je Objektart.
|
||||||
flash("Zu diesem Eintrag gibt es ein Ziel -- bitte dort verwerfen.", "warning")
|
flash("Zu diesem Eintrag gibt es ein Ziel – bitte dort verwerfen.", "warning")
|
||||||
return redirect(url_for("hostkeys.overview"))
|
return redirect(url_for("hostkeys.overview"))
|
||||||
|
|
||||||
with transaction():
|
with transaction():
|
||||||
ssh.forget_key(conn, host, port)
|
ssh.forget_key(conn, host, port)
|
||||||
_audit("host_key.orphan_forgotten", f"{host}:{port}", severity="warning")
|
_audit("host_key.orphan_forgotten", f"{host}:{port}", severity="warning")
|
||||||
flash(f"Verwaiste Freigabe fuer {host}:{port} entfernt.", "info")
|
flash(f"Verwaiste Freigabe für {host}:{port} entfernt.", "info")
|
||||||
return redirect(url_for("hostkeys.overview"))
|
return redirect(url_for("hostkeys.overview"))
|
||||||
@@ -23,7 +23,7 @@ from tesm_core.licensing_client import LICENSED_FEATURES, LicenseError
|
|||||||
|
|
||||||
import tesm_licensing as lic
|
import tesm_licensing as lic
|
||||||
|
|
||||||
bp = Blueprint("license", __name__, url_prefix="/lizenz")
|
bp = Blueprint("license", __name__, url_prefix="/administration/license")
|
||||||
|
|
||||||
MAX_UPLOAD_BYTES = 256 * 1024
|
MAX_UPLOAD_BYTES = 256 * 1024
|
||||||
|
|
||||||
@@ -43,7 +43,7 @@ def _audit(action: str, target: str = "", detail: str = "", severity: str = "inf
|
|||||||
|
|
||||||
|
|
||||||
@bp.get("/")
|
@bp.get("/")
|
||||||
@permission_required("settings_license.view")
|
@permission_required("settings_license.read")
|
||||||
def overview():
|
def overview():
|
||||||
extension = core()
|
extension = core()
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -69,8 +69,8 @@ def overview():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/einspielen")
|
@bp.post("/install")
|
||||||
@permission_required("settings_license.edit")
|
@permission_required("settings_license.write")
|
||||||
def upload():
|
def upload():
|
||||||
extension = core()
|
extension = core()
|
||||||
manager = extension.license
|
manager = extension.license
|
||||||
@@ -80,11 +80,11 @@ def upload():
|
|||||||
|
|
||||||
upload_file = request.files.get("license_file")
|
upload_file = request.files.get("license_file")
|
||||||
if upload_file is None or not upload_file.filename:
|
if upload_file is None or not upload_file.filename:
|
||||||
flash("Keine Datei ausgewaehlt.", "danger")
|
flash("Keine Datei ausgewählt.", "danger")
|
||||||
return redirect(url_for("license.overview"))
|
return redirect(url_for("license.overview"))
|
||||||
raw = upload_file.stream.read(MAX_UPLOAD_BYTES + 1)
|
raw = upload_file.stream.read(MAX_UPLOAD_BYTES + 1)
|
||||||
if len(raw) > MAX_UPLOAD_BYTES:
|
if len(raw) > MAX_UPLOAD_BYTES:
|
||||||
flash("Die Datei ist zu gross fuer eine Lizenzdatei.", "danger")
|
flash("Die Datei ist zu groß für eine Lizenzdatei.", "danger")
|
||||||
return redirect(url_for("license.overview"))
|
return redirect(url_for("license.overview"))
|
||||||
|
|
||||||
allow_new_issuer = request.form.get("allow_new_issuer") == "1" and current_user.is_admin
|
allow_new_issuer = request.form.get("allow_new_issuer") == "1" and current_user.is_admin
|
||||||
@@ -104,20 +104,20 @@ def upload():
|
|||||||
if state.status and state.status.role != "customer":
|
if state.status and state.status.role != "customer":
|
||||||
flash(
|
flash(
|
||||||
f"Achtung: diese Lizenz hat die Rolle {state.status.role!r}. "
|
f"Achtung: diese Lizenz hat die Rolle {state.status.role!r}. "
|
||||||
"In einer TESM-Kundeninstanz ist das ungewoehnlich.",
|
"In einer TESM-Kundeninstanz ist das ungewöhnlich.",
|
||||||
"warning",
|
"warning",
|
||||||
)
|
)
|
||||||
return redirect(url_for("license.overview"))
|
return redirect(url_for("license.overview"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/aktivieren")
|
@bp.post("/activate")
|
||||||
@permission_required("settings_license.edit")
|
@permission_required("settings_license.write")
|
||||||
def activate():
|
def activate():
|
||||||
return _handshake("activate")
|
return _handshake("activate")
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/deaktivieren")
|
@bp.post("/deactivate")
|
||||||
@permission_required("settings_license.edit")
|
@permission_required("settings_license.write")
|
||||||
@reauth_required
|
@reauth_required
|
||||||
def deactivate():
|
def deactivate():
|
||||||
"""Gibt die Lizenz frei, damit sie auf einer anderen Maschine genutzt werden kann.
|
"""Gibt die Lizenz frei, damit sie auf einer anderen Maschine genutzt werden kann.
|
||||||
@@ -129,7 +129,7 @@ def deactivate():
|
|||||||
|
|
||||||
|
|
||||||
@bp.post("/heartbeat")
|
@bp.post("/heartbeat")
|
||||||
@permission_required("settings_license.edit")
|
@permission_required("settings_license.write")
|
||||||
def heartbeat():
|
def heartbeat():
|
||||||
return _handshake("heartbeat")
|
return _handshake("heartbeat")
|
||||||
|
|
||||||
@@ -158,8 +158,8 @@ def _handshake(action: str):
|
|||||||
return redirect(url_for("license.overview"))
|
return redirect(url_for("license.overview"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/offline-antwort")
|
@bp.post("/offline-response")
|
||||||
@permission_required("settings_license.edit")
|
@permission_required("settings_license.write")
|
||||||
def offline_response():
|
def offline_response():
|
||||||
extension = core()
|
extension = core()
|
||||||
manager = extension.license
|
manager = extension.license
|
||||||
@@ -177,8 +177,8 @@ def offline_response():
|
|||||||
return redirect(url_for("license.overview"))
|
return redirect(url_for("license.overview"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/serverzertifikat")
|
@bp.post("/server-certificate")
|
||||||
@permission_required("settings_license.edit")
|
@permission_required("settings_license.write")
|
||||||
def server_certificate():
|
def server_certificate():
|
||||||
"""Hinterlegt das Zertifikat des Lizenzservers.
|
"""Hinterlegt das Zertifikat des Lizenzservers.
|
||||||
|
|
||||||
@@ -214,15 +214,15 @@ def server_certificate():
|
|||||||
severity="notice",
|
severity="notice",
|
||||||
)
|
)
|
||||||
flash(
|
flash(
|
||||||
"Zertifikat hinterlegt. Der naechste Versuch laeuft wieder ueber den Online-Weg."
|
"Zertifikat hinterlegt. Der nächste Versuch läuft wieder über den Online-Weg."
|
||||||
if pem
|
if pem
|
||||||
else "Zertifikat entfernt -- es gelten wieder die Systemanker.",
|
else "Zertifikat entfernt – es gelten wieder die Systemanker.",
|
||||||
"success",
|
"success",
|
||||||
)
|
)
|
||||||
return redirect(url_for("license.overview"))
|
return redirect(url_for("license.overview"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/aussteller/<key_id>/entfernen")
|
@bp.post("/issuer/<key_id>/remove")
|
||||||
@admin_required
|
@admin_required
|
||||||
@reauth_required
|
@reauth_required
|
||||||
def remove_trusted_key(key_id: str):
|
def remove_trusted_key(key_id: str):
|
||||||
@@ -246,5 +246,5 @@ def remove_trusted_key(key_id: str):
|
|||||||
if manager is not None:
|
if manager is not None:
|
||||||
with core().database.session() as conn:
|
with core().database.session() as conn:
|
||||||
manager.reload(conn)
|
manager.reload(conn)
|
||||||
flash("Aussteller-Schluessel entfernt.", "info")
|
flash("Aussteller-Schlüssel entfernt.", "info")
|
||||||
return redirect(url_for("license.overview"))
|
return redirect(url_for("license.overview"))
|
||||||
@@ -13,13 +13,13 @@ from tesm_core.extension import core, db
|
|||||||
|
|
||||||
from ..services import kea, monitor
|
from ..services import kea, monitor
|
||||||
|
|
||||||
bp = Blueprint("tesmlogs", __name__, url_prefix="/protokolle")
|
bp = Blueprint("tesmlogs", __name__, url_prefix="/logs")
|
||||||
|
|
||||||
MAX_TAIL_LINES = 2000
|
MAX_TAIL_LINES = 2000
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/kea")
|
@bp.get("/dhcp")
|
||||||
@permission_required("logs_dhcp.view")
|
@permission_required("logs_dhcp.read")
|
||||||
def dhcp():
|
def dhcp():
|
||||||
extension = core()
|
extension = core()
|
||||||
if extension.license is not None and not extension.license.module_active("dhcp"):
|
if extension.license is not None and not extension.license.module_active("dhcp"):
|
||||||
@@ -34,12 +34,12 @@ def dhcp():
|
|||||||
else:
|
else:
|
||||||
error = result.message
|
error = result.message
|
||||||
else:
|
else:
|
||||||
error = "Systemhelfer nicht verfuegbar."
|
error = "Systemhelfer nicht verfügbar."
|
||||||
return render_template("tesm/logs_kea.html", lines=lines, path=kea.LOG_PATH, error=error)
|
return render_template("tesm/logs_kea.html", lines=lines, path=kea.LOG_PATH, error=error)
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/neustarts")
|
@bp.get("/restarts")
|
||||||
@permission_required("devices.view")
|
@permission_required("devices.read")
|
||||||
def restarts():
|
def restarts():
|
||||||
"""Alle Neustarts -- ueber den PoE-Port und per Befehl aus der Wartung.
|
"""Alle Neustarts -- ueber den PoE-Port und per Befehl aus der Wartung.
|
||||||
|
|
||||||
@@ -48,7 +48,7 @@ def restarts():
|
|||||||
der Neustart-Historie vollstaendig -- obwohl genau dort jemand nachsieht,
|
der Neustart-Historie vollstaendig -- obwohl genau dort jemand nachsieht,
|
||||||
der wissen will, warum ein Geraet weg war.
|
der wissen will, warum ein Geraet weg war.
|
||||||
"""
|
"""
|
||||||
method = (request.args.get("methode") or "").strip()
|
method = (request.args.get("method") or "").strip()
|
||||||
if method and method not in monitor.METHOD_LABELS:
|
if method and method not in monitor.METHOD_LABELS:
|
||||||
method = ""
|
method = ""
|
||||||
sql = (
|
sql = (
|
||||||
|
|||||||
@@ -16,15 +16,17 @@ geratene Kategorie schickt ein ``reboot`` an das falsche Geraet).
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from flask import Blueprint, abort, flash, jsonify, redirect, render_template, request, url_for
|
from flask import Blueprint, abort, flash, jsonify, redirect, render_template, request, url_for
|
||||||
|
from tesm_core.security.sessions import fragment
|
||||||
from tesm_core import audit
|
from tesm_core import audit
|
||||||
from tesm_core.auth.models import current_user, permission_required
|
from tesm_core.auth.models import current_user, permission_required
|
||||||
from tesm_core.auth.service import client_ip
|
from tesm_core.auth.service import client_ip
|
||||||
from tesm_core.db import Database
|
from tesm_core.db import Database
|
||||||
from tesm_core.extension import core, db, transaction
|
from tesm_core.extension import core, db, transaction
|
||||||
|
|
||||||
|
from ..permissions import SERVICING_PAGE
|
||||||
from ..services import inventory, maintenance as service, ssh, windowsops, monitor
|
from ..services import inventory, maintenance as service, ssh, windowsops, monitor
|
||||||
|
|
||||||
bp = Blueprint("maintenance", __name__, url_prefix="/wartung")
|
bp = Blueprint("maintenance", __name__, url_prefix="/maintenance")
|
||||||
|
|
||||||
|
|
||||||
@bp.before_request
|
@bp.before_request
|
||||||
@@ -74,7 +76,7 @@ def _query_devices(conn) -> list[dict]: # type: ignore[no-untyped-def]
|
|||||||
|
|
||||||
|
|
||||||
@bp.get("/")
|
@bp.get("/")
|
||||||
@permission_required("maintenance.view", "maintenance.execute", mode="any")
|
@permission_required(*SERVICING_PAGE, mode="any")
|
||||||
def overview():
|
def overview():
|
||||||
conn = db()
|
conn = db()
|
||||||
return render_template(
|
return render_template(
|
||||||
@@ -84,7 +86,7 @@ def overview():
|
|||||||
category_labels=dict(inventory.CREDENTIAL_CATEGORIES),
|
category_labels=dict(inventory.CREDENTIAL_CATEGORIES),
|
||||||
category_effects=inventory.CATEGORY_EFFECTS,
|
category_effects=inventory.CATEGORY_EFFECTS,
|
||||||
update_category=inventory.MAINTENANCE_CATEGORY,
|
update_category=inventory.MAINTENANCE_CATEGORY,
|
||||||
terminal_enabled=bool(core().settings.get(conn, "terminal_enabled")),
|
terminal_enabled=bool(core().settings.get(conn, "terminal_clients_enabled")),
|
||||||
windows_tool_missing=_windows_tool_missing(conn),
|
windows_tool_missing=_windows_tool_missing(conn),
|
||||||
windows_package=windowsops.REQUIRED_PACKAGE,
|
windows_package=windowsops.REQUIRED_PACKAGE,
|
||||||
)
|
)
|
||||||
@@ -104,8 +106,8 @@ def _windows_tool_missing(conn) -> bool: # type: ignore[no-untyped-def]
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/paket-nachinstallieren")
|
@bp.post("/install-package")
|
||||||
@permission_required("maintenance.execute")
|
@permission_required("servicing_tool.execute")
|
||||||
def install_windows_tool():
|
def install_windows_tool():
|
||||||
"""Installiert das Paket, das der Fehlermeldung nach fehlt.
|
"""Installiert das Paket, das der Fehlermeldung nach fehlt.
|
||||||
|
|
||||||
@@ -115,7 +117,7 @@ def install_windows_tool():
|
|||||||
"""
|
"""
|
||||||
sysops = core().sysops
|
sysops = core().sysops
|
||||||
if sysops is None or not sysops.available:
|
if sysops is None or not sysops.available:
|
||||||
flash("Systemaktionen sind in dieser Umgebung nicht verfuegbar.", "warning")
|
flash("Systemaktionen sind in dieser Umgebung nicht verfügbar.", "warning")
|
||||||
return redirect(url_for("maintenance.overview"))
|
return redirect(url_for("maintenance.overview"))
|
||||||
|
|
||||||
result = sysops.apt_install(windowsops.REQUIRED_PACKAGE)
|
result = sysops.apt_install(windowsops.REQUIRED_PACKAGE)
|
||||||
@@ -138,7 +140,7 @@ def install_windows_tool():
|
|||||||
|
|
||||||
|
|
||||||
@bp.get("/status")
|
@bp.get("/status")
|
||||||
@permission_required("maintenance.view", "maintenance.execute", mode="any")
|
@permission_required(*SERVICING_PAGE, mode="any")
|
||||||
def status():
|
def status():
|
||||||
"""Zustand der Auftraege -- aus der Datenbank, nicht aus dem Prozessspeicher."""
|
"""Zustand der Auftraege -- aus der Datenbank, nicht aus dem Prozessspeicher."""
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -164,8 +166,8 @@ def status():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/auftrag/<job_id>")
|
@bp.get("/job/<job_id>")
|
||||||
@permission_required("maintenance.view", "maintenance.execute", mode="any")
|
@permission_required(*SERVICING_PAGE, mode="any")
|
||||||
def job_detail(job_id: str):
|
def job_detail(job_id: str):
|
||||||
conn = db()
|
conn = db()
|
||||||
job = service.get_job(conn, job_id)
|
job = service.get_job(conn, job_id)
|
||||||
@@ -174,8 +176,9 @@ def job_detail(job_id: str):
|
|||||||
return render_template("tesm/maintenance_job.html", job=job)
|
return render_template("tesm/maintenance_job.html", job=job)
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/auftrag/<job_id>/ausgabe")
|
@bp.get("/job/<job_id>/output")
|
||||||
@permission_required("maintenance.view", "maintenance.execute", mode="any")
|
@permission_required(*SERVICING_PAGE, mode="any")
|
||||||
|
@fragment
|
||||||
def job_output(job_id: str):
|
def job_output(job_id: str):
|
||||||
conn = db()
|
conn = db()
|
||||||
job = service.get_job(conn, job_id)
|
job = service.get_job(conn, job_id)
|
||||||
@@ -189,7 +192,7 @@ def _start(kind: str) -> None:
|
|||||||
conn = db()
|
conn = db()
|
||||||
device_ids = [int(value) for value in request.form.getlist("device_ids") if value.isdigit()]
|
device_ids = [int(value) for value in request.form.getlist("device_ids") if value.isdigit()]
|
||||||
if not device_ids:
|
if not device_ids:
|
||||||
flash("Es wurde kein Geraet ausgewaehlt.", "warning")
|
flash("Es wurde kein Gerät ausgewählt.", "warning")
|
||||||
return
|
return
|
||||||
|
|
||||||
eligible = {int(row["id"]): row for row in _eligible_devices(conn)}
|
eligible = {int(row["id"]): row for row in _eligible_devices(conn)}
|
||||||
@@ -202,8 +205,8 @@ def _start(kind: str) -> None:
|
|||||||
# zurueck, der nie gestartet wird und ewig auf "wartet" steht.
|
# zurueck, der nie gestartet wird und ewig auf "wartet" steht.
|
||||||
if kind == service.JOB_KIND_UPDATE and str(device["category"]) != "linux":
|
if kind == service.JOB_KIND_UPDATE and str(device["category"]) != "linux":
|
||||||
flash(
|
flash(
|
||||||
f"{device['name']}: Paketaktualisierung gibt es nur fuer Linux-Clients. "
|
f"{device['name']}: Paketaktualisierung gibt es nur für Linux-Clients. "
|
||||||
"Windows-Geraete lassen sich hier nur neu starten.",
|
"Windows-Geräte lassen sich hier nur neu starten.",
|
||||||
"warning",
|
"warning",
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
@@ -271,18 +274,18 @@ def _start(kind: str) -> None:
|
|||||||
started += 1
|
started += 1
|
||||||
|
|
||||||
if started:
|
if started:
|
||||||
flash(f"{started} Auftrag/Auftraege gestartet.", "success")
|
flash(f"{started} Auftrag/Aufträge gestartet.", "success")
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/aktualisieren")
|
@bp.post("/update")
|
||||||
@permission_required("maintenance.execute")
|
@permission_required("servicing_update.execute")
|
||||||
def start_update():
|
def start_update():
|
||||||
_start(service.JOB_KIND_UPDATE)
|
_start(service.JOB_KIND_UPDATE)
|
||||||
return redirect(url_for("maintenance.overview"))
|
return redirect(url_for("maintenance.overview"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/neustarten")
|
@bp.post("/restart")
|
||||||
@permission_required("maintenance.execute")
|
@permission_required("servicing_reboot.execute")
|
||||||
def start_reboot():
|
def start_reboot():
|
||||||
_start(service.JOB_KIND_REBOOT)
|
_start(service.JOB_KIND_REBOOT)
|
||||||
return redirect(url_for("maintenance.overview"))
|
return redirect(url_for("maintenance.overview"))
|
||||||
@@ -0,0 +1,302 @@
|
|||||||
|
"""Bildschirm eines Clients im Browser -- noVNC ueber eine WebSocket-Strecke.
|
||||||
|
|
||||||
|
Warum ueberhaupt im Browser und nicht mit dem Viewer auf dem Arbeitsplatz: der
|
||||||
|
Viewer muss installiert, gepflegt und mit Zugangsdaten versorgt werden, und vom
|
||||||
|
Telefon aus gibt es ihn gar nicht. Hier genuegt die Anmeldung an TESM -- die
|
||||||
|
Rechte, die Protokollierung und die Frage "darf dieser Mensch auf diesen
|
||||||
|
Bildschirm?" haengen ohnehin schon dort.
|
||||||
|
|
||||||
|
**Die Strecke reicht durch, sie versteht nichts.** Der Server oeffnet eine
|
||||||
|
gewoehnliche TCP-Verbindung zum VNC-Server und schaufelt Bytes in beide
|
||||||
|
Richtungen; das RFB-Protokoll samt Authentifizierung handelt der Browser
|
||||||
|
unmittelbar mit dem Geraet aus. Das ist Absicht: eine Gegenstelle, die das
|
||||||
|
Protokoll auspackt, muesste die Verschluesselung aufbrechen und wieder
|
||||||
|
aufbauen -- mehr Code an der empfindlichsten Stelle, ohne dass jemand etwas
|
||||||
|
davon haette.
|
||||||
|
|
||||||
|
**Was daraus folgt** und nicht verschwiegen werden soll: weil der Browser die
|
||||||
|
Anmeldung fuehrt, muss er die Zugangsdaten kennen. Hinterlegte Zugangsdaten
|
||||||
|
gibt TESM deshalb nur an einen Menschen heraus, der Geheimnisse ohnehin sehen
|
||||||
|
darf (``credentials.secrets``) -- jede Herausgabe steht im Aenderungsprotokoll.
|
||||||
|
Wer das Recht nicht hat, tippt sie in der Seite selbst ein; sie gehen dann
|
||||||
|
durch den Browser, aber nie durch unsere Datenbank.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import socket
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from flask import Blueprint, abort, flash, redirect, render_template, url_for
|
||||||
|
from tesm_core import audit
|
||||||
|
from tesm_core.auth.models import current_user, permission_required, reauth_required
|
||||||
|
from tesm_core.auth.service import client_ip, reauth_is_fresh
|
||||||
|
from tesm_core.extension import core, db
|
||||||
|
|
||||||
|
from ..services import inventory, vnc
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
bp = Blueprint("screen", __name__)
|
||||||
|
|
||||||
|
#: Wie lange auf den VNC-Server gewartet wird, bis die Strecke steht.
|
||||||
|
VERBINDUNGSLIMIT = 6.0
|
||||||
|
|
||||||
|
#: Groesse der Haeppchen, in denen vom Geraet gelesen wird. Ein Bildschirm
|
||||||
|
#: liefert in Schueben; kleiner hiesse mehr Runden, groesser mehr Wartezeit,
|
||||||
|
#: bis ein Schub weitergereicht wird.
|
||||||
|
BLOCK = 32 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
def _geraet(conn: Any, device_id: int) -> dict[str, Any] | None:
|
||||||
|
device = inventory.get_device(conn, device_id)
|
||||||
|
if device is None or device.get("deleted_at"):
|
||||||
|
return None
|
||||||
|
return device
|
||||||
|
|
||||||
|
|
||||||
|
def _port(conn: Any) -> int:
|
||||||
|
extension = core()
|
||||||
|
if not extension.settings.get(conn, "vnc_enabled"):
|
||||||
|
return 0
|
||||||
|
return int(extension.settings.get(conn, "vnc_port") or vnc.STANDARD_PORT)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("/monitoring/clients/<int:device_id>/screen")
|
||||||
|
@permission_required("device_vnc.execute")
|
||||||
|
@reauth_required
|
||||||
|
def screen(device_id: int):
|
||||||
|
"""Die Seite mit der Leinwand.
|
||||||
|
|
||||||
|
Sie beruehrt das Geraet nicht: die Sitzung selbst ist der einzige Anlass,
|
||||||
|
dort anzuklopfen. Was ueber die Anmeldeverfahren bekannt ist, steht in der
|
||||||
|
Datenbank -- aus der letzten ausdruecklichen Pruefung (Knopf "Verfahren
|
||||||
|
pruefen"). Dass diese Seite frueher selbst gemessen hat, war ein Fehler:
|
||||||
|
jede Messung ist eine angefangene und abgebrochene Protokollverhandlung,
|
||||||
|
und ein RealVNC Server zaehlt so etwas -- das Oeffnen der Seite hat der
|
||||||
|
Sitzung also selbst im Weg gestanden.
|
||||||
|
"""
|
||||||
|
conn = db()
|
||||||
|
device = _geraet(conn, device_id)
|
||||||
|
if device is None:
|
||||||
|
abort(404)
|
||||||
|
port = _port(conn)
|
||||||
|
if not port:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
zugang = vnc.zugang_gemerkt(device, port=port)
|
||||||
|
benutzer, passwort = "", ""
|
||||||
|
darf_geheimnis = bool(current_user.has("credentials.secrets"))
|
||||||
|
if device.get("credential_id") and darf_geheimnis:
|
||||||
|
try:
|
||||||
|
benutzer, passwort = inventory.credential_secret(
|
||||||
|
conn, core().keystore, int(device["credential_id"])
|
||||||
|
)
|
||||||
|
except inventory.ValidationError:
|
||||||
|
benutzer, passwort = "", ""
|
||||||
|
if passwort:
|
||||||
|
with core().database.transaction(conn):
|
||||||
|
audit.log(
|
||||||
|
conn,
|
||||||
|
actor=current_user.username,
|
||||||
|
actor_id=current_user.id,
|
||||||
|
action="device.vnc_credentials_used",
|
||||||
|
target=str(device["name"]),
|
||||||
|
detail=(
|
||||||
|
f"Hinterlegte Zugangsdaten ({benutzer}) an die Bildschirmsitzung "
|
||||||
|
"übergeben"
|
||||||
|
),
|
||||||
|
ip=client_ip(),
|
||||||
|
severity="warning",
|
||||||
|
)
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
"tesm/screen.html",
|
||||||
|
device=device,
|
||||||
|
zugang=zugang,
|
||||||
|
websocket_path=f"/ws/screen/{device_id}",
|
||||||
|
vorgaben={"username": benutzer, "password": passwort},
|
||||||
|
darf_geheimnis=darf_geheimnis,
|
||||||
|
verfahren_name=vnc.verfahren_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/monitoring/clients/<int:device_id>/screen/probe")
|
||||||
|
@permission_required("device_vnc.execute")
|
||||||
|
@reauth_required
|
||||||
|
def probe(device_id: int):
|
||||||
|
"""Fragt das Geraet ausdruecklich, wie es sich authentifizieren will.
|
||||||
|
|
||||||
|
Ein Knopf und keine Nebenwirkung des Seitenaufbaus: die Messung bricht eine
|
||||||
|
Protokollverhandlung ab, und RealVNC zaehlt das. Sie gehoert in die Hand
|
||||||
|
dessen, der gerade sucht -- nicht in jeden Aufruf.
|
||||||
|
"""
|
||||||
|
conn = db()
|
||||||
|
device = _geraet(conn, device_id)
|
||||||
|
if device is None:
|
||||||
|
abort(404)
|
||||||
|
port = _port(conn)
|
||||||
|
if not port:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
from ..services import monitor
|
||||||
|
|
||||||
|
zugang = vnc.zugang(device, port=port)
|
||||||
|
with core().database.transaction(conn):
|
||||||
|
monitor.merke_vnc(conn, int(device["id"]), zugang.erreichbar, zugang.ziel)
|
||||||
|
monitor.merke_verfahren(conn, int(device["id"]), zugang.verfahren, zugang.absage)
|
||||||
|
if zugang.absage:
|
||||||
|
flash(f"Der Server antwortet: „{zugang.absage}“", "warning")
|
||||||
|
elif zugang.verfahren:
|
||||||
|
flash(
|
||||||
|
"Angebotene Anmeldeverfahren: "
|
||||||
|
+ ", ".join(vnc.verfahren_name(v) for v in zugang.verfahren),
|
||||||
|
"info",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
flash(f"{zugang.ziel}:{port} hat nicht geantwortet.", "danger")
|
||||||
|
return redirect(url_for("screen.screen", device_id=device_id))
|
||||||
|
|
||||||
|
|
||||||
|
def init_app(app) -> None: # type: ignore[no-untyped-def]
|
||||||
|
"""Meldet Seite und -- falls verfuegbar -- die WebSocket-Strecke an."""
|
||||||
|
app.register_blueprint(bp)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from flask_sock import Sock
|
||||||
|
except ImportError: # pragma: no cover - optionale Abhaengigkeit
|
||||||
|
logger.info("flask-sock fehlt – die Bildschirmsitzung bleibt abgeschaltet.")
|
||||||
|
return
|
||||||
|
|
||||||
|
sock = Sock(app)
|
||||||
|
|
||||||
|
@sock.route("/ws/screen/<int:device_id>")
|
||||||
|
def screen_socket(ws, device_id: int): # type: ignore[no-untyped-def]
|
||||||
|
_strecke(ws, device_id)
|
||||||
|
|
||||||
|
@sock.route("/ws/probe")
|
||||||
|
def probe_socket(ws): # type: ignore[no-untyped-def]
|
||||||
|
"""Sagt "da", und mehr nicht.
|
||||||
|
|
||||||
|
Wozu: bricht eine Bildschirmsitzung ab, gibt es zwei sehr verschiedene
|
||||||
|
Ursachen -- der VNC-Server mag nicht, oder die WebSocket-Verbindung
|
||||||
|
kommt gar nicht erst durch (ein Proxy zwischen Browser und TESM, der
|
||||||
|
"Upgrade" nicht weiterreicht, ist der haeufigste Fall). Von aussen
|
||||||
|
sehen beide gleich aus: ein Abbruch ohne Grund. Diese Probe trennt
|
||||||
|
sie, ohne dass jemand ein Protokoll lesen muss.
|
||||||
|
"""
|
||||||
|
if not current_user.is_authenticated:
|
||||||
|
ws.close(1008, "Nicht angemeldet.")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
ws.send("da")
|
||||||
|
ws.receive(timeout=2)
|
||||||
|
except Exception: # noqa: BLE001 - der Browser ist weg, das ist keine Stoerung
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _strecke(ws, device_id: int) -> None: # type: ignore[no-untyped-def]
|
||||||
|
"""Bytes hin, Bytes her -- und sonst nichts.
|
||||||
|
|
||||||
|
Die Pruefungen stehen hier ausdruecklich noch einmal: ein WebSocket geht an
|
||||||
|
den Dekoratoren der HTTP-Route vorbei.
|
||||||
|
"""
|
||||||
|
extension = core()
|
||||||
|
conn = db()
|
||||||
|
|
||||||
|
if not current_user.is_authenticated or not current_user.has("device_vnc.execute"):
|
||||||
|
ws.close(1008, "Keine Berechtigung für den Bildschirm dieses Clients.")
|
||||||
|
return
|
||||||
|
if not reauth_is_fresh():
|
||||||
|
ws.close(1008, "Die Passwortbestätigung ist abgelaufen.")
|
||||||
|
return
|
||||||
|
port = _port(conn)
|
||||||
|
if not port:
|
||||||
|
ws.close(1008, "Der Bildschirmzugriff ist in dieser Installation abgeschaltet.")
|
||||||
|
return
|
||||||
|
device = _geraet(conn, device_id)
|
||||||
|
if device is None:
|
||||||
|
ws.close(1008, "Client nicht gefunden.")
|
||||||
|
return
|
||||||
|
host = vnc.ziel(device)
|
||||||
|
if not host:
|
||||||
|
ws.close(1008, "Für diesen Client ist kein Ziel bekannt.")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
gegenstelle = socket.create_connection((host, port), timeout=VERBINDUNGSLIMIT)
|
||||||
|
except OSError as exc:
|
||||||
|
ws.close(1011, f"{host}:{port} antwortet nicht ({exc}).")
|
||||||
|
return
|
||||||
|
|
||||||
|
begonnen = time.monotonic()
|
||||||
|
with extension.database.transaction(conn):
|
||||||
|
audit.log(
|
||||||
|
conn,
|
||||||
|
actor=current_user.username,
|
||||||
|
actor_id=current_user.id,
|
||||||
|
action="device.screen_opened",
|
||||||
|
target=str(device["name"]),
|
||||||
|
detail=f"Bildschirmsitzung zu {host}:{port}",
|
||||||
|
ip=client_ip(),
|
||||||
|
severity="warning",
|
||||||
|
)
|
||||||
|
|
||||||
|
laeuft = threading.Event()
|
||||||
|
laeuft.set()
|
||||||
|
|
||||||
|
def vom_geraet() -> None:
|
||||||
|
"""Alles, was der VNC-Server schickt, geht als Binaerrahmen weiter."""
|
||||||
|
try:
|
||||||
|
while laeuft.is_set():
|
||||||
|
daten = gegenstelle.recv(BLOCK)
|
||||||
|
if not daten:
|
||||||
|
break
|
||||||
|
ws.send(daten)
|
||||||
|
except Exception: # noqa: BLE001 - Abbruch ist der Normalfall, nicht die Stoerung
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
laeuft.clear()
|
||||||
|
try:
|
||||||
|
ws.close()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
|
||||||
|
leser = threading.Thread(target=vom_geraet, name=f"vnc-{device_id}", daemon=True)
|
||||||
|
leser.start()
|
||||||
|
|
||||||
|
try:
|
||||||
|
while laeuft.is_set():
|
||||||
|
nachricht = ws.receive()
|
||||||
|
if nachricht is None:
|
||||||
|
break
|
||||||
|
if isinstance(nachricht, str):
|
||||||
|
# Der Browser-Client schickt ausschliesslich Binaerrahmen. Text
|
||||||
|
# waere entweder ein Irrtum oder ein Versuch -- beides endet
|
||||||
|
# hier und nicht auf dem Geraet.
|
||||||
|
continue
|
||||||
|
gegenstelle.sendall(nachricht)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
logger.debug("Bildschirmsitzung beendet (%s)", device["name"], exc_info=True)
|
||||||
|
finally:
|
||||||
|
laeuft.clear()
|
||||||
|
try:
|
||||||
|
gegenstelle.shutdown(socket.SHUT_RDWR)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
gegenstelle.close()
|
||||||
|
dauer = int(time.monotonic() - begonnen)
|
||||||
|
with extension.database.session() as frisch, extension.database.transaction(frisch):
|
||||||
|
audit.log(
|
||||||
|
frisch,
|
||||||
|
actor=current_user.username,
|
||||||
|
actor_id=current_user.id,
|
||||||
|
action="device.screen_closed",
|
||||||
|
target=str(device["name"]),
|
||||||
|
detail=f"Dauer {dauer}s",
|
||||||
|
ip=client_ip(),
|
||||||
|
severity="info",
|
||||||
|
)
|
||||||
@@ -18,7 +18,9 @@ from tesm_core.auth.service import client_ip
|
|||||||
from tesm_core.extension import core, db, transaction
|
from tesm_core.extension import core, db, transaction
|
||||||
from tesm_core.licensing_client import LicenseError
|
from tesm_core.licensing_client import LicenseError
|
||||||
|
|
||||||
bp = Blueprint("settings", __name__, url_prefix="/einstellungen")
|
import tesm_licensing as lic
|
||||||
|
|
||||||
|
bp = Blueprint("settings", __name__, url_prefix="/administration")
|
||||||
|
|
||||||
|
|
||||||
def _audit(action: str, target: str = "", detail: str = "", severity: str = "info") -> None:
|
def _audit(action: str, target: str = "", detail: str = "", severity: str = "info") -> None:
|
||||||
@@ -46,6 +48,28 @@ def _license_hint(feature: str) -> str:
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _module_rows(conn: Any) -> list[dict[str, Any]]:
|
||||||
|
"""Die drei Module mit ihrem Zustand -- lizenziert, benutzt.
|
||||||
|
|
||||||
|
Zwei verschiedene Fragen, die die Seite auseinanderhalten muss: was die
|
||||||
|
Lizenz erlaubt (dagegen hilft kein Schalter) und was dieses Haus benutzen
|
||||||
|
will (dafuer ist der Schalter da).
|
||||||
|
"""
|
||||||
|
manager = core().license
|
||||||
|
if manager is None:
|
||||||
|
return []
|
||||||
|
state = manager.reload(conn)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"key": name,
|
||||||
|
"label": lic.MODULE_LABELS.get(name, name),
|
||||||
|
"licensed": name in state.modules,
|
||||||
|
"enabled": name not in state.modules_off,
|
||||||
|
}
|
||||||
|
for name in lic.ALL_MODULES
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def _current_timezone() -> str:
|
def _current_timezone() -> str:
|
||||||
sysops = core().sysops
|
sysops = core().sysops
|
||||||
if sysops is None:
|
if sysops is None:
|
||||||
@@ -65,7 +89,7 @@ def _timezone_choices() -> list[str]:
|
|||||||
|
|
||||||
|
|
||||||
@bp.get("/system")
|
@bp.get("/system")
|
||||||
@permission_required("settings_system.view")
|
@permission_required("settings_system.read")
|
||||||
def system():
|
def system():
|
||||||
extension = core()
|
extension = core()
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -79,9 +103,16 @@ def system():
|
|||||||
"monitor_failures_before_restart",
|
"monitor_failures_before_restart",
|
||||||
"monitor_auto_restart",
|
"monitor_auto_restart",
|
||||||
"dashboard_public",
|
"dashboard_public",
|
||||||
"terminal_enabled",
|
"vnc_enabled",
|
||||||
|
"vnc_port",
|
||||||
|
"vnc_warn_unencrypted_off",
|
||||||
|
"terminal_switches_enabled",
|
||||||
|
"terminal_clients_enabled",
|
||||||
"terminal_idle_timeout",
|
"terminal_idle_timeout",
|
||||||
|
"signature_witness_enabled",
|
||||||
"trash_retention_days",
|
"trash_retention_days",
|
||||||
|
"session_idle_minutes",
|
||||||
|
"session_absolute_hours",
|
||||||
"log_rotate_interval",
|
"log_rotate_interval",
|
||||||
"log_rotate_keep",
|
"log_rotate_keep",
|
||||||
],
|
],
|
||||||
@@ -90,15 +121,21 @@ def system():
|
|||||||
hostname=(sysops.read_command(["hostname"]).stdout.strip() if sysops else ""),
|
hostname=(sysops.read_command(["hostname"]).stdout.strip() if sysops else ""),
|
||||||
timezone=_current_timezone(),
|
timezone=_current_timezone(),
|
||||||
timezones=_timezone_choices(),
|
timezones=_timezone_choices(),
|
||||||
nav_items=extension.nav.items,
|
# Nicht ``nav_items``: dieser Name gehoert dem Seitenrahmen. Wer ihn hier
|
||||||
|
# belegt, ueberschreibt die gefilterte Navigation aus dem Kontext --
|
||||||
|
# und die Seitenleiste zeigte dann jeden Punkt, auch den, den dieser
|
||||||
|
# Benutzer nicht darf und den die Lizenz nicht freischaltet.
|
||||||
|
nav_baum=extension.nav.items,
|
||||||
nav_order=extension.settings.get(conn, "nav_order") or extension.nav.default_order(),
|
nav_order=extension.settings.get(conn, "nav_order") or extension.nav.default_order(),
|
||||||
|
nav_child_order=extension.settings.get(conn, "nav_child_order") or {},
|
||||||
license_hint=_license_hint("monitor_interval"),
|
license_hint=_license_hint("monitor_interval"),
|
||||||
sysops_available=bool(sysops and sysops.available),
|
sysops_available=bool(sysops and sysops.available),
|
||||||
|
module_rows=_module_rows(conn),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/system")
|
@bp.post("/system")
|
||||||
@permission_required("settings_system.edit")
|
@permission_required("settings_system.write")
|
||||||
def system_save():
|
def system_save():
|
||||||
extension = core()
|
extension = core()
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -109,9 +146,16 @@ def system_save():
|
|||||||
"monitor_failures_before_restart",
|
"monitor_failures_before_restart",
|
||||||
"monitor_auto_restart",
|
"monitor_auto_restart",
|
||||||
"dashboard_public",
|
"dashboard_public",
|
||||||
"terminal_enabled",
|
"vnc_enabled",
|
||||||
|
"vnc_port",
|
||||||
|
"vnc_warn_unencrypted_off",
|
||||||
|
"terminal_switches_enabled",
|
||||||
|
"terminal_clients_enabled",
|
||||||
"terminal_idle_timeout",
|
"terminal_idle_timeout",
|
||||||
|
"signature_witness_enabled",
|
||||||
"trash_retention_days",
|
"trash_retention_days",
|
||||||
|
"session_idle_minutes",
|
||||||
|
"session_absolute_hours",
|
||||||
"log_rotate_interval",
|
"log_rotate_interval",
|
||||||
"log_rotate_keep",
|
"log_rotate_keep",
|
||||||
):
|
):
|
||||||
@@ -151,10 +195,60 @@ def system_save():
|
|||||||
if changed:
|
if changed:
|
||||||
message = "Einstellungen gespeichert."
|
message = "Einstellungen gespeichert."
|
||||||
if interval_changed:
|
if interval_changed:
|
||||||
message += " Das Pruefintervall greift beim naechsten Durchlauf."
|
message += " Das Prüfintervall greift beim nächsten Durchlauf."
|
||||||
flash(message, "success")
|
flash(message, "success")
|
||||||
else:
|
else:
|
||||||
flash("Keine Aenderungen.", "info")
|
flash("Keine Änderungen.", "info")
|
||||||
|
return redirect(url_for("settings.system"))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/system/modules")
|
||||||
|
@permission_required("settings_system.write")
|
||||||
|
def modules_save():
|
||||||
|
"""Module ein- und ausschalten.
|
||||||
|
|
||||||
|
Abgeschaltet heisst wirklich abgeschaltet: der Punkt verschwindet aus der
|
||||||
|
Navigation, und die Routen antworten mit 404 -- dieselbe Sperre, die auch
|
||||||
|
eine fehlende Lizenz zieht. Daten bleiben unangetastet; wer das Modul
|
||||||
|
wieder einschaltet, findet alles vor wie zuvor.
|
||||||
|
"""
|
||||||
|
extension = core()
|
||||||
|
manager = extension.license
|
||||||
|
if manager is None:
|
||||||
|
flash("Diese Installation nutzt keine Lizenzierung.", "info")
|
||||||
|
return redirect(url_for("settings.system"))
|
||||||
|
|
||||||
|
aus = [name for name in lic.ALL_MODULES if request.form.get(f"module_{name}") != "1"]
|
||||||
|
with transaction() as conn:
|
||||||
|
eingeschaltet, abgeschaltet = manager.module_abschalten(
|
||||||
|
conn, aus, actor=current_user.username
|
||||||
|
)
|
||||||
|
if eingeschaltet or abgeschaltet:
|
||||||
|
teile = []
|
||||||
|
if abgeschaltet:
|
||||||
|
teile.append(
|
||||||
|
"abgeschaltet: "
|
||||||
|
+ ", ".join(lic.MODULE_LABELS.get(n, n) for n in sorted(abgeschaltet))
|
||||||
|
)
|
||||||
|
if eingeschaltet:
|
||||||
|
teile.append(
|
||||||
|
"eingeschaltet: "
|
||||||
|
+ ", ".join(lic.MODULE_LABELS.get(n, n) for n in sorted(eingeschaltet))
|
||||||
|
)
|
||||||
|
audit.log(
|
||||||
|
conn,
|
||||||
|
actor=current_user.username,
|
||||||
|
actor_id=current_user.id,
|
||||||
|
action="settings.modules_changed",
|
||||||
|
detail="; ".join(teile),
|
||||||
|
ip=client_ip(),
|
||||||
|
severity="notice",
|
||||||
|
)
|
||||||
|
|
||||||
|
if eingeschaltet or abgeschaltet:
|
||||||
|
flash("Module gespeichert.", "success")
|
||||||
|
else:
|
||||||
|
flash("Keine Änderungen.", "info")
|
||||||
return redirect(url_for("settings.system"))
|
return redirect(url_for("settings.system"))
|
||||||
|
|
||||||
|
|
||||||
@@ -165,10 +259,10 @@ def set_hostname():
|
|||||||
sysops = extension.sysops
|
sysops = extension.sysops
|
||||||
hostname = (request.form.get("hostname") or "").strip()
|
hostname = (request.form.get("hostname") or "").strip()
|
||||||
if not hostname or len(hostname) > 63 or not hostname.replace("-", "").isalnum():
|
if not hostname or len(hostname) > 63 or not hostname.replace("-", "").isalnum():
|
||||||
flash("Der Hostname ist ungueltig.", "danger")
|
flash("Der Hostname ist ungültig.", "danger")
|
||||||
return redirect(url_for("settings.system"))
|
return redirect(url_for("settings.system"))
|
||||||
if sysops is None or not sysops.available:
|
if sysops is None or not sysops.available:
|
||||||
flash("Systemaktionen sind in dieser Umgebung nicht verfuegbar.", "warning")
|
flash("Systemaktionen sind in dieser Umgebung nicht verfügbar.", "warning")
|
||||||
return redirect(url_for("settings.system"))
|
return redirect(url_for("settings.system"))
|
||||||
result = sysops.set_hostname(hostname)
|
result = sysops.set_hostname(hostname)
|
||||||
if result.ok:
|
if result.ok:
|
||||||
@@ -181,7 +275,7 @@ def set_hostname():
|
|||||||
return redirect(url_for("settings.system"))
|
return redirect(url_for("settings.system"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/system/zeitzone")
|
@bp.post("/system/timezone")
|
||||||
@permission_required("settings_system.execute")
|
@permission_required("settings_system.execute")
|
||||||
def set_timezone():
|
def set_timezone():
|
||||||
extension = core()
|
extension = core()
|
||||||
@@ -191,7 +285,7 @@ def set_timezone():
|
|||||||
flash("Unbekannte Zeitzone.", "danger")
|
flash("Unbekannte Zeitzone.", "danger")
|
||||||
return redirect(url_for("settings.system"))
|
return redirect(url_for("settings.system"))
|
||||||
if sysops is None or not sysops.available:
|
if sysops is None or not sysops.available:
|
||||||
flash("Systemaktionen sind in dieser Umgebung nicht verfuegbar.", "warning")
|
flash("Systemaktionen sind in dieser Umgebung nicht verfügbar.", "warning")
|
||||||
return redirect(url_for("settings.system"))
|
return redirect(url_for("settings.system"))
|
||||||
result = sysops.set_timezone(timezone)
|
result = sysops.set_timezone(timezone)
|
||||||
if result.ok:
|
if result.ok:
|
||||||
@@ -210,14 +304,14 @@ def set_timezone():
|
|||||||
return redirect(url_for("settings.system"))
|
return redirect(url_for("settings.system"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/system/pruefung-jetzt")
|
@bp.post("/system/check-now")
|
||||||
@permission_required("settings_system.execute")
|
@permission_required("settings_system.execute")
|
||||||
def run_check_now():
|
def run_check_now():
|
||||||
"""Startet einen Ueberwachungsdurchlauf sofort, im Hintergrund."""
|
"""Startet einen Ueberwachungsdurchlauf sofort, im Hintergrund."""
|
||||||
extension = core()
|
extension = core()
|
||||||
|
|
||||||
def worker() -> None:
|
def worker() -> None:
|
||||||
from ..services import inventory, monitor
|
from ..services import inventory, monitor, vnc
|
||||||
|
|
||||||
allowed = extension.license is None or extension.license.feature_allowed(
|
allowed = extension.license is None or extension.license.feature_allowed(
|
||||||
"auto_poe_restart"
|
"auto_poe_restart"
|
||||||
@@ -227,6 +321,7 @@ def run_check_now():
|
|||||||
live=extension.registry["live_log"],
|
live=extension.registry["live_log"],
|
||||||
keystore=extension.keystore,
|
keystore=extension.keystore,
|
||||||
license_allows_auto_restart=allowed,
|
license_allows_auto_restart=allowed,
|
||||||
|
vnc_port=vnc.port_aus_einstellungen(extension),
|
||||||
)
|
)
|
||||||
|
|
||||||
threading.Thread(target=worker, name="manual-sweep", daemon=True).start()
|
threading.Thread(target=worker, name="manual-sweep", daemon=True).start()
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ from tesm_core.extension import core, db, transaction
|
|||||||
|
|
||||||
from ..services import inventory, ssh, switchcli
|
from ..services import inventory, ssh, switchcli
|
||||||
|
|
||||||
bp = Blueprint("switches", __name__, url_prefix="/switche")
|
bp = Blueprint("switches", __name__, url_prefix="/monitoring/switches")
|
||||||
|
|
||||||
|
|
||||||
def _audit(conn: Any, action: str, target: str, detail: str = "", severity: str = "info") -> None:
|
def _audit(conn: Any, action: str, target: str, detail: str = "", severity: str = "info") -> None:
|
||||||
@@ -47,18 +47,18 @@ def _form_values() -> dict[str, Any]:
|
|||||||
|
|
||||||
|
|
||||||
@bp.get("/")
|
@bp.get("/")
|
||||||
@permission_required("switches.view")
|
@permission_required("switches.read")
|
||||||
def overview():
|
def overview():
|
||||||
conn = db()
|
conn = db()
|
||||||
return render_template(
|
return render_template(
|
||||||
"tesm/switches.html",
|
"tesm/switches.html",
|
||||||
switches=inventory.list_switches(conn),
|
switches=inventory.list_switches(conn),
|
||||||
credentials=inventory.list_credentials(conn),
|
credentials=inventory.list_credentials(conn),
|
||||||
terminal_enabled=bool(core().settings.get(conn, "terminal_enabled")),
|
terminal_enabled=bool(core().settings.get(conn, "terminal_switches_enabled")),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/neu")
|
@bp.post("/new")
|
||||||
@permission_required("switches.create")
|
@permission_required("switches.create")
|
||||||
def create():
|
def create():
|
||||||
try:
|
try:
|
||||||
@@ -71,7 +71,7 @@ def create():
|
|||||||
f"IP {request.form.get('ip')}",
|
f"IP {request.form.get('ip')}",
|
||||||
severity="notice",
|
severity="notice",
|
||||||
)
|
)
|
||||||
flash("Switch angelegt. Der Host-Schluessel muss vor dem ersten Zugriff freigegeben werden.", "success")
|
flash("Switch angelegt. Der Host-Schlüssel muss vor dem ersten Zugriff freigegeben werden.", "success")
|
||||||
return redirect(url_for("switches.detail", switch_id=switch_id))
|
return redirect(url_for("switches.detail", switch_id=switch_id))
|
||||||
except inventory.ValidationError as exc:
|
except inventory.ValidationError as exc:
|
||||||
flash(str(exc), "danger")
|
flash(str(exc), "danger")
|
||||||
@@ -79,7 +79,7 @@ def create():
|
|||||||
|
|
||||||
|
|
||||||
@bp.post("/<int:switch_id>")
|
@bp.post("/<int:switch_id>")
|
||||||
@permission_required("switches.edit")
|
@permission_required("switches.write")
|
||||||
def update(switch_id: int):
|
def update(switch_id: int):
|
||||||
try:
|
try:
|
||||||
with transaction() as conn:
|
with transaction() as conn:
|
||||||
@@ -92,13 +92,13 @@ def update(switch_id: int):
|
|||||||
"; ".join(changes),
|
"; ".join(changes),
|
||||||
severity="notice",
|
severity="notice",
|
||||||
)
|
)
|
||||||
flash("Aenderungen gespeichert." if changes else "Keine Aenderungen.", "success" if changes else "info")
|
flash("Änderungen gespeichert." if changes else "Keine Änderungen.", "success" if changes else "info")
|
||||||
except inventory.ValidationError as exc:
|
except inventory.ValidationError as exc:
|
||||||
flash(str(exc), "danger")
|
flash(str(exc), "danger")
|
||||||
return redirect(request.referrer or url_for("switches.overview"))
|
return redirect(request.referrer or url_for("switches.overview"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/<int:switch_id>/loeschen")
|
@bp.post("/<int:switch_id>/delete")
|
||||||
@permission_required("switches.delete")
|
@permission_required("switches.delete")
|
||||||
def delete(switch_id: int):
|
def delete(switch_id: int):
|
||||||
try:
|
try:
|
||||||
@@ -115,7 +115,7 @@ def delete(switch_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@bp.get("/<int:switch_id>")
|
@bp.get("/<int:switch_id>")
|
||||||
@permission_required("switches.view")
|
@permission_required("switches.read")
|
||||||
def detail(switch_id: int):
|
def detail(switch_id: int):
|
||||||
conn = db()
|
conn = db()
|
||||||
switch = inventory.get_switch(conn, switch_id)
|
switch = inventory.get_switch(conn, switch_id)
|
||||||
@@ -142,7 +142,7 @@ def detail(switch_id: int):
|
|||||||
devices=devices,
|
devices=devices,
|
||||||
credentials=inventory.list_credentials(conn),
|
credentials=inventory.list_credentials(conn),
|
||||||
safe_commands=switchcli.SAFE_COMMANDS,
|
safe_commands=switchcli.SAFE_COMMANDS,
|
||||||
terminal_enabled=bool(core().settings.get(conn, "terminal_enabled")),
|
terminal_enabled=bool(core().settings.get(conn, "terminal_switches_enabled")),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -151,8 +151,8 @@ def detail(switch_id: int):
|
|||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/<int:switch_id>/hostkey/pruefen")
|
@bp.post("/<int:switch_id>/host-key/probe")
|
||||||
@permission_required("switches.edit")
|
@permission_required("switches.write")
|
||||||
def probe_host_key(switch_id: int):
|
def probe_host_key(switch_id: int):
|
||||||
conn = db()
|
conn = db()
|
||||||
switch = inventory.get_switch(conn, switch_id)
|
switch = inventory.get_switch(conn, switch_id)
|
||||||
@@ -176,8 +176,8 @@ def probe_host_key(switch_id: int):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/<int:switch_id>/hostkey/freigeben")
|
@bp.post("/<int:switch_id>/host-key/trust")
|
||||||
@permission_required("switches.edit")
|
@permission_required("switches.write")
|
||||||
@reauth_required
|
@reauth_required
|
||||||
def trust_host_key(switch_id: int):
|
def trust_host_key(switch_id: int):
|
||||||
"""Gibt einen Host-Schluessel ausdruecklich frei.
|
"""Gibt einen Host-Schluessel ausdruecklich frei.
|
||||||
@@ -192,7 +192,7 @@ def trust_host_key(switch_id: int):
|
|||||||
key_type = (request.form.get("key_type") or "").strip()
|
key_type = (request.form.get("key_type") or "").strip()
|
||||||
fingerprint = (request.form.get("fingerprint") or "").strip()
|
fingerprint = (request.form.get("fingerprint") or "").strip()
|
||||||
if not key_type or not ssh.valid_fingerprint(fingerprint):
|
if not key_type or not ssh.valid_fingerprint(fingerprint):
|
||||||
flash("Es wurde kein gueltiger Fingerprint uebergeben.", "danger")
|
flash("Es wurde kein gültiger Fingerprint übergeben.", "danger")
|
||||||
return redirect(url_for("switches.detail", switch_id=switch_id))
|
return redirect(url_for("switches.detail", switch_id=switch_id))
|
||||||
|
|
||||||
with transaction():
|
with transaction():
|
||||||
@@ -211,12 +211,12 @@ def trust_host_key(switch_id: int):
|
|||||||
f"{key_type} {fingerprint}",
|
f"{key_type} {fingerprint}",
|
||||||
severity="notice",
|
severity="notice",
|
||||||
)
|
)
|
||||||
flash("Host-Schluessel freigegeben.", "success")
|
flash("Host-Schlüssel freigegeben.", "success")
|
||||||
return redirect(url_for("switches.detail", switch_id=switch_id))
|
return redirect(url_for("switches.detail", switch_id=switch_id))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/<int:switch_id>/hostkey/verwerfen")
|
@bp.post("/<int:switch_id>/host-key/forget")
|
||||||
@permission_required("switches.edit")
|
@permission_required("switches.write")
|
||||||
def forget_host_key(switch_id: int):
|
def forget_host_key(switch_id: int):
|
||||||
conn = db()
|
conn = db()
|
||||||
switch = inventory.get_switch(conn, switch_id)
|
switch = inventory.get_switch(conn, switch_id)
|
||||||
@@ -225,7 +225,7 @@ def forget_host_key(switch_id: int):
|
|||||||
with transaction():
|
with transaction():
|
||||||
ssh.forget_key(conn, str(switch["ip"]), int(switch["ssh_port"]))
|
ssh.forget_key(conn, str(switch["ip"]), int(switch["ssh_port"]))
|
||||||
_audit(conn, "switch.host_key_forgotten", str(switch["hostname"]), severity="warning")
|
_audit(conn, "switch.host_key_forgotten", str(switch["hostname"]), severity="warning")
|
||||||
flash("Gespeicherte Host-Schluessel entfernt.", "info")
|
flash("Gespeicherte Host-Schlüssel entfernt.", "info")
|
||||||
return redirect(url_for("switches.detail", switch_id=switch_id))
|
return redirect(url_for("switches.detail", switch_id=switch_id))
|
||||||
|
|
||||||
|
|
||||||
@@ -234,8 +234,8 @@ def forget_host_key(switch_id: int):
|
|||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/<int:switch_id>/kommando")
|
@bp.post("/<int:switch_id>/command")
|
||||||
@permission_required("switches.execute")
|
@permission_required("switch_console.execute")
|
||||||
@reauth_required
|
@reauth_required
|
||||||
def run_command(switch_id: int):
|
def run_command(switch_id: int):
|
||||||
"""Fuehrt ein Kommando aus der Positivliste aus.
|
"""Fuehrt ein Kommando aus der Positivliste aus.
|
||||||
@@ -256,7 +256,7 @@ def run_command(switch_id: int):
|
|||||||
flash("Dieses Kommando steht nicht auf der Positivliste.", "danger")
|
flash("Dieses Kommando steht nicht auf der Positivliste.", "danger")
|
||||||
return redirect(url_for("switches.detail", switch_id=switch_id))
|
return redirect(url_for("switches.detail", switch_id=switch_id))
|
||||||
if not switch["credential_id"]:
|
if not switch["credential_id"]:
|
||||||
flash("Fuer diesen Switch sind keine Zugangsdaten hinterlegt.", "danger")
|
flash("Für diesen Switch sind keine Zugangsdaten hinterlegt.", "danger")
|
||||||
return redirect(url_for("switches.detail", switch_id=switch_id))
|
return redirect(url_for("switches.detail", switch_id=switch_id))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ Zwei Ziele, ein Ablauf:
|
|||||||
|
|
||||||
* **Switch** -- ``switches.execute``. Interaktive ArubaOS-Sitzung ueber
|
* **Switch** -- ``switches.execute``. Interaktive ArubaOS-Sitzung ueber
|
||||||
:class:`~tesm.services.switchcli.SwitchCli`.
|
:class:`~tesm.services.switchcli.SwitchCli`.
|
||||||
* **Client** -- ``maintenance.execute`` und das lizenzpflichtige Modul
|
* **Client** -- ``device_console.execute`` und das lizenzpflichtige Modul
|
||||||
``maintenance``. Ein Kommando je ``exec``-Kanal ueber
|
``maintenance``. Ein Kommando je ``exec``-Kanal ueber
|
||||||
:class:`~tesm.services.shellcli.ShellSession`.
|
:class:`~tesm.services.shellcli.ShellSession`.
|
||||||
|
|
||||||
@@ -24,7 +24,10 @@ Rechteausweitung, die dieses Projekt an anderer Stelle ausschliesst.
|
|||||||
Gemeinsame Sicherheitsmerkmale:
|
Gemeinsame Sicherheitsmerkmale:
|
||||||
|
|
||||||
* Frische Passwortbestaetigung, zusaetzlich zum Recht.
|
* Frische Passwortbestaetigung, zusaetzlich zum Recht.
|
||||||
* Abschaltbar ueber die Einstellung ``terminal_enabled``.
|
* Abschaltbar -- getrennt fuer Switche (``terminal_switches_enabled``) und
|
||||||
|
Clients (``terminal_clients_enabled``). Das Freigeben von Host-Schluesseln
|
||||||
|
haengt an eigenen Routen und bleibt davon unberuehrt: es wird fuer den
|
||||||
|
PoE-Neustart gebraucht.
|
||||||
* Der Host-Schluessel muss vorher freigegeben sein -- die Konsole akzeptiert
|
* Der Host-Schluessel muss vorher freigegeben sein -- die Konsole akzeptiert
|
||||||
keine unbekannten Schluessel (anders als der Vorgaenger, der sie hier
|
keine unbekannten Schluessel (anders als der Vorgaenger, der sie hier
|
||||||
interaktiv uebernahm). Freigegeben wird auf der jeweiligen Detailseite.
|
interaktiv uebernahm). Freigegeben wird auf der jeweiligen Detailseite.
|
||||||
@@ -71,7 +74,7 @@ CLIENT_COMMANDS: tuple[tuple[str, str], ...] = (
|
|||||||
("ip -brief address", "Netzwerkadressen"),
|
("ip -brief address", "Netzwerkadressen"),
|
||||||
("systemctl --failed", "Fehlgeschlagene Dienste"),
|
("systemctl --failed", "Fehlgeschlagene Dienste"),
|
||||||
("journalctl -p err -n 50 --no-pager", "Letzte Fehler im Journal"),
|
("journalctl -p err -n 50 --no-pager", "Letzte Fehler im Journal"),
|
||||||
("apt list --upgradable 2>/dev/null", "Verfuegbare Aktualisierungen"),
|
("apt list --upgradable 2>/dev/null", "Verfügbare Aktualisierungen"),
|
||||||
("uname -a", "Kernel und Architektur"),
|
("uname -a", "Kernel und Architektur"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -128,6 +131,18 @@ def _load_target(conn: Any, kind: str, target_id: int) -> Target | None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _konsole_erlaubt(conn: Any, kind: str) -> bool:
|
||||||
|
"""Ist die Konsole fuer diese Art Ziel eingeschaltet?
|
||||||
|
|
||||||
|
Zwei Schalter, weil beides unabhaengig voneinander gebraucht wird: eine
|
||||||
|
Werkstatt will vielleicht auf Switche, aber nicht auf Clients.
|
||||||
|
"""
|
||||||
|
schluessel = (
|
||||||
|
"terminal_switches_enabled" if kind == KIND_SWITCH else "terminal_clients_enabled"
|
||||||
|
)
|
||||||
|
return bool(core().settings.get(conn, schluessel))
|
||||||
|
|
||||||
|
|
||||||
def _client_module_active() -> bool:
|
def _client_module_active() -> bool:
|
||||||
manager = core().license
|
manager = core().license
|
||||||
return manager is None or manager.module_active("maintenance")
|
return manager is None or manager.module_active("maintenance")
|
||||||
@@ -136,10 +151,10 @@ def _client_module_active() -> bool:
|
|||||||
def _render_console(target: Target): # type: ignore[no-untyped-def]
|
def _render_console(target: Target): # type: ignore[no-untyped-def]
|
||||||
extension = core()
|
extension = core()
|
||||||
conn = db()
|
conn = db()
|
||||||
if not extension.settings.get(conn, "terminal_enabled"):
|
if not _konsole_erlaubt(conn, target.kind):
|
||||||
abort(404)
|
abort(404)
|
||||||
if not target.credential_id:
|
if not target.credential_id:
|
||||||
abort(409, description="Fuer dieses Ziel sind keine Zugangsdaten hinterlegt.")
|
abort(409, description="Für dieses Ziel sind keine Zugangsdaten hinterlegt.")
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
"tesm/console.html",
|
"tesm/console.html",
|
||||||
@@ -151,8 +166,8 @@ def _render_console(target: Target): # type: ignore[no-untyped-def]
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/switche/<int:switch_id>/konsole")
|
@bp.get("/monitoring/switches/<int:switch_id>/console")
|
||||||
@permission_required("switches.execute")
|
@permission_required("switch_console.execute")
|
||||||
@reauth_required
|
@reauth_required
|
||||||
def switch_console(switch_id: int):
|
def switch_console(switch_id: int):
|
||||||
target = _load_target(db(), KIND_SWITCH, switch_id)
|
target = _load_target(db(), KIND_SWITCH, switch_id)
|
||||||
@@ -161,8 +176,8 @@ def switch_console(switch_id: int):
|
|||||||
return _render_console(target)
|
return _render_console(target)
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/clients/<int:device_id>/konsole")
|
@bp.get("/monitoring/clients/<int:device_id>/console")
|
||||||
@permission_required("maintenance.execute")
|
@permission_required("device_console.execute")
|
||||||
@reauth_required
|
@reauth_required
|
||||||
def device_console(device_id: int):
|
def device_console(device_id: int):
|
||||||
if not _client_module_active():
|
if not _client_module_active():
|
||||||
@@ -180,7 +195,7 @@ def init_app(app) -> None: # type: ignore[no-untyped-def]
|
|||||||
try:
|
try:
|
||||||
from flask_sock import Sock
|
from flask_sock import Sock
|
||||||
except ImportError: # pragma: no cover - optionale Abhaengigkeit
|
except ImportError: # pragma: no cover - optionale Abhaengigkeit
|
||||||
logger.info("flask-sock ist nicht installiert -- die Konsole bleibt deaktiviert.")
|
logger.info("flask-sock ist nicht installiert – die Konsole bleibt deaktiviert.")
|
||||||
return
|
return
|
||||||
|
|
||||||
sock = Sock(app)
|
sock = Sock(app)
|
||||||
@@ -196,7 +211,17 @@ def init_app(app) -> None: # type: ignore[no-untyped-def]
|
|||||||
|
|
||||||
|
|
||||||
def _send(ws, kind: str, text: str) -> None: # type: ignore[no-untyped-def]
|
def _send(ws, kind: str, text: str) -> None: # type: ignore[no-untyped-def]
|
||||||
|
"""Schickt eine Nachricht. Ein geschlossener Socket ist kein Fehler.
|
||||||
|
|
||||||
|
Bricht der Browser die Sitzung ab, schlaegt das Senden fehl -- und die
|
||||||
|
Ausnahme daraus verdeckte bisher den eigentlichen Grund, warum die
|
||||||
|
Sitzung endete: im Protokoll stand ein Stapelverlauf aus ``ws.send``,
|
||||||
|
nicht die Meldung des Switches.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
ws.send(json.dumps({"type": kind, "data": text}))
|
ws.send(json.dumps({"type": kind, "data": text}))
|
||||||
|
except Exception: # noqa: BLE001 - der Browser ist weg, das ist keine Stoerung
|
||||||
|
logger.debug("Konsolennachricht nicht mehr zustellbar (%s)", kind)
|
||||||
|
|
||||||
|
|
||||||
def _transcript_name(label: str) -> str:
|
def _transcript_name(label: str) -> str:
|
||||||
@@ -205,23 +230,34 @@ def _transcript_name(label: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _handle_socket(ws, kind: str, target_id: int) -> None: # type: ignore[no-untyped-def]
|
def _handle_socket(ws, kind: str, target_id: int) -> None: # type: ignore[no-untyped-def]
|
||||||
|
"""Eine Konsolensitzung, von der Berechtigung bis zum Abmelden.
|
||||||
|
|
||||||
|
Jede Stufe schickt eine Zeile in die Oberflaeche. Das ist keine Zierde:
|
||||||
|
eine Konsole, die nichts sagt, laesst offen, ob die Berechtigung fehlt,
|
||||||
|
die Zugangsdaten, die Verbindung oder die Antwort des Geraets -- und genau
|
||||||
|
diese Frage stand im Betrieb im Raum. Sichtbar sind die Zeilen erst
|
||||||
|
nuetzlich, wenn sie **vor** dem Warten kommen, nicht danach.
|
||||||
|
"""
|
||||||
extension = core()
|
extension = core()
|
||||||
conn = db()
|
conn = db()
|
||||||
|
_send(ws, "info", "Sitzung wird geöffnet …")
|
||||||
|
|
||||||
# -- Berechtigungen: dieselben Pruefungen wie in der HTTP-Route. Ein
|
# -- Berechtigungen: dieselben Pruefungen wie in der HTTP-Route. Ein
|
||||||
# WebSocket umgeht Dekoratoren, also stehen sie hier ausdruecklich.
|
# WebSocket umgeht Dekoratoren, also stehen sie hier ausdruecklich.
|
||||||
required = "switches.execute" if kind == KIND_SWITCH else "maintenance.execute"
|
required = (
|
||||||
|
"switch_console.execute" if kind == KIND_SWITCH else "device_console.execute"
|
||||||
|
)
|
||||||
if not current_user.is_authenticated or not current_user.has(required):
|
if not current_user.is_authenticated or not current_user.has(required):
|
||||||
_send(ws, "error", "Keine Berechtigung fuer diese Konsole.")
|
_send(ws, "error", "Keine Berechtigung für diese Konsole.")
|
||||||
return
|
return
|
||||||
if not reauth_is_fresh():
|
if not reauth_is_fresh():
|
||||||
_send(ws, "error", "Die Passwortbestaetigung ist abgelaufen. Bitte Seite neu laden.")
|
_send(ws, "error", "Die Passwortbestätigung ist abgelaufen. Bitte Seite neu laden.")
|
||||||
return
|
return
|
||||||
if not extension.settings.get(conn, "terminal_enabled"):
|
if not _konsole_erlaubt(conn, kind):
|
||||||
_send(ws, "error", "Die Konsole ist in dieser Installation abgeschaltet.")
|
_send(ws, "error", "Die Konsole ist in dieser Installation abgeschaltet.")
|
||||||
return
|
return
|
||||||
if kind == KIND_DEVICE and not _client_module_active():
|
if kind == KIND_DEVICE and not _client_module_active():
|
||||||
_send(ws, "error", "Die Client-Konsole gehoert zum Modul Wartung -- es ist nicht aktiv.")
|
_send(ws, "error", "Die Client-Konsole gehört zum Modul Wartung – es ist nicht aktiv.")
|
||||||
return
|
return
|
||||||
|
|
||||||
target = _load_target(conn, kind, target_id)
|
target = _load_target(conn, kind, target_id)
|
||||||
@@ -238,6 +274,7 @@ def _handle_socket(ws, kind: str, target_id: int) -> None: # type: ignore[no-un
|
|||||||
/ f"{stamp}-{_transcript_name(current_user.username)}-{_transcript_name(target.label)}.log"
|
/ f"{stamp}-{_transcript_name(current_user.username)}-{_transcript_name(target.label)}.log"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
_send(ws, "info", f"Ziel: {target.label} ({target.host}:{target.port})")
|
||||||
try:
|
try:
|
||||||
username, password = inventory.credential_secret(
|
username, password = inventory.credential_secret(
|
||||||
conn, extension.keystore, target.credential_id
|
conn, extension.keystore, target.credential_id
|
||||||
@@ -245,6 +282,7 @@ def _handle_socket(ws, kind: str, target_id: int) -> None: # type: ignore[no-un
|
|||||||
except inventory.ValidationError as exc:
|
except inventory.ValidationError as exc:
|
||||||
_send(ws, "error", str(exc))
|
_send(ws, "error", str(exc))
|
||||||
return
|
return
|
||||||
|
_send(ws, "info", f"Anmeldung als {username} …")
|
||||||
|
|
||||||
with extension.database.transaction(conn):
|
with extension.database.transaction(conn):
|
||||||
audit.log(
|
audit.log(
|
||||||
@@ -278,8 +316,8 @@ def _handle_socket(ws, kind: str, target_id: int) -> None: # type: ignore[no-un
|
|||||||
_send(
|
_send(
|
||||||
ws,
|
ws,
|
||||||
"error",
|
"error",
|
||||||
f"{exc} Die Konsole akzeptiert keine unbekannten Schluessel -- bitte auf der "
|
f"{exc} Die Konsole akzeptiert keine unbekannten Schlüssel – bitte auf der "
|
||||||
f"{page} ausdruecklich freigeben.",
|
f"{page} ausdrücklich freigeben.",
|
||||||
)
|
)
|
||||||
except ssh.SshError as exc:
|
except ssh.SshError as exc:
|
||||||
_send(ws, "error", str(exc))
|
_send(ws, "error", str(exc))
|
||||||
@@ -304,7 +342,7 @@ def _read_command(ws, idle_timeout: int) -> str | None: # type: ignore[no-untyp
|
|||||||
"""Naechstes Kommando, oder ``None`` wenn die Sitzung enden soll."""
|
"""Naechstes Kommando, oder ``None`` wenn die Sitzung enden soll."""
|
||||||
raw = ws.receive(timeout=idle_timeout)
|
raw = ws.receive(timeout=idle_timeout)
|
||||||
if raw is None:
|
if raw is None:
|
||||||
_send(ws, "info", "Verbindung wegen Untaetigkeit getrennt.")
|
_send(ws, "info", "Verbindung wegen Untätigkeit getrennt.")
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
message = json.loads(raw)
|
message = json.loads(raw)
|
||||||
@@ -326,6 +364,7 @@ def _run_switch_session( # type: ignore[no-untyped-def]
|
|||||||
ws, conn, target: Target, username: str, password: str, transcript, idle_timeout: int
|
ws, conn, target: Target, username: str, password: str, transcript, idle_timeout: int
|
||||||
) -> int:
|
) -> int:
|
||||||
commands = 0
|
commands = 0
|
||||||
|
_send(ws, "info", "Warte auf die Eingabeaufforderung des Switches …")
|
||||||
with switchcli.SwitchCli(
|
with switchcli.SwitchCli(
|
||||||
conn, host=target.host, port=target.port, username=username, password=password
|
conn, host=target.host, port=target.port, username=username, password=password
|
||||||
) as cli:
|
) as cli:
|
||||||
@@ -358,6 +397,7 @@ def _run_client_session( # type: ignore[no-untyped-def]
|
|||||||
ws, conn, target: Target, username: str, password: str, transcript, idle_timeout: int
|
ws, conn, target: Target, username: str, password: str, transcript, idle_timeout: int
|
||||||
) -> int:
|
) -> int:
|
||||||
commands = 0
|
commands = 0
|
||||||
|
_send(ws, "info", "Verbindung wird aufgebaut …")
|
||||||
with shellcli.ShellSession(
|
with shellcli.ShellSession(
|
||||||
conn, host=target.host, port=target.port, username=username, password=password
|
conn, host=target.host, port=target.port, username=username, password=password
|
||||||
) as session:
|
) as session:
|
||||||
@@ -392,6 +432,6 @@ def _run_client_session( # type: ignore[no-untyped-def]
|
|||||||
_send(
|
_send(
|
||||||
ws,
|
ws,
|
||||||
"error",
|
"error",
|
||||||
f"Rueckgabewert {result['status']} nach {result['duration_ms']} ms.",
|
f"Rückgabewert {result['status']} nach {result['duration_ms']} ms.",
|
||||||
)
|
)
|
||||||
return commands
|
return commands
|
||||||
+371
-16
@@ -39,7 +39,7 @@ def cmd_create_admin(args: argparse.Namespace) -> int:
|
|||||||
if not password:
|
if not password:
|
||||||
password = getpass.getpass("Passwort: ")
|
password = getpass.getpass("Passwort: ")
|
||||||
if password != getpass.getpass("Passwort wiederholen: "):
|
if password != getpass.getpass("Passwort wiederholen: "):
|
||||||
print("Die Passwoerter stimmen nicht ueberein.", file=sys.stderr)
|
print("Die Passwörter stimmen nicht überein.", file=sys.stderr)
|
||||||
return 2
|
return 2
|
||||||
|
|
||||||
problems = extension.password_policy.check(password, username=username)
|
problems = extension.password_policy.check(password, username=username)
|
||||||
@@ -69,13 +69,41 @@ def cmd_create_admin(args: argparse.Namespace) -> int:
|
|||||||
)
|
)
|
||||||
user_id = int(cursor.lastrowid or 0)
|
user_id = int(cursor.lastrowid or 0)
|
||||||
action = "angelegt"
|
action = "angelegt"
|
||||||
|
# Der Vollzugriff gehoert dazu. Seit 2.8.0 gibt das
|
||||||
|
# Administratorkennzeichen den Verwaltungsbereich und sonst
|
||||||
|
# nichts; Funktionsrechte kommen ueber Gruppen. Ein Konto, das
|
||||||
|
# jemand an der Kommandozeile anlegt, ist aber der Notausgang --
|
||||||
|
# es entsteht, wenn sonst niemand mehr hineinkommt, und dann
|
||||||
|
# nuetzt eine Verwaltung ohne Geraete, Freigaben und Protokolle
|
||||||
|
# wenig. Wer das nicht will, nimmt die Gruppe in einem Klick
|
||||||
|
# wieder weg.
|
||||||
|
from .permissions import FUNKTIONEN_VOLLZUGRIFF, PERMISSIONS
|
||||||
|
|
||||||
|
gruppe = rbac.funktionsgruppe_anlegen(
|
||||||
|
conn, PERMISSIONS, FUNKTIONEN_VOLLZUGRIFF
|
||||||
|
)
|
||||||
|
# Liegt sie im Papierkorb, kommt sie zurueck. Sonst haette dieser
|
||||||
|
# Aufruf nichts bewirkt: das Konto waere in einer geloeschten
|
||||||
|
# Gruppe -- und das faellt genau dann auf, wenn man es am
|
||||||
|
# wenigsten gebrauchen kann.
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE groups SET deleted_at=NULL WHERE id=? AND deleted_at IS NOT NULL",
|
||||||
|
(gruppe,),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?,?)",
|
||||||
|
(user_id, gruppe),
|
||||||
|
)
|
||||||
audit.log(
|
audit.log(
|
||||||
conn,
|
conn,
|
||||||
actor="cli",
|
actor="cli",
|
||||||
actor_id=user_id,
|
actor_id=user_id,
|
||||||
action="user.admin_created",
|
action="user.admin_created",
|
||||||
target=username,
|
target=username,
|
||||||
detail=f"ueber die Kommandozeile {action}",
|
detail=(
|
||||||
|
f"über die Kommandozeile {action}, Gruppe "
|
||||||
|
f"{FUNKTIONEN_VOLLZUGRIFF.name} zugewiesen"
|
||||||
|
),
|
||||||
severity="warning",
|
severity="warning",
|
||||||
)
|
)
|
||||||
print(f"Administrator {username!r} {action}.")
|
print(f"Administrator {username!r} {action}.")
|
||||||
@@ -133,7 +161,7 @@ def cmd_status(args: argparse.Namespace) -> int:
|
|||||||
print(
|
print(
|
||||||
"Audit-Kette : "
|
"Audit-Kette : "
|
||||||
+ ("unversehrt" if chain["ok"] else f"GEBROCHEN bei Eintrag {chain['broken_at']}")
|
+ ("unversehrt" if chain["ok"] else f"GEBROCHEN bei Eintrag {chain['broken_at']}")
|
||||||
+ f" ({chain['checked']} geprueft)"
|
+ f" ({chain['checked']} geprüft)"
|
||||||
)
|
)
|
||||||
for key, value in counts.items():
|
for key, value in counts.items():
|
||||||
print(f"{key.capitalize():<15}: {value}")
|
print(f"{key.capitalize():<15}: {value}")
|
||||||
@@ -168,9 +196,30 @@ def cmd_housekeeping(_args: argparse.Namespace) -> int:
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_restore_signatures(_args: argparse.Namespace) -> int:
|
||||||
|
"""Traegt Unterschriften aus dem Journal wieder in die Datenbank ein.
|
||||||
|
|
||||||
|
Gebraucht nach einer Wiederherstellung: die Freigabe traegt die Dateien
|
||||||
|
von gestern, die zurueckgespielte Datenbank ist von vorgestern -- die
|
||||||
|
Unterschriften dazwischen stehen dann nur noch im Journal neben dem
|
||||||
|
Protokollarchiv.
|
||||||
|
"""
|
||||||
|
app, extension = _app_and_core()
|
||||||
|
from .services import fileshare
|
||||||
|
|
||||||
|
with app.app_context(), extension.database.session() as conn:
|
||||||
|
with extension.database.transaction(conn):
|
||||||
|
uebernommen = fileshare.signaturen_aus_journal(conn)
|
||||||
|
gesamt = int(
|
||||||
|
conn.execute("SELECT COUNT(*) FROM document_signatures").fetchone()[0]
|
||||||
|
)
|
||||||
|
print(f"{uebernommen} Unterschrift(en) übernommen, {gesamt} insgesamt.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def cmd_check_now(_args: argparse.Namespace) -> int:
|
def cmd_check_now(_args: argparse.Namespace) -> int:
|
||||||
app, extension = _app_and_core()
|
app, extension = _app_and_core()
|
||||||
from .services import inventory, monitor
|
from .services import inventory, monitor, vnc
|
||||||
|
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
allowed = extension.license is None or extension.license.feature_allowed(
|
allowed = extension.license is None or extension.license.feature_allowed(
|
||||||
@@ -181,9 +230,10 @@ def cmd_check_now(_args: argparse.Namespace) -> int:
|
|||||||
live=extension.registry["live_log"],
|
live=extension.registry["live_log"],
|
||||||
keystore=extension.keystore,
|
keystore=extension.keystore,
|
||||||
license_allows_auto_restart=allowed,
|
license_allows_auto_restart=allowed,
|
||||||
|
vnc_port=vnc.port_aus_einstellungen(extension),
|
||||||
)
|
)
|
||||||
print(
|
print(
|
||||||
f"{report.checked} geprueft, {report.online} online, {report.offline} offline, "
|
f"{report.checked} geprüft, {report.online} online, {report.offline} offline, "
|
||||||
f"{report.restarts_triggered} Neustarts ({report.restarts_ok} erfolgreich), "
|
f"{report.restarts_triggered} Neustarts ({report.restarts_ok} erfolgreich), "
|
||||||
f"{report.duration_ms} ms"
|
f"{report.duration_ms} ms"
|
||||||
)
|
)
|
||||||
@@ -195,6 +245,123 @@ def cmd_web_setup(args: argparse.Namespace) -> int:
|
|||||||
return run_web_setup(app, extension, args)
|
return run_web_setup(app, extension, args)
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_switch_test(args: argparse.Namespace) -> int:
|
||||||
|
"""Prueft Stufe fuer Stufe, wo eine Switch-Sitzung haengt.
|
||||||
|
|
||||||
|
Warum ein eigener Befehl und nicht die Konsole im Browser: dort liegen
|
||||||
|
zwischen dem Switch und dem Auge ein WebSocket, ein Reverse Proxy und
|
||||||
|
JavaScript. Wenn nichts ankommt, ist die erste Frage, *welche* dieser
|
||||||
|
Schichten schweigt. Hier gibt es keine davon -- und jede Stufe sagt, was
|
||||||
|
sie bekommen hat, notfalls als rohe Bytes.
|
||||||
|
|
||||||
|
Liest nur, ausser mit ``--zyklus``: dann wird der Port wirklich
|
||||||
|
geschaltet.
|
||||||
|
"""
|
||||||
|
from .services import inventory, switchcli
|
||||||
|
from .services.ssh import SshError
|
||||||
|
from tesm_core.db import Database
|
||||||
|
from tesm_core.extension import core
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
|
erweiterung = core(app)
|
||||||
|
|
||||||
|
def sagen(stufe: str, text: str = "") -> None:
|
||||||
|
print(f" {stufe:<28} {text}".rstrip())
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
with erweiterung.database.session() as conn:
|
||||||
|
zeile = Database.one(
|
||||||
|
conn,
|
||||||
|
"SELECT id, hostname, ip, ssh_port, credential_id FROM switches "
|
||||||
|
"WHERE (hostname=? COLLATE NOCASE OR ip=?) AND deleted_at IS NULL",
|
||||||
|
(args.switch, args.switch),
|
||||||
|
)
|
||||||
|
if zeile is None:
|
||||||
|
print(f"Kein Switch mit Name oder Adresse {args.switch!r}.")
|
||||||
|
print("Vorhanden:")
|
||||||
|
for andere in Database.all(
|
||||||
|
conn, "SELECT hostname, ip FROM switches WHERE deleted_at IS NULL"
|
||||||
|
):
|
||||||
|
print(f" {andere['hostname']} ({andere['ip']})")
|
||||||
|
return 2
|
||||||
|
|
||||||
|
host = str(zeile["ip"])
|
||||||
|
ssh_port = int(zeile["ssh_port"] or 22)
|
||||||
|
print(f"Switch {zeile['hostname']} ({host}:{ssh_port})")
|
||||||
|
print()
|
||||||
|
|
||||||
|
if not zeile["credential_id"]:
|
||||||
|
sagen("Zugangsdaten", "FEHLT -- dem Switch sind keine zugeordnet")
|
||||||
|
return 1
|
||||||
|
try:
|
||||||
|
benutzer, passwort = inventory.credential_secret(
|
||||||
|
conn, erweiterung.keystore, int(zeile["credential_id"])
|
||||||
|
)
|
||||||
|
except Exception as fehler: # noqa: BLE001
|
||||||
|
sagen("Zugangsdaten", f"nicht lesbar: {fehler}")
|
||||||
|
return 1
|
||||||
|
sagen("Zugangsdaten", f"{benutzer} (Passwort {len(passwort)} Zeichen)")
|
||||||
|
|
||||||
|
from .services import ssh as ssh_modul
|
||||||
|
|
||||||
|
for record in ssh_modul.all_known_keys(conn, host, ssh_port):
|
||||||
|
sagen("Freigegebener Schluessel", f"{record.key_type} {record.fingerprint}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
sitzung = switchcli.SwitchCli(
|
||||||
|
conn,
|
||||||
|
host=host,
|
||||||
|
port=ssh_port,
|
||||||
|
username=benutzer,
|
||||||
|
password=passwort,
|
||||||
|
)
|
||||||
|
with sitzung as cli:
|
||||||
|
sagen("Anmeldung", "erfolgreich")
|
||||||
|
erstes = cli.full_transcript
|
||||||
|
sagen("Erste Antwort", f"{len(erstes)} Zeichen")
|
||||||
|
print()
|
||||||
|
print("--- was der Switch nach der Anmeldung geschickt hat ---")
|
||||||
|
print(erstes if erstes.strip() else "(nichts)")
|
||||||
|
print("--- Ende ---")
|
||||||
|
print()
|
||||||
|
|
||||||
|
if args.port:
|
||||||
|
ausgabe = cli.send(
|
||||||
|
f"show power-over-ethernet {args.port}", tolerate_error=True
|
||||||
|
)
|
||||||
|
sagen("Zustandsabfrage", f"{len(ausgabe)} Zeichen")
|
||||||
|
print("--- show power-over-ethernet ---")
|
||||||
|
print(ausgabe)
|
||||||
|
print("--- Ende ---")
|
||||||
|
sagen("Erkannter Zustand", cli.poe_state(args.port))
|
||||||
|
|
||||||
|
if args.zyklus:
|
||||||
|
if not args.port:
|
||||||
|
print("Fuer --zyklus braucht es --port.")
|
||||||
|
return 2
|
||||||
|
ergebnis = cli.cycle_poe(args.port)
|
||||||
|
sagen("Portzyklus", "erfolgreich" if ergebnis.ok else ergebnis.error)
|
||||||
|
for schritt in ergebnis.steps:
|
||||||
|
sagen("", schritt)
|
||||||
|
if not ergebnis.ok:
|
||||||
|
return 1
|
||||||
|
except SshError as fehler:
|
||||||
|
sagen("FEHLGESCHLAGEN", str(fehler))
|
||||||
|
# Gerade dann sind die Bytes interessant: "kein Prompt" kann
|
||||||
|
# heissen, dass nichts kam -- oder dass etwas kam, das nicht
|
||||||
|
# erkannt wurde. Das sieht man nur im Rohtext, und
|
||||||
|
# Steuerzeichen sieht man nur als repr().
|
||||||
|
roh = "".join(sitzung.transcript)
|
||||||
|
print()
|
||||||
|
print("--- rohe Antwort des Switches (mit Steuerzeichen) ---")
|
||||||
|
print(repr(roh) if roh else "(nichts)")
|
||||||
|
print("--- Ende ---")
|
||||||
|
return 1
|
||||||
|
print()
|
||||||
|
print("Fertig.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def cmd_convert_legacy(args: argparse.Namespace) -> int:
|
def cmd_convert_legacy(args: argparse.Namespace) -> int:
|
||||||
"""Uebersetzt einen Export des alten TESM in das neue Format.
|
"""Uebersetzt einen Export des alten TESM in das neue Format.
|
||||||
|
|
||||||
@@ -214,7 +381,7 @@ def cmd_convert_legacy(args: argparse.Namespace) -> int:
|
|||||||
return 2
|
return 2
|
||||||
ziel = Path(args.ausgabe) if args.ausgabe else quelle.with_name(f"{quelle.stem}-neu.json")
|
ziel = Path(args.ausgabe) if args.ausgabe else quelle.with_name(f"{quelle.stem}-neu.json")
|
||||||
if ziel.exists() and not args.force:
|
if ziel.exists() and not args.force:
|
||||||
print(f"{ziel} existiert bereits. Mit --force ueberschreiben.")
|
print(f"{ziel} existiert bereits. Mit --force überschreiben.")
|
||||||
return 2
|
return 2
|
||||||
|
|
||||||
alt_pass = args.passphrase or getpass("Passphrase der alten Datei: ")
|
alt_pass = args.passphrase or getpass("Passphrase der alten Datei: ")
|
||||||
@@ -231,7 +398,7 @@ def cmd_convert_legacy(args: argparse.Namespace) -> int:
|
|||||||
print("--dry-run: keine Datei geschrieben.")
|
print("--dry-run: keine Datei geschrieben.")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
neu_pass = args.neue_passphrase or getpass("Passphrase fuer die neue Datei: ")
|
neu_pass = args.neue_passphrase or getpass("Passphrase für die neue Datei: ")
|
||||||
try:
|
try:
|
||||||
ziel.write_text(transfer.seal(nutzlast, neu_pass), encoding="utf-8", newline="\n")
|
ziel.write_text(transfer.seal(nutzlast, neu_pass), encoding="utf-8", newline="\n")
|
||||||
except Exception as exc: # noqa: BLE001 - Passphrase zu kurz, Pfad nicht schreibbar
|
except Exception as exc: # noqa: BLE001 - Passphrase zu kurz, Pfad nicht schreibbar
|
||||||
@@ -243,15 +410,141 @@ def cmd_convert_legacy(args: argparse.Namespace) -> int:
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_legacy_export(args: argparse.Namespace) -> int:
|
||||||
|
"""Liest eine alte Installation und schreibt deren Daten als Exportdatei.
|
||||||
|
|
||||||
|
Der Vorgaenger kann nur ueber die Weboberflaeche exportieren: anmelden,
|
||||||
|
Kategorien anhaken, Datei herunterladen. Fuer ein Upgrade ist das der
|
||||||
|
falsche Weg -- es soll ohne Browser laufen und auch dann noch, wenn die
|
||||||
|
alte Anwendung nicht mehr startet. Gelesen wird ausschliesslich; die alte
|
||||||
|
Installation bleibt unberuehrt.
|
||||||
|
|
||||||
|
Herauskommt eine Datei im **alten** Format. Damit ist sie genau das, was
|
||||||
|
der Vorgaenger selbst exportiert haette -- und taugt deshalb auch als
|
||||||
|
Rueckweg.
|
||||||
|
"""
|
||||||
|
from getpass import getpass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .services import legacy_transfer as legacy
|
||||||
|
|
||||||
|
quelle = Path(args.source)
|
||||||
|
ziel = Path(args.out)
|
||||||
|
if ziel.exists() and not args.force:
|
||||||
|
print(f"{ziel} existiert bereits. Mit --force überschreiben.")
|
||||||
|
return 2
|
||||||
|
|
||||||
|
passphrase = args.passphrase or getpass("Passphrase für die Exportdatei: ")
|
||||||
|
try:
|
||||||
|
nutzlast, bericht = legacy.read_installation(quelle)
|
||||||
|
inhalt = legacy.seal_legacy(nutzlast, passphrase)
|
||||||
|
except legacy.LegacyTransferError as exc:
|
||||||
|
print(f"Fehlgeschlagen: {exc}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print(bericht.as_text("Gelesen"))
|
||||||
|
if args.dry_run:
|
||||||
|
print()
|
||||||
|
print("--dry-run: keine Datei geschrieben.")
|
||||||
|
return 0
|
||||||
|
try:
|
||||||
|
ziel.write_text(inhalt, encoding="utf-8", newline="\n")
|
||||||
|
except OSError as exc:
|
||||||
|
print(f"Schreiben fehlgeschlagen: {exc}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
print()
|
||||||
|
print(f"Geschrieben: {ziel}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_legacy_import(args: argparse.Namespace) -> int:
|
||||||
|
"""Liest eine Datei im neuen Format ein -- derselbe Weg wie in der Oberflaeche.
|
||||||
|
|
||||||
|
Gebraucht wird das fuer das Upgrade: dort laeuft niemand durch einen
|
||||||
|
Browser. Geschrieben wird ueber ``transfer.apply_import`` -- dieselbe
|
||||||
|
Funktion, die die Seite "Sicherung" benutzt. Ein zweiter Schreibpfad fuer
|
||||||
|
Fremddaten waere die Stelle, die niemand pflegt.
|
||||||
|
"""
|
||||||
|
from getpass import getpass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .services import transfer
|
||||||
|
|
||||||
|
quelle = Path(args.datei)
|
||||||
|
if not quelle.is_file():
|
||||||
|
print(f"Datei nicht gefunden: {quelle}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
app, extension = _app_and_core()
|
||||||
|
passphrase = args.passphrase or getpass("Passphrase der Datei: ")
|
||||||
|
try:
|
||||||
|
nutzlast, kopf = transfer.unseal(quelle.read_bytes(), passphrase)
|
||||||
|
except transfer.TransferError as exc:
|
||||||
|
print(f"Fehlgeschlagen: {exc}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
vorhanden = [eintrag["key"] for eintrag in transfer.summarize(nutzlast)]
|
||||||
|
gewaehlt = (
|
||||||
|
[key for key in args.categories.split(",") if key.strip()]
|
||||||
|
if args.categories
|
||||||
|
else vorhanden
|
||||||
|
)
|
||||||
|
unbekannt = sorted(set(gewaehlt) - set(transfer.CATEGORY_BY_KEY))
|
||||||
|
if unbekannt:
|
||||||
|
print(f"Unbekannte Kategorie(n): {', '.join(unbekannt)}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
print(f"Datei vom {kopf.get('created_at') or 'unbekannt'}:")
|
||||||
|
for eintrag in transfer.summarize(nutzlast):
|
||||||
|
marke = "x" if eintrag["key"] in gewaehlt else " "
|
||||||
|
print(f" [{marke}] {eintrag['label']}: {eintrag['count']}")
|
||||||
|
if args.dry_run:
|
||||||
|
print()
|
||||||
|
print("--dry-run: nichts geschrieben.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
with app.app_context(), extension.database.session() as conn:
|
||||||
|
with extension.database.transaction(conn):
|
||||||
|
bericht = transfer.apply_import(
|
||||||
|
conn,
|
||||||
|
extension.keystore,
|
||||||
|
nutzlast,
|
||||||
|
categories=gewaehlt,
|
||||||
|
set_setting=lambda key, value: extension.settings.set(
|
||||||
|
conn, key, value, actor=args.actor
|
||||||
|
),
|
||||||
|
)
|
||||||
|
audit.log(
|
||||||
|
conn,
|
||||||
|
actor=args.actor,
|
||||||
|
action="backup.imported",
|
||||||
|
detail="; ".join(
|
||||||
|
f"{key}: {zahlen['created']} neu, {zahlen['updated']} aktualisiert, "
|
||||||
|
f"{zahlen['skipped']} übersprungen"
|
||||||
|
for key, zahlen in bericht.items()
|
||||||
|
)
|
||||||
|
+ " (Kommandozeile)",
|
||||||
|
severity="warning",
|
||||||
|
)
|
||||||
|
print()
|
||||||
|
for key, zahlen in sorted(bericht.items()):
|
||||||
|
print(
|
||||||
|
f" {key}: {zahlen['created']} neu, {zahlen['updated']} aktualisiert, "
|
||||||
|
f"{zahlen['skipped']} übersprungen"
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def build_parser() -> argparse.ArgumentParser:
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
prog="tesm-admin", description="Verwaltungswerkzeuge fuer TESM"
|
prog="tesm-admin", description="Verwaltungswerkzeuge für TESM"
|
||||||
)
|
)
|
||||||
sub = parser.add_subparsers(dest="command", required=True)
|
sub = parser.add_subparsers(dest="command", required=True)
|
||||||
|
|
||||||
admin = sub.add_parser("create-admin", help="Administratorkonto anlegen oder zuruecksetzen")
|
admin = sub.add_parser("create-admin", help="Administratorkonto anlegen oder zurücksetzen")
|
||||||
admin.add_argument("--username")
|
admin.add_argument("--username")
|
||||||
admin.add_argument("--password", help="Nur fuer automatisierte Installationen")
|
admin.add_argument("--password", help="Nur für automatisierte Installationen")
|
||||||
admin.add_argument("--force", action="store_true", help="Passwortregeln ignorieren")
|
admin.add_argument("--force", action="store_true", help="Passwortregeln ignorieren")
|
||||||
admin.set_defaults(func=cmd_create_admin)
|
admin.set_defaults(func=cmd_create_admin)
|
||||||
|
|
||||||
@@ -259,18 +552,40 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
status.add_argument("--json", action="store_true")
|
status.add_argument("--json", action="store_true")
|
||||||
status.set_defaults(func=cmd_status)
|
status.set_defaults(func=cmd_status)
|
||||||
|
|
||||||
house = sub.add_parser("housekeeping", help="Sitzungen, Ratenereignisse, Protokolle aufraeumen")
|
house = sub.add_parser("housekeeping", help="Sitzungen, Ratenereignisse, Protokolle aufräumen")
|
||||||
house.set_defaults(func=cmd_housekeeping)
|
house.set_defaults(func=cmd_housekeeping)
|
||||||
|
|
||||||
check = sub.add_parser("check-now", help="Einen Ueberwachungsdurchlauf sofort ausfuehren")
|
restore = sub.add_parser(
|
||||||
|
"restore-signatures",
|
||||||
|
help="Unterschriften aus dem Journal neben dem Protokollarchiv übernehmen",
|
||||||
|
)
|
||||||
|
restore.set_defaults(func=cmd_restore_signatures)
|
||||||
|
|
||||||
|
check = sub.add_parser("check-now", help="Einen Überwachungsdurchlauf sofort ausführen")
|
||||||
check.set_defaults(func=cmd_check_now)
|
check.set_defaults(func=cmd_check_now)
|
||||||
|
|
||||||
|
pruefung = sub.add_parser(
|
||||||
|
"switch-test",
|
||||||
|
help="Eine Switch-Sitzung Stufe fuer Stufe pruefen",
|
||||||
|
description=(
|
||||||
|
"Meldet sich am Switch an und zeigt, was er schickt -- ohne Browser, "
|
||||||
|
"ohne WebSocket, ohne Reverse Proxy. Liest nur; mit --zyklus wird der "
|
||||||
|
"Port wirklich geschaltet."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
pruefung.add_argument("switch", help="Name oder IP des Switches, wie in TESM hinterlegt")
|
||||||
|
pruefung.add_argument("--port", help="Portbezeichnung, z. B. 1/8 -- fragt den Zustand ab")
|
||||||
|
pruefung.add_argument(
|
||||||
|
"--zyklus", action="store_true", help="PoE wirklich aus- und wieder einschalten"
|
||||||
|
)
|
||||||
|
pruefung.set_defaults(func=cmd_switch_test)
|
||||||
|
|
||||||
legacy = sub.add_parser(
|
legacy = sub.add_parser(
|
||||||
"convert-legacy",
|
"convert-legacy",
|
||||||
help="Export des alten TESM in das neue Format uebersetzen",
|
help="Export des alten TESM in das neue Format übersetzen",
|
||||||
description=(
|
description=(
|
||||||
"Liest eine Exportdatei des Vorgaengers und schreibt eine Datei im "
|
"Liest eine Exportdatei des Vorgängers und schreibt eine Datei im "
|
||||||
"Format dieser Anwendung. Uebersetzt werden Clients, Switche, "
|
"Format dieser Anwendung. Übersetzt werden Clients, Switche, "
|
||||||
"Zugangsdaten und die DHCP-Konfiguration."
|
"Zugangsdaten und die DHCP-Konfiguration."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -285,9 +600,49 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
legacy.add_argument(
|
legacy.add_argument(
|
||||||
"--dry-run", action="store_true", help="Nur den Bericht zeigen, nichts schreiben"
|
"--dry-run", action="store_true", help="Nur den Bericht zeigen, nichts schreiben"
|
||||||
)
|
)
|
||||||
legacy.add_argument("--force", action="store_true", help="Zieldatei ueberschreiben")
|
legacy.add_argument("--force", action="store_true", help="Zieldatei überschreiben")
|
||||||
legacy.set_defaults(func=cmd_convert_legacy)
|
legacy.set_defaults(func=cmd_convert_legacy)
|
||||||
|
|
||||||
|
|
||||||
|
export_alt = sub.add_parser(
|
||||||
|
"legacy-export",
|
||||||
|
help="Daten einer alten Installation als Exportdatei sichern",
|
||||||
|
description=(
|
||||||
|
"Liest die Datenbank einer Installation der Vorgängerfassung und schreibt "
|
||||||
|
"Clients, Switche, Zugangsdaten und DHCP in eine Datei im alten Format. "
|
||||||
|
"Die alte Installation wird nur gelesen. Ohne Browser und auch dann noch "
|
||||||
|
"möglich, wenn die alte Anwendung nicht mehr startet."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
export_alt.add_argument(
|
||||||
|
"--source", default="/srv/tesm", help="Verzeichnis der alten Installation"
|
||||||
|
)
|
||||||
|
export_alt.add_argument("--out", required=True, help="Zieldatei")
|
||||||
|
export_alt.add_argument("--passphrase", help="Passphrase der Zieldatei (sonst wird gefragt)")
|
||||||
|
export_alt.add_argument("--dry-run", action="store_true", help="Nur den Bericht zeigen")
|
||||||
|
export_alt.add_argument("--force", action="store_true", help="Zieldatei überschreiben")
|
||||||
|
export_alt.set_defaults(func=cmd_legacy_export)
|
||||||
|
|
||||||
|
import_neu = sub.add_parser(
|
||||||
|
"legacy-import",
|
||||||
|
help="Datei im Format dieser Anwendung einlesen",
|
||||||
|
description=(
|
||||||
|
"Liest eine Sicherungs- oder übersetzte Datei ein – derselbe Weg wie unter "
|
||||||
|
"Verwaltung -> Sicherung, nur ohne Browser. Ohne --categories werden alle "
|
||||||
|
"Kategorien übernommen, die in der Datei stehen."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
import_neu.add_argument("datei", help="Datei im Format dieser Anwendung")
|
||||||
|
import_neu.add_argument("--passphrase", help="Passphrase der Datei (sonst wird gefragt)")
|
||||||
|
import_neu.add_argument(
|
||||||
|
"--categories", help="Kommaliste, z.B. devices,switches,credentials,dhcp"
|
||||||
|
)
|
||||||
|
import_neu.add_argument(
|
||||||
|
"--actor", default="upgrade", help="Name, der im Auditlog erscheint"
|
||||||
|
)
|
||||||
|
import_neu.add_argument("--dry-run", action="store_true", help="Nur zeigen, was drin steht")
|
||||||
|
import_neu.set_defaults(func=cmd_legacy_import)
|
||||||
|
|
||||||
add_web_setup(sub, cmd_web_setup)
|
add_web_setup(sub, cmd_web_setup)
|
||||||
|
|
||||||
return parser
|
return parser
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ from typing import Any
|
|||||||
from tesm_core.extension import core
|
from tesm_core.extension import core
|
||||||
from tesm_core.nav import NavItem, NavTree
|
from tesm_core.nav import NavItem, NavTree
|
||||||
|
|
||||||
|
from .permissions import DHCP_PAGE, SERVICING_PAGE
|
||||||
|
|
||||||
|
|
||||||
def _module(name: str):
|
def _module(name: str):
|
||||||
"""Bedingung: Vollmodul lizenziert?"""
|
"""Bedingung: Vollmodul lizenziert?"""
|
||||||
@@ -36,7 +38,7 @@ def _has_fileshare_mounts(user: Any) -> bool:
|
|||||||
return False
|
return False
|
||||||
if not _module("fileshare")(user):
|
if not _module("fileshare")(user):
|
||||||
return False
|
return False
|
||||||
if user.has("fileshare.edit"):
|
if user.has("fileshare.write"):
|
||||||
return True
|
return True
|
||||||
from .services import fileshare
|
from .services import fileshare
|
||||||
|
|
||||||
@@ -45,10 +47,10 @@ def _has_fileshare_mounts(user: Any) -> bool:
|
|||||||
|
|
||||||
NAVIGATION = NavTree(
|
NAVIGATION = NavTree(
|
||||||
[
|
[
|
||||||
NavItem(key="index", label="Statusuebersicht", icon="grid", endpoint="dashboard.index"),
|
NavItem(key="index", label="Statusübersicht", icon="grid", endpoint="dashboard.index"),
|
||||||
NavItem(
|
NavItem(
|
||||||
key="monitoring",
|
key="monitoring",
|
||||||
label="Ueberwachung",
|
label="Überwachung",
|
||||||
icon="cpu",
|
icon="cpu",
|
||||||
children=(
|
children=(
|
||||||
NavItem(
|
NavItem(
|
||||||
@@ -56,45 +58,45 @@ NAVIGATION = NavTree(
|
|||||||
label="Clients",
|
label="Clients",
|
||||||
icon="cpu",
|
icon="cpu",
|
||||||
endpoint="devices.overview",
|
endpoint="devices.overview",
|
||||||
permissions=("devices.view",),
|
permissions=("devices.read",),
|
||||||
),
|
),
|
||||||
NavItem(
|
NavItem(
|
||||||
key="switches",
|
key="switches",
|
||||||
label="Switche",
|
label="Switche",
|
||||||
icon="switch",
|
icon="switch",
|
||||||
endpoint="switches.overview",
|
endpoint="switches.overview",
|
||||||
permissions=("switches.view",),
|
permissions=("switches.read",),
|
||||||
),
|
),
|
||||||
NavItem(
|
NavItem(
|
||||||
key="credentials",
|
key="credentials",
|
||||||
label="Zugangsdaten",
|
label="Zugangsdaten",
|
||||||
icon="key",
|
icon="key",
|
||||||
endpoint="credentials.overview",
|
endpoint="credentials.overview",
|
||||||
permissions=("credentials.view",),
|
permissions=("credentials.read",),
|
||||||
),
|
),
|
||||||
NavItem(
|
NavItem(
|
||||||
key="hostkeys",
|
key="hostkeys",
|
||||||
label="Host-Schluessel",
|
label="Host-Schlüssel",
|
||||||
icon="shield",
|
icon="shield",
|
||||||
endpoint="hostkeys.overview",
|
endpoint="hostkeys.overview",
|
||||||
permissions=("devices.view", "switches.view"),
|
permissions=("devices.read", "switches.read"),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
NavItem(
|
NavItem(
|
||||||
key="maintenance",
|
key="maintenance",
|
||||||
label="Wartung",
|
label="Wartung",
|
||||||
icon="wrench",
|
icon="wrench",
|
||||||
endpoint="maintenance.overview",
|
endpoint="maintenance.overview",
|
||||||
permissions=("maintenance.view", "maintenance.execute"),
|
permissions=SERVICING_PAGE,
|
||||||
condition=_module("maintenance"),
|
condition=_module("maintenance"),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
),
|
|
||||||
NavItem(
|
NavItem(
|
||||||
key="dhcp",
|
key="dhcp",
|
||||||
label="DHCP-Server",
|
label="DHCP-Server",
|
||||||
icon="network",
|
icon="network",
|
||||||
endpoint="dhcp.overview",
|
endpoint="dhcp.overview",
|
||||||
permissions=("dhcp.view", "dhcp.edit", "dhcp.execute"),
|
permissions=DHCP_PAGE,
|
||||||
condition=_module("dhcp"),
|
condition=_module("dhcp"),
|
||||||
),
|
),
|
||||||
NavItem(
|
NavItem(
|
||||||
@@ -108,14 +110,14 @@ NAVIGATION = NavTree(
|
|||||||
label="Meine Freigaben",
|
label="Meine Freigaben",
|
||||||
icon="folder",
|
icon="folder",
|
||||||
endpoint="fileshare.browse",
|
endpoint="fileshare.browse",
|
||||||
permissions=("fileshare.view",),
|
permissions=("fileshare.read",),
|
||||||
),
|
),
|
||||||
NavItem(
|
NavItem(
|
||||||
key="fileshare_mappings",
|
key="fileshare_mappings",
|
||||||
label="Zuordnungen",
|
label="Zuordnungen",
|
||||||
icon="link",
|
icon="link",
|
||||||
endpoint="fileshare.mappings",
|
endpoint="fileshare.mappings",
|
||||||
permissions=("fileshare.edit",),
|
permissions=("fileshare.write",),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -129,28 +131,28 @@ NAVIGATION = NavTree(
|
|||||||
label="Laufend",
|
label="Laufend",
|
||||||
icon="terminal",
|
icon="terminal",
|
||||||
endpoint="logs.live",
|
endpoint="logs.live",
|
||||||
permissions=("logs_live.view",),
|
permissions=("logs_live.read",),
|
||||||
),
|
),
|
||||||
NavItem(
|
NavItem(
|
||||||
key="logs_history",
|
key="logs_history",
|
||||||
label="Verlauf",
|
label="Verlauf",
|
||||||
icon="clock",
|
icon="clock",
|
||||||
endpoint="logs.history",
|
endpoint="logs.history",
|
||||||
permissions=("logs_history.view", "logs_history.export"),
|
permissions=("logs_history.read", "logs_history.export"),
|
||||||
),
|
),
|
||||||
NavItem(
|
NavItem(
|
||||||
key="logs_audit",
|
key="logs_audit",
|
||||||
label="Audit",
|
label="Audit",
|
||||||
icon="history",
|
icon="history",
|
||||||
endpoint="logs.audit_view",
|
endpoint="logs.audit_view",
|
||||||
permissions=("logs_audit.view", "logs_audit.export"),
|
permissions=("logs_audit.read", "logs_audit.export"),
|
||||||
),
|
),
|
||||||
NavItem(
|
NavItem(
|
||||||
key="logs_dhcp",
|
key="logs_dhcp",
|
||||||
label="Kea-DHCP",
|
label="DHCP",
|
||||||
icon="network",
|
icon="network",
|
||||||
endpoint="tesmlogs.dhcp",
|
endpoint="tesmlogs.dhcp",
|
||||||
permissions=("logs_dhcp.view",),
|
permissions=("logs_dhcp.read",),
|
||||||
condition=_module("dhcp"),
|
condition=_module("dhcp"),
|
||||||
),
|
),
|
||||||
NavItem(
|
NavItem(
|
||||||
@@ -158,7 +160,7 @@ NAVIGATION = NavTree(
|
|||||||
label="Neustarts",
|
label="Neustarts",
|
||||||
icon="power",
|
icon="power",
|
||||||
endpoint="tesmlogs.restarts",
|
endpoint="tesmlogs.restarts",
|
||||||
permissions=("devices.view",),
|
permissions=("devices.read",),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -172,42 +174,42 @@ NAVIGATION = NavTree(
|
|||||||
label="Benutzer",
|
label="Benutzer",
|
||||||
icon="users",
|
icon="users",
|
||||||
endpoint="admin.users",
|
endpoint="admin.users",
|
||||||
permissions=("users.view",),
|
permissions=("users.read",),
|
||||||
),
|
),
|
||||||
NavItem(
|
NavItem(
|
||||||
key="groups",
|
key="groups",
|
||||||
label="Gruppen und Rechte",
|
label="Gruppen und Rechte",
|
||||||
icon="shield-users",
|
icon="shield-users",
|
||||||
endpoint="admin.groups",
|
endpoint="admin.groups",
|
||||||
permissions=("groups.view",),
|
permissions=("groups.read",),
|
||||||
),
|
),
|
||||||
NavItem(
|
NavItem(
|
||||||
key="settings_system",
|
key="settings_system",
|
||||||
label="System",
|
label="System",
|
||||||
icon="sliders",
|
icon="sliders",
|
||||||
endpoint="settings.system",
|
endpoint="settings.system",
|
||||||
permissions=("settings_system.view",),
|
permissions=("settings_system.read",),
|
||||||
),
|
),
|
||||||
NavItem(
|
NavItem(
|
||||||
key="settings_network",
|
key="settings_network",
|
||||||
label="Netzwerk",
|
label="Netzwerk",
|
||||||
icon="network",
|
icon="network",
|
||||||
endpoint="hostsettings.network",
|
endpoint="hostsettings.network",
|
||||||
permissions=("settings_network.view",),
|
permissions=("settings_network.read",),
|
||||||
),
|
),
|
||||||
NavItem(
|
NavItem(
|
||||||
key="settings_web",
|
key="settings_web",
|
||||||
label="Webserver und TLS",
|
label="Webserver und TLS",
|
||||||
icon="server",
|
icon="server",
|
||||||
endpoint="hostsettings.web",
|
endpoint="hostsettings.web",
|
||||||
permissions=("settings_web.view",),
|
permissions=("settings_web.read",),
|
||||||
),
|
),
|
||||||
NavItem(
|
NavItem(
|
||||||
key="settings_directory",
|
key="settings_directory",
|
||||||
label="Verzeichnisdienst",
|
label="Verzeichnisdienst",
|
||||||
icon="users",
|
icon="users",
|
||||||
endpoint="hostsettings.directory_settings",
|
endpoint="hostsettings.directory_settings",
|
||||||
permissions=("settings_directory.view",),
|
permissions=("settings_directory.read",),
|
||||||
),
|
),
|
||||||
NavItem(
|
NavItem(
|
||||||
key="settings_license",
|
key="settings_license",
|
||||||
@@ -216,28 +218,28 @@ NAVIGATION = NavTree(
|
|||||||
endpoint="license.overview",
|
endpoint="license.overview",
|
||||||
# Bewusst immer erreichbar, auch ohne gueltige Lizenz --
|
# Bewusst immer erreichbar, auch ohne gueltige Lizenz --
|
||||||
# sonst liesse sich eine neue Lizenz nie einspielen.
|
# sonst liesse sich eine neue Lizenz nie einspielen.
|
||||||
permissions=("settings_license.view",),
|
permissions=("settings_license.read",),
|
||||||
),
|
),
|
||||||
NavItem(
|
NavItem(
|
||||||
key="backup",
|
key="backup",
|
||||||
label="Sicherung",
|
label="Sicherung",
|
||||||
icon="transfer",
|
icon="transfer",
|
||||||
endpoint="backup.overview",
|
endpoint="backup.overview",
|
||||||
permissions=("backup.view", "backup.export", "backup.execute"),
|
permissions=("backup_export.read", "backup_import.read"),
|
||||||
),
|
),
|
||||||
NavItem(
|
NavItem(
|
||||||
key="trash",
|
key="trash",
|
||||||
label="Papierkorb",
|
label="Papierkorb",
|
||||||
icon="trash",
|
icon="trash",
|
||||||
endpoint="trash.overview",
|
endpoint="trash.overview",
|
||||||
permissions=("trash.view",),
|
permissions=("trash.read",),
|
||||||
),
|
),
|
||||||
NavItem(
|
NavItem(
|
||||||
key="diagnostics",
|
key="diagnostics",
|
||||||
label="Diagnose",
|
label="Diagnose",
|
||||||
icon="info",
|
icon="info",
|
||||||
endpoint="diagnostics.overview",
|
endpoint="diagnostics.overview",
|
||||||
permissions=("diagnostics.view",),
|
permissions=("diagnostics.read",),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -30,101 +30,351 @@ A = Action
|
|||||||
DEVICES = Resource(
|
DEVICES = Resource(
|
||||||
key="devices",
|
key="devices",
|
||||||
label="Clients",
|
label="Clients",
|
||||||
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE, A.EXECUTE),
|
actions=(A.READ, A.CREATE, A.WRITE, A.DELETE),
|
||||||
hint="Ueberwachte PoE-Endgeraete",
|
hint="Stammdaten überwachter PoE-Endgeräte: Name, MAC, IP, Switch und Port",
|
||||||
action_hints={
|
action_hints={
|
||||||
A.EXECUTE: "PoE-Neustart eines Geraets manuell ausloesen. Der automatische "
|
A.DELETE: "Gerät in den Papierkorb legen.",
|
||||||
"Neustart bei Ausfall ist davon unabhaengig und laeuft immer.",
|
},
|
||||||
A.DELETE: "Geraet in den Papierkorb legen.",
|
)
|
||||||
|
|
||||||
|
#: Drei Wege, drei Rechte -- bisher hingen alle an ``devices.execute``.
|
||||||
|
#:
|
||||||
|
#: Wer jemandem den PoE-Neustart erlauben wollte (der Weg, der auch bei einem
|
||||||
|
#: haengenden Geraet noch wirkt und nichts Laufendes unterbricht), erlaubte ihm
|
||||||
|
#: damit zugleich, ein arbeitendes System ueber SSH herunterzufahren. Das sind
|
||||||
|
#: drei verschiedene Eingriffe mit drei verschiedenen Folgen -- also drei
|
||||||
|
#: Zeilen. Die Reihenfolge ist die der Rangfolge in ``services.restart``.
|
||||||
|
DEVICE_RESTART_POE = Resource(
|
||||||
|
key="device_restart_poe",
|
||||||
|
parent="devices",
|
||||||
|
label="Clients: Neustart über PoE",
|
||||||
|
actions=(A.READ, A.EXECUTE),
|
||||||
|
hint="Portzyklus am Switch – wirkt in Sekunden, auch wenn das Gerät hängt",
|
||||||
|
action_hints={
|
||||||
|
A.READ: "Den Knopf sehen.",
|
||||||
|
A.EXECUTE: "Neustart auslösen. Bewusst nicht lizenzpflichtig: ein hängendes "
|
||||||
|
"Gerät soll man auch ohne gültige Lizenz von Hand neu starten können.",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
DEVICE_RESTART_SSH = Resource(
|
||||||
|
key="device_restart_ssh",
|
||||||
|
parent="devices",
|
||||||
|
label="Clients: Neustart über SSH",
|
||||||
|
actions=(A.READ, A.EXECUTE),
|
||||||
|
hint="Sauberes Herunterfahren eines Linux-Geräts (Modul Wartung)",
|
||||||
|
action_hints={
|
||||||
|
A.EXECUTE: "Neustartauftrag anlegen. Dauert Minuten und setzt ein "
|
||||||
|
"antwortendes Gerät samt freigegebenem Host-Schlüssel voraus.",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
DEVICE_RESTART_RPC = Resource(
|
||||||
|
key="device_restart_rpc",
|
||||||
|
parent="devices",
|
||||||
|
label="Clients: Neustart über RPC",
|
||||||
|
actions=(A.READ, A.EXECUTE),
|
||||||
|
hint="Windows-Gerät ohne SSH und ohne Host-Schlüssel (Modul Wartung)",
|
||||||
|
action_hints={A.EXECUTE: "Neustartauftrag anlegen (wie 'shutdown /m')."},
|
||||||
|
)
|
||||||
|
|
||||||
|
#: Bildschirmzugriff ist **nicht** dasselbe wie die Konsole, auch wenn beides
|
||||||
|
#: "auf das Geraet" fuehrt: hier sieht jemand den Bildschirm, wie er ist --
|
||||||
|
#: samt dem, was ein Mensch dort gerade offen hat. Das ist eine eigene
|
||||||
|
#: Entscheidung, also eine eigene Zeile. Sie wird **niemandem** mitgegeben:
|
||||||
|
#: keine Migration setzt sie, auch nicht bei denen, die schon eine Konsole
|
||||||
|
#: oeffnen duerfen. Wer sie haben soll, bekommt sie in der Matrix -- einmal,
|
||||||
|
#: sichtbar, mit Zeile im Aenderungsprotokoll.
|
||||||
|
DEVICE_VNC = Resource(
|
||||||
|
key="device_vnc",
|
||||||
|
parent="devices",
|
||||||
|
label="Clients: Bildschirm (VNC)",
|
||||||
|
actions=(A.READ, A.EXECUTE),
|
||||||
|
hint="Verbindungsdatei bzw. vnc://-Adresse für den Bildschirm des Clients",
|
||||||
|
action_hints={
|
||||||
|
A.READ: "Den Knopf sehen, wo ein VNC-Server antwortet.",
|
||||||
|
A.EXECUTE: "Verbindung öffnen. Die Datei enthält nur Ziel und Namen – "
|
||||||
|
"was beim Verbinden verlangt wird, entscheidet der VNC-Server auf dem Gerät.",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
#: Die Client-Konsole lief unter ``maintenance.execute`` mit -- demselben Recht
|
||||||
|
#: wie die Paketaktualisierung. Eine Sitzung auf einem Geraet ist aber etwas
|
||||||
|
#: anderes als ein Auftrag, den der Server abarbeitet: hier tippt ein Mensch.
|
||||||
|
DEVICE_CONSOLE = Resource(
|
||||||
|
key="device_console",
|
||||||
|
parent="devices",
|
||||||
|
label="Clients: Konsole",
|
||||||
|
actions=(A.READ, A.EXECUTE),
|
||||||
|
hint="Browser-Terminal auf einem Client (Modul Wartung)",
|
||||||
|
action_hints={
|
||||||
|
A.EXECUTE: "Sitzung öffnen. Verlangt eine frische Passwortbestätigung; "
|
||||||
|
"jede Sitzung wird vollständig mitgeschrieben.",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
SWITCHES = Resource(
|
SWITCHES = Resource(
|
||||||
key="switches",
|
key="switches",
|
||||||
label="Switche",
|
label="Switche",
|
||||||
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE, A.EXECUTE),
|
actions=(A.READ, A.CREATE, A.WRITE, A.DELETE),
|
||||||
hint="Aruba-Switche und ihre Erreichbarkeit",
|
hint="Aruba-Switche, ihre Erreichbarkeit und Ports",
|
||||||
|
)
|
||||||
|
|
||||||
|
SWITCH_CONSOLE = Resource(
|
||||||
|
key="switch_console",
|
||||||
|
parent="switches",
|
||||||
|
label="Switche: Konsole",
|
||||||
|
actions=(A.READ, A.EXECUTE),
|
||||||
|
hint="Kommandos auf dem Switch ausführen",
|
||||||
action_hints={
|
action_hints={
|
||||||
A.EXECUTE: "Browser-Terminal oeffnen und Kommandos auf dem Switch ausfuehren. "
|
A.EXECUTE: "Sitzung öffnen. Verlangt eine frische Passwortbestätigung; "
|
||||||
"Verlangt zusaetzlich eine frische Passwortbestaetigung; jede Sitzung wird "
|
"jede Sitzung wird vollständig mitgeschrieben.",
|
||||||
"vollstaendig protokolliert.",
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
#: Einen geaenderten Host-Schluessel freizugeben hing an "Gerät ändern" bzw.
|
||||||
|
#: "Switch ändern" -- also an einem Recht fuer Stammdaten. Es ist aber der
|
||||||
|
#: Vorgang, mit dem man sagt: "dieses Geraet ist wirklich das, fuer das es sich
|
||||||
|
#: ausgibt". Wer Namen und Standort pflegt, muss das nicht entscheiden duerfen.
|
||||||
|
HOSTKEYS = Resource(
|
||||||
|
key="hostkeys",
|
||||||
|
label="Host-Schlüssel",
|
||||||
|
actions=(A.READ, A.EXECUTE),
|
||||||
|
hint="Ein Gerät meldet sich mit einem anderen SSH-Schlüssel als beim letzten Mal",
|
||||||
|
action_hints={
|
||||||
|
A.READ: "Offene und freigegebene Schlüssel sehen.",
|
||||||
|
A.EXECUTE: "Einen geänderten Schlüssel freigeben.",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
CREDENTIALS = Resource(
|
CREDENTIALS = Resource(
|
||||||
key="credentials",
|
key="credentials",
|
||||||
label="Zugangsdaten",
|
label="Zugangsdaten",
|
||||||
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE, A.SECRETS),
|
actions=(A.READ, A.CREATE, A.WRITE, A.DELETE, A.SECRETS),
|
||||||
hint="Wiederverwendbare SSH-Logins",
|
hint="Wiederverwendbare SSH-Logins",
|
||||||
action_hints={
|
action_hints={
|
||||||
A.VIEW: "Namen und Benutzer sehen -- nicht das Passwort.",
|
A.READ: "Namen und Benutzer sehen – nicht das Passwort.",
|
||||||
A.SECRETS: "Passwort im Klartext anzeigen. Verlangt eine frische "
|
A.SECRETS: "Passwort im Klartext anzeigen. Verlangt eine frische "
|
||||||
"Passwortbestaetigung und wird protokolliert.",
|
"Passwortbestätigung und wird protokolliert.",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
MAINTENANCE = Resource(
|
#: Aus einer Zeile ("Aktualisierung und Neustart") wurden drei. Sie deckte
|
||||||
key="maintenance",
|
#: vier Funktionen ab: Pakete aktualisieren, reihenweise neu starten, das
|
||||||
label="Wartung",
|
#: Windows-Werkzeug installieren -- und die Client-Konsole, die jetzt bei den
|
||||||
actions=(A.VIEW, A.EXECUTE),
|
#: Clients steht, wo sie hingehoert.
|
||||||
hint="Paketaktualisierung und Neustart per SSH (lizenzpflichtiges Modul)",
|
SERVICING_UPDATE = Resource(
|
||||||
action_hints={A.EXECUTE: "Aktualisierung oder Neustart auf ausgewaehlten Geraeten starten."},
|
key="servicing_update",
|
||||||
|
label="Paketaktualisierung",
|
||||||
|
actions=(A.READ, A.EXECUTE),
|
||||||
|
hint="Pakete auf ausgewählten Geräten aktualisieren (lizenzpflichtiges Modul)",
|
||||||
|
action_hints={A.EXECUTE: "Auftrag starten."},
|
||||||
)
|
)
|
||||||
|
|
||||||
DHCP = Resource(
|
SERVICING_REBOOT = Resource(
|
||||||
key="dhcp",
|
key="servicing_reboot",
|
||||||
label="DHCP-Server",
|
label="Neustart als Auftrag",
|
||||||
actions=(A.VIEW, A.EDIT, A.EXECUTE),
|
actions=(A.READ, A.EXECUTE),
|
||||||
hint="Kea-DHCP: Subnetze, Optionen, Reservierungen (lizenzpflichtiges Modul)",
|
hint="Reihenweise Neustarts mit Wartezeit und Rückmeldung",
|
||||||
|
action_hints={A.EXECUTE: "Auftrag starten."},
|
||||||
|
)
|
||||||
|
|
||||||
|
SERVICING_TOOL = Resource(
|
||||||
|
key="servicing_tool",
|
||||||
|
label="Windows-Werkzeug installieren",
|
||||||
|
actions=(A.READ, A.EXECUTE),
|
||||||
|
hint="Voraussetzung für den RPC-Weg auf einem Windows-Gerät",
|
||||||
|
action_hints={A.EXECUTE: "Installation auslösen."},
|
||||||
|
)
|
||||||
|
|
||||||
|
#: Die Wartungsseite zeigt alle drei Aufgaben. Sie oeffnet, wer **eine** davon
|
||||||
|
#: sehen darf -- und zeigt dann nur die. Als Konstante, damit die vier Routen
|
||||||
|
#: dieselbe Antwort geben; einzeln getippt liefen sie irgendwann auseinander.
|
||||||
|
SERVICING_PAGE: tuple[str, ...] = (
|
||||||
|
"servicing_update.read",
|
||||||
|
"servicing_reboot.read",
|
||||||
|
"servicing_tool.read",
|
||||||
|
)
|
||||||
|
|
||||||
|
#: Elf Funktionen hingen an ``dhcp.edit``: Grundeinstellungen, Subnetze,
|
||||||
|
#: Optionen, Geraeteoptionen, eigene Optionen, Reservierungen und das
|
||||||
|
#: Schreiben der Dienstkonfiguration. Wer eine Reservierung eintragen sollte,
|
||||||
|
#: durfte damit auch Subnetze loeschen und die Konfiguration ueberschreiben.
|
||||||
|
DHCP_SUBNETS = Resource(
|
||||||
|
key="dhcp_subnets",
|
||||||
|
label="DHCP: Subnetze",
|
||||||
|
actions=(A.READ, A.CREATE, A.WRITE, A.DELETE),
|
||||||
|
hint="Bereiche, Gateway, DNS, Reihenfolge (lizenzpflichtiges Modul)",
|
||||||
|
)
|
||||||
|
|
||||||
|
DHCP_OPTIONS = Resource(
|
||||||
|
key="dhcp_options",
|
||||||
|
parent="dhcp_subnets",
|
||||||
|
label="DHCP: Optionen",
|
||||||
|
actions=(A.READ, A.WRITE),
|
||||||
|
hint="Globale Optionen, Optionen je Gerät und eigene Optionen",
|
||||||
|
action_hints={A.WRITE: "Werte setzen, eigene Optionen anlegen und entfernen."},
|
||||||
|
)
|
||||||
|
|
||||||
|
DHCP_RESERVATIONS = Resource(
|
||||||
|
key="dhcp_reservations",
|
||||||
|
parent="dhcp_subnets",
|
||||||
|
label="DHCP: Reservierungen",
|
||||||
|
actions=(A.READ, A.CREATE, A.WRITE, A.DELETE),
|
||||||
|
hint="Feste Adressen zu einer MAC",
|
||||||
|
)
|
||||||
|
|
||||||
|
DHCP_CONFIG = Resource(
|
||||||
|
key="dhcp_config",
|
||||||
|
parent="dhcp_subnets",
|
||||||
|
label="DHCP: Konfiguration schreiben",
|
||||||
|
actions=(A.READ, A.EXECUTE),
|
||||||
|
hint="Den bearbeiteten Stand in die Dienstkonfiguration übernehmen",
|
||||||
action_hints={
|
action_hints={
|
||||||
A.EDIT: "Konfiguration aendern und schreiben.",
|
A.EXECUTE: "Schreiben und neu laden. Bis dahin ändert sich am laufenden "
|
||||||
A.EXECUTE: "Dienst installieren, starten oder stoppen.",
|
"Dienst nichts – Bearbeiten und Wirksamwerden sind zwei Schritte.",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
DHCP_SERVICE = Resource(
|
||||||
|
key="dhcp_service",
|
||||||
|
parent="dhcp_subnets",
|
||||||
|
label="DHCP: Dienst",
|
||||||
|
actions=(A.READ, A.EXECUTE),
|
||||||
|
hint="Kea installieren, starten, stoppen",
|
||||||
|
action_hints={A.EXECUTE: "Dienstzustand ändern."},
|
||||||
|
)
|
||||||
|
|
||||||
|
#: Die DHCP-Seite zeigt alle Abschnitte; jeder Abschnitt haengt an seiner
|
||||||
|
#: eigenen Zeile. Geoeffnet wird sie, wer einen davon sehen darf.
|
||||||
|
DHCP_PAGE: tuple[str, ...] = (
|
||||||
|
"dhcp_subnets.read",
|
||||||
|
"dhcp_options.read",
|
||||||
|
"dhcp_reservations.read",
|
||||||
|
"dhcp_config.read",
|
||||||
|
"dhcp_service.read",
|
||||||
|
)
|
||||||
|
|
||||||
FILESHARE = Resource(
|
FILESHARE = Resource(
|
||||||
key="fileshare",
|
key="fileshare",
|
||||||
label="Dateifreigaben",
|
label="Dateifreigaben",
|
||||||
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE),
|
actions=(A.READ, A.CREATE, A.WRITE, A.DELETE, A.EXPORT),
|
||||||
hint="SMB-Freigaben aus dem Verzeichnisdienst (lizenzpflichtiges Modul)",
|
hint="SMB-Freigaben aus dem Verzeichnisdienst (lizenzpflichtiges Modul)",
|
||||||
action_hints={
|
action_hints={
|
||||||
A.CREATE: "Ordner anlegen und Dateien hochladen.",
|
A.CREATE: "Ordner anlegen und Dateien hochladen.",
|
||||||
A.EDIT: "Umbenennen und verschieben.",
|
A.WRITE: "Umbenennen und verschieben.",
|
||||||
|
# Genau die Bedeutung aus der Legende: Daten aus dem System
|
||||||
|
# heraustragen. Ansehen und Mitnehmen sind zweierlei -- eine Vorschau
|
||||||
|
# bleibt im Haus, eine heruntergeladene Datei nicht.
|
||||||
|
A.EXPORT: "Dateien herunterladen.",
|
||||||
},
|
},
|
||||||
|
# Exporte verlangen sonst eine frische Passwortbestaetigung. Hier nicht:
|
||||||
|
# Herunterladen ist in einer Dateifreigabe der Alltag, nicht der
|
||||||
|
# Sonderfall. Alle 15 Minuten ein Passwort waere Schikane -- und wuerde
|
||||||
|
# nur dazu fuehren, dass jemand das Recht pauschal vergibt oder ganz
|
||||||
|
# weglaesst. Die Grenze zieht hier das Recht selbst, nicht die Abfrage.
|
||||||
|
ohne_wiederanmeldung=(A.EXPORT,),
|
||||||
|
)
|
||||||
|
|
||||||
|
#: Eigene Zeile statt Anhaengsel von "Dateifreigaben": ein Formular auszufuellen
|
||||||
|
#: ist ein anderer Vorgang als eine Datei hochzuladen, und wer umbenennen darf,
|
||||||
|
#: soll deshalb nicht automatisch veraenderbare Formulare ablegen duerfen.
|
||||||
|
FILESHARE_PDF = Resource(
|
||||||
|
key="fileshare_pdf",
|
||||||
|
parent="fileshare",
|
||||||
|
label="PDF-Formulare",
|
||||||
|
actions=(A.READ, A.CREATE, A.WRITE),
|
||||||
|
hint="Formulare in der Vorschau ausfüllen und in der Freigabe ablegen",
|
||||||
|
action_hints={
|
||||||
|
A.READ: "In der Vorschau ausfüllen (ohne Speichern).",
|
||||||
|
A.CREATE: "Ausgefüllt speichern – abgeschlossen, Felder gesperrt.",
|
||||||
|
A.WRITE: "Ausgefüllt speichern – weiter ausfüllbar, inklusive Bild.",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
#: Unterschreiben ist kein Aendern: am Ende steht eine Fassung, die niemand
|
||||||
|
#: mehr bearbeiten kann, mit Namen und Zeitpunkt im Dokument und einer
|
||||||
|
#: Pruefsumme im Aenderungsprotokoll. Wer Dateien umbenennen darf, soll das
|
||||||
|
#: nicht automatisch duerfen.
|
||||||
|
FILESHARE_SIGNATURE = Resource(
|
||||||
|
key="fileshare_signature",
|
||||||
|
parent="fileshare",
|
||||||
|
label="Unterschriften",
|
||||||
|
actions=(A.READ, A.EXECUTE),
|
||||||
|
hint="Mit dem Finger unterschreiben; das Ergebnis ist unveränderbar",
|
||||||
|
action_hints={
|
||||||
|
A.READ: "Unterschriebene Dateien öffnen und die Prüfung sehen.",
|
||||||
|
A.EXECUTE: "Unterschreiben und abgeschlossen ablegen.",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
#: Welche Verzeichnisfreigabe unter welchem Namen erscheint, ist Administration
|
||||||
|
#: und kein Dateibetrieb -- es lief unter demselben Recht wie "umbenennen".
|
||||||
|
FILESHARE_MAPPINGS = Resource(
|
||||||
|
key="fileshare_mappings",
|
||||||
|
parent="fileshare",
|
||||||
|
label="Freigabe-Zuordnungen",
|
||||||
|
actions=(A.READ, A.CREATE, A.WRITE, A.DELETE),
|
||||||
|
hint="Verzeichnisgruppe, UNC-Pfad und Anzeigename einer Freigabe",
|
||||||
)
|
)
|
||||||
|
|
||||||
LOGS_DHCP = Resource(
|
LOGS_DHCP = Resource(
|
||||||
key="logs_dhcp",
|
key="logs_dhcp",
|
||||||
label="Kea-DHCP-Protokoll",
|
label="DHCP-Protokoll",
|
||||||
actions=(A.VIEW,),
|
actions=(A.READ,),
|
||||||
)
|
)
|
||||||
|
|
||||||
MONITORING_AREA = Area(
|
MONITORING_AREA = Area(
|
||||||
key="monitoring",
|
key="monitoring",
|
||||||
label="Ueberwachung",
|
label="Überwachung",
|
||||||
icon="cpu",
|
icon="cpu",
|
||||||
hint="Geraete, Switche, Zugangsdaten und Wartung. Ohne diesen Zugang bleibt der "
|
hint="Geräte, Switche und Zugangsdaten. Ohne diesen Zugang bleibt der "
|
||||||
"gesamte Bereich verborgen.",
|
"gesamte Bereich verborgen.",
|
||||||
resources=(DEVICES, SWITCHES, CREDENTIALS, MAINTENANCE),
|
resources=(
|
||||||
|
DEVICES,
|
||||||
|
DEVICE_RESTART_POE,
|
||||||
|
DEVICE_RESTART_SSH,
|
||||||
|
DEVICE_RESTART_RPC,
|
||||||
|
DEVICE_CONSOLE,
|
||||||
|
DEVICE_VNC,
|
||||||
|
SWITCHES,
|
||||||
|
SWITCH_CONSOLE,
|
||||||
|
HOSTKEYS,
|
||||||
|
CREDENTIALS,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
#: Wartung ueberwacht nichts, sie greift ein -- und sie steht seit 2.5.0 als
|
||||||
|
#: eigener Punkt im Menue. Der Bereichsschluessel heisst ``servicing``, weil
|
||||||
|
#: ``maintenance`` schon die Ressource ist und Bereiche und Ressourcen sich
|
||||||
|
#: einen Namensraum teilen. Nach aussen heisst beides "Wartung".
|
||||||
|
MAINTENANCE_AREA = Area(
|
||||||
|
key="servicing",
|
||||||
|
label="Wartung",
|
||||||
|
icon="wrench",
|
||||||
|
hint="Paketpflege und Neustarts. Ohne diesen Zugang bleibt der Bereich "
|
||||||
|
"verborgen – auch wenn einzelne Rechte darunter gesetzt sind.",
|
||||||
|
resources=(SERVICING_UPDATE, SERVICING_REBOOT, SERVICING_TOOL),
|
||||||
)
|
)
|
||||||
|
|
||||||
NETWORK_AREA = Area(
|
NETWORK_AREA = Area(
|
||||||
key="network",
|
key="network",
|
||||||
label="Netzwerkdienste",
|
label="Netzwerkdienste",
|
||||||
icon="network",
|
icon="network",
|
||||||
resources=(DHCP,),
|
resources=(DHCP_SUBNETS, DHCP_OPTIONS, DHCP_RESERVATIONS, DHCP_CONFIG, DHCP_SERVICE),
|
||||||
)
|
)
|
||||||
|
|
||||||
FILES_AREA = Area(
|
FILES_AREA = Area(
|
||||||
key="files",
|
key="files",
|
||||||
label="Dateifreigaben",
|
label="Dateifreigaben",
|
||||||
icon="folder",
|
icon="folder",
|
||||||
resources=(FILESHARE,),
|
resources=(FILESHARE, FILESHARE_PDF, FILESHARE_SIGNATURE, FILESHARE_MAPPINGS),
|
||||||
)
|
)
|
||||||
|
|
||||||
PERMISSIONS = PermissionTree(
|
PERMISSIONS = PermissionTree(
|
||||||
[
|
[
|
||||||
MONITORING_AREA,
|
MONITORING_AREA,
|
||||||
|
MAINTENANCE_AREA,
|
||||||
NETWORK_AREA,
|
NETWORK_AREA,
|
||||||
FILES_AREA,
|
FILES_AREA,
|
||||||
observability_area(LOGS_DHCP),
|
observability_area(LOGS_DHCP),
|
||||||
@@ -133,90 +383,80 @@ PERMISSIONS = PermissionTree(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
#: Alle Funktionsrechte ausserhalb der Verwaltung, in einer Gruppe.
|
||||||
|
#:
|
||||||
|
#: Seit 2.8.0 heisst das Administratorkennzeichen "Verwaltung" und nicht
|
||||||
|
#: "alles": Geraete, Neustarts, DHCP und Freigaben kommen ueber Gruppen. Diese
|
||||||
|
#: Vorlage ist die Gruppe, die man sich dafuer zuweist -- und die Migration
|
||||||
|
#: gibt sie bestehenden Administratoren, damit nach dem Update nichts fehlt,
|
||||||
|
#: was vorher ging. Entfernen kann man sie mit einem Klick.
|
||||||
|
#:
|
||||||
|
#: Die Liste entsteht aus dem Baum und nicht aus Tippen: eine neue Zeile in der
|
||||||
|
#: Matrix ist sonst genau die, die hier vergessen wird.
|
||||||
|
FUNKTIONEN_VOLLZUGRIFF = RolePreset(
|
||||||
|
key="functions",
|
||||||
|
name="Funktionen (Vollzugriff)",
|
||||||
|
description=(
|
||||||
|
"Alle Rechte ausserhalb der Verwaltung. Gedacht fuer Administratoren, "
|
||||||
|
"die nicht nur einstellen, sondern auch bedienen."
|
||||||
|
),
|
||||||
|
grants=tuple(
|
||||||
|
(resource.key, "*")
|
||||||
|
for area in PERMISSIONS.areas
|
||||||
|
if area.key != PermissionTree.VERWALTUNGSBEREICH
|
||||||
|
for resource in area.resources
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
#: Die Gruppen, die eine frische Installation mitbringt -- drei, nicht sieben.
|
||||||
|
#:
|
||||||
|
#: Vorher standen hier zusaetzlich Betrieb, Support, Netzwerk und Revision.
|
||||||
|
#: Gut gemeint, aber sie beantworten eine Frage, die jeder Betrieb anders
|
||||||
|
#: beantwortet: welche Rolle es gibt, weiss der Kunde, nicht die Anwendung.
|
||||||
|
#: Was bleibt, sind die drei, ohne die man nicht anfangen kann -- eine
|
||||||
|
#: Standardgruppe fuer jeden neuen Nutzer, eine fuer die Verwaltung und eine,
|
||||||
|
#: die alle Funktionen buendelt. Weitere legt man in der Matrix an, und die
|
||||||
|
#: Matrix zeigt seit 2.8.0 je Funktion eine Zeile.
|
||||||
ROLE_PRESETS: tuple[RolePreset, ...] = (
|
ROLE_PRESETS: tuple[RolePreset, ...] = (
|
||||||
RolePreset(
|
RolePreset(
|
||||||
key="viewer",
|
key="viewer",
|
||||||
name="Benutzer",
|
name="Benutzer",
|
||||||
description="Standardgruppe: Geraete und Switche ansehen, laufendes Protokoll lesen.",
|
description="Standardgruppe: Geräte und Switche ansehen, laufendes Protokoll lesen.",
|
||||||
grants=(
|
grants=(
|
||||||
("devices", (A.VIEW,)),
|
("devices", (A.READ,)),
|
||||||
("switches", (A.VIEW,)),
|
("switches", (A.READ,)),
|
||||||
("logs_live", (A.VIEW,)),
|
("logs_live", (A.READ,)),
|
||||||
),
|
),
|
||||||
is_default=True,
|
is_default=True,
|
||||||
),
|
),
|
||||||
RolePreset(
|
|
||||||
key="operations",
|
|
||||||
name="Betrieb",
|
|
||||||
description="Geraete und Switche pflegen, Neustarts ausloesen, Wartung fahren.",
|
|
||||||
grants=(
|
|
||||||
("devices", "*"),
|
|
||||||
("switches", (A.VIEW, A.CREATE, A.EDIT, A.EXECUTE)),
|
|
||||||
("credentials", (A.VIEW,)),
|
|
||||||
("maintenance", "*"),
|
|
||||||
("logs_live", (A.VIEW,)),
|
|
||||||
("logs_history", (A.VIEW,)),
|
|
||||||
("trash", (A.VIEW, A.EDIT)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
RolePreset(
|
|
||||||
key="support",
|
|
||||||
name="Support",
|
|
||||||
description="Erste Hilfe am Telefon: Status sehen, PoE-Neustart ausloesen.",
|
|
||||||
grants=(
|
|
||||||
("devices", (A.VIEW, A.EXECUTE)),
|
|
||||||
("switches", (A.VIEW,)),
|
|
||||||
("logs_live", (A.VIEW,)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
RolePreset(
|
|
||||||
key="network",
|
|
||||||
name="Netzwerk",
|
|
||||||
description="DHCP und Host-Netzwerkeinstellungen.",
|
|
||||||
grants=(
|
|
||||||
("dhcp", "*"),
|
|
||||||
("settings_network", "*"),
|
|
||||||
("logs_dhcp", (A.VIEW,)),
|
|
||||||
("devices", (A.VIEW,)),
|
|
||||||
("switches", (A.VIEW,)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
RolePreset(
|
|
||||||
key="auditor",
|
|
||||||
name="Revision",
|
|
||||||
description="Nur lesen -- inklusive Aenderungsprotokoll und Diagnose.",
|
|
||||||
grants=(
|
|
||||||
("devices", (A.VIEW,)),
|
|
||||||
("switches", (A.VIEW,)),
|
|
||||||
("credentials", (A.VIEW,)),
|
|
||||||
("dhcp", (A.VIEW,)),
|
|
||||||
("users", (A.VIEW,)),
|
|
||||||
("groups", (A.VIEW,)),
|
|
||||||
("logs_live", (A.VIEW,)),
|
|
||||||
("logs_history", (A.VIEW, A.EXPORT)),
|
|
||||||
("logs_audit", (A.VIEW, A.EXPORT)),
|
|
||||||
("diagnostics", (A.VIEW,)),
|
|
||||||
("trash", (A.VIEW,)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
RolePreset(
|
RolePreset(
|
||||||
key="administration",
|
key="administration",
|
||||||
name="Administration",
|
name="Administration",
|
||||||
description="Benutzer, Gruppen und Einstellungen -- ohne Admin-Kennzeichen.",
|
description="Benutzer, Gruppen und Einstellungen – ohne Admin-Kennzeichen.",
|
||||||
grants=(
|
grants=(
|
||||||
("users", "*"),
|
("users", "*"),
|
||||||
("groups", "*"),
|
("groups", "*"),
|
||||||
("settings_system", "*"),
|
("settings_system", "*"),
|
||||||
|
("settings_nav", "*"),
|
||||||
("settings_license", "*"),
|
("settings_license", "*"),
|
||||||
("settings_directory", (A.VIEW, A.EDIT, A.EXECUTE)),
|
("settings_directory", (A.READ, A.WRITE, A.EXECUTE)),
|
||||||
("settings_web", "*"),
|
("settings_web", "*"),
|
||||||
("settings_network", "*"),
|
("settings_network", "*"),
|
||||||
("backup", (A.VIEW, A.EXECUTE, A.EXPORT)),
|
("backup_export", (A.READ, A.EXPORT)),
|
||||||
|
("backup_import", "*"),
|
||||||
("trash", "*"),
|
("trash", "*"),
|
||||||
("diagnostics", (A.VIEW,)),
|
("diagnostics", (A.READ,)),
|
||||||
("logs_audit", (A.VIEW,)),
|
("logs_audit", (A.READ,)),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
FUNKTIONEN_VOLLZUGRIFF,
|
||||||
)
|
)
|
||||||
|
|
||||||
__all__ = ["PERMISSIONS", "ROLE_PRESETS", "LOGS_DHCP"]
|
__all__ = [
|
||||||
|
"PERMISSIONS",
|
||||||
|
"ROLE_PRESETS",
|
||||||
|
"LOGS_DHCP",
|
||||||
|
"SERVICING_PAGE",
|
||||||
|
"DHCP_PAGE",
|
||||||
|
]
|
||||||
@@ -23,6 +23,8 @@ Frische Zeitlinie ab Version 1 -- kein Nachbau der 35 gewachsenen
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from tesm_core.migrations import Migration
|
from tesm_core.migrations import Migration
|
||||||
|
|
||||||
V1 = """
|
V1 = """
|
||||||
@@ -250,8 +252,337 @@ CREATE INDEX IF NOT EXISTS idx_restart_events_method ON restart_events(method, t
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# Neue Rechte sind nach einem Update bei niemandem gesetzt -- ohne diesen
|
||||||
|
# Schritt koennte niemand mehr herunterladen oder ein Formular speichern,
|
||||||
|
# obwohl sich an den Gruppen nichts geaendert hat. Die Zuordnung bildet ab, was
|
||||||
|
# vorher galt.
|
||||||
|
V4 = """
|
||||||
|
INSERT OR IGNORE INTO group_permissions (group_id, permission)
|
||||||
|
SELECT group_id, 'fileshare.export' FROM group_permissions
|
||||||
|
WHERE permission = 'fileshare.read';
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO group_permissions (group_id, permission)
|
||||||
|
SELECT group_id, 'fileshare_pdf.read' FROM group_permissions
|
||||||
|
WHERE permission = 'fileshare.read';
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO group_permissions (group_id, permission)
|
||||||
|
SELECT group_id, 'fileshare_pdf.create' FROM group_permissions
|
||||||
|
WHERE permission = 'fileshare.create';
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO group_permissions (group_id, permission)
|
||||||
|
SELECT group_id, 'fileshare_pdf.write' FROM group_permissions
|
||||||
|
WHERE permission = 'fileshare.write';
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# Aus einem Schalter werden zwei. Wer die Konsole abgeschaltet hatte, soll sie
|
||||||
|
# nicht durch ein Update zurueckbekommen -- der alte Wert gilt deshalb fuer
|
||||||
|
# beide weiter. Fehlt er, greift die Vorgabe der Einstellung.
|
||||||
|
V5 = """
|
||||||
|
INSERT OR IGNORE INTO settings (key, value, updated_at, updated_by)
|
||||||
|
SELECT 'terminal_switches_enabled', value, datetime('now'), 'migration'
|
||||||
|
FROM settings WHERE key = 'terminal_enabled';
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO settings (key, value, updated_at, updated_by)
|
||||||
|
SELECT 'terminal_clients_enabled', value, datetime('now'), 'migration'
|
||||||
|
FROM settings WHERE key = 'terminal_enabled';
|
||||||
|
|
||||||
|
DELETE FROM settings WHERE key = 'terminal_enabled';
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# Wartung ist ein eigener Bereich geworden und hat damit einen eigenen
|
||||||
|
# Kill-Switch. Ohne diesen Schritt waere sie nach dem Update bei allen weg:
|
||||||
|
# das neue Bereichsrecht ist bei niemandem gesetzt, und ohne Bereichsrecht
|
||||||
|
# greift kein Recht darunter.
|
||||||
|
#
|
||||||
|
# Bewusst nicht "alle, die ein Wartungsrecht haben": wer den Bereich
|
||||||
|
# Ueberwachung nicht hatte, kam auch vorher nicht an die Wartung. Ein Update
|
||||||
|
# darf Zugang erhalten, aber keinen neuen schaffen.
|
||||||
|
#
|
||||||
|
# Die Bedingung nennt absichtlich keine festen Schluessel, sondern Praefixe:
|
||||||
|
# Wer von einer Fassung vor diesem Schritt direkt auf 2.8 geht, hat vorher die
|
||||||
|
# Migrationen des Kerns durchlaufen (core laeuft vor der Anwendung) -- die
|
||||||
|
# Rechte heissen dann schon "read"/"write" und die Wartungszeilen "servicing_".
|
||||||
|
# Mit ausgeschriebenen Schluesseln fand dieser Schritt in genau diesem Fall
|
||||||
|
# nichts und der Bereich waere doch weg. Ein Ueberrest ist die Zeile nicht:
|
||||||
|
# eine angewendete Migration laeuft nie wieder.
|
||||||
|
V6 = """
|
||||||
|
INSERT OR IGNORE INTO group_permissions (group_id, permission)
|
||||||
|
SELECT DISTINCT group_id, 'servicing.read'
|
||||||
|
FROM group_permissions
|
||||||
|
WHERE (permission LIKE 'maintenance.%' OR permission LIKE 'servicing%')
|
||||||
|
AND group_id IN (
|
||||||
|
SELECT group_id FROM group_permissions WHERE permission = 'monitoring.read'
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# Die Pruefung "unveraendert seit der Unterschrift" hing am Aenderungsprotokoll
|
||||||
|
# -- und das wird ab 20.000 Zeilen tageweise in Dateien ausgelagert. Mit dem
|
||||||
|
# ausgelagerten Tag waere die Pruefsumme aus der Datenbank verschwunden und
|
||||||
|
# jede aeltere unterschriebene Fassung waere still zu "unbekannt" geworden.
|
||||||
|
# Schlimmer noch: auch eine **veraenderte** Fassung, denn ohne Vergleichswert
|
||||||
|
# gibt es keinen Widerspruch.
|
||||||
|
#
|
||||||
|
# Deshalb eine eigene, winzige Tabelle: eine Zeile je Unterschrift, nie
|
||||||
|
# archiviert. Sie ist ein Verzeichnis, keine Wahrheit -- die Wahrheit bleibt
|
||||||
|
# das Aenderungsprotokoll mit seiner Hash-Kette, auch im Archiv.
|
||||||
|
V7 = """
|
||||||
|
CREATE TABLE IF NOT EXISTS document_signatures (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
sha256 TEXT NOT NULL,
|
||||||
|
share_label TEXT NOT NULL DEFAULT '',
|
||||||
|
path TEXT NOT NULL DEFAULT '',
|
||||||
|
signed_at TEXT NOT NULL,
|
||||||
|
actor TEXT NOT NULL DEFAULT '',
|
||||||
|
names TEXT NOT NULL DEFAULT '',
|
||||||
|
audit_id INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_document_signatures_sha
|
||||||
|
ON document_signatures(sha256);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_document_signatures_ort
|
||||||
|
ON document_signatures(share_label, path);
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def uebernehme_signaturen(conn) -> None: # type: ignore[no-untyped-def]
|
||||||
|
"""Traegt die bereits protokollierten Unterschriften ins Verzeichnis ein.
|
||||||
|
|
||||||
|
Ohne diesen Schritt waeren alle vor dem Update unterschriebenen Fassungen
|
||||||
|
ab sofort "unbekannt" -- richtig waere das nicht, die Eintraege stehen ja
|
||||||
|
noch da.
|
||||||
|
"""
|
||||||
|
from .services import fileshare as service
|
||||||
|
|
||||||
|
zeilen = conn.execute(
|
||||||
|
"SELECT id, ts, actor, detail FROM audit_log WHERE action='fileshare.form_signed' "
|
||||||
|
"ORDER BY id"
|
||||||
|
).fetchall()
|
||||||
|
for zeile in zeilen:
|
||||||
|
detail = str(zeile["detail"] or "")
|
||||||
|
gelesen = service.signaturdetail_lesen(detail)
|
||||||
|
if not gelesen["sha256"]:
|
||||||
|
continue
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO document_signatures (sha256, share_label, path, signed_at, actor, "
|
||||||
|
"names, audit_id) VALUES (?,?,?,?,?,?,?)",
|
||||||
|
(
|
||||||
|
gelesen["sha256"],
|
||||||
|
gelesen["label"],
|
||||||
|
gelesen["pfad"],
|
||||||
|
str(zeile["ts"]),
|
||||||
|
str(zeile["actor"] or ""),
|
||||||
|
", ".join(gelesen["namen"]),
|
||||||
|
int(zeile["id"]),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def uebergang_funktionsgruppe(conn) -> None: # type: ignore[no-untyped-def]
|
||||||
|
"""Bestehende Administratoren behalten ihre Funktionen -- ueber eine Gruppe.
|
||||||
|
|
||||||
|
Steht hier und nicht im Kern, weil erst die Anwendung weiss, welche Zeilen
|
||||||
|
ausserhalb der Verwaltung es gibt. Laeuft nach den Migrationen des Kerns
|
||||||
|
(die Rechte heissen dann schon read/write und sind aufgeteilt).
|
||||||
|
"""
|
||||||
|
from tesm_core.rbac import service as rbac
|
||||||
|
|
||||||
|
from .permissions import FUNKTIONEN_VOLLZUGRIFF, PERMISSIONS
|
||||||
|
|
||||||
|
rbac.funktionsgruppe_fuer_administratoren(conn, PERMISSIONS, FUNKTIONEN_VOLLZUGRIFF)
|
||||||
|
|
||||||
|
|
||||||
|
#: Die vier Vorlagen, die es bis 2.8.6 gab und ab 2.8.7 nicht mehr.
|
||||||
|
#:
|
||||||
|
#: Stehen hier als eingefrorene Kopie, weil eine Migration ihre eigenen Daten
|
||||||
|
#: mitbringen muss: in ``permissions.py`` sind sie weg, und trotzdem muss
|
||||||
|
#: dieser Schritt noch erkennen koennen, ob eine vorgefundene Gruppe wirklich
|
||||||
|
#: unveraendert die ausgelieferte ist.
|
||||||
|
ABGELEGTE_VORLAGEN: tuple[tuple[str, str, tuple[tuple[str, Any], ...]], ...] = (
|
||||||
|
(
|
||||||
|
"operations",
|
||||||
|
"Betrieb",
|
||||||
|
(
|
||||||
|
("devices", "*"),
|
||||||
|
("device_restart_poe", "*"),
|
||||||
|
("device_restart_ssh", "*"),
|
||||||
|
("device_restart_rpc", "*"),
|
||||||
|
("device_console", "*"),
|
||||||
|
("switches", ("read", "create", "write")),
|
||||||
|
("switch_console", "*"),
|
||||||
|
("hostkeys", "*"),
|
||||||
|
("credentials", ("read",)),
|
||||||
|
("servicing_update", "*"),
|
||||||
|
("servicing_reboot", "*"),
|
||||||
|
("servicing_tool", "*"),
|
||||||
|
("logs_live", ("read",)),
|
||||||
|
("logs_history", ("read",)),
|
||||||
|
("trash", ("read", "write")),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"support",
|
||||||
|
"Support",
|
||||||
|
(
|
||||||
|
("devices", ("read",)),
|
||||||
|
("device_restart_poe", "*"),
|
||||||
|
("switches", ("read",)),
|
||||||
|
("logs_live", ("read",)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"network",
|
||||||
|
"Netzwerk",
|
||||||
|
(
|
||||||
|
("dhcp_subnets", "*"),
|
||||||
|
("dhcp_options", "*"),
|
||||||
|
("dhcp_reservations", "*"),
|
||||||
|
("dhcp_config", "*"),
|
||||||
|
("dhcp_service", "*"),
|
||||||
|
("settings_network", "*"),
|
||||||
|
("logs_dhcp", ("read",)),
|
||||||
|
("devices", ("read",)),
|
||||||
|
("switches", ("read",)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"auditor",
|
||||||
|
"Revision",
|
||||||
|
(
|
||||||
|
("devices", ("read",)),
|
||||||
|
("switches", ("read",)),
|
||||||
|
("credentials", ("read",)),
|
||||||
|
("dhcp_subnets", ("read",)),
|
||||||
|
("dhcp_options", ("read",)),
|
||||||
|
("dhcp_reservations", ("read",)),
|
||||||
|
("users", ("read",)),
|
||||||
|
("groups", ("read",)),
|
||||||
|
("logs_live", ("read",)),
|
||||||
|
("logs_history", ("read", "export")),
|
||||||
|
("logs_audit", ("read", "export")),
|
||||||
|
("diagnostics", ("read",)),
|
||||||
|
("trash", ("read",)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def eingefrorene_vorlagen(
|
||||||
|
beschreibung: tuple[tuple[str, str, tuple[tuple[str, Any], ...]], ...],
|
||||||
|
) -> list[Any]:
|
||||||
|
"""Baut aus der Kopie oben wieder ``RolePreset``-Objekte.
|
||||||
|
|
||||||
|
Die Aktionen stehen dort als Text und nicht als ``Action``, damit diese
|
||||||
|
Migration auch dann noch lesbar bleibt, wenn das Enum sich weiterentwickelt.
|
||||||
|
"""
|
||||||
|
from tesm_core.rbac.model import Action, RolePreset
|
||||||
|
|
||||||
|
vorlagen = []
|
||||||
|
for key, name, grants in beschreibung:
|
||||||
|
gewandelt = tuple(
|
||||||
|
(ressource, aktionen if aktionen == "*" else tuple(Action(a) for a in aktionen))
|
||||||
|
for ressource, aktionen in grants
|
||||||
|
)
|
||||||
|
vorlagen.append(RolePreset(key=key, name=name, description="", grants=gewandelt))
|
||||||
|
return vorlagen
|
||||||
|
|
||||||
|
|
||||||
|
# Version 10 ----------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# Wo antwortet ein VNC-Server? Die Frage beantwortet der Ueberwachungs-
|
||||||
|
# durchlauf mit, der ohnehin jedes Geraet anfasst -- einmal je Intervall statt
|
||||||
|
# einmal je Seitenaufruf. Die Kacheln der Statusuebersicht koennen den Knopf
|
||||||
|
# damit zeigen, ohne dass das Rendern der Seite ein Dutzend Verbindungen
|
||||||
|
# aufbaut.
|
||||||
|
V10 = """
|
||||||
|
ALTER TABLE device_status ADD COLUMN vnc_open INTEGER NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE device_status ADD COLUMN vnc_checked_at TEXT;
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# Version 11 ----------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# Nicht nur *ob* dort ein VNC-Server antwortet, sondern *wohin* gefragt wurde.
|
||||||
|
# Die Kacheln der Statusuebersicht verlinken die Verbindung unmittelbar
|
||||||
|
# (vnc://hap02); das Ziel dafuer aus dem Namen neu herzuleiten hiesse, bei
|
||||||
|
# jedem Seitenaufbau je Kachel eine Namensaufloesung zu versuchen. Der
|
||||||
|
# Durchlauf hat sie ohnehin schon gemacht -- also schreibt er sie hin.
|
||||||
|
V11 = """
|
||||||
|
ALTER TABLE device_status ADD COLUMN vnc_target TEXT NOT NULL DEFAULT '';
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# Version 12 ----------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# Welche Sicherheitsverfahren der VNC-Server angeboten hat. Daran entscheidet
|
||||||
|
# sich, ob eine Sitzung im Browser ueberhaupt zustande kommen kann -- und wer
|
||||||
|
# das erst erfaehrt, wenn er vor einer schwarzen Flaeche sitzt, sucht den
|
||||||
|
# Fehler bei uns.
|
||||||
|
V12 = """
|
||||||
|
ALTER TABLE device_status ADD COLUMN vnc_types TEXT NOT NULL DEFAULT '';
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# Version 13 ----------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# Die Absage eines VNC-Servers und der Zeitpunkt der Verfahrensmessung. Beide
|
||||||
|
# gehoeren getrennt vom Ueberwachungsdurchlauf gefuehrt: der fragt nur noch,
|
||||||
|
# ob der Port annimmt (eine abgebrochene Protokollverhandlung sperrt bei
|
||||||
|
# RealVNC die eigene Adresse aus), die Verfahren misst die Seite -- und auch
|
||||||
|
# die nur, wenn die letzte Messung alt ist.
|
||||||
|
V13 = """
|
||||||
|
ALTER TABLE device_status ADD COLUMN vnc_reason TEXT NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE device_status ADD COLUMN vnc_types_at TEXT;
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def uebergang_standardgruppen(conn) -> None: # type: ignore[no-untyped-def]
|
||||||
|
"""Aus sieben Standardgruppen werden drei -- ohne jemandem etwas wegzunehmen.
|
||||||
|
|
||||||
|
Was eine Anwendung an Rollen mitbringt, ist eine Vermutung darueber, wie
|
||||||
|
ein Betrieb organisiert ist. Sieben davon sind sechs zu viele: wer sie
|
||||||
|
nicht braucht, muss sie trotzdem jedes Mal ueberlesen, und in der
|
||||||
|
Rechtematrix stand fuer jede eine Spalte. Es bleiben "Benutzer",
|
||||||
|
"Administration" und "Funktionen (Vollzugriff)".
|
||||||
|
|
||||||
|
Weggeraeumt wird nur, was unberuehrt ist -- keine Mitglieder, Rechte
|
||||||
|
unveraendert. Alles andere bleibt stehen, wo es steht. Und auch das
|
||||||
|
Weggeraeumte liegt nur im Papierkorb.
|
||||||
|
"""
|
||||||
|
from tesm_core.rbac import service as rbac
|
||||||
|
|
||||||
|
from .permissions import PERMISSIONS
|
||||||
|
|
||||||
|
rbac.unberuehrte_vorlagen_entfernen(
|
||||||
|
conn, PERMISSIONS, eingefrorene_vorlagen(ABGELEGTE_VORLAGEN)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
TESM_MIGRATIONS: tuple[Migration, ...] = (
|
TESM_MIGRATIONS: tuple[Migration, ...] = (
|
||||||
Migration(version=1, name="baseline", sql=V1),
|
Migration(version=1, name="baseline", sql=V1),
|
||||||
Migration(version=2, name="seed-dhcp-options", fn=seed_dhcp_options),
|
Migration(version=2, name="seed-dhcp-options", fn=seed_dhcp_options),
|
||||||
Migration(version=3, name="restart-events", sql=V3),
|
Migration(version=3, name="restart-events", sql=V3),
|
||||||
|
Migration(
|
||||||
|
version=4,
|
||||||
|
name="fileshare-pdf-rechte",
|
||||||
|
sql=V4,
|
||||||
|
geaendert_weil="Die Rechteschluessel heissen seit 2.8.0 read/write. Auf einer frischen Installation laufen die Migrationen des Kerns zuerst, also muss dieser Schritt die neuen Namen setzen -- mit den alten blieben sie ungueltig.",
|
||||||
|
),
|
||||||
|
Migration(version=5, name="terminal-getrennt", sql=V5),
|
||||||
|
Migration(
|
||||||
|
version=6,
|
||||||
|
name="wartung-eigener-bereich",
|
||||||
|
sql=V6,
|
||||||
|
geaendert_weil="Die Rechteschluessel heissen seit 2.8.0 read/write. Auf einer frischen Installation laufen die Migrationen des Kerns zuerst, also muss dieser Schritt die neuen Namen setzen -- mit den alten blieben sie ungueltig.",
|
||||||
|
),
|
||||||
|
Migration(version=7, name="signaturverzeichnis", sql=V7, fn=uebernehme_signaturen),
|
||||||
|
Migration(version=8, name="funktionsgruppe-fuer-admins", fn=uebergang_funktionsgruppe),
|
||||||
|
Migration(version=9, name="standardgruppen-aufraeumen", fn=uebergang_standardgruppen),
|
||||||
|
Migration(version=10, name="vnc-erreichbarkeit", sql=V10),
|
||||||
|
Migration(version=11, name="vnc-ziel", sql=V11),
|
||||||
|
Migration(version=12, name="vnc-verfahren", sql=V12),
|
||||||
|
Migration(version=13, name="vnc-absage", sql=V13),
|
||||||
)
|
)
|
||||||
@@ -21,6 +21,9 @@ Sicherheitsrelevante Aenderungen gegenueber dem Vorgaenger:
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import zlib
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import os
|
import os
|
||||||
@@ -32,7 +35,7 @@ import sqlite3
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from pathlib import Path, PurePosixPath
|
from pathlib import Path, PurePosixPath
|
||||||
from typing import Any, Iterable
|
from typing import Any, Iterable, Mapping
|
||||||
|
|
||||||
from tesm_core.db import Database
|
from tesm_core.db import Database
|
||||||
from tesm_core.extension import core
|
from tesm_core.extension import core
|
||||||
@@ -40,7 +43,7 @@ from tesm_core.extension import core
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_UNC_HINT = (
|
_UNC_HINT = (
|
||||||
"Der Freigabepfad muss die Form \\\\server\\freigabe haben. Schraegstriche "
|
"Der Freigabepfad muss die Form \\\\server\\freigabe haben. Schrägstriche "
|
||||||
"gehen auch: //server/freigabe."
|
"gehen auch: //server/freigabe."
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -57,6 +60,10 @@ PREVIEW_MIME: dict[str, str] = {
|
|||||||
".gif": "image/gif",
|
".gif": "image/gif",
|
||||||
".webp": "image/webp",
|
".webp": "image/webp",
|
||||||
".bmp": "image/bmp",
|
".bmp": "image/bmp",
|
||||||
|
# Das Format, in dem ein iPad fotografiert. Safari zeigt es an, andere
|
||||||
|
# Browser nicht -- die Vorschau sagt es dann und bietet den Download an.
|
||||||
|
".heic": "image/heic",
|
||||||
|
".heif": "image/heif",
|
||||||
".txt": "text/plain; charset=utf-8",
|
".txt": "text/plain; charset=utf-8",
|
||||||
".csv": "text/plain; charset=utf-8",
|
".csv": "text/plain; charset=utf-8",
|
||||||
".log": "text/plain; charset=utf-8",
|
".log": "text/plain; charset=utf-8",
|
||||||
@@ -68,6 +75,21 @@ PREVIEW_MIME: dict[str, str] = {
|
|||||||
#: im Ursprung der Anwendung ausgefuehrt. Download bleibt moeglich.
|
#: im Ursprung der Anwendung ausgefuehrt. Download bleibt moeglich.
|
||||||
|
|
||||||
|
|
||||||
|
def preview_kind(name: str) -> str:
|
||||||
|
"""Welche Anzeige passt: ``bild``, ``pdf`` oder ``text``?
|
||||||
|
|
||||||
|
Die Unterscheidung gehoert hierher und nicht in die Vorlage: die
|
||||||
|
Zuordnung Endung -> MIME-Typ steht schon hier, und eine zweite Liste in
|
||||||
|
Jinja liefe frueher oder spaeter auseinander.
|
||||||
|
"""
|
||||||
|
mime = PREVIEW_MIME.get(Path(name).suffix.lower(), "")
|
||||||
|
if mime.startswith("image/"):
|
||||||
|
return "bild"
|
||||||
|
if mime == "application/pdf":
|
||||||
|
return "pdf"
|
||||||
|
return "text"
|
||||||
|
|
||||||
|
|
||||||
class FileshareError(Exception):
|
class FileshareError(Exception):
|
||||||
"""Fehler mit Klartextmeldung fuer die Oberflaeche."""
|
"""Fehler mit Klartextmeldung fuer die Oberflaeche."""
|
||||||
|
|
||||||
@@ -174,11 +196,11 @@ def resolve_unc_host(unc: str, suffixes: Iterable[str]) -> tuple[str, str]:
|
|||||||
kandidat = f"{host}.{suffix.strip('.')}"
|
kandidat = f"{host}.{suffix.strip('.')}"
|
||||||
if _resolvable(kandidat):
|
if _resolvable(kandidat):
|
||||||
neu = chr(92) * 2 + kandidat + (chr(92) + rest if rest else "")
|
neu = chr(92) * 2 + kandidat + (chr(92) + rest if rest else "")
|
||||||
return neu, f"Name {host} ueber {kandidat} aufgeloest"
|
return neu, f"Name {host} über {kandidat} aufgelöst"
|
||||||
return unc, (
|
return unc, (
|
||||||
f"Der Name {host} ist auf diesem Server nicht aufloesbar. Bitte den "
|
f"Der Name {host} ist auf diesem Server nicht auflösbar. Bitte den "
|
||||||
"vollqualifizierten Namen oder die IP-Adresse eintragen -- oder unter "
|
"vollqualifizierten Namen oder die IP-Adresse eintragen – oder unter "
|
||||||
"Netzwerk eine Suchdomaene setzen."
|
"Netzwerk eine Suchdomäne setzen."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -188,6 +210,50 @@ def list_mappings(conn: sqlite3.Connection) -> list[dict[str, Any]]:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
#: Felder einer Zuordnung mit der Beschriftung, unter der sie in der
|
||||||
|
#: Oberflaeche stehen. Das Protokoll soll dieselben Woerter benutzen wie das
|
||||||
|
#: Formular -- sonst muss man beim Lesen uebersetzen.
|
||||||
|
MAPPING_FELDER: tuple[tuple[str, str], ...] = (
|
||||||
|
("label", "Anzeigename"),
|
||||||
|
("unc", "Pfad"),
|
||||||
|
("ad_group_name", "Gruppe"),
|
||||||
|
("ad_group_dn", "Gruppen-DN"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_mapping(conn: sqlite3.Connection, mapping_id: int) -> dict[str, Any] | None:
|
||||||
|
return Database.one(conn, "SELECT * FROM fileshare_mappings WHERE id=?", (mapping_id,))
|
||||||
|
|
||||||
|
|
||||||
|
def describe_mapping(row: Mapping[str, Any] | None) -> str:
|
||||||
|
"""Eine Zuordnung in einer Zeile -- fuer das Protokoll.
|
||||||
|
|
||||||
|
Ohne sie stand beim Loeschen nur die Nummer der Zuordnung im Protokoll.
|
||||||
|
Die ist nach dem Loeschen nirgends mehr nachschlagbar; damit war der
|
||||||
|
Eintrag da, aber wertlos.
|
||||||
|
"""
|
||||||
|
if row is None:
|
||||||
|
return ""
|
||||||
|
teile = [str(row["unc"] or "")]
|
||||||
|
gruppe = str(row["ad_group_name"] or row["ad_group_dn"] or "")
|
||||||
|
if gruppe:
|
||||||
|
teile.append(f"Gruppe: {gruppe}")
|
||||||
|
return " – ".join(teil for teil in teile if teil)
|
||||||
|
|
||||||
|
|
||||||
|
def mapping_changes(vorher: Mapping[str, Any] | None, nachher: Mapping[str, Any] | None) -> str:
|
||||||
|
"""Was sich geaendert hat, Feld fuer Feld: ``Anzeigename: alt -> neu``."""
|
||||||
|
if vorher is None or nachher is None:
|
||||||
|
return ""
|
||||||
|
geaendert = []
|
||||||
|
for feld, beschriftung in MAPPING_FELDER:
|
||||||
|
alt = str(vorher[feld] or "")
|
||||||
|
neu = str(nachher[feld] or "")
|
||||||
|
if alt != neu:
|
||||||
|
geaendert.append(f"{beschriftung}: {alt or '(leer)'} -> {neu or '(leer)'}")
|
||||||
|
return "; ".join(geaendert)
|
||||||
|
|
||||||
|
|
||||||
def add_mapping(
|
def add_mapping(
|
||||||
conn: sqlite3.Connection, *, ad_group_dn: str, ad_group_name: str, label: str, unc: str
|
conn: sqlite3.Connection, *, ad_group_dn: str, ad_group_name: str, label: str, unc: str
|
||||||
) -> int:
|
) -> int:
|
||||||
@@ -196,7 +262,7 @@ def add_mapping(
|
|||||||
if not clean_label:
|
if not clean_label:
|
||||||
raise FileshareError("Der Anzeigename darf nicht leer sein.")
|
raise FileshareError("Der Anzeigename darf nicht leer sein.")
|
||||||
if not (ad_group_dn or "").strip():
|
if not (ad_group_dn or "").strip():
|
||||||
raise FileshareError("Es ist keine Verzeichnisgruppe ausgewaehlt.")
|
raise FileshareError("Es ist keine Verzeichnisgruppe ausgewählt.")
|
||||||
cursor = conn.execute(
|
cursor = conn.execute(
|
||||||
"INSERT INTO fileshare_mappings (ad_group_dn, ad_group_name, label, unc, created_at) "
|
"INSERT INTO fileshare_mappings (ad_group_dn, ad_group_name, label, unc, created_at) "
|
||||||
"VALUES (?,?,?,?,?) ON CONFLICT(ad_group_dn, unc) DO NOTHING",
|
"VALUES (?,?,?,?,?) ON CONFLICT(ad_group_dn, unc) DO NOTHING",
|
||||||
@@ -228,7 +294,7 @@ def update_mapping(
|
|||||||
if not clean_label:
|
if not clean_label:
|
||||||
raise FileshareError("Der Anzeigename darf nicht leer sein.")
|
raise FileshareError("Der Anzeigename darf nicht leer sein.")
|
||||||
if not clean_dn:
|
if not clean_dn:
|
||||||
raise FileshareError("Es ist keine Verzeichnisgruppe ausgewaehlt.")
|
raise FileshareError("Es ist keine Verzeichnisgruppe ausgewählt.")
|
||||||
|
|
||||||
vorhanden = Database.one(
|
vorhanden = Database.one(
|
||||||
conn, "SELECT id FROM fileshare_mappings WHERE id=?", (mapping_id,)
|
conn, "SELECT id FROM fileshare_mappings WHERE id=?", (mapping_id,)
|
||||||
@@ -299,12 +365,32 @@ def mount_root() -> Path:
|
|||||||
return Path(str(core().settings.get(db(), "fileshare_mount_root") or "/mnt/tesm-shares"))
|
return Path(str(core().settings.get(db(), "fileshare_mount_root") or "/mnt/tesm-shares"))
|
||||||
|
|
||||||
|
|
||||||
def active_mounts(conn: sqlite3.Connection, user_id: int) -> list[Mount]:
|
def aktuelle_sitzung() -> str:
|
||||||
|
"""Die ID der laufenden Sitzung -- oder leer ausserhalb einer Anfrage."""
|
||||||
|
from flask import session
|
||||||
|
|
||||||
|
return str(getattr(session, "sid", "") or "")
|
||||||
|
|
||||||
|
|
||||||
|
def active_mounts(
|
||||||
|
conn: sqlite3.Connection, user_id: int, session_id: str | None = None
|
||||||
|
) -> list[Mount]:
|
||||||
|
"""Die offenen Einbindungen dieses Kontos.
|
||||||
|
|
||||||
|
Mit ``session_id`` nur die dieser Sitzung. Das braucht das Abmelden: wer
|
||||||
|
an zwei Geraeten angemeldet ist, hat je Geraet eine eigene Einbindung mit
|
||||||
|
eigenem Einhaengepunkt, und das Abmelden am einen darf das andere nicht
|
||||||
|
aushaengen.
|
||||||
|
"""
|
||||||
|
bedingung = "AND session_id = ? " if session_id else ""
|
||||||
|
werte: list[Any] = [user_id, _iso(_now())]
|
||||||
|
if session_id:
|
||||||
|
werte.insert(1, session_id)
|
||||||
rows = Database.all(
|
rows = Database.all(
|
||||||
conn,
|
conn,
|
||||||
"SELECT * FROM fileshare_mounts WHERE user_id=? AND released_at IS NULL "
|
"SELECT * FROM fileshare_mounts WHERE user_id=? " + bedingung
|
||||||
"AND expires_at > ? ORDER BY label COLLATE NOCASE",
|
+ "AND released_at IS NULL AND expires_at > ? ORDER BY label COLLATE NOCASE, id DESC",
|
||||||
(user_id, _iso(_now())),
|
tuple(werte),
|
||||||
)
|
)
|
||||||
return [
|
return [
|
||||||
Mount(
|
Mount(
|
||||||
@@ -325,6 +411,38 @@ def has_mounts(user_id: int) -> bool:
|
|||||||
return bool(active_mounts(db(), user_id))
|
return bool(active_mounts(db(), user_id))
|
||||||
|
|
||||||
|
|
||||||
|
def doppelte_zusammenfassen(
|
||||||
|
conn: sqlite3.Connection, mounts: list[Mount], session_id: str = ""
|
||||||
|
) -> list[Mount]:
|
||||||
|
"""Je Freigabe eine Karteikarte -- vorzugsweise die dieser Sitzung.
|
||||||
|
|
||||||
|
Wer an zwei Geraeten angemeldet ist, hat zwei Einbindungen derselben
|
||||||
|
Freigabe: eine je Sitzung, jede mit eigenem Einhaengepunkt. In der Leiste
|
||||||
|
stand sie deshalb doppelt, einmal "Share" und einmal wieder "Share" -- ohne
|
||||||
|
jeden Unterschied fuer den Betrachter.
|
||||||
|
|
||||||
|
Genommen wird die Einbindung der eigenen Sitzung. Nur wenn es die nicht
|
||||||
|
gibt (alte Zeile, Hintergrundprozess), bleibt eine fremde stehen.
|
||||||
|
"""
|
||||||
|
eigene: set[int] = set()
|
||||||
|
if session_id:
|
||||||
|
eigene = {
|
||||||
|
int(zeile["id"])
|
||||||
|
for zeile in Database.all(
|
||||||
|
conn,
|
||||||
|
"SELECT id FROM fileshare_mounts WHERE session_id=? AND released_at IS NULL",
|
||||||
|
(session_id,),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
gewaehlt: dict[str, Mount] = {}
|
||||||
|
for mount in mounts:
|
||||||
|
schluessel = mount.unc.lower()
|
||||||
|
vorhanden = gewaehlt.get(schluessel)
|
||||||
|
if vorhanden is None or (mount.id in eigene and vorhanden.id not in eigene):
|
||||||
|
gewaehlt[schluessel] = mount
|
||||||
|
return sorted(gewaehlt.values(), key=lambda m: m.label.lower())
|
||||||
|
|
||||||
|
|
||||||
def mount_for_login(
|
def mount_for_login(
|
||||||
conn: sqlite3.Connection,
|
conn: sqlite3.Connection,
|
||||||
*,
|
*,
|
||||||
@@ -344,7 +462,7 @@ def mount_for_login(
|
|||||||
extension = core()
|
extension = core()
|
||||||
sysops = extension.sysops
|
sysops = extension.sysops
|
||||||
if sysops is None or not sysops.available:
|
if sysops is None or not sysops.available:
|
||||||
return ["Systemhelfer nicht verfuegbar -- keine Einbindungen."]
|
return ["Systemhelfer nicht verfügbar – keine Einbindungen."]
|
||||||
|
|
||||||
dns = [dn for dn in group_dns if dn and dn.strip()]
|
dns = [dn for dn in group_dns if dn and dn.strip()]
|
||||||
shares = shares_for_groups(conn, dns)
|
shares = shares_for_groups(conn, dns)
|
||||||
@@ -367,9 +485,41 @@ def mount_for_login(
|
|||||||
# eingebunden, aber leer. Das betrifft die Anmeldung mit zweitem Faktor, bei
|
# eingebunden, aber leer. Das betrifft die Anmeldung mit zweitem Faktor, bei
|
||||||
# der das Passwort im zweiten Schritt nicht mehr vorliegt.
|
# der das Passwort im zweiten Schritt nicht mehr vorliegt.
|
||||||
if not password:
|
if not password:
|
||||||
return ["Kein Passwort fuer den Fileserver verfuegbar -- keine Einbindungen."]
|
return ["Kein Passwort für den Fileserver verfügbar – keine Einbindungen."]
|
||||||
|
|
||||||
|
# Was zuerst kommt: das Ende der Sitzung oder die eigene Obergrenze. Die
|
||||||
|
# Sitzung ist die eigentliche Klammer -- mit ihr endet die Einbindung; die
|
||||||
|
# Obergrenze sorgt dafuer, dass eine sehr lange Sitzung nicht beliebig
|
||||||
|
# lange auf fremden Zugangsdaten sitzt.
|
||||||
hours = int(extension.settings.get(conn, "fileshare_max_age_hours") or 12)
|
hours = int(extension.settings.get(conn, "fileshare_max_age_hours") or 12)
|
||||||
|
frist = _now() + timedelta(hours=hours)
|
||||||
|
# Die Sitzungszeile gibt es hier noch nicht: eingebunden wird waehrend der
|
||||||
|
# Anmeldung, geschrieben wird sie erst beim Speichern der Antwort. Die
|
||||||
|
# Dauer steht aber in derselben Einstellung, aus der die Sitzung sie
|
||||||
|
# gleich bekommt -- und falls die Zeile doch schon steht (erneutes
|
||||||
|
# Einbinden), gilt deren Ende.
|
||||||
|
zeile = (
|
||||||
|
Database.one(
|
||||||
|
conn, "SELECT absolute_expires_at FROM user_sessions WHERE id=?", (session_id,)
|
||||||
|
)
|
||||||
|
if session_id
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
if zeile is not None and zeile["absolute_expires_at"]:
|
||||||
|
try:
|
||||||
|
sitzungsende = datetime.fromisoformat(str(zeile["absolute_expires_at"]))
|
||||||
|
if sitzungsende.tzinfo is None:
|
||||||
|
sitzungsende = sitzungsende.replace(tzinfo=timezone.utc)
|
||||||
|
frist = min(frist, sitzungsende)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
dauer = int(extension.settings.get(conn, "session_absolute_hours") or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
dauer = 0
|
||||||
|
if dauer > 0:
|
||||||
|
frist = min(frist, _now() + timedelta(hours=dauer))
|
||||||
root = Path(str(extension.settings.get(conn, "fileshare_mount_root") or "/mnt/tesm-shares"))
|
root = Path(str(extension.settings.get(conn, "fileshare_mount_root") or "/mnt/tesm-shares"))
|
||||||
messages: list[str] = []
|
messages: list[str] = []
|
||||||
|
|
||||||
@@ -399,7 +549,7 @@ def mount_for_login(
|
|||||||
str(share["unc"]),
|
str(share["unc"]),
|
||||||
str(target),
|
str(target),
|
||||||
_iso(_now()),
|
_iso(_now()),
|
||||||
_iso(_now() + timedelta(hours=hours)),
|
_iso(frist),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
messages.append(f"{share['label']}: eingebunden")
|
messages.append(f"{share['label']}: eingebunden")
|
||||||
@@ -436,14 +586,30 @@ def forget_all_mounts(conn: sqlite3.Connection) -> int:
|
|||||||
return int(cursor.rowcount or 0)
|
return int(cursor.rowcount or 0)
|
||||||
|
|
||||||
|
|
||||||
def release_all_for_user(conn: sqlite3.Connection, user_id: int) -> int:
|
def release_all_for_user(conn: sqlite3.Connection, user_id: int, session_id: str = "") -> int:
|
||||||
|
"""Loest die Einbindungen -- ohne Sitzung die des ganzen Kontos.
|
||||||
|
|
||||||
|
Beim Abmelden wird die Sitzung uebergeben: das andere Geraet desselben
|
||||||
|
Benutzers arbeitet weiter, seine Einbindung bleibt. Ohne Sitzung (etwa
|
||||||
|
beim Loeschen eines Kontos) faellt alles.
|
||||||
|
"""
|
||||||
count = 0
|
count = 0
|
||||||
for mount in active_mounts(conn, user_id):
|
for mount in active_mounts(conn, user_id, session_id):
|
||||||
|
if session_id and not _gehoert_zur_sitzung(conn, mount.id, session_id):
|
||||||
|
continue
|
||||||
release_mount(conn, mount.id)
|
release_mount(conn, mount.id)
|
||||||
count += 1
|
count += 1
|
||||||
return count
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
def _gehoert_zur_sitzung(conn: sqlite3.Connection, mount_id: int, session_id: str) -> bool:
|
||||||
|
"""Zeilen ohne Sitzung werden beim Abmelden nicht mitgerissen."""
|
||||||
|
zeile = Database.one(
|
||||||
|
conn, "SELECT session_id FROM fileshare_mounts WHERE id=?", (mount_id,)
|
||||||
|
)
|
||||||
|
return zeile is not None and str(zeile["session_id"] or "") == session_id
|
||||||
|
|
||||||
|
|
||||||
def sweep_expired(database: Any) -> int:
|
def sweep_expired(database: Any) -> int:
|
||||||
"""Loest abgelaufene Einbindungen -- laeuft im Hintergrundprozess.
|
"""Loest abgelaufene Einbindungen -- laeuft im Hintergrundprozess.
|
||||||
|
|
||||||
@@ -454,7 +620,10 @@ def sweep_expired(database: Any) -> int:
|
|||||||
released = 0
|
released = 0
|
||||||
with database.session() as conn:
|
with database.session() as conn:
|
||||||
# Eine Einbindung lebt genau so lange wie die Sitzung, aus der sie
|
# Eine Einbindung lebt genau so lange wie die Sitzung, aus der sie
|
||||||
# entstanden ist. Vorher hing sie an einer eigenen Frist von zwoelf
|
# entstanden ist. Ohne Sitzungsangabe gilt allein die eigene Frist:
|
||||||
|
# "keine Sitzung eingetragen" ist etwas anderes als "Sitzung
|
||||||
|
# abgelaufen". Diese Verwechslung hat jede frische Einbindung binnen
|
||||||
|
# Minuten wieder geloest, weil die ID beim Anmelden noch nicht stand. Vorher hing sie an einer eigenen Frist von zwoelf
|
||||||
# Stunden: nach dem Abmelden -- oder nach der automatischen Abmeldung
|
# Stunden: nach dem Abmelden -- oder nach der automatischen Abmeldung
|
||||||
# bei Leerlauf -- blieb die Freigabe mit den Zugangsdaten des Benutzers
|
# bei Leerlauf -- blieb die Freigabe mit den Zugangsdaten des Benutzers
|
||||||
# weiter eingebunden. Die eigene Frist bleibt als Obergrenze bestehen;
|
# weiter eingebunden. Die eigene Frist bleibt als Obergrenze bestehen;
|
||||||
@@ -467,10 +636,11 @@ def sweep_expired(database: Any) -> int:
|
|||||||
LEFT JOIN user_sessions s ON s.id = m.session_id
|
LEFT JOIN user_sessions s ON s.id = m.session_id
|
||||||
WHERE m.released_at IS NULL
|
WHERE m.released_at IS NULL
|
||||||
AND (m.expires_at <= ?
|
AND (m.expires_at <= ?
|
||||||
OR s.id IS NULL
|
OR (COALESCE(m.session_id, '') <> ''
|
||||||
|
AND (s.id IS NULL
|
||||||
OR s.revoked_at IS NOT NULL
|
OR s.revoked_at IS NOT NULL
|
||||||
OR s.idle_expires_at <= ?
|
OR s.idle_expires_at <= ?
|
||||||
OR s.absolute_expires_at <= ?)
|
OR s.absolute_expires_at <= ?)))
|
||||||
""",
|
""",
|
||||||
(_iso(_now()), _iso(_now()), _iso(_now())),
|
(_iso(_now()), _iso(_now()), _iso(_now())),
|
||||||
)
|
)
|
||||||
@@ -496,14 +666,260 @@ def resolve_path(mount: Mount, relative: str) -> Path:
|
|||||||
"""
|
"""
|
||||||
candidate = PurePosixPath((relative or "").strip().lstrip("/"))
|
candidate = PurePosixPath((relative or "").strip().lstrip("/"))
|
||||||
if any(part == ".." for part in candidate.parts):
|
if any(part == ".." for part in candidate.parts):
|
||||||
raise FileshareError("Ungueltiger Pfad.")
|
raise FileshareError("Ungültiger Pfad.")
|
||||||
root = mount.path.resolve()
|
root = mount.path.resolve()
|
||||||
target = (root / str(candidate)).resolve()
|
target = (root / str(candidate)).resolve()
|
||||||
if target != root and root not in target.parents:
|
if target != root and root not in target.parents:
|
||||||
raise FileshareError("Der Pfad liegt ausserhalb der Freigabe.")
|
raise FileshareError("Der Pfad liegt außerhalb der Freigabe.")
|
||||||
return target
|
return target
|
||||||
|
|
||||||
|
|
||||||
|
#: Wie eine unterschriebene Fassung im Aenderungsprotokoll steht. Die
|
||||||
|
#: Pruefsumme ist der Anker -- sie steht im selben Eintrag wie der Name, den
|
||||||
|
#: der Unterschreibende angegeben hat.
|
||||||
|
SIGNATUR_AKTION = "fileshare.form_signed"
|
||||||
|
|
||||||
|
|
||||||
|
def pruefsumme(pfad: Path) -> str:
|
||||||
|
"""SHA-256 der Datei, blockweise gelesen."""
|
||||||
|
kessel = hashlib.sha256()
|
||||||
|
with pfad.open("rb") as strom:
|
||||||
|
for block in iter(lambda: strom.read(1024 * 1024), b""):
|
||||||
|
kessel.update(block)
|
||||||
|
return kessel.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def signaturdetail_lesen(detail: str) -> dict[str, Any]:
|
||||||
|
"""Zerlegt die Protokollzeile einer Unterschrift.
|
||||||
|
|
||||||
|
Aufbau: ``<Freigabe>:<Pfad> sha256=<Summe> -- angegeben unterschrieben
|
||||||
|
von: <Namen>``. Gelesen wird sie an zwei Stellen -- beim Uebernehmen
|
||||||
|
alter Eintraege und wenn das Verzeichnis nichts weiss.
|
||||||
|
"""
|
||||||
|
# Der Trenner ist heute ein Zeilenumbruch, frueher ein Leerzeichen -- die
|
||||||
|
# Zerlegung muss beides vertragen, sonst sind alte Eintraege stumm.
|
||||||
|
ort, _, rest = detail.partition("sha256=")
|
||||||
|
ort = ort.strip()
|
||||||
|
label, _, pfad = ort.partition(":")
|
||||||
|
summe = rest.split(" ", 1)[0].strip() if rest else ""
|
||||||
|
return {
|
||||||
|
"sha256": summe,
|
||||||
|
"label": label.strip(),
|
||||||
|
"pfad": pfad.strip(),
|
||||||
|
"namen": _namen_aus_detail(detail),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def signatur_merken(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
*,
|
||||||
|
sha256: str,
|
||||||
|
label: str,
|
||||||
|
pfad: str,
|
||||||
|
actor: str,
|
||||||
|
namen: Iterable[str],
|
||||||
|
audit_id: int | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Haelt eine Unterschrift im Verzeichnis fest.
|
||||||
|
|
||||||
|
Das Aenderungsprotokoll bleibt der Beleg -- mit Hash-Kette und, nach der
|
||||||
|
Archivierung, in einer Datei. Dieses Verzeichnis ist nur der schnelle Weg
|
||||||
|
dorthin und wird nie ausgelagert; eine Zeile misst ein paar hundert Byte.
|
||||||
|
"""
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO document_signatures (sha256, share_label, path, signed_at, actor, "
|
||||||
|
"names, audit_id) VALUES (?,?,?,?,?,?,?)",
|
||||||
|
(
|
||||||
|
sha256,
|
||||||
|
label,
|
||||||
|
pfad,
|
||||||
|
_iso(_now()),
|
||||||
|
actor,
|
||||||
|
", ".join(namen),
|
||||||
|
audit_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def signaturjournal() -> Path:
|
||||||
|
"""Wo das zweite Exemplar liegt: neben dem Archiv des Protokolls."""
|
||||||
|
return core().audit_archive_dir / "signaturen.jsonl"
|
||||||
|
|
||||||
|
|
||||||
|
def signatur_journalisieren(
|
||||||
|
*, sha256: str, label: str, pfad: str, actor: str, namen: Iterable[str]
|
||||||
|
) -> None:
|
||||||
|
"""Schreibt die Unterschrift zusaetzlich in eine Datei -- nur angehaengt.
|
||||||
|
|
||||||
|
Zwei Exemplare an zwei Orten: die Tabelle beantwortet die Frage schnell,
|
||||||
|
diese Datei ueberlebt auch eine zurueckgespielte aeltere Datenbank. Genau
|
||||||
|
das ist der Fall, den eine Sicherung erzeugt: die Datei auf der Freigabe
|
||||||
|
ist von gestern, die Datenbank von vorgestern -- ohne das Journal waere
|
||||||
|
die Unterschrift dazwischen verloren.
|
||||||
|
|
||||||
|
Ein Fehlschlag hier darf das Speichern nicht kippen: die Datei liegt
|
||||||
|
bereits auf der Freigabe, und Tabelle wie Aenderungsprotokoll haben den
|
||||||
|
Eintrag. Er wird protokolliert, nicht geworfen.
|
||||||
|
"""
|
||||||
|
eintrag = {
|
||||||
|
"ts": _iso(_now()),
|
||||||
|
"sha256": sha256,
|
||||||
|
"share": label,
|
||||||
|
"path": pfad,
|
||||||
|
"actor": actor,
|
||||||
|
"names": list(namen),
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
ziel = signaturjournal()
|
||||||
|
ziel.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
neu = not ziel.exists()
|
||||||
|
with ziel.open("a", encoding="utf-8", newline=chr(10)) as strom:
|
||||||
|
strom.write(json.dumps(eintrag, ensure_ascii=False, sort_keys=True) + chr(10))
|
||||||
|
strom.flush()
|
||||||
|
# Ohne fsync steht die Zeile im Puffer des Kernels -- genau der
|
||||||
|
# Zustand, gegen den dieses Journal geschrieben wurde.
|
||||||
|
os.fsync(strom.fileno())
|
||||||
|
if neu:
|
||||||
|
try:
|
||||||
|
os.chmod(ziel, 0o640)
|
||||||
|
except OSError: # pragma: no cover - andere Dateisysteme
|
||||||
|
pass
|
||||||
|
except OSError as fehler: # pragma: no cover - Plattenfehler
|
||||||
|
logger.warning("Signaturjournal nicht geschrieben: %s", fehler)
|
||||||
|
|
||||||
|
|
||||||
|
def signaturen_aus_journal(conn: sqlite3.Connection) -> int:
|
||||||
|
"""Traegt fehlende Unterschriften aus dem Journal in die Tabelle ein.
|
||||||
|
|
||||||
|
Der Weg zurueck nach einer Wiederherstellung. Vorhandene bleiben, wie sie
|
||||||
|
sind -- verglichen wird ueber Pruefsumme und Ablageort.
|
||||||
|
"""
|
||||||
|
quelle = signaturjournal()
|
||||||
|
if not quelle.is_file():
|
||||||
|
return 0
|
||||||
|
uebernommen = 0
|
||||||
|
with quelle.open("r", encoding="utf-8") as strom:
|
||||||
|
for zeile in strom:
|
||||||
|
zeile = zeile.strip()
|
||||||
|
if not zeile:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
eintrag = json.loads(zeile)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
vorhanden = Database.value(
|
||||||
|
conn,
|
||||||
|
"SELECT 1 FROM document_signatures WHERE sha256=? AND path=? LIMIT 1",
|
||||||
|
(str(eintrag.get("sha256", "")), str(eintrag.get("path", ""))),
|
||||||
|
)
|
||||||
|
if vorhanden:
|
||||||
|
continue
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO document_signatures (sha256, share_label, path, signed_at, "
|
||||||
|
"actor, names, audit_id) VALUES (?,?,?,?,?,?,NULL)",
|
||||||
|
(
|
||||||
|
str(eintrag.get("sha256", "")),
|
||||||
|
str(eintrag.get("share", "")),
|
||||||
|
str(eintrag.get("path", "")),
|
||||||
|
str(eintrag.get("ts", "")),
|
||||||
|
str(eintrag.get("actor", "")),
|
||||||
|
", ".join(eintrag.get("names", []) or []),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
uebernommen += 1
|
||||||
|
return uebernommen
|
||||||
|
|
||||||
|
|
||||||
|
def signaturstand(conn: sqlite3.Connection, mount: "Mount", relative: str) -> dict[str, Any]:
|
||||||
|
"""Ist diese Datei die, die unterschrieben wurde?
|
||||||
|
|
||||||
|
Drei moegliche Antworten, und die dritte ist die wichtigste:
|
||||||
|
|
||||||
|
* ``unveraendert`` -- es gibt einen Protokolleintrag mit genau dieser
|
||||||
|
Pruefsumme. Die Datei ist Byte fuer Byte die unterschriebene.
|
||||||
|
* ``veraendert`` -- es gibt einen Eintrag zu genau diesem Ablageort, aber
|
||||||
|
mit anderer Pruefsumme. Jemand hat nach der Unterschrift etwas an der
|
||||||
|
Datei geaendert. Das ist der Fall, wegen dem es diese Pruefung gibt.
|
||||||
|
* ``unbekannt`` -- kein Eintrag. Kein Verdacht: die meisten Dateien in
|
||||||
|
einer Freigabe sind nie durch TESM gegangen.
|
||||||
|
|
||||||
|
Nachgeschlagen wird im Signaturverzeichnis (``document_signatures``) und
|
||||||
|
nicht im Aenderungsprotokoll: das wird ab 20.000 Zeilen tageweise in
|
||||||
|
Dateien ausgelagert, und mit dem ausgelagerten Tag waere die Pruefsumme aus
|
||||||
|
der Datenbank verschwunden. Eine aeltere unterschriebene Fassung waere
|
||||||
|
still zu "unbekannt" geworden -- und eine **veraenderte** ebenso, weil ohne
|
||||||
|
Vergleichswert niemand widerspricht. Das Verzeichnis wird nie archiviert.
|
||||||
|
"""
|
||||||
|
ziel = resolve_path(mount, relative)
|
||||||
|
if not ziel.is_file():
|
||||||
|
return {"status": "unbekannt"}
|
||||||
|
hash_ = pruefsumme(ziel)
|
||||||
|
|
||||||
|
zeile = Database.one(
|
||||||
|
conn,
|
||||||
|
"SELECT signed_at, actor, names FROM document_signatures WHERE sha256=? "
|
||||||
|
"ORDER BY id DESC LIMIT 1",
|
||||||
|
(hash_,),
|
||||||
|
)
|
||||||
|
if zeile is not None:
|
||||||
|
return _stand("unveraendert", hash_, zeile)
|
||||||
|
|
||||||
|
# Nichts mit dieser Pruefsumme -- aber vielleicht etwas zu diesem Ort.
|
||||||
|
zeile = Database.one(
|
||||||
|
conn,
|
||||||
|
"SELECT signed_at, actor, names FROM document_signatures WHERE share_label=? "
|
||||||
|
"AND path=? ORDER BY id DESC LIMIT 1",
|
||||||
|
(mount.label, relative),
|
||||||
|
)
|
||||||
|
if zeile is not None:
|
||||||
|
return _stand("veraendert", hash_, zeile)
|
||||||
|
return {"status": "unbekannt", "sha256": hash_}
|
||||||
|
|
||||||
|
|
||||||
|
def _stand(status: str, hash_: str, zeile: Any) -> dict[str, Any]:
|
||||||
|
namen = [teil.strip() for teil in str(zeile["names"] or "").split(",") if teil.strip()]
|
||||||
|
return {
|
||||||
|
"status": status,
|
||||||
|
"sha256": hash_,
|
||||||
|
"zeit": str(zeile["signed_at"]),
|
||||||
|
"konto": str(zeile["actor"] or ""),
|
||||||
|
"namen": namen,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _namen_aus_detail(detail: str) -> list[str]:
|
||||||
|
"""Die angegebenen Namen aus dem Protokolltext -- ohne sie zu deuten."""
|
||||||
|
marke = "angegeben unterschrieben von: "
|
||||||
|
if marke not in detail:
|
||||||
|
return []
|
||||||
|
return [teil.strip() for teil in detail.split(marke, 1)[1].split(",") if teil.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
#: Mehr als das zaehlt niemand mehr mit dem Auge -- und auf einer
|
||||||
|
#: Netzwerkfreigabe kostet jeder weitere Eintrag Zeit.
|
||||||
|
ZAEHLGRENZE = 500
|
||||||
|
|
||||||
|
|
||||||
|
def _inhalt_zaehlen(verzeichnis: Path) -> int:
|
||||||
|
"""Wie viele Eintraege liegen im Ordner? Begrenzt gezaehlt.
|
||||||
|
|
||||||
|
Gezeigt wird die Zahl an der Stelle, an der bei einer Datei die Groesse
|
||||||
|
steht -- dort stand bisher ein Gedankenstrich. Bei mehr als
|
||||||
|
``ZAEHLGRENZE`` bricht das Zaehlen ab: die Oberflaeche schreibt dann
|
||||||
|
"500+", und niemand wartet auf eine Freigabe mit 40.000 Dateien.
|
||||||
|
"""
|
||||||
|
anzahl = 0
|
||||||
|
try:
|
||||||
|
with os.scandir(verzeichnis) as eintraege:
|
||||||
|
for _ in eintraege:
|
||||||
|
anzahl += 1
|
||||||
|
if anzahl >= ZAEHLGRENZE:
|
||||||
|
break
|
||||||
|
except OSError:
|
||||||
|
return -1
|
||||||
|
return anzahl
|
||||||
|
|
||||||
|
|
||||||
def list_directory(mount: Mount, relative: str = "") -> dict[str, Any]:
|
def list_directory(mount: Mount, relative: str = "") -> dict[str, Any]:
|
||||||
path = resolve_path(mount, relative)
|
path = resolve_path(mount, relative)
|
||||||
if not path.is_dir():
|
if not path.is_dir():
|
||||||
@@ -525,9 +941,15 @@ def list_directory(mount: Mount, relative: str = "") -> dict[str, Any]:
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
if entry.is_dir():
|
if entry.is_dir():
|
||||||
|
record["entries"] = _inhalt_zaehlen(entry)
|
||||||
directories.append(record)
|
directories.append(record)
|
||||||
else:
|
else:
|
||||||
record["previewable"] = entry.suffix.lower() in PREVIEW_MIME
|
record["previewable"] = entry.suffix.lower() in PREVIEW_MIME
|
||||||
|
record["preview_kind"] = preview_kind(entry.name)
|
||||||
|
# Getrennt, damit die Oberflaeche nur den Namen zum Bearbeiten
|
||||||
|
# gibt und die Endung danebenstellt.
|
||||||
|
record["stem"] = entry.stem
|
||||||
|
record["suffix"] = entry.suffix
|
||||||
files.append(record)
|
files.append(record)
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
raise FileshareError(f"Verzeichnis nicht lesbar ({exc}).") from exc
|
raise FileshareError(f"Verzeichnis nicht lesbar ({exc}).") from exc
|
||||||
@@ -550,7 +972,7 @@ def preview_mime(name: str) -> str:
|
|||||||
suffix = Path(name).suffix.lower()
|
suffix = Path(name).suffix.lower()
|
||||||
if suffix in PREVIEW_MIME:
|
if suffix in PREVIEW_MIME:
|
||||||
return PREVIEW_MIME[suffix]
|
return PREVIEW_MIME[suffix]
|
||||||
raise FileshareError("Fuer diesen Dateityp gibt es keine Vorschau -- bitte herunterladen.")
|
raise FileshareError("Für diesen Dateityp gibt es keine Vorschau – bitte herunterladen.")
|
||||||
|
|
||||||
|
|
||||||
def download_mime(name: str) -> str:
|
def download_mime(name: str) -> str:
|
||||||
@@ -562,16 +984,86 @@ def safe_filename(name: str) -> str:
|
|||||||
"""Verhindert Pfadanteile und versteckte Dateien in Uploads/Neuanlagen."""
|
"""Verhindert Pfadanteile und versteckte Dateien in Uploads/Neuanlagen."""
|
||||||
cleaned = os.path.basename((name or "").strip().replace("\\", "/"))
|
cleaned = os.path.basename((name or "").strip().replace("\\", "/"))
|
||||||
if not cleaned or cleaned in {".", ".."} or cleaned.startswith("."):
|
if not cleaned or cleaned in {".", ".."} or cleaned.startswith("."):
|
||||||
raise FileshareError("Ungueltiger Dateiname.")
|
raise FileshareError("Ungültiger Dateiname.")
|
||||||
if len(cleaned) > 200:
|
if len(cleaned) > 200:
|
||||||
raise FileshareError("Der Dateiname ist zu lang.")
|
raise FileshareError("Der Dateiname ist zu lang.")
|
||||||
return cleaned
|
return cleaned
|
||||||
|
|
||||||
|
|
||||||
|
def all_directories(mount: Mount, *, tiefe: int = 4, grenze: int = 400) -> list[str]:
|
||||||
|
"""Die Ordner der Freigabe, relativ und alphabetisch -- fuer eine Auswahlliste.
|
||||||
|
|
||||||
|
Begrenzt in Tiefe und Anzahl: eine Netzwerkfreigabe kann zehntausende
|
||||||
|
Verzeichnisse haben, und eine Liste, die den Browser lahmlegt, hilft
|
||||||
|
niemandem. Wer tiefer speichern will, tippt den Pfad -- das Feld nimmt
|
||||||
|
beides.
|
||||||
|
"""
|
||||||
|
wurzel = mount.path.resolve()
|
||||||
|
gefunden: list[str] = []
|
||||||
|
|
||||||
|
def hinein(verzeichnis: Path, ebene: int) -> None:
|
||||||
|
if ebene > tiefe or len(gefunden) >= grenze:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
eintraege = sorted(verzeichnis.iterdir(), key=lambda e: e.name.lower())
|
||||||
|
except OSError:
|
||||||
|
return
|
||||||
|
for eintrag in eintraege:
|
||||||
|
if len(gefunden) >= grenze:
|
||||||
|
return
|
||||||
|
if not eintrag.is_dir() or eintrag.name.startswith("."):
|
||||||
|
continue
|
||||||
|
gefunden.append(eintrag.relative_to(wurzel).as_posix())
|
||||||
|
hinein(eintrag, ebene + 1)
|
||||||
|
|
||||||
|
hinein(wurzel, 1)
|
||||||
|
return gefunden
|
||||||
|
|
||||||
|
|
||||||
|
def hat_formularfelder(daten: bytes) -> bool:
|
||||||
|
"""Steckt in diesen PDF-Bytes noch ein ausfuellbares Formular?
|
||||||
|
|
||||||
|
Gesucht wird nach Formularfeldern (``/Subtype /Widget``) -- auch in
|
||||||
|
gepackten Objektstroemen, denn moderne Schreiber legen fast alles dort ab.
|
||||||
|
Ein flach gerechnetes Formular hat keine mehr.
|
||||||
|
|
||||||
|
Bewusst ohne Zusatzpaket: die Frage ist einfach genug, und eine
|
||||||
|
PDF-Bibliothek auf jedem Zielsystem waere ein hoher Preis dafuer.
|
||||||
|
"""
|
||||||
|
text = bytearray(daten)
|
||||||
|
for treffer in re.finditer(rb"stream\r?\n", daten):
|
||||||
|
start = treffer.end()
|
||||||
|
ende = daten.find(b"endstream", start)
|
||||||
|
if ende == -1:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
text += b"\n" + zlib.decompress(daten[start:ende])
|
||||||
|
except zlib.error:
|
||||||
|
continue # kein zlib-Strom -- Bilder, Schriften, alles moegliche
|
||||||
|
return re.search(rb"/Subtype\s*/Widget", bytes(text)) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def free_name(parent: Path, stem: str, zusatz: str, suffix: str) -> Path:
|
||||||
|
"""Ein freier Name neben dem Original: ``vorlage-ausgefuellt.pdf``.
|
||||||
|
|
||||||
|
Nichts ueberschreiben: auf einer Freigabe liegt meist die leere Vorlage,
|
||||||
|
und zwei Leute fuellen sie nacheinander aus. Ab dem zweiten Mal wird
|
||||||
|
durchnummeriert.
|
||||||
|
"""
|
||||||
|
kandidat = parent / f"{stem}{zusatz}{suffix}"
|
||||||
|
zaehler = 2
|
||||||
|
while kandidat.exists():
|
||||||
|
kandidat = parent / f"{stem}{zusatz}-{zaehler}{suffix}"
|
||||||
|
zaehler += 1
|
||||||
|
if zaehler > 200:
|
||||||
|
raise FileshareError("Zu viele Dateien dieses Namens – bitte aufräumen.")
|
||||||
|
return kandidat
|
||||||
|
|
||||||
|
|
||||||
def delete_entry(mount: Mount, relative: str) -> str:
|
def delete_entry(mount: Mount, relative: str) -> str:
|
||||||
path = resolve_path(mount, relative)
|
path = resolve_path(mount, relative)
|
||||||
if path == mount.path.resolve():
|
if path == mount.path.resolve():
|
||||||
raise FileshareError("Das Wurzelverzeichnis der Freigabe kann nicht geloescht werden.")
|
raise FileshareError("Das Wurzelverzeichnis der Freigabe kann nicht gelöscht werden.")
|
||||||
if path.is_dir():
|
if path.is_dir():
|
||||||
shutil.rmtree(path)
|
shutil.rmtree(path)
|
||||||
return "Verzeichnis"
|
return "Verzeichnis"
|
||||||
@@ -589,8 +1081,26 @@ def make_directory(mount: Mount, relative: str, name: str) -> Path:
|
|||||||
|
|
||||||
|
|
||||||
def rename_entry(mount: Mount, relative: str, new_name: str) -> Path:
|
def rename_entry(mount: Mount, relative: str, new_name: str) -> Path:
|
||||||
|
"""Benennt um und behaelt die Endung der Datei.
|
||||||
|
|
||||||
|
Eingegeben wird nur der Name ohne Endung. Eine Datei, die danach auf
|
||||||
|
``.pdf.txt`` oder auf gar nichts endet, waere auf einer Windows-Freigabe
|
||||||
|
praktisch kaputt -- sie oeffnet sich nicht mehr mit dem richtigen
|
||||||
|
Programm. Verzeichnisse haben keine Endung; dort bleibt der Name, wie er
|
||||||
|
eingegeben wurde.
|
||||||
|
|
||||||
|
Wer die Endung trotzdem mittippt, bekommt sie nicht doppelt.
|
||||||
|
"""
|
||||||
path = resolve_path(mount, relative)
|
path = resolve_path(mount, relative)
|
||||||
target = path.parent / safe_filename(new_name)
|
endung = path.suffix if path.is_file() else ""
|
||||||
|
gewuenscht = (new_name or "").strip()
|
||||||
|
if endung and gewuenscht.lower().endswith(endung.lower()):
|
||||||
|
gewuenscht = gewuenscht[: -len(endung)]
|
||||||
|
if not gewuenscht:
|
||||||
|
raise FileshareError("Der Name darf nicht leer sein.")
|
||||||
|
target = path.parent / safe_filename(gewuenscht + endung)
|
||||||
|
if target == path:
|
||||||
|
return path
|
||||||
if target.exists():
|
if target.exists():
|
||||||
raise FileshareError("Ein Eintrag mit diesem Namen existiert bereits.")
|
raise FileshareError("Ein Eintrag mit diesem Namen existiert bereits.")
|
||||||
path.rename(target)
|
path.rename(target)
|
||||||
|
|||||||
@@ -54,13 +54,13 @@ def is_ssh_managed(category: str | None) -> bool:
|
|||||||
#: war "Windows-Client" vorher -- waehlbar, aber ohne jede Wirkung.
|
#: war "Windows-Client" vorher -- waehlbar, aber ohne jede Wirkung.
|
||||||
CATEGORY_EFFECTS: dict[str, str] = {
|
CATEGORY_EFFECTS: dict[str, str] = {
|
||||||
"switch": "Anmeldung an Switchen: PoE schalten, Konsole, Statusabfragen.",
|
"switch": "Anmeldung an Switchen: PoE schalten, Konsole, Statusabfragen.",
|
||||||
"linux": "Wartung ueber SSH: Paketaktualisierung (apt) und Neustart. Konsole.",
|
"linux": "Wartung über SSH: Paketaktualisierung (apt) und Neustart. Konsole.",
|
||||||
"windows": (
|
"windows": (
|
||||||
"Neustart ueber RPC (wie 'shutdown /m'). Kein SSH, kein Host-Schluessel. Das "
|
"Neustart über RPC (wie 'shutdown /m'). Kein SSH, kein Host-Schlüssel. Das "
|
||||||
"Geraet braucht offene Datei- und Druckerfreigabe (Port 445); das Konto braucht "
|
"Gerät braucht offene Datei- und Druckerfreigabe (Port 445); das Konto braucht "
|
||||||
"das Recht, aus der Ferne herunterzufahren. Paketaktualisierung gibt es hier nicht."
|
"das Recht, aus der Ferne herunterzufahren. Paketaktualisierung gibt es hier nicht."
|
||||||
),
|
),
|
||||||
"router": "Nur Dokumentation -- diese Kategorie loest keine Aktion aus.",
|
"router": "Nur Dokumentation – diese Kategorie löst keine Aktion aus.",
|
||||||
}
|
}
|
||||||
|
|
||||||
DEVICE_CATEGORIES: tuple[tuple[str, str], ...] = (
|
DEVICE_CATEGORIES: tuple[tuple[str, str], ...] = (
|
||||||
@@ -95,7 +95,7 @@ def normalize_mac(value: str) -> str:
|
|||||||
cleaned = re.sub(r"[^0-9a-fA-F]", "", value or "")
|
cleaned = re.sub(r"[^0-9a-fA-F]", "", value or "")
|
||||||
if len(cleaned) != 12:
|
if len(cleaned) != 12:
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
"Die MAC-Adresse muss zwoelf hexadezimale Zeichen enthalten "
|
"Die MAC-Adresse muss zwölf hexadezimale Zeichen enthalten "
|
||||||
"(z. B. aa:bb:cc:dd:ee:ff)."
|
"(z. B. aa:bb:cc:dd:ee:ff)."
|
||||||
)
|
)
|
||||||
return ":".join(cleaned[index : index + 2] for index in range(0, 12, 2)).lower()
|
return ":".join(cleaned[index : index + 2] for index in range(0, 12, 2)).lower()
|
||||||
@@ -106,7 +106,7 @@ def validate_ip(value: str, *, field: str = "IP-Adresse") -> str:
|
|||||||
try:
|
try:
|
||||||
ipaddress.ip_address(text)
|
ipaddress.ip_address(text)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise ValidationError(f"{field}: {text!r} ist keine gueltige IP-Adresse.") from exc
|
raise ValidationError(f"{field}: {text!r} ist keine gültige IP-Adresse.") from exc
|
||||||
return text
|
return text
|
||||||
|
|
||||||
|
|
||||||
@@ -116,7 +116,7 @@ def validate_port_name(value: str) -> str:
|
|||||||
return ""
|
return ""
|
||||||
if not PORT_RE.match(text):
|
if not PORT_RE.match(text):
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
"Die Portbezeichnung enthaelt unerlaubte Zeichen (erlaubt: Buchstaben, "
|
"Die Portbezeichnung enthält unerlaubte Zeichen (erlaubt: Buchstaben, "
|
||||||
"Ziffern, / . : -)."
|
"Ziffern, / . : -)."
|
||||||
)
|
)
|
||||||
return text
|
return text
|
||||||
@@ -125,7 +125,7 @@ def validate_port_name(value: str) -> str:
|
|||||||
def validate_hostname(value: str) -> str:
|
def validate_hostname(value: str) -> str:
|
||||||
text = (value or "").strip()
|
text = (value or "").strip()
|
||||||
if not HOSTNAME_RE.match(text):
|
if not HOSTNAME_RE.match(text):
|
||||||
raise ValidationError("Der Hostname enthaelt unerlaubte Zeichen oder ist leer.")
|
raise ValidationError("Der Hostname enthält unerlaubte Zeichen oder ist leer.")
|
||||||
return text
|
return text
|
||||||
|
|
||||||
|
|
||||||
@@ -208,7 +208,7 @@ def create_credential(
|
|||||||
if existing["deleted_at"]:
|
if existing["deleted_at"]:
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
f"Zugangsdaten namens {clean_name!r} liegen im Papierkorb. Stellen Sie sie "
|
f"Zugangsdaten namens {clean_name!r} liegen im Papierkorb. Stellen Sie sie "
|
||||||
"wieder her oder loeschen Sie sie endgueltig."
|
"wieder her oder löschen Sie sie endgültig."
|
||||||
)
|
)
|
||||||
raise ValidationError(f"Zugangsdaten namens {clean_name!r} existieren bereits.")
|
raise ValidationError(f"Zugangsdaten namens {clean_name!r} existieren bereits.")
|
||||||
|
|
||||||
@@ -258,11 +258,11 @@ def update_credential(
|
|||||||
if clean_name != str(row["name"]):
|
if clean_name != str(row["name"]):
|
||||||
changes.append(f"Name: {row['name']} -> {clean_name}")
|
changes.append(f"Name: {row['name']} -> {clean_name}")
|
||||||
if (username or "").strip() != str(row["username"] or ""):
|
if (username or "").strip() != str(row["username"] or ""):
|
||||||
changes.append("Benutzer geaendert")
|
changes.append("Benutzer geändert")
|
||||||
if category != str(row["category"]):
|
if category != str(row["category"]):
|
||||||
changes.append(f"Kategorie: {row['category']} -> {category}")
|
changes.append(f"Kategorie: {row['category']} -> {category}")
|
||||||
if (description or "").strip() != str(row["description"] or ""):
|
if (description or "").strip() != str(row["description"] or ""):
|
||||||
changes.append("Beschreibung geaendert")
|
changes.append("Beschreibung geändert")
|
||||||
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE credentials SET name=?, username=?, category=?, description=?, updated_at=?, "
|
"UPDATE credentials SET name=?, username=?, category=?, description=?, updated_at=?, "
|
||||||
@@ -274,7 +274,7 @@ def update_credential(
|
|||||||
"UPDATE credentials SET secret_encrypted=? WHERE id=?",
|
"UPDATE credentials SET secret_encrypted=? WHERE id=?",
|
||||||
(keystore.encrypt(secret, aad=aad_for_credential(credential_id)), credential_id),
|
(keystore.encrypt(secret, aad=aad_for_credential(credential_id)), credential_id),
|
||||||
)
|
)
|
||||||
changes.append("Passwort geaendert")
|
changes.append("Passwort geändert")
|
||||||
return changes
|
return changes
|
||||||
|
|
||||||
|
|
||||||
@@ -296,8 +296,8 @@ def credential_secret(
|
|||||||
secret = keystore.decrypt(encrypted, aad=aad_for_credential(int(credential_id)))
|
secret = keystore.decrypt(encrypted, aad=aad_for_credential(int(credential_id)))
|
||||||
except DecryptionError as exc:
|
except DecryptionError as exc:
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
"Das gespeicherte Passwort konnte nicht entschluesselt werden. Wurde der "
|
"Das gespeicherte Passwort konnte nicht entschlüsselt werden. Wurde der "
|
||||||
"Datenschluessel ausgetauscht? Bitte die Zugangsdaten neu eingeben."
|
"Datenschlüssel ausgetauscht? Bitte die Zugangsdaten neu eingeben."
|
||||||
) from exc
|
) from exc
|
||||||
return str(row["username"] or ""), secret
|
return str(row["username"] or ""), secret
|
||||||
|
|
||||||
@@ -315,7 +315,7 @@ def delete_credential(conn: sqlite3.Connection, credential_id: int) -> None:
|
|||||||
if int(row.get("switches") or 0) or int(row.get("devices") or 0):
|
if int(row.get("switches") or 0) or int(row.get("devices") or 0):
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
f"Diese Zugangsdaten werden noch von {row.get('switches')} Switch(en) und "
|
f"Diese Zugangsdaten werden noch von {row.get('switches')} Switch(en) und "
|
||||||
f"{row.get('devices')} Geraet(en) verwendet."
|
f"{row.get('devices')} Gerät(en) verwendet."
|
||||||
)
|
)
|
||||||
conn.execute("UPDATE credentials SET deleted_at=? WHERE id=?", (_now(), credential_id))
|
conn.execute("UPDATE credentials SET deleted_at=? WHERE id=?", (_now(), credential_id))
|
||||||
|
|
||||||
@@ -509,7 +509,7 @@ def delete_switch(conn: sqlite3.Connection, switch_id: int) -> None:
|
|||||||
)
|
)
|
||||||
if int(count or 0):
|
if int(count or 0):
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
f"Diesem Switch sind noch {count} Geraet(e) zugeordnet. Bitte zuerst umhaengen."
|
f"Diesem Switch sind noch {count} Gerät(e) zugeordnet. Bitte zuerst umhängen."
|
||||||
)
|
)
|
||||||
conn.execute("UPDATE switches SET deleted_at=? WHERE id=?", (_now(), switch_id))
|
conn.execute("UPDATE switches SET deleted_at=? WHERE id=?", (_now(), switch_id))
|
||||||
|
|
||||||
@@ -542,7 +542,10 @@ def list_devices(
|
|||||||
WHERE k.host = d.ip AND k.port = d.ssh_port) AS host_key_known,
|
WHERE k.host = d.ip AND k.port = d.ssh_port) AS host_key_known,
|
||||||
COALESCE(st.state,'unknown') AS state, st.latency_ms, st.last_check_at,
|
COALESCE(st.state,'unknown') AS state, st.latency_ms, st.last_check_at,
|
||||||
st.last_ok_at, st.last_change_at, st.last_restart_at, st.restart_count,
|
st.last_ok_at, st.last_change_at, st.last_restart_at, st.restart_count,
|
||||||
st.fail_count
|
st.fail_count, COALESCE(st.vnc_open, 0) AS vnc_open, st.vnc_checked_at,
|
||||||
|
COALESCE(st.vnc_target, '') AS vnc_target,
|
||||||
|
COALESCE(st.vnc_types, '') AS vnc_types,
|
||||||
|
COALESCE(st.vnc_reason, '') AS vnc_reason, st.vnc_types_at
|
||||||
FROM devices d
|
FROM devices d
|
||||||
LEFT JOIN switches s ON s.id = d.switch_id AND s.deleted_at IS NULL
|
LEFT JOIN switches s ON s.id = d.switch_id AND s.deleted_at IS NULL
|
||||||
LEFT JOIN credentials c ON c.id = d.credential_id AND c.deleted_at IS NULL
|
LEFT JOIN credentials c ON c.id = d.credential_id AND c.deleted_at IS NULL
|
||||||
@@ -590,13 +593,13 @@ def create_device(
|
|||||||
)
|
)
|
||||||
if existing is not None:
|
if existing is not None:
|
||||||
if existing["deleted_at"]:
|
if existing["deleted_at"]:
|
||||||
raise ValidationError(f"Ein Geraet mit der MAC {clean_mac} liegt im Papierkorb.")
|
raise ValidationError(f"Ein Gerät mit der MAC {clean_mac} liegt im Papierkorb.")
|
||||||
raise ValidationError(f"Ein Geraet mit der MAC {clean_mac} existiert bereits.")
|
raise ValidationError(f"Ein Gerät mit der MAC {clean_mac} existiert bereits.")
|
||||||
|
|
||||||
if switch_id and not clean_port:
|
if switch_id and not clean_port:
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
"Zu einem zugeordneten Switch gehoert eine Portbezeichnung -- sonst ist kein "
|
"Zu einem zugeordneten Switch gehört eine Portbezeichnung – sonst ist kein "
|
||||||
"PoE-Neustart moeglich."
|
"PoE-Neustart möglich."
|
||||||
)
|
)
|
||||||
|
|
||||||
cursor = conn.execute(
|
cursor = conn.execute(
|
||||||
@@ -651,7 +654,7 @@ def update_device(
|
|||||||
conn, "SELECT * FROM devices WHERE id=? AND deleted_at IS NULL", (device_id,)
|
conn, "SELECT * FROM devices WHERE id=? AND deleted_at IS NULL", (device_id,)
|
||||||
)
|
)
|
||||||
if row is None:
|
if row is None:
|
||||||
raise ValidationError("Dieses Geraet existiert nicht (mehr).")
|
raise ValidationError("Dieses Gerät existiert nicht (mehr).")
|
||||||
clean_name = (name or "").strip()
|
clean_name = (name or "").strip()
|
||||||
if not clean_name:
|
if not clean_name:
|
||||||
raise ValidationError("Der Name darf nicht leer sein.")
|
raise ValidationError("Der Name darf nicht leer sein.")
|
||||||
@@ -664,9 +667,9 @@ def update_device(
|
|||||||
(clean_mac, device_id),
|
(clean_mac, device_id),
|
||||||
)
|
)
|
||||||
if clash is not None:
|
if clash is not None:
|
||||||
raise ValidationError(f"Ein Geraet mit der MAC {clean_mac} existiert bereits.")
|
raise ValidationError(f"Ein Gerät mit der MAC {clean_mac} existiert bereits.")
|
||||||
if switch_id and not clean_port:
|
if switch_id and not clean_port:
|
||||||
raise ValidationError("Zu einem zugeordneten Switch gehoert eine Portbezeichnung.")
|
raise ValidationError("Zu einem zugeordneten Switch gehört eine Portbezeichnung.")
|
||||||
|
|
||||||
changes: list[str] = []
|
changes: list[str] = []
|
||||||
for label, before, after in (
|
for label, before, after in (
|
||||||
@@ -677,6 +680,12 @@ def update_device(
|
|||||||
("Port", str(row["port"] or ""), clean_port),
|
("Port", str(row["port"] or ""), clean_port),
|
||||||
("Aktiv", str(bool(row["is_active"])), str(bool(is_active))),
|
("Aktiv", str(bool(row["is_active"])), str(bool(is_active))),
|
||||||
("Automatischer Neustart", str(bool(row["auto_restart"])), str(bool(auto_restart))),
|
("Automatischer Neustart", str(bool(row["auto_restart"])), str(bool(auto_restart))),
|
||||||
|
# Diese vier fehlten: eine geaenderte Kategorie oder ein anderer
|
||||||
|
# SSH-Port stand nirgends, obwohl beides das Verhalten aendert.
|
||||||
|
("SSH-Port", str(row["ssh_port"] or ""), str(validate_ssh_port(ssh_port) or "")),
|
||||||
|
("Zugangsdaten", str(row["credential_id"] or ""), str(credential_id or "")),
|
||||||
|
("Kategorie", str(row["category"] or ""), (category or "").strip()),
|
||||||
|
("Standort", str(row["location"] or ""), (location or "").strip()),
|
||||||
):
|
):
|
||||||
if before != after:
|
if before != after:
|
||||||
changes.append(f"{label}: {before or '-'} -> {after or '-'}")
|
changes.append(f"{label}: {before or '-'} -> {after or '-'}")
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ STANDARD_OPTIONS: tuple[tuple[int, str, str, str], ...] = (
|
|||||||
(9, "lpr-servers", "ipv4-address", "LPR-Druckserver"),
|
(9, "lpr-servers", "ipv4-address", "LPR-Druckserver"),
|
||||||
(10, "impress-servers", "ipv4-address", "Impress-Server"),
|
(10, "impress-servers", "ipv4-address", "Impress-Server"),
|
||||||
(11, "resource-location-servers", "ipv4-address", "Resource Location Server"),
|
(11, "resource-location-servers", "ipv4-address", "Resource Location Server"),
|
||||||
(13, "boot-size", "uint16", "Boot-Image-Groesse (Bloecke)"),
|
(13, "boot-size", "uint16", "Boot-Image-Größe (Blöcke)"),
|
||||||
(14, "merit-dump", "string", "Crash-Dump-Datei"),
|
(14, "merit-dump", "string", "Crash-Dump-Datei"),
|
||||||
(16, "swap-server", "ipv4-address", "Swap-Server"),
|
(16, "swap-server", "ipv4-address", "Swap-Server"),
|
||||||
(17, "root-path", "string", "Root-Pfad (NFS)"),
|
(17, "root-path", "string", "Root-Pfad (NFS)"),
|
||||||
@@ -54,8 +54,8 @@ STANDARD_OPTIONS: tuple[tuple[int, str, str, str], ...] = (
|
|||||||
(19, "ip-forwarding", "boolean", "IP-Forwarding aktivieren"),
|
(19, "ip-forwarding", "boolean", "IP-Forwarding aktivieren"),
|
||||||
(20, "non-local-source-routing", "boolean", "Non-Local Source Routing"),
|
(20, "non-local-source-routing", "boolean", "Non-Local Source Routing"),
|
||||||
(21, "policy-filter", "string", "Policy Filter (Ziel,Maske,...)"),
|
(21, "policy-filter", "string", "Policy Filter (Ziel,Maske,...)"),
|
||||||
(22, "max-dgram-reassembly", "uint16", "Maximale Datagramm-Reassemblierungsgroesse"),
|
(22, "max-dgram-reassembly", "uint16", "Maximale Datagramm-Reassemblierungsgröße"),
|
||||||
(23, "default-ip-ttl", "uint8", "Standard-TTL fuer IP"),
|
(23, "default-ip-ttl", "uint8", "Standard-TTL für IP"),
|
||||||
(24, "path-mtu-aging-timeout", "uint32", "Path-MTU-Aging-Timeout (Sek.)"),
|
(24, "path-mtu-aging-timeout", "uint32", "Path-MTU-Aging-Timeout (Sek.)"),
|
||||||
(25, "path-mtu-plateau-table", "uint16", "Path-MTU-Plateau-Tabelle"),
|
(25, "path-mtu-plateau-table", "uint16", "Path-MTU-Plateau-Tabelle"),
|
||||||
(26, "interface-mtu", "uint16", "MTU des Interfaces"),
|
(26, "interface-mtu", "uint16", "MTU des Interfaces"),
|
||||||
@@ -69,7 +69,7 @@ STANDARD_OPTIONS: tuple[tuple[int, str, str, str], ...] = (
|
|||||||
(34, "trailer-encapsulation", "boolean", "Trailer Encapsulation"),
|
(34, "trailer-encapsulation", "boolean", "Trailer Encapsulation"),
|
||||||
(35, "arp-cache-timeout", "uint32", "ARP-Cache-Timeout (Sek.)"),
|
(35, "arp-cache-timeout", "uint32", "ARP-Cache-Timeout (Sek.)"),
|
||||||
(36, "ieee802-3-encapsulation", "boolean", "Ethernet-Encapsulation (IEEE 802.3)"),
|
(36, "ieee802-3-encapsulation", "boolean", "Ethernet-Encapsulation (IEEE 802.3)"),
|
||||||
(37, "default-tcp-ttl", "uint8", "Standard-TTL fuer TCP"),
|
(37, "default-tcp-ttl", "uint8", "Standard-TTL für TCP"),
|
||||||
(38, "tcp-keepalive-interval", "uint32", "TCP-Keepalive-Intervall (Sek.)"),
|
(38, "tcp-keepalive-interval", "uint32", "TCP-Keepalive-Intervall (Sek.)"),
|
||||||
(40, "nis-domain", "string", "NIS-Domain"),
|
(40, "nis-domain", "string", "NIS-Domain"),
|
||||||
(41, "nis-servers", "ipv4-address", "NIS-Server"),
|
(41, "nis-servers", "ipv4-address", "NIS-Server"),
|
||||||
@@ -190,12 +190,12 @@ def validate_subnet(
|
|||||||
) -> str:
|
) -> str:
|
||||||
"""Rueckgabe: leerer String bei Erfolg, sonst die Fehlermeldung."""
|
"""Rueckgabe: leerer String bei Erfolg, sonst die Fehlermeldung."""
|
||||||
if not interface:
|
if not interface:
|
||||||
return "Es ist kein Interface ausgewaehlt."
|
return "Es ist kein Interface ausgewählt."
|
||||||
try:
|
try:
|
||||||
start = ipaddress.ip_address(range_start)
|
start = ipaddress.ip_address(range_start)
|
||||||
end = ipaddress.ip_address(range_end)
|
end = ipaddress.ip_address(range_end)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return "Start- und Endadresse muessen gueltige IPv4-Adressen sein."
|
return "Start- und Endadresse müssen gültige IPv4-Adressen sein."
|
||||||
if int(end) < int(start):
|
if int(end) < int(start):
|
||||||
return "Die Endadresse liegt vor der Startadresse."
|
return "Die Endadresse liegt vor der Startadresse."
|
||||||
if matching_network(networks, interface, range_start, range_end) is None:
|
if matching_network(networks, interface, range_start, range_end) is None:
|
||||||
@@ -405,7 +405,7 @@ def render_config(
|
|||||||
# fuehren, dass gar keine Konfiguration mehr geschrieben wird.
|
# fuehren, dass gar keine Konfiguration mehr geschrieben wird.
|
||||||
warnings.append(
|
warnings.append(
|
||||||
f"Subnetz {subnet['range_start']}-{subnet['range_end']} auf {interface} "
|
f"Subnetz {subnet['range_start']}-{subnet['range_end']} auf {interface} "
|
||||||
"passt in kein aktuelles Netz und wurde uebersprungen."
|
"passt in kein aktuelles Netz und wurde übersprungen."
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
interfaces.add(interface)
|
interfaces.add(interface)
|
||||||
@@ -444,7 +444,7 @@ def render_config(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if not subnet_entries:
|
if not subnet_entries:
|
||||||
warnings.append("Es ist kein gueltiges Subnetz konfiguriert -- Kea wuerde nicht starten.")
|
warnings.append("Es ist kein gültiges Subnetz konfiguriert | Kea würde nicht starten.")
|
||||||
|
|
||||||
global_option_data = option_data(global_values)
|
global_option_data = option_data(global_values)
|
||||||
domain = str(settings.get("dhcp_domain") or "").strip()
|
domain = str(settings.get("dhcp_domain") or "").strip()
|
||||||
@@ -560,14 +560,14 @@ class Lease:
|
|||||||
#: benutzte, und ein Geraet, das noch auf einer alten Adresse haengt, faellt
|
#: benutzte, und ein Geraet, das noch auf einer alten Adresse haengt, faellt
|
||||||
#: ueberhaupt nicht auf.
|
#: ueberhaupt nicht auf.
|
||||||
RESERVATION_STATES: dict[str, tuple[str, str]] = {
|
RESERVATION_STATES: dict[str, tuple[str, str]] = {
|
||||||
"aktiv": ("online", "Das Geraet benutzt diese Adresse gerade."),
|
"aktiv": ("online", "Das Gerät benutzt diese Adresse gerade."),
|
||||||
"abgelaufen": ("", "Lease auf dieser Adresse ist abgelaufen -- Geraet derzeit nicht da."),
|
"abgelaufen": ("", "Lease auf dieser Adresse ist abgelaufen | Gerät derzeit nicht da."),
|
||||||
"andere_adresse": (
|
"andere_adresse": (
|
||||||
"warning",
|
"warning",
|
||||||
"Das Geraet haelt noch eine Lease auf einer anderen Adresse. Es uebernimmt die "
|
"Das Gerät hält noch eine Lease auf einer anderen Adresse. Es übernimmt die "
|
||||||
"Reservierung erst nach Ablauf oder Neuanforderung.",
|
"Reservierung erst nach Ablauf oder Neuanforderung.",
|
||||||
),
|
),
|
||||||
"unbenutzt": ("", "Fuer dieses Geraet liegt keine Lease vor."),
|
"unbenutzt": ("", "Für dieses Gerät liegt keine Lease vor."),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -16,10 +16,20 @@ eingelesen. Ein eigener Schreibpfad fuer Altdaten waere eine zweite Stelle, an
|
|||||||
der Fremdeingaben in die Datenbank gelangen -- und die zweite Stelle ist die,
|
der Fremdeingaben in die Datenbank gelangen -- und die zweite Stelle ist die,
|
||||||
die niemand pflegt.
|
die niemand pflegt.
|
||||||
|
|
||||||
Uebersetzt werden Clients, Switche, Zugangsdaten und die DHCP-Konfiguration.
|
Uebersetzt werden Clients, Switche, Zugangsdaten, die DHCP-Konfiguration, die
|
||||||
Was in der Datei sonst noch steht, wird nicht stillschweigend verworfen,
|
Freigabe-Zuordnungen und die Anbindung an das Verzeichnis (Server, Transport,
|
||||||
sondern im Bericht benannt -- damit sichtbar bleibt, was von Hand nachzuziehen
|
Basis-DN, Anmeldeattribut, Bind-Konto samt Passwort, und die Zuordnung
|
||||||
ist.
|
"diese AD-Gruppe wird Administrator"). Was in der Datei sonst noch steht, wird nicht
|
||||||
|
stillschweigend verworfen, sondern im Bericht benannt -- damit sichtbar bleibt,
|
||||||
|
was von Hand nachzuziehen ist.
|
||||||
|
|
||||||
|
Die Freigaben standen bis 2.8.0 auf der Liste "von Hand nachtragen", obwohl es
|
||||||
|
dafuer keinen Grund gab: der Vorgaenger fuehrt sie in
|
||||||
|
``ldap_fileshare_mappings`` mit denselben vier Angaben, die diese Anwendung in
|
||||||
|
``fileshare_mappings`` braucht (Verzeichnisgruppe, deren Name, Beschriftung,
|
||||||
|
UNC-Pfad), und einen Import dafuer gibt es hier ohnehin. Was sich uebernehmen
|
||||||
|
laesst, soll man nicht abtippen muessen -- ein UNC-Pfad von Hand ist ein
|
||||||
|
Tippfehler, der erst auffaellt, wenn jemand die Freigabe braucht.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -27,6 +37,7 @@ from __future__ import annotations
|
|||||||
import base64
|
import base64
|
||||||
import json
|
import json
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
#: Runden der alten Schluesselableitung. Steht so im Vorgaenger und darf sich
|
#: Runden der alten Schluesselableitung. Steht so im Vorgaenger und darf sich
|
||||||
@@ -35,25 +46,24 @@ LEGACY_KDF_ITERATIONS = 390000
|
|||||||
LEGACY_APP_TAG = "tesm"
|
LEGACY_APP_TAG = "tesm"
|
||||||
|
|
||||||
#: Diese Abschnitte werden uebersetzt.
|
#: Diese Abschnitte werden uebersetzt.
|
||||||
SUPPORTED = ("credentials", "switches", "devices", "dhcp")
|
SUPPORTED = ("credentials", "switches", "devices", "dhcp", "ldap")
|
||||||
|
|
||||||
#: Was der alte Export sonst enthaelt. Wird gemeldet, nicht uebersetzt.
|
#: Was der alte Export sonst enthaelt. Wird gemeldet, nicht uebersetzt.
|
||||||
KNOWN_UNSUPPORTED: dict[str, str] = {
|
KNOWN_UNSUPPORTED: dict[str, str] = {
|
||||||
"users": (
|
"users": (
|
||||||
"Lokale Konten: der alte Passwort-Hash ist bcrypt, diese Anwendung "
|
"Lokale Konten: der alte Passwort-Hash ist bcrypt, diese Anwendung "
|
||||||
"verwendet Argon2id. Ein Hash laesst sich nicht umrechnen -- das ist sein "
|
"verwendet Argon2id. Ein Hash lässt sich nicht umrechnen – das ist sein "
|
||||||
"Zweck. Konten neu anlegen."
|
"Zweck. Konten neu anlegen."
|
||||||
),
|
),
|
||||||
"groups": "Gruppen und Rechte: die Rechteschluessel wurden neu geschnitten.",
|
"groups": "Gruppen und Rechte: die Rechteschlüssel wurden neu geschnitten.",
|
||||||
"ldap": (
|
# Der Abschnitt "ldap" wird uebersetzt (siehe _verzeichnis) -- was dabei
|
||||||
"Verzeichnisdienst: Einstellungen, Gruppen- und Freigabe-Zuordnungen von "
|
# nicht mitkommt, benennt die Uebersetzung einzeln und genauer, als eine
|
||||||
"Hand uebertragen."
|
# pauschale Zeile hier es koennte.
|
||||||
),
|
"nginx": "Webserver: Domain, Ports und Zertifikat von Hand übertragen.",
|
||||||
"nginx": "Webserver: Domain, Ports und Zertifikat von Hand uebertragen.",
|
|
||||||
"logs": "Auditlog: bleibt bewusst beim alten System.",
|
"logs": "Auditlog: bleibt bewusst beim alten System.",
|
||||||
"known_hosts": (
|
"known_hosts": (
|
||||||
"SSH-Host-Schluessel: werden hier je Geraet mit Fingerprint gefuehrt und "
|
"SSH-Host-Schlüssel: werden hier je Gerät mit Fingerprint geführt und "
|
||||||
"ueber die Seite 'Host-Schluessel' freigegeben."
|
"über die Seite 'Host-Schlüssel' freigegeben."
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,8 +94,13 @@ class Report:
|
|||||||
if text not in self.notes:
|
if text not in self.notes:
|
||||||
self.notes.append(text)
|
self.notes.append(text)
|
||||||
|
|
||||||
def as_text(self) -> str:
|
def as_text(self, titel: str = "Uebersetzt") -> str:
|
||||||
zeilen = ["Uebersetzt:"]
|
"""Bericht als Text. Der Titel gehoert dem Aufrufer.
|
||||||
|
|
||||||
|
Beim Lesen einer alten Installation ist noch nichts uebersetzt --
|
||||||
|
"Uebersetzt: devices 4" waere an dieser Stelle einfach falsch.
|
||||||
|
"""
|
||||||
|
zeilen = [f"{titel}:"]
|
||||||
zeilen += [f" {key}: {value}" for key, value in sorted(self.counts.items())] or [
|
zeilen += [f" {key}: {value}" for key, value in sorted(self.counts.items())] or [
|
||||||
" nichts"
|
" nichts"
|
||||||
]
|
]
|
||||||
@@ -113,10 +128,10 @@ def open_legacy(raw: bytes | str, passphrase: str) -> dict[str, Any]:
|
|||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise LegacyTransferError("Die Datei ist kein JSON.") from exc
|
raise LegacyTransferError("Die Datei ist kein JSON.") from exc
|
||||||
if not isinstance(envelope, dict):
|
if not isinstance(envelope, dict):
|
||||||
raise LegacyTransferError("Die Datei enthaelt kein Objekt.")
|
raise LegacyTransferError("Die Datei enthält kein Objekt.")
|
||||||
if str(envelope.get("app") or "") != LEGACY_APP_TAG:
|
if str(envelope.get("app") or "") != LEGACY_APP_TAG:
|
||||||
raise LegacyTransferError(
|
raise LegacyTransferError(
|
||||||
f"Diese Datei gehoert zu {envelope.get('app') or 'einer unbekannten Anwendung'!r}, "
|
f"Diese Datei gehört zu {envelope.get('app') or 'einer unbekannten Anwendung'!r}, "
|
||||||
"nicht zu TESM."
|
"nicht zu TESM."
|
||||||
)
|
)
|
||||||
for schluessel in ("salt", "payload"):
|
for schluessel in ("salt", "payload"):
|
||||||
@@ -136,15 +151,15 @@ def open_legacy(raw: bytes | str, passphrase: str) -> dict[str, Any]:
|
|||||||
klartext = Fernet(key).decrypt(str(envelope["payload"]).encode("utf-8"))
|
klartext = Fernet(key).decrypt(str(envelope["payload"]).encode("utf-8"))
|
||||||
except (InvalidToken, ValueError) as exc:
|
except (InvalidToken, ValueError) as exc:
|
||||||
raise LegacyTransferError(
|
raise LegacyTransferError(
|
||||||
"Entschluesselung fehlgeschlagen -- falsche Passphrase oder beschaedigte Datei."
|
"Entschlüsselung fehlgeschlagen – falsche Passphrase oder beschädigte Datei."
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
try:
|
try:
|
||||||
payload = json.loads(klartext.decode("utf-8"))
|
payload = json.loads(klartext.decode("utf-8"))
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise LegacyTransferError("Die entschluesselte Nutzlast ist kein JSON.") from exc
|
raise LegacyTransferError("Die entschlüsselte Nutzlast ist kein JSON.") from exc
|
||||||
if not isinstance(payload, dict):
|
if not isinstance(payload, dict):
|
||||||
raise LegacyTransferError("Die Nutzlast enthaelt kein Objekt.")
|
raise LegacyTransferError("Die Nutzlast enthält kein Objekt.")
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|
||||||
@@ -174,14 +189,14 @@ def _credentials(items: Any, report: Report) -> list[dict[str, Any]]:
|
|||||||
for entry in _liste(items):
|
for entry in _liste(items):
|
||||||
name = str(entry.get("name") or "").strip()
|
name = str(entry.get("name") or "").strip()
|
||||||
if not name:
|
if not name:
|
||||||
report.note("Zugangsdaten ohne Namen uebersprungen.")
|
report.note("Zugangsdaten ohne Namen übersprungen.")
|
||||||
continue
|
continue
|
||||||
umgesetzt: dict[str, Any] = {
|
umgesetzt: dict[str, Any] = {
|
||||||
"name": name,
|
"name": name,
|
||||||
"username": str(entry.get("username") or ""),
|
"username": str(entry.get("username") or ""),
|
||||||
"category": _category(entry.get("category"), report, f"Zugangsdaten {name!r}"),
|
"category": _category(entry.get("category"), report, f"Zugangsdaten {name!r}"),
|
||||||
# Der Vorgaenger kennt kein Beschreibungsfeld.
|
# Der Vorgaenger kennt kein Beschreibungsfeld.
|
||||||
"description": "aus altem TESM uebernommen",
|
"description": "aus altem TESM übernommen",
|
||||||
}
|
}
|
||||||
# Der alte Export entschluesselt das Passwort und legt es im Klartext in
|
# Der alte Export entschluesselt das Passwort und legt es im Klartext in
|
||||||
# die -- als Ganzes passphrasenverschluesselte -- Datei. Beim Import
|
# die -- als Ganzes passphrasenverschluesselte -- Datei. Beim Import
|
||||||
@@ -193,8 +208,8 @@ def _credentials(items: Any, report: Report) -> list[dict[str, Any]]:
|
|||||||
ergebnis.append(umgesetzt)
|
ergebnis.append(umgesetzt)
|
||||||
if ohne_passwort:
|
if ohne_passwort:
|
||||||
report.note(
|
report.note(
|
||||||
f"{ohne_passwort} Zugangsdaten ohne Passwort in der Datei -- "
|
f"{ohne_passwort} Zugangsdaten ohne Passwort in der Datei – "
|
||||||
"sie muessen nachgetragen werden."
|
"sie müssen nachgetragen werden."
|
||||||
)
|
)
|
||||||
report.took("credentials", len(ergebnis))
|
report.took("credentials", len(ergebnis))
|
||||||
return ergebnis
|
return ergebnis
|
||||||
@@ -205,7 +220,7 @@ def _switches(items: Any, report: Report) -> list[dict[str, Any]]:
|
|||||||
for entry in _liste(items):
|
for entry in _liste(items):
|
||||||
hostname = str(entry.get("hostname") or "").strip()
|
hostname = str(entry.get("hostname") or "").strip()
|
||||||
if not hostname:
|
if not hostname:
|
||||||
report.note("Switch ohne Hostnamen uebersprungen.")
|
report.note("Switch ohne Hostnamen übersprungen.")
|
||||||
continue
|
continue
|
||||||
ergebnis.append(
|
ergebnis.append(
|
||||||
{
|
{
|
||||||
@@ -234,7 +249,7 @@ def _devices(items: Any, credentials: list[dict[str, Any]], report: Report) -> l
|
|||||||
for entry in _liste(items):
|
for entry in _liste(items):
|
||||||
mac = str(entry.get("mac") or "").strip()
|
mac = str(entry.get("mac") or "").strip()
|
||||||
if not mac:
|
if not mac:
|
||||||
report.note("Client ohne MAC-Adresse uebersprungen.")
|
report.note("Client ohne MAC-Adresse übersprungen.")
|
||||||
continue
|
continue
|
||||||
zugang = entry.get("credential_name")
|
zugang = entry.get("credential_name")
|
||||||
kategorie = kategorie_je_zugang.get(str(zugang or ""), "")
|
kategorie = kategorie_je_zugang.get(str(zugang or ""), "")
|
||||||
@@ -260,12 +275,174 @@ def _devices(items: Any, credentials: list[dict[str, Any]], report: Report) -> l
|
|||||||
if abgeleitet:
|
if abgeleitet:
|
||||||
report.note(
|
report.note(
|
||||||
f"Kategorie von {abgeleitet} Client(s) aus den zugeordneten Zugangsdaten "
|
f"Kategorie von {abgeleitet} Client(s) aus den zugeordneten Zugangsdaten "
|
||||||
"abgeleitet -- der Vorgaenger kennt kein solches Feld. Bitte pruefen."
|
"abgeleitet – der Vorgänger kennt kein solches Feld. Bitte prüfen."
|
||||||
)
|
)
|
||||||
report.took("devices", len(ergebnis))
|
report.took("devices", len(ergebnis))
|
||||||
return ergebnis
|
return ergebnis
|
||||||
|
|
||||||
|
|
||||||
|
def _verzeichnis(data: Any, report: Report) -> dict[str, Any]:
|
||||||
|
"""Die Anbindung an das Verzeichnis -- so weit sie sich abbilden laesst.
|
||||||
|
|
||||||
|
Zwei Stellen sind **nicht** 1:1, und beide werden benannt statt geraten:
|
||||||
|
|
||||||
|
* **Transport.** Der Vorgaenger kannte ``ldap_use_ssl`` (an/aus). Aus "an"
|
||||||
|
wird LDAPS. Aus "aus" wird **StartTLS auf demselben Port** und nicht
|
||||||
|
"unverschluesselt": diese Anwendung verlangt fuer eine unverschluesselte
|
||||||
|
Verbindung einen ausdruecklichen Schalter, weil das Passwort dabei im
|
||||||
|
Klartext ueber das Netz geht. Diesen Schalter setzt eine Uebernahme
|
||||||
|
nicht fuer jemanden -- ein Active Directory beantwortet StartTLS auf 389
|
||||||
|
ohnehin, und wenn nicht, sagt der Verbindungstest es sofort.
|
||||||
|
* **Standard- und Anmeldegruppe.** Es gibt sie hier nicht als
|
||||||
|
Einstellung: die Standardgruppe ist ein Kennzeichen an der Gruppe
|
||||||
|
selbst, und eine erzwungene Anmeldegruppe kennt diese Anwendung nicht.
|
||||||
|
Waren sie gesetzt, steht es im Bericht.
|
||||||
|
"""
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return {}
|
||||||
|
alt = {str(k): v for k, v in (data.get("settings") or {}).items()}
|
||||||
|
ergebnis: dict[str, Any] = {}
|
||||||
|
einstellungen: dict[str, Any] = {}
|
||||||
|
|
||||||
|
def ja(schluessel: str) -> bool:
|
||||||
|
return str(alt.get(schluessel) or "").strip().lower() in {"1", "true", "yes", "on"}
|
||||||
|
|
||||||
|
server = str(alt.get("ldap_server") or "").strip()
|
||||||
|
if server:
|
||||||
|
einstellungen["ldap_host"] = server
|
||||||
|
if str(alt.get("ldap_port") or "").strip().isdigit():
|
||||||
|
einstellungen["ldap_port"] = int(str(alt["ldap_port"]).strip())
|
||||||
|
if "ldap_enabled" in alt:
|
||||||
|
einstellungen["ldap_enabled"] = ja("ldap_enabled")
|
||||||
|
if str(alt.get("ldap_base_dn") or "").strip():
|
||||||
|
einstellungen["ldap_base_dn"] = str(alt["ldap_base_dn"]).strip()
|
||||||
|
if str(alt.get("ldap_user_filter_attr") or "").strip():
|
||||||
|
einstellungen["ldap_user_attribute"] = str(alt["ldap_user_filter_attr"]).strip()
|
||||||
|
|
||||||
|
if "ldap_use_ssl" in alt:
|
||||||
|
if ja("ldap_use_ssl"):
|
||||||
|
einstellungen["ldap_security"] = "ldaps"
|
||||||
|
else:
|
||||||
|
einstellungen["ldap_security"] = "starttls"
|
||||||
|
report.note(
|
||||||
|
"Der Vorgänger hat unverschlüsselt verbunden. Übernommen als StartTLS auf "
|
||||||
|
"demselben Port – eine unverschlüsselte Verbindung verlangt hier einen "
|
||||||
|
"ausdrücklichen Schalter, den eine Übernahme nicht für Sie setzt. Bitte den "
|
||||||
|
"Verbindungstest ausführen."
|
||||||
|
)
|
||||||
|
if ja("ldap_tls_skip_verify"):
|
||||||
|
einstellungen["ldap_verify_cert"] = False
|
||||||
|
report.note(
|
||||||
|
"Die Prüfung des Serverzertifikats war abgeschaltet und bleibt es – sonst "
|
||||||
|
"scheitert die Anmeldung nach dem Upgrade. Damit schützt die Verschlüsselung "
|
||||||
|
"nur gegen Mitlesen, nicht gegen einen Man-in-the-Middle: bitte ein "
|
||||||
|
"CA-Zertifikat hinterlegen und die Prüfung einschalten."
|
||||||
|
)
|
||||||
|
if einstellungen:
|
||||||
|
ergebnis["settings"] = einstellungen
|
||||||
|
|
||||||
|
for schluessel, text in (
|
||||||
|
(
|
||||||
|
"ldap_default_group",
|
||||||
|
"Standardgruppe {wert!r}: hier ist das ein Kennzeichen an der Gruppe selbst "
|
||||||
|
"(Verwaltung -> Gruppen). Bitte dort setzen.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"ldap_required_login_group",
|
||||||
|
"Erzwungene Anmeldegruppe {wert!r}: diese Anwendung kennt sie nicht. Wer sich "
|
||||||
|
"anmelden darf, entscheidet die Gruppen-Zuordnung.",
|
||||||
|
),
|
||||||
|
):
|
||||||
|
wert = str(alt.get(schluessel) or "").strip()
|
||||||
|
if wert:
|
||||||
|
report.note(text.format(wert=wert))
|
||||||
|
|
||||||
|
benutzer = str(data.get("bind_username") or alt.get("ldap_bind_dn") or "").strip()
|
||||||
|
passwort = str(data.get("bind_password") or "")
|
||||||
|
if benutzer:
|
||||||
|
ergebnis["bind_username"] = benutzer
|
||||||
|
ergebnis["bind_password"] = passwort
|
||||||
|
if not passwort:
|
||||||
|
report.note(
|
||||||
|
f"Bind-Konto {benutzer!r} übernommen, aber ohne Passwort – es stand nicht in "
|
||||||
|
"der Datei. Unter Verwaltung -> Verzeichnisdienst nachtragen."
|
||||||
|
)
|
||||||
|
|
||||||
|
zuordnungen: list[dict[str, Any]] = []
|
||||||
|
offen: list[str] = []
|
||||||
|
for eintrag in _liste(data.get("group_mappings")):
|
||||||
|
dn = str(eintrag.get("ad_group_dn") or "").strip()
|
||||||
|
if not dn:
|
||||||
|
continue
|
||||||
|
name = str(eintrag.get("ad_group_name") or "").strip() or dn
|
||||||
|
ziel = str(
|
||||||
|
eintrag.get("app_group_name") or eintrag.get("app_group_id") or ""
|
||||||
|
).strip()
|
||||||
|
# Der Vorgaenger kannte den Sonderwert "admin" (bzw. "Admin" im
|
||||||
|
# Export): diese AD-Gruppe macht Administratoren. Genau dafuer gibt es
|
||||||
|
# hier seit 2.8.0 ein Ziel -- das Administratorkennzeichen.
|
||||||
|
if ziel.lower() == "admin":
|
||||||
|
zuordnungen.append(
|
||||||
|
{"ad_group_dn": dn, "ad_group_name": name, "grants_admin": True}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if ziel:
|
||||||
|
offen.append(f"{name} -> {ziel}")
|
||||||
|
if zuordnungen:
|
||||||
|
ergebnis["group_mappings"] = zuordnungen
|
||||||
|
if offen:
|
||||||
|
report.note(
|
||||||
|
"Diese Gruppen-Zuordnungen zeigen auf lokale Gruppen, die nicht mit übernommen "
|
||||||
|
"werden (die Rechteschlüssel sind neu geschnitten): "
|
||||||
|
+ ", ".join(sorted(offen))
|
||||||
|
+ ". Nach dem Anlegen der Gruppen unter Verwaltung -> Verzeichnisdienst "
|
||||||
|
"nachtragen."
|
||||||
|
)
|
||||||
|
return ergebnis
|
||||||
|
|
||||||
|
|
||||||
|
def _freigaben(data: Any, report: Report) -> list[dict[str, Any]]:
|
||||||
|
"""Freigabe-Zuordnungen aus dem Abschnitt "ldap" des alten Exports.
|
||||||
|
|
||||||
|
Der Vorgaenger legt sie unter ``ldap.fileshare_mappings`` ab; beim Lesen
|
||||||
|
einer Installation stehen sie unmittelbar in der Tabelle. Beides landet
|
||||||
|
hier, damit es nur eine Stelle gibt, die die Felder umbenennt.
|
||||||
|
"""
|
||||||
|
if isinstance(data, dict):
|
||||||
|
eintraege = _liste(data.get("fileshare_mappings"))
|
||||||
|
else:
|
||||||
|
eintraege = _liste(data)
|
||||||
|
ergebnis: list[dict[str, Any]] = []
|
||||||
|
ohne_pfad = 0
|
||||||
|
for eintrag in eintraege:
|
||||||
|
# "share_label"/"share_unc" heissen hier "label"/"unc" -- dieselbe
|
||||||
|
# Angabe, kuerzerer Name, weil die Tabelle schon "fileshare" heisst.
|
||||||
|
unc = str(eintrag.get("share_unc") or eintrag.get("unc") or "").strip()
|
||||||
|
gruppe = str(eintrag.get("ad_group_dn") or "").strip()
|
||||||
|
if not unc or not gruppe:
|
||||||
|
ohne_pfad += 1
|
||||||
|
continue
|
||||||
|
beschriftung = str(eintrag.get("share_label") or eintrag.get("label") or "").strip()
|
||||||
|
ergebnis.append(
|
||||||
|
{
|
||||||
|
"ad_group_dn": gruppe,
|
||||||
|
"ad_group_name": str(eintrag.get("ad_group_name") or "").strip() or gruppe,
|
||||||
|
# Ohne Beschriftung der letzte Teil des Pfades -- besser als
|
||||||
|
# eine Freigabe ohne Namen. Beide Trenner: der Vorgaenger
|
||||||
|
# speichert "//server/share", getippt wird "\server\share".
|
||||||
|
"label": beschriftung
|
||||||
|
or unc.replace(chr(92), "/").rstrip("/").rsplit("/", 1)[-1],
|
||||||
|
"unc": unc,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if ohne_pfad:
|
||||||
|
report.note(
|
||||||
|
f"{ohne_pfad} Freigabe-Zuordnung(en) ohne Verzeichnisgruppe oder UNC-Pfad "
|
||||||
|
"übersprungen."
|
||||||
|
)
|
||||||
|
return ergebnis
|
||||||
|
|
||||||
|
|
||||||
def _dhcp(data: Any, report: Report) -> tuple[dict[str, Any], dict[str, str]]:
|
def _dhcp(data: Any, report: Report) -> tuple[dict[str, Any], dict[str, str]]:
|
||||||
"""DHCP-Teil uebersetzen. Rueckgabe: ``(dhcp, settings)``."""
|
"""DHCP-Teil uebersetzen. Rueckgabe: ``(dhcp, settings)``."""
|
||||||
if not isinstance(data, dict):
|
if not isinstance(data, dict):
|
||||||
@@ -292,14 +469,14 @@ def _dhcp(data: Any, report: Report) -> tuple[dict[str, Any], dict[str, str]]:
|
|||||||
for entry in _liste(data.get("reservations")):
|
for entry in _liste(data.get("reservations")):
|
||||||
mac = str(entry.get("mac") or "").strip()
|
mac = str(entry.get("mac") or "").strip()
|
||||||
if not mac:
|
if not mac:
|
||||||
report.note("Manuelle Reservierung ohne MAC-Adresse uebersprungen.")
|
report.note("Manuelle Reservierung ohne MAC-Adresse übersprungen.")
|
||||||
continue
|
continue
|
||||||
reservations.append(
|
reservations.append(
|
||||||
{
|
{
|
||||||
"mac": mac,
|
"mac": mac,
|
||||||
"ip": str(entry.get("ip") or ""),
|
"ip": str(entry.get("ip") or ""),
|
||||||
"hostname": str(entry.get("name") or ""),
|
"hostname": str(entry.get("name") or ""),
|
||||||
"comment": "aus altem TESM uebernommen",
|
"comment": "aus altem TESM übernommen",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -316,7 +493,7 @@ def _dhcp(data: Any, report: Report) -> tuple[dict[str, Any], dict[str, str]]:
|
|||||||
"is_standard": int(bool(definition.get("is_standard"))),
|
"is_standard": int(bool(definition.get("is_standard"))),
|
||||||
}
|
}
|
||||||
if not gemeinsam["code"] or not gemeinsam["name"]:
|
if not gemeinsam["code"] or not gemeinsam["name"]:
|
||||||
report.note("DHCP-Option ohne Code oder Namen uebersprungen.")
|
report.note("DHCP-Option ohne Code oder Namen übersprungen.")
|
||||||
continue
|
continue
|
||||||
werte = _liste(definition.get("values"))
|
werte = _liste(definition.get("values"))
|
||||||
if not werte:
|
if not werte:
|
||||||
@@ -333,7 +510,7 @@ def _dhcp(data: Any, report: Report) -> tuple[dict[str, Any], dict[str, str]]:
|
|||||||
)
|
)
|
||||||
if ohne_wert:
|
if ohne_wert:
|
||||||
report.note(
|
report.note(
|
||||||
f"{ohne_wert} eigene DHCP-Option(en) ohne Wert uebersprungen -- ohne Wert "
|
f"{ohne_wert} eigene DHCP-Option(en) ohne Wert übersprungen – ohne Wert "
|
||||||
"gibt es nichts zu senden. Bei Bedarf von Hand anlegen."
|
"gibt es nichts zu senden. Bei Bedarf von Hand anlegen."
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -376,6 +553,14 @@ def convert(legacy: dict[str, Any]) -> tuple[dict[str, Any], Report]:
|
|||||||
if devices:
|
if devices:
|
||||||
payload["devices"] = devices
|
payload["devices"] = devices
|
||||||
|
|
||||||
|
freigaben = _freigaben(legacy.get("ldap") or legacy.get("fileshare"), report)
|
||||||
|
if freigaben:
|
||||||
|
payload["fileshare"] = freigaben
|
||||||
|
|
||||||
|
verzeichnis = _verzeichnis(legacy.get("ldap"), report)
|
||||||
|
if verzeichnis:
|
||||||
|
payload["directory"] = verzeichnis
|
||||||
|
|
||||||
dhcp, settings = _dhcp(legacy.get("dhcp"), report)
|
dhcp, settings = _dhcp(legacy.get("dhcp"), report)
|
||||||
if dhcp:
|
if dhcp:
|
||||||
payload["dhcp"] = dhcp
|
payload["dhcp"] = dhcp
|
||||||
@@ -399,7 +584,7 @@ def convert(legacy: dict[str, Any]) -> tuple[dict[str, Any], Report]:
|
|||||||
report.note(
|
report.note(
|
||||||
"Diese Zugangsdaten sind zugeordnet, fehlen aber in der Datei und bleiben "
|
"Diese Zugangsdaten sind zugeordnet, fehlen aber in der Datei und bleiben "
|
||||||
f"deshalb leer: {', '.join(fehlend)}. Beim Export im alten TESM die "
|
f"deshalb leer: {', '.join(fehlend)}. Beim Export im alten TESM die "
|
||||||
"Kategorie 'Zugangsdaten' mitwaehlen."
|
"Kategorie 'Zugangsdaten' mitwählen."
|
||||||
)
|
)
|
||||||
|
|
||||||
for schluessel, grund in KNOWN_UNSUPPORTED.items():
|
for schluessel, grund in KNOWN_UNSUPPORTED.items():
|
||||||
@@ -407,11 +592,356 @@ def convert(legacy: dict[str, Any]) -> tuple[dict[str, Any], Report]:
|
|||||||
report.note(grund)
|
report.note(grund)
|
||||||
unbekannt = sorted(set(legacy) - set(SUPPORTED) - set(KNOWN_UNSUPPORTED))
|
unbekannt = sorted(set(legacy) - set(SUPPORTED) - set(KNOWN_UNSUPPORTED))
|
||||||
if unbekannt:
|
if unbekannt:
|
||||||
report.note(f"Unbekannte Abschnitte uebersprungen: {', '.join(unbekannt)}.")
|
report.note(f"Unbekannte Abschnitte übersprungen: {', '.join(unbekannt)}.")
|
||||||
|
|
||||||
if not payload:
|
if not payload:
|
||||||
raise LegacyTransferError(
|
raise LegacyTransferError(
|
||||||
"Die Datei enthaelt keine Clients, Switche, Zugangsdaten oder DHCP-Daten. "
|
"Die Datei enthält keine Clients, Switche, Zugangsdaten, DHCP-Daten oder "
|
||||||
"Beim Export im alten TESM diese Kategorien mitwaehlen."
|
"Freigaben. Beim Export im alten TESM diese Kategorien mitwählen."
|
||||||
)
|
)
|
||||||
return payload, report
|
return payload, report
|
||||||
|
|
||||||
|
|
||||||
|
def _eine(conn: Any, sql: str, args: tuple[Any, ...] = ()) -> Any:
|
||||||
|
"""Eine Zeile oder ``None`` -- die alte Datenbank kennt kein Database."""
|
||||||
|
return conn.execute(sql, args).fetchone()
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Alte Installation lesen
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
#: Dateinamen der Vorgaengerfassung, relativ zu ihrem Verzeichnis.
|
||||||
|
LEGACY_DB_NAME = "sqlite.db"
|
||||||
|
LEGACY_FERNET_NAME = "fernet.key"
|
||||||
|
|
||||||
|
#: Genau die Abfragen des alten Exports. Sie stehen hier als Text und nicht als
|
||||||
|
#: Nachbau: was der Vorgaenger exportiert hat, ist das, was uebersetzbar ist.
|
||||||
|
_ABFRAGEN: dict[str, str] = {
|
||||||
|
"devices": """
|
||||||
|
SELECT devices.mac, devices.ip, devices.port, devices.name,
|
||||||
|
devices.switch_hostname, devices.is_active, devices.ssh_port,
|
||||||
|
credentials.name AS credential_name
|
||||||
|
FROM devices LEFT JOIN credentials ON credentials.id = devices.credential_id
|
||||||
|
WHERE devices.deleted_at IS NULL
|
||||||
|
""",
|
||||||
|
"switches": """
|
||||||
|
SELECT switches.hostname, switches.ip, switches.ssh_port,
|
||||||
|
credentials.name AS credential_name
|
||||||
|
FROM switches LEFT JOIN credentials ON credentials.id = switches.credential_id
|
||||||
|
WHERE switches.deleted_at IS NULL
|
||||||
|
""",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _oeffne_alt(pfad: Path) -> Any:
|
||||||
|
"""Oeffnet die alte Datenbank **nur lesend**.
|
||||||
|
|
||||||
|
Der alte Dienst laeuft womoeglich noch. Ein Schreibzugriff waere hier in
|
||||||
|
jedem Fall falsch, und ``mode=ro`` macht daraus einen Fehler statt einer
|
||||||
|
Moeglichkeit.
|
||||||
|
"""
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
if not pfad.is_file():
|
||||||
|
raise LegacyTransferError(f"Keine Datenbank gefunden: {pfad}")
|
||||||
|
conn = sqlite3.connect(f"file:{pfad.as_posix()}?mode=ro", uri=True)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def _tabellen(conn: Any) -> set[str]:
|
||||||
|
return {
|
||||||
|
str(row[0])
|
||||||
|
for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _entschluessler(root: Path, report: Report) -> Any:
|
||||||
|
"""Gibt eine Funktion zurueck, die ein altes Passwort entschluesselt.
|
||||||
|
|
||||||
|
Fehlt der Schluessel, bleibt der Datensatz erhalten und das Passwort leer.
|
||||||
|
Ein Zugangsdatensatz ohne Passwort ist nachtragbar; ein abgebrochenes
|
||||||
|
Upgrade ist es nicht.
|
||||||
|
"""
|
||||||
|
schluesseldatei = root / LEGACY_FERNET_NAME
|
||||||
|
if not schluesseldatei.is_file():
|
||||||
|
report.note(
|
||||||
|
f"{LEGACY_FERNET_NAME} nicht gefunden – die Passwörter der Zugangsdaten "
|
||||||
|
"bleiben leer und sind nachzutragen."
|
||||||
|
)
|
||||||
|
return lambda _token: ""
|
||||||
|
|
||||||
|
from cryptography.fernet import Fernet, InvalidToken
|
||||||
|
|
||||||
|
try:
|
||||||
|
fernet = Fernet(schluesseldatei.read_bytes().strip())
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
report.note(
|
||||||
|
f"{LEGACY_FERNET_NAME} ist unlesbar – die Passwörter der Zugangsdaten "
|
||||||
|
"bleiben leer und sind nachzutragen."
|
||||||
|
)
|
||||||
|
return lambda _token: ""
|
||||||
|
|
||||||
|
gescheitert: list[int] = []
|
||||||
|
|
||||||
|
def entschluesseln(token: Any) -> str:
|
||||||
|
if not token:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
return fernet.decrypt(str(token).encode("utf-8")).decode("utf-8")
|
||||||
|
except (InvalidToken, ValueError):
|
||||||
|
gescheitert.append(1)
|
||||||
|
if len(gescheitert) == 1:
|
||||||
|
report.note(
|
||||||
|
"Mindestens ein Passwort ließ sich mit dem gefundenen Schlüssel "
|
||||||
|
"nicht entschlüsseln – es bleibt leer."
|
||||||
|
)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
return entschluesseln
|
||||||
|
|
||||||
|
|
||||||
|
def _alt_dhcp(conn: Any, vorhanden: set[str]) -> dict[str, Any] | None:
|
||||||
|
"""Baut den DHCP-Abschnitt so, wie der alte Export ihn geschrieben hat."""
|
||||||
|
if "dhcp_subnets" not in vorhanden:
|
||||||
|
return None
|
||||||
|
|
||||||
|
einstellungen: dict[str, Any] = {}
|
||||||
|
if "settings" in vorhanden:
|
||||||
|
# Nur die Schluessel holen, die der Uebersetzer kennt -- der alte
|
||||||
|
# Einstellungsbestand ist gross und heisst groesstenteils anders.
|
||||||
|
for key in DHCP_SETTING_KEYS:
|
||||||
|
row = _eine(conn, "SELECT value FROM settings WHERE key=?", (key,))
|
||||||
|
if row and str(row["value"] or ""):
|
||||||
|
einstellungen[key] = row["value"]
|
||||||
|
|
||||||
|
subnets = [
|
||||||
|
dict(row)
|
||||||
|
for row in conn.execute(
|
||||||
|
"SELECT interface, range_start, range_end, gateway, dns, sort_order, enabled "
|
||||||
|
"FROM dhcp_subnets"
|
||||||
|
).fetchall()
|
||||||
|
]
|
||||||
|
reservations = (
|
||||||
|
[
|
||||||
|
dict(row)
|
||||||
|
for row in conn.execute(
|
||||||
|
"SELECT mac, ip, name FROM dhcp_manual_reservations"
|
||||||
|
).fetchall()
|
||||||
|
]
|
||||||
|
if "dhcp_manual_reservations" in vorhanden
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
|
||||||
|
options: list[dict[str, Any]] = []
|
||||||
|
if "dhcp_option_defs" in vorhanden:
|
||||||
|
for definition in conn.execute(
|
||||||
|
"SELECT id, code, name, type, description, is_standard FROM dhcp_option_defs"
|
||||||
|
).fetchall():
|
||||||
|
werte = (
|
||||||
|
conn.execute(
|
||||||
|
"SELECT device_mac, value FROM dhcp_option_values WHERE option_def_id=?",
|
||||||
|
(definition["id"],),
|
||||||
|
).fetchall()
|
||||||
|
if "dhcp_option_values" in vorhanden
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
if not werte and definition["is_standard"]:
|
||||||
|
continue
|
||||||
|
options.append(
|
||||||
|
{
|
||||||
|
"code": definition["code"],
|
||||||
|
"name": definition["name"],
|
||||||
|
"type": definition["type"],
|
||||||
|
"description": definition["description"],
|
||||||
|
"is_standard": definition["is_standard"],
|
||||||
|
"values": [
|
||||||
|
{"device_mac": w["device_mac"], "value": w["value"]} for w in werte
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if not (einstellungen or subnets or reservations or options):
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"settings": einstellungen,
|
||||||
|
"subnets": subnets,
|
||||||
|
"reservations": reservations,
|
||||||
|
"options": options,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def read_installation(root: Path) -> tuple[dict[str, Any], Report]:
|
||||||
|
"""Liest eine alte Installation und gibt deren Nutzlast zurueck.
|
||||||
|
|
||||||
|
Ergebnis ist dieselbe Struktur, die ``open_legacy`` aus einer Exportdatei
|
||||||
|
liefert -- der Uebersetzer dahinter bleibt also derselbe. Das ist der
|
||||||
|
Unterschied zum Weg ueber die Oberflaeche: fuer ein Upgrade darf niemand
|
||||||
|
sich anmelden, eine Kategorie anhaken und eine Datei herunterladen muessen.
|
||||||
|
|
||||||
|
Gelesen wird ausschliesslich; die alte Installation bleibt unberuehrt.
|
||||||
|
"""
|
||||||
|
root = Path(root)
|
||||||
|
report = Report()
|
||||||
|
conn = _oeffne_alt(root / LEGACY_DB_NAME)
|
||||||
|
try:
|
||||||
|
vorhanden = _tabellen(conn)
|
||||||
|
payload: dict[str, Any] = {}
|
||||||
|
|
||||||
|
for abschnitt, sql in _ABFRAGEN.items():
|
||||||
|
tabelle = abschnitt
|
||||||
|
if tabelle not in vorhanden:
|
||||||
|
report.note(f"Tabelle {tabelle!r} fehlt in der alten Datenbank.")
|
||||||
|
continue
|
||||||
|
payload[abschnitt] = [dict(row) for row in conn.execute(sql).fetchall()]
|
||||||
|
|
||||||
|
entschluesseln_bind = _entschluessler(root, report)
|
||||||
|
if "credentials" in vorhanden:
|
||||||
|
entschluesseln = entschluesseln_bind
|
||||||
|
payload["credentials"] = [
|
||||||
|
{
|
||||||
|
"name": row["name"],
|
||||||
|
"username": row["username"],
|
||||||
|
"password": entschluesseln(row["password"]),
|
||||||
|
"category": row["category"],
|
||||||
|
}
|
||||||
|
for row in conn.execute(
|
||||||
|
"SELECT name, username, password, category FROM credentials "
|
||||||
|
"WHERE deleted_at IS NULL"
|
||||||
|
).fetchall()
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
report.note("Tabelle 'credentials' fehlt in der alten Datenbank.")
|
||||||
|
|
||||||
|
# Der Abschnitt "ldap" -- genau so aufgebaut wie im alten Export, damit
|
||||||
|
# die Uebersetzung beide Wege gleich liest.
|
||||||
|
ldap: dict[str, Any] = {}
|
||||||
|
if "ldap_fileshare_mappings" in vorhanden:
|
||||||
|
freigaben = [
|
||||||
|
dict(row)
|
||||||
|
for row in conn.execute(
|
||||||
|
"SELECT ad_group_dn, ad_group_name, share_label, share_unc "
|
||||||
|
"FROM ldap_fileshare_mappings"
|
||||||
|
).fetchall()
|
||||||
|
]
|
||||||
|
if freigaben:
|
||||||
|
ldap["fileshare_mappings"] = freigaben
|
||||||
|
else:
|
||||||
|
report.note("Tabelle 'ldap_fileshare_mappings' fehlt in der alten Datenbank.")
|
||||||
|
|
||||||
|
if "settings" in vorhanden:
|
||||||
|
# Nur anlegen, wenn etwas drinsteht: ein leerer Abschnitt sieht im
|
||||||
|
# Bericht wie eine Uebernahme aus und ist keine.
|
||||||
|
ldap_einstellungen = {
|
||||||
|
str(row["key"]): str(row["value"])
|
||||||
|
for row in conn.execute(
|
||||||
|
"SELECT key, value FROM settings WHERE key LIKE 'ldap%'"
|
||||||
|
).fetchall()
|
||||||
|
}
|
||||||
|
if ldap_einstellungen:
|
||||||
|
ldap["settings"] = ldap_einstellungen
|
||||||
|
|
||||||
|
if "ldap_group_mappings" in vorhanden:
|
||||||
|
# Der Sonderwert "admin" steht in derselben Spalte wie eine
|
||||||
|
# Gruppen-Id; der Name dahinter ist das, was ueber die
|
||||||
|
# Systemgrenze traegt.
|
||||||
|
gruppen_da = "groups" in vorhanden
|
||||||
|
zuordnungen = [
|
||||||
|
{
|
||||||
|
"ad_group_dn": str(row["ad_group_dn"]),
|
||||||
|
"ad_group_name": str(row["ad_group_name"]),
|
||||||
|
"app_group_name": str(row["ziel"] or ""),
|
||||||
|
}
|
||||||
|
for row in conn.execute(
|
||||||
|
"SELECT m.ad_group_dn, m.ad_group_name, CASE "
|
||||||
|
" WHEN m.app_group_id = 'admin' THEN 'admin' "
|
||||||
|
+ (" ELSE g.name " if gruppen_da else " ELSE NULL ")
|
||||||
|
+ "END AS ziel FROM ldap_group_mappings m"
|
||||||
|
+ (
|
||||||
|
" LEFT JOIN groups g ON g.id = m.app_group_id"
|
||||||
|
if gruppen_da
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
).fetchall()
|
||||||
|
]
|
||||||
|
if zuordnungen:
|
||||||
|
ldap["group_mappings"] = zuordnungen
|
||||||
|
|
||||||
|
# Das Bind-Konto: erst das Dienstkonto (so liest es auch der alte
|
||||||
|
# Export), sonst die beiden Einstellungen aus aelteren Fassungen.
|
||||||
|
if "service_accounts" in vorhanden:
|
||||||
|
konto = _eine(
|
||||||
|
conn,
|
||||||
|
"SELECT username, password FROM service_accounts WHERE purpose='ldap_bind'",
|
||||||
|
)
|
||||||
|
if konto is not None:
|
||||||
|
ldap["bind_username"] = str(konto["username"] or "")
|
||||||
|
ldap["bind_password"] = entschluesseln_bind(str(konto["password"] or ""))
|
||||||
|
if not ldap.get("bind_username"):
|
||||||
|
aus_einstellungen = (ldap.get("settings") or {}).get("ldap_bind_dn") or ""
|
||||||
|
if aus_einstellungen:
|
||||||
|
ldap["bind_username"] = str(aus_einstellungen)
|
||||||
|
ldap["bind_password"] = entschluesseln_bind(
|
||||||
|
str((ldap.get("settings") or {}).get("ldap_bind_password") or "")
|
||||||
|
)
|
||||||
|
if ldap:
|
||||||
|
payload["ldap"] = ldap
|
||||||
|
|
||||||
|
dhcp = _alt_dhcp(conn, vorhanden)
|
||||||
|
if dhcp:
|
||||||
|
payload["dhcp"] = dhcp
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
payload = {key: value for key, value in payload.items() if value}
|
||||||
|
if not payload:
|
||||||
|
raise LegacyTransferError(
|
||||||
|
f"In {root} stehen keine übernehmbaren Daten (Clients, Switche, "
|
||||||
|
"Zugangsdaten, DHCP, Freigaben)."
|
||||||
|
)
|
||||||
|
for abschnitt, inhalt in payload.items():
|
||||||
|
if abschnitt == "ldap" and isinstance(inhalt, dict):
|
||||||
|
report.took("Freigaben", len(_liste(inhalt.get("fileshare_mappings"))))
|
||||||
|
report.took("Verzeichniseinstellungen", len(inhalt.get("settings") or {}))
|
||||||
|
report.took("Gruppen-Zuordnungen", len(_liste(inhalt.get("group_mappings"))))
|
||||||
|
if inhalt.get("bind_username"):
|
||||||
|
report.took("Bind-Konto", 1)
|
||||||
|
continue
|
||||||
|
report.took(abschnitt, len(inhalt) if isinstance(inhalt, list) else 1)
|
||||||
|
return payload, report
|
||||||
|
|
||||||
|
|
||||||
|
def seal_legacy(payload: dict[str, Any], passphrase: str) -> str:
|
||||||
|
"""Schreibt eine Nutzlast im **alten** Umschlag.
|
||||||
|
|
||||||
|
Damit ist die Datei genau das, was der Vorgaenger selbst exportiert haette
|
||||||
|
-- lesbar fuer ``open_legacy`` und fuer den Import des alten TESM. Das ist
|
||||||
|
beim Upgrade der Unterschied zwischen einem Zwischenschritt und einem
|
||||||
|
Rueckweg: geht danach etwas schief, laesst sich diese Datei in die alte
|
||||||
|
Installation zurueckspielen.
|
||||||
|
"""
|
||||||
|
import secrets
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
from cryptography.hazmat.primitives import hashes
|
||||||
|
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
||||||
|
|
||||||
|
if len(passphrase) < 8:
|
||||||
|
raise LegacyTransferError("Die Passphrase braucht mindestens 8 Zeichen.")
|
||||||
|
|
||||||
|
salt = secrets.token_bytes(16)
|
||||||
|
kdf = PBKDF2HMAC(
|
||||||
|
algorithm=hashes.SHA256(), length=32, salt=salt, iterations=LEGACY_KDF_ITERATIONS
|
||||||
|
)
|
||||||
|
key = base64.urlsafe_b64encode(kdf.derive(passphrase.encode("utf-8")))
|
||||||
|
token = Fernet(key).encrypt(json.dumps(payload).encode("utf-8"))
|
||||||
|
umschlag = {
|
||||||
|
"app": LEGACY_APP_TAG,
|
||||||
|
"version": 2,
|
||||||
|
"exported_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
|
"salt": base64.urlsafe_b64encode(salt).decode("utf-8"),
|
||||||
|
"payload": token.decode("utf-8"),
|
||||||
|
}
|
||||||
|
return json.dumps(umschlag, indent=2, ensure_ascii=False) + "\n"
|
||||||
@@ -110,47 +110,47 @@ def is_noise(line: str) -> bool:
|
|||||||
HINTS: tuple[tuple[str, str], ...] = (
|
HINTS: tuple[tuple[str, str], ...] = (
|
||||||
(
|
(
|
||||||
"incorrect password attempt",
|
"incorrect password attempt",
|
||||||
"Das Passwort aus den Zugangsdaten wurde von 'sudo' abgewiesen. Fuer die "
|
"Das Passwort aus den Zugangsdaten wurde von 'sudo' abgewiesen. Für die "
|
||||||
"SSH-Anmeldung hat es gereicht -- 'sudo' auf diesem Geraet verlangt also ein "
|
"SSH-Anmeldung hat es gereicht – 'sudo' auf diesem Gerät verlangt also ein "
|
||||||
"anderes Passwort, oder das Konto steht nicht in der sudoers-Datei.",
|
"anderes Passwort, oder das Konto steht nicht in der sudoers-Datei.",
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"is not in the sudoers file",
|
"is not in the sudoers file",
|
||||||
"Das Wartungskonto darf auf diesem Geraet kein 'sudo' ausfuehren. Auf dem "
|
"Das Wartungskonto darf auf diesem Gerät kein 'sudo' ausführen. Auf dem "
|
||||||
"Zielgeraet eintragen, z. B. in /etc/sudoers.d/tesm-wartung: "
|
"Zielgerät eintragen, z. B. in /etc/sudoers.d/tesm-wartung: "
|
||||||
"'<benutzer> ALL=(root) /usr/bin/apt-get, /sbin/reboot'. Mit vorangestelltem "
|
"'<benutzer> ALL=(root) /usr/bin/apt-get, /sbin/reboot'. Mit vorangestelltem "
|
||||||
"NOPASSWD: entfaellt die Passwortabfrage ganz.",
|
"NOPASSWD: entfällt die Passwortabfrage ganz.",
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"a password is required",
|
"a password is required",
|
||||||
"'sudo' verlangt ein Passwort, und in den Zugangsdaten dieses Geraets ist keines "
|
"'sudo' verlangt ein Passwort, und in den Zugangsdaten dieses Geräts ist keines "
|
||||||
"hinterlegt. Tragen Sie dort das Passwort des Wartungskontos ein -- TESM reicht "
|
"hinterlegt. Tragen Sie dort das Passwort des Wartungskontos ein | TESM reicht "
|
||||||
"es ueber die bereits verschluesselte SSH-Verbindung an 'sudo' weiter.",
|
"es über die bereits verschlüsselte SSH-Verbindung an 'sudo' weiter.",
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"no tty present",
|
"no tty present",
|
||||||
"Das Wartungskonto verlangt fuer 'sudo' ein Terminal. In der sudoers-Datei des "
|
"Das Wartungskonto verlangt für 'sudo' ein Terminal. In der sudoers-Datei des "
|
||||||
"Zielgeraets 'Defaults requiretty' entfernen oder fuer dieses Konto abschalten.",
|
"Zielgeräts 'Defaults requiretty' entfernen oder für dieses Konto abschalten.",
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"command not found",
|
"command not found",
|
||||||
"Das Kommando gibt es auf dem Zielgeraet nicht. Die Wartung erwartet ein "
|
"Das Kommando gibt es auf dem Zielgerät nicht. Die Wartung erwartet ein "
|
||||||
"Debian- oder Ubuntu-System mit apt-get.",
|
"Debian- oder Ubuntu-System mit apt-get.",
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"Access is denied",
|
"Access is denied",
|
||||||
"Das Konto darf auf diesem Windows-Geraet keinen Neustart ausloesen. Es muss "
|
"Das Konto darf auf diesem Windows-Gerät keinen Neustart auslösen. Es muss "
|
||||||
"Mitglied der lokalen Administratoren sein.",
|
"Mitglied der lokalen Administratoren sein.",
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"is not recognized as an internal or external command",
|
"is not recognized as an internal or external command",
|
||||||
"Das Kommando gibt es auf diesem Geraet nicht. Ist bei den Zugangsdaten die "
|
"Das Kommando gibt es auf diesem Gerät nicht. Ist bei den Zugangsdaten die "
|
||||||
"richtige Kategorie gewaehlt (Linux-Client bzw. Windows-Client)?",
|
"richtige Kategorie gewählt (Linux-Client bzw. Windows-Client)?",
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"Could not get lock",
|
"Could not get lock",
|
||||||
"Ein anderer Paketvorgang laeuft bereits auf dem Zielgeraet (etwa "
|
"Ein anderer Paketvorgang läuft bereits auf dem Zielgerät (etwa "
|
||||||
"unattended-upgrades). Spaeter erneut versuchen.",
|
"unattended-upgrades). Später erneut versuchen.",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -256,8 +256,8 @@ def mark_orphaned_jobs(conn: sqlite3.Connection) -> int:
|
|||||||
"UPDATE jobs SET state='unknown', message=?, finished_at=?, updated_at=? "
|
"UPDATE jobs SET state='unknown', message=?, finished_at=?, updated_at=? "
|
||||||
"WHERE state IN ('pending','running')",
|
"WHERE state IN ('pending','running')",
|
||||||
(
|
(
|
||||||
"Der Dienst wurde waehrend des Laufs neu gestartet. Was auf dem Zielgeraet "
|
"Der Dienst wurde während des Laufs neu gestartet. Was auf dem Zielgerät "
|
||||||
"passiert ist, laesst sich von hier nicht mehr feststellen.",
|
"passiert ist, lässt sich von hier nicht mehr feststellen.",
|
||||||
_now(),
|
_now(),
|
||||||
_now(),
|
_now(),
|
||||||
),
|
),
|
||||||
@@ -293,7 +293,7 @@ def _run_remote_command(
|
|||||||
channel.sendall(stdin_text.encode("utf-8"))
|
channel.sendall(stdin_text.encode("utf-8"))
|
||||||
channel.shutdown_write()
|
channel.shutdown_write()
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
on_line(f"[Eingabe konnte nicht uebergeben werden: {exc}]")
|
on_line(f"[Eingabe konnte nicht übergeben werden: {exc}]")
|
||||||
deadline = time.monotonic() + timeout
|
deadline = time.monotonic() + timeout
|
||||||
pending = b""
|
pending = b""
|
||||||
while True:
|
while True:
|
||||||
@@ -308,7 +308,7 @@ def _run_remote_command(
|
|||||||
elif channel.exit_status_ready():
|
elif channel.exit_status_ready():
|
||||||
break
|
break
|
||||||
elif time.monotonic() > deadline:
|
elif time.monotonic() > deadline:
|
||||||
on_line("[Zeitlimit ueberschritten -- Verbindung wird getrennt]")
|
on_line("[Zeitlimit überschritten | Verbindung wird getrennt]")
|
||||||
channel.close()
|
channel.close()
|
||||||
return 124
|
return 124
|
||||||
else:
|
else:
|
||||||
@@ -341,14 +341,14 @@ def _sudo_mode(
|
|||||||
except Exception: # noqa: BLE001 - dann eben der Weg mit Passwort
|
except Exception: # noqa: BLE001 - dann eben der Weg mit Passwort
|
||||||
status = 1
|
status = 1
|
||||||
if status == 0:
|
if status == 0:
|
||||||
on_line("sudo ist ohne Passwort moeglich.")
|
on_line("sudo ist ohne Passwort möglich.")
|
||||||
return SUDO_PASSWORDLESS, ""
|
return SUDO_PASSWORDLESS, ""
|
||||||
if not password:
|
if not password:
|
||||||
on_line(
|
on_line(
|
||||||
"sudo verlangt ein Passwort, in den Zugangsdaten ist keines hinterlegt."
|
"sudo verlangt ein Passwort, in den Zugangsdaten ist keines hinterlegt."
|
||||||
)
|
)
|
||||||
return SUDO_PASSWORDLESS, ""
|
return SUDO_PASSWORDLESS, ""
|
||||||
on_line("sudo verlangt ein Passwort -- es wird aus den Zugangsdaten uebergeben.")
|
on_line("sudo verlangt ein Passwort – es wird aus den Zugangsdaten übergeben.")
|
||||||
return SUDO_WITH_PASSWORD, password + "\n"
|
return SUDO_WITH_PASSWORD, password + "\n"
|
||||||
|
|
||||||
|
|
||||||
@@ -411,7 +411,7 @@ def run_update(
|
|||||||
)
|
)
|
||||||
if status != 0:
|
if status != 0:
|
||||||
failed = True
|
failed = True
|
||||||
emit(f"Schritt {label!r} endete mit Rueckgabewert {status}.")
|
emit(f"Schritt {label!r} endete mit Rückgabewert {status}.")
|
||||||
problem = hint_for("\n".join(collected))
|
problem = hint_for("\n".join(collected))
|
||||||
if problem:
|
if problem:
|
||||||
emit("")
|
emit("")
|
||||||
@@ -465,7 +465,7 @@ def _reboot_windows(
|
|||||||
Rueckkehr danach ist dasselbe -- und das steht beim Aufrufer.
|
Rueckkehr danach ist dasselbe -- und das steht beim Aufrufer.
|
||||||
"""
|
"""
|
||||||
host = str(device["ip"])
|
host = str(device["ip"])
|
||||||
emit(f"Windows-Geraet -- Neustart ueber RPC an {host} (Port {WINDOWS_PROBE_PORT}).")
|
emit(f"Windows-Gerät | Neustart über RPC an {host} (Port {WINDOWS_PROBE_PORT}).")
|
||||||
result = windowsops.reboot(host, username, password)
|
result = windowsops.reboot(host, username, password)
|
||||||
|
|
||||||
for line in result.output.splitlines():
|
for line in result.output.splitlines():
|
||||||
@@ -582,13 +582,13 @@ def run_reboot(
|
|||||||
sudo, sudo_stdin = _sudo_mode(transport, password, emit)
|
sudo, sudo_stdin = _sudo_mode(transport, password, emit)
|
||||||
command = REBOOT_COMMAND.format(sudo=sudo)
|
command = REBOOT_COMMAND.format(sudo=sudo)
|
||||||
try:
|
try:
|
||||||
emit(f"Neustart wird ausgeloest ({command})")
|
emit(f"Neustart wird ausgelöst ({command})")
|
||||||
try:
|
try:
|
||||||
_run_remote_command(
|
_run_remote_command(
|
||||||
transport, command, timeout=20, on_line=capture, stdin_text=sudo_stdin
|
transport, command, timeout=20, on_line=capture, stdin_text=sudo_stdin
|
||||||
)
|
)
|
||||||
except Exception as exc: # noqa: BLE001 - Abbruch ist hier normal
|
except Exception as exc: # noqa: BLE001 - Abbruch ist hier normal
|
||||||
emit(f"Verbindung getrennt ({exc}) -- das ist beim Neustart zu erwarten.")
|
emit(f"Verbindung getrennt ({exc}) – das ist beim Neustart zu erwarten.")
|
||||||
finally:
|
finally:
|
||||||
try:
|
try:
|
||||||
transport.close()
|
transport.close()
|
||||||
@@ -621,7 +621,7 @@ def run_reboot(
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
update_job(database, job_id, progress=40, message="Warte auf Rueckkehr des Geraets")
|
update_job(database, job_id, progress=40, message="Warte auf Rückkehr des Geräts")
|
||||||
time.sleep(REBOOT_GRACE_SECONDS)
|
time.sleep(REBOOT_GRACE_SECONDS)
|
||||||
deadline = time.monotonic() + wait_seconds
|
deadline = time.monotonic() + wait_seconds
|
||||||
ssh_port = probe_port
|
ssh_port = probe_port
|
||||||
@@ -632,7 +632,7 @@ def run_reboot(
|
|||||||
# Zeitueberschreitung, obwohl das Geraet zurueck war. Ausserdem ist
|
# Zeitueberschreitung, obwohl das Geraet zurueck war. Ausserdem ist
|
||||||
# "SSH antwortet wieder" die Aussage, auf die es hier ankommt.
|
# "SSH antwortet wieder" die Aussage, auf die es hier ankommt.
|
||||||
if tcp_reachable(host, ssh_port):
|
if tcp_reachable(host, ssh_port):
|
||||||
emit(f"Geraet ist wieder erreichbar (Port {ssh_port} antwortet).")
|
emit(f"Gerät ist wieder erreichbar (Port {ssh_port} antwortet).")
|
||||||
update_job(
|
update_job(
|
||||||
database,
|
database,
|
||||||
job_id,
|
job_id,
|
||||||
@@ -656,7 +656,7 @@ def run_reboot(
|
|||||||
result="ok",
|
result="ok",
|
||||||
actor=actor,
|
actor=actor,
|
||||||
duration_ms=elapsed(),
|
duration_ms=elapsed(),
|
||||||
detail="Neustart ueber SSH, Geraet ist zurueckgekehrt.",
|
detail="Neustart über SSH, Gerät ist zurückgekehrt.",
|
||||||
method=method,
|
method=method,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
@@ -668,7 +668,7 @@ def run_reboot(
|
|||||||
job_id,
|
job_id,
|
||||||
state="failed",
|
state="failed",
|
||||||
progress=100,
|
progress=100,
|
||||||
message=f"Geraet nicht zurueckgekehrt ({host}:{ssh_port} antwortet nicht).",
|
message=f"Gerät nicht zurückgekehrt ({host}:{ssh_port} antwortet nicht).",
|
||||||
finished=True,
|
finished=True,
|
||||||
)
|
)
|
||||||
_log_restart(
|
_log_restart(
|
||||||
@@ -678,7 +678,7 @@ def run_reboot(
|
|||||||
actor=actor,
|
actor=actor,
|
||||||
duration_ms=elapsed(),
|
duration_ms=elapsed(),
|
||||||
detail=(
|
detail=(
|
||||||
f"Neustart ausgeloest, aber {host}:{ssh_port} hat innerhalb von "
|
f"Neustart ausgelöst, aber {host}:{ssh_port} hat innerhalb von "
|
||||||
f"{wait_seconds}s nicht wieder geantwortet."
|
f"{wait_seconds}s nicht wieder geantwortet."
|
||||||
),
|
),
|
||||||
method=method,
|
method=method,
|
||||||
@@ -718,7 +718,7 @@ def _log_restart(
|
|||||||
method=method,
|
method=method,
|
||||||
)
|
)
|
||||||
except Exception: # noqa: BLE001 - Protokollieren darf den Auftrag nie kippen
|
except Exception: # noqa: BLE001 - Protokollieren darf den Auftrag nie kippen
|
||||||
logger.exception("Neustart konnte nicht protokolliert werden (Geraet %s)", device_id)
|
logger.exception("Neustart konnte nicht protokolliert werden (Gerät %s)", device_id)
|
||||||
|
|
||||||
|
|
||||||
def start_background(target: Callable[..., None], **kwargs: Any) -> None:
|
def start_background(target: Callable[..., None], **kwargs: Any) -> None:
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ def ping(host: str, *, timeout: int = PING_TIMEOUT_SECONDS) -> PingResult:
|
|||||||
check=False,
|
check=False,
|
||||||
)
|
)
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
return PingResult(False, detail="Zeitlimit ueberschritten")
|
return PingResult(False, detail="Zeitlimit überschritten")
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
return PingResult(False, detail=str(exc))
|
return PingResult(False, detail=str(exc))
|
||||||
|
|
||||||
@@ -274,8 +274,8 @@ METHOD_SSH = "ssh"
|
|||||||
METHOD_RPC = "rpc"
|
METHOD_RPC = "rpc"
|
||||||
METHOD_LABELS = {
|
METHOD_LABELS = {
|
||||||
METHOD_POE: "PoE-Port",
|
METHOD_POE: "PoE-Port",
|
||||||
METHOD_SSH: "Neustart ueber SSH",
|
METHOD_SSH: "Neustart über SSH",
|
||||||
METHOD_RPC: "Neustart ueber RPC",
|
METHOD_RPC: "Neustart über RPC",
|
||||||
}
|
}
|
||||||
|
|
||||||
#: Zustand zwischen ausgeloestem Neustart und Rueckkehr.
|
#: Zustand zwischen ausgeloestem Neustart und Rueckkehr.
|
||||||
@@ -406,7 +406,7 @@ def restart_device_poe(
|
|||||||
|
|
||||||
if not port or not switch_ip:
|
if not port or not switch_ip:
|
||||||
message = (
|
message = (
|
||||||
"Kein Switch oder kein Port zugeordnet -- ein PoE-Neustart ist nicht moeglich."
|
"Kein Switch oder kein Port zugeordnet – ein PoE-Neustart ist nicht möglich."
|
||||||
)
|
)
|
||||||
with write():
|
with write():
|
||||||
record_restart(
|
record_restart(
|
||||||
@@ -422,7 +422,7 @@ def restart_device_poe(
|
|||||||
return {"ok": False, "message": message, "transcript": ""}
|
return {"ok": False, "message": message, "transcript": ""}
|
||||||
|
|
||||||
if not credential_id:
|
if not credential_id:
|
||||||
message = f"Fuer Switch {device.get('switch_hostname') or switch_ip} sind keine Zugangsdaten hinterlegt."
|
message = f"Für Switch {device.get('switch_hostname') or switch_ip} sind keine Zugangsdaten hinterlegt."
|
||||||
with write():
|
with write():
|
||||||
record_restart(
|
record_restart(
|
||||||
conn,
|
conn,
|
||||||
@@ -486,7 +486,7 @@ def restart_device_poe(
|
|||||||
live.error("poe", f"{name}: PoE-Neustart fehlgeschlagen -- {outcome.error}")
|
live.error("poe", f"{name}: PoE-Neustart fehlgeschlagen -- {outcome.error}")
|
||||||
return {
|
return {
|
||||||
"ok": outcome.ok,
|
"ok": outcome.ok,
|
||||||
"message": outcome.error or "PoE-Neustart durchgefuehrt.",
|
"message": outcome.error or "PoE-Neustart durchgeführt.",
|
||||||
"transcript": outcome.transcript,
|
"transcript": outcome.transcript,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -507,6 +507,39 @@ class SweepReport:
|
|||||||
duration_ms: int = 0
|
duration_ms: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
def merke_vnc(conn: sqlite3.Connection, device_id: int, offen: bool, ziel: str = "") -> None:
|
||||||
|
"""Haelt fest, ob dort ein VNC-Server antwortet -- und unter welchem Ziel.
|
||||||
|
|
||||||
|
Das Ziel gehoert dazu, weil die Statusuebersicht die Verbindung unmittelbar
|
||||||
|
verlinkt. Es aus dem Namen neu herzuleiten hiesse, je Kachel und
|
||||||
|
Seitenaufbau eine Namensaufloesung zu versuchen; hier steht es einmal
|
||||||
|
geschrieben, von dem, der ohnehin gefragt hat.
|
||||||
|
|
||||||
|
Die Anmeldeverfahren bleiben unberuehrt: sie kommen aus einer anderen
|
||||||
|
Quelle (:func:`merke_verfahren`) und haetten hier nur den Wert "nicht
|
||||||
|
gefragt" -- der wuerde eine gute Messung ueberschreiben.
|
||||||
|
"""
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE device_status SET vnc_open=?, vnc_checked_at=?, vnc_target=? WHERE device_id=?",
|
||||||
|
(1 if offen else 0, _now(), ziel, device_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def merke_verfahren(
|
||||||
|
conn: sqlite3.Connection, device_id: int, verfahren: tuple[int, ...], absage: str = ""
|
||||||
|
) -> None:
|
||||||
|
"""Haelt fest, wie sich der VNC-Server authentifizieren will.
|
||||||
|
|
||||||
|
Mit Zeitstempel, denn diese Messung kostet eine angefangene
|
||||||
|
Protokollverhandlung -- und die soll sich nicht bei jedem Seitenaufbau
|
||||||
|
wiederholen.
|
||||||
|
"""
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE device_status SET vnc_types=?, vnc_reason=?, vnc_types_at=? WHERE device_id=?",
|
||||||
|
(",".join(str(v) for v in verfahren), absage, _now(), device_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def run_sweep(
|
def run_sweep(
|
||||||
*,
|
*,
|
||||||
database: Any,
|
database: Any,
|
||||||
@@ -514,13 +547,25 @@ def run_sweep(
|
|||||||
keystore: Any,
|
keystore: Any,
|
||||||
license_allows_auto_restart: bool,
|
license_allows_auto_restart: bool,
|
||||||
failures_before_restart: int = DEFAULT_FAILURES_BEFORE_RESTART,
|
failures_before_restart: int = DEFAULT_FAILURES_BEFORE_RESTART,
|
||||||
|
vnc_port: int = 0,
|
||||||
) -> SweepReport:
|
) -> SweepReport:
|
||||||
"""Ein vollstaendiger Ueberwachungsdurchlauf.
|
"""Ein vollstaendiger Ueberwachungsdurchlauf.
|
||||||
|
|
||||||
Die Pruefungen laufen parallel (I/O-gebunden), die Datenbankschreibvorgaenge
|
Die Pruefungen laufen parallel (I/O-gebunden), die Datenbankschreibvorgaenge
|
||||||
danach in **einer** Transaktion -- so gibt es keinen Schreib-Wettlauf und
|
danach in **einer** Transaktion -- so gibt es keinen Schreib-Wettlauf und
|
||||||
keine halb geschriebenen Durchlaeufe.
|
keine halb geschriebenen Durchlaeufe.
|
||||||
|
|
||||||
|
``vnc_port`` (0 = aus) haengt eine zweite, sehr kurze Frage an jedes
|
||||||
|
erreichbare Geraet: nimmt dort ein VNC-Server Verbindungen an? Sie gehoert
|
||||||
|
hierher und nicht in den Seitenaufbau -- einmal je Intervall statt einmal
|
||||||
|
je Aufruf, und nur fuer Geraete, die ohnehin gerade antworten.
|
||||||
|
|
||||||
|
**Nur die Frage nach dem Port**, kein Wort Protokoll: ein RealVNC Server
|
||||||
|
zaehlt abgebrochene Verhandlungen und sperrt die Adresse eine Weile aus.
|
||||||
|
Welche Anmeldeverfahren er anbietet, misst die Seite, die es braucht.
|
||||||
"""
|
"""
|
||||||
|
from . import vnc as vnc_dienst # spaet: vnc benutzt tcp_reachable von hier
|
||||||
|
|
||||||
report = SweepReport()
|
report = SweepReport()
|
||||||
started = time.monotonic()
|
started = time.monotonic()
|
||||||
|
|
||||||
@@ -531,15 +576,28 @@ def run_sweep(
|
|||||||
report.duration_ms = int((time.monotonic() - started) * 1000)
|
report.duration_ms = int((time.monotonic() - started) * 1000)
|
||||||
return report
|
return report
|
||||||
|
|
||||||
|
def pruefen(
|
||||||
|
device: dict[str, Any],
|
||||||
|
) -> tuple[dict[str, Any], PingResult, vnc_dienst.Zugang | None]:
|
||||||
|
ergebnis = ping(str(device["ip"]))
|
||||||
|
if not vnc_port or not ergebnis.ok:
|
||||||
|
# Kein Ergebnis heisst "nicht gefragt" -- und laesst den zuletzt
|
||||||
|
# bekannten Stand stehen. Ein Geraet, das gerade neu startet, soll
|
||||||
|
# seinen Knopf nicht verlieren.
|
||||||
|
return device, ergebnis, None
|
||||||
|
return device, ergebnis, vnc_dienst.zugang_kurz(device, port=vnc_port)
|
||||||
|
|
||||||
with ThreadPoolExecutor(max_workers=min(MAX_PARALLEL_CHECKS, len(devices))) as pool:
|
with ThreadPoolExecutor(max_workers=min(MAX_PARALLEL_CHECKS, len(devices))) as pool:
|
||||||
results = list(pool.map(lambda device: (device, ping(str(device["ip"]))), devices))
|
results = list(pool.map(pruefen, devices))
|
||||||
|
|
||||||
restart_candidates: list[dict[str, Any]] = []
|
restart_candidates: list[dict[str, Any]] = []
|
||||||
with database.session() as conn:
|
with database.session() as conn:
|
||||||
with database.transaction(conn):
|
with database.transaction(conn):
|
||||||
for device, result in results:
|
for device, result, vnc_offen in results:
|
||||||
report.checked += 1
|
report.checked += 1
|
||||||
previous, current = record_status(conn, int(device["id"]), result)
|
previous, current = record_status(conn, int(device["id"]), result)
|
||||||
|
if vnc_offen is not None:
|
||||||
|
merke_vnc(conn, int(device["id"]), vnc_offen.erreichbar, vnc_offen.ziel)
|
||||||
if result.ok:
|
if result.ok:
|
||||||
report.online += 1
|
report.online += 1
|
||||||
else:
|
else:
|
||||||
@@ -567,7 +625,7 @@ def run_sweep(
|
|||||||
report.skipped_unlicensed += 1
|
report.skipped_unlicensed += 1
|
||||||
live.warning(
|
live.warning(
|
||||||
"poe",
|
"poe",
|
||||||
f"{name}: automatischer PoE-Neustart uebersprungen -- keine gueltige Lizenz.",
|
f"{name}: automatischer PoE-Neustart übersprungen – keine gültige Lizenz.",
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
report.restarts_triggered += 1
|
report.restarts_triggered += 1
|
||||||
@@ -636,10 +694,12 @@ def next_check_at(conn: sqlite3.Connection, interval_seconds: int) -> datetime |
|
|||||||
return stamp + timedelta(seconds=max(1, int(interval_seconds)))
|
return stamp + timedelta(seconds=max(1, int(interval_seconds)))
|
||||||
|
|
||||||
|
|
||||||
def dashboard_counts(conn: sqlite3.Connection) -> dict[str, int]:
|
#: Zaehlung fuer die Statusuebersicht. Steht als Konstante, weil das
|
||||||
row = Database.one(
|
#: Anmeldebanner (``deploy/tesm-motd``) dieselben Zahlen zeigt und ohne
|
||||||
conn,
|
#: Anwendung laeuft -- es kann diese Datei nicht importieren, also vergleicht
|
||||||
"""
|
#: ein Hygienetest die beiden Zeichenketten. Zwei Wahrheiten ueber "online"
|
||||||
|
#: waeren schlimmer als eine doppelte Zeichenkette.
|
||||||
|
DASHBOARD_COUNT_SQL = """
|
||||||
SELECT
|
SELECT
|
||||||
COUNT(*) AS total,
|
COUNT(*) AS total,
|
||||||
SUM(CASE WHEN d.is_active=0 THEN 1 ELSE 0 END) AS disabled,
|
SUM(CASE WHEN d.is_active=0 THEN 1 ELSE 0 END) AS disabled,
|
||||||
@@ -649,8 +709,11 @@ def dashboard_counts(conn: sqlite3.Connection) -> dict[str, int]:
|
|||||||
SUM(CASE WHEN d.is_active=1 AND st.state='restarting' THEN 1 ELSE 0 END) AS restarting
|
SUM(CASE WHEN d.is_active=1 AND st.state='restarting' THEN 1 ELSE 0 END) AS restarting
|
||||||
FROM devices d LEFT JOIN device_status st ON st.device_id = d.id
|
FROM devices d LEFT JOIN device_status st ON st.device_id = d.id
|
||||||
WHERE d.deleted_at IS NULL
|
WHERE d.deleted_at IS NULL
|
||||||
""",
|
"""
|
||||||
)
|
|
||||||
|
|
||||||
|
def dashboard_counts(conn: sqlite3.Connection) -> dict[str, int]:
|
||||||
|
row = Database.one(conn, DASHBOARD_COUNT_SQL)
|
||||||
row = row or {}
|
row = row or {}
|
||||||
return {
|
return {
|
||||||
key: int(row.get(key) or 0)
|
key: int(row.get(key) or 0)
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ etwa bei einem Geraet, das schreibend auf eine Platte zugreift.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any
|
from typing import Any, Callable
|
||||||
|
|
||||||
METHOD_POE = "poe"
|
METHOD_POE = "poe"
|
||||||
METHOD_SSH = "ssh"
|
METHOD_SSH = "ssh"
|
||||||
@@ -40,6 +40,16 @@ class Method:
|
|||||||
#: Braucht dieser Weg das lizenzpflichtige Modul "Wartung"?
|
#: Braucht dieser Weg das lizenzpflichtige Modul "Wartung"?
|
||||||
licensed: bool = False
|
licensed: bool = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def recht(self) -> str:
|
||||||
|
"""Das Recht, das genau diesen Weg freigibt.
|
||||||
|
|
||||||
|
Am Weg und nicht in der Route: die Liste der Wege wird an vier Stellen
|
||||||
|
gebraucht (Dashboard, Geraeteliste, Detailseite, Route), und ein Recht,
|
||||||
|
das man an vier Stellen von Hand dazuschreibt, fehlt an der fuenften.
|
||||||
|
"""
|
||||||
|
return f"device_restart_{self.key}.execute"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_poe(self) -> bool:
|
def is_poe(self) -> bool:
|
||||||
return self.key == METHOD_POE
|
return self.key == METHOD_POE
|
||||||
@@ -54,36 +64,46 @@ DEFINITIONS: dict[str, Method] = {
|
|||||||
POWER_ON: Method(
|
POWER_ON: Method(
|
||||||
key=METHOD_POE,
|
key=METHOD_POE,
|
||||||
label="Einschalten (PoE)",
|
label="Einschalten (PoE)",
|
||||||
hint="Schaltet den Switch-Port aus und wieder ein. Bei einem Geraet, das nicht "
|
hint="Schaltet den Switch-Port aus und wieder ein. Bei einem Gerät, das nicht "
|
||||||
"antwortet, ist das der einzige Weg, der noch etwas bewirkt.",
|
"antwortet, ist das der einzige Weg, der noch etwas bewirkt.",
|
||||||
),
|
),
|
||||||
METHOD_POE: Method(
|
METHOD_POE: Method(
|
||||||
key=METHOD_POE,
|
key=METHOD_POE,
|
||||||
label="PoE-Port",
|
label="PoE-Port",
|
||||||
hint="Schaltet den Switch-Port kurz ab. Wirkt auch, wenn das Geraet haengt.",
|
hint="Schaltet den Switch-Port kurz ab. Wirkt auch, wenn das Gerät hängt.",
|
||||||
),
|
),
|
||||||
METHOD_SSH: Method(
|
METHOD_SSH: Method(
|
||||||
key=METHOD_SSH,
|
key=METHOD_SSH,
|
||||||
label="Neustart ueber SSH",
|
label="Neustart über SSH",
|
||||||
hint="Faehrt das Betriebssystem sauber herunter. Setzt ein antwortendes Geraet "
|
hint="Fährt das Betriebssystem sauber herunter. Setzt ein antwortendes Gerät "
|
||||||
"und einen freigegebenen Host-Schluessel voraus.",
|
"und einen freigegebenen Host-Schlüssel voraus.",
|
||||||
licensed=True,
|
licensed=True,
|
||||||
),
|
),
|
||||||
METHOD_RPC: Method(
|
METHOD_RPC: Method(
|
||||||
key=METHOD_RPC,
|
key=METHOD_RPC,
|
||||||
label="Neustart ueber RPC",
|
label="Neustart über RPC",
|
||||||
hint="Windows: wie 'shutdown /m'. Setzt offene Datei- und Druckerfreigabe voraus.",
|
hint="Windows: wie 'shutdown /m'. Setzt offene Datei- und Druckerfreigabe voraus.",
|
||||||
licensed=True,
|
licensed=True,
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def available(device: Any, *, maintenance_active: bool = True) -> list[Method]:
|
def available(
|
||||||
|
device: Any,
|
||||||
|
*,
|
||||||
|
maintenance_active: bool = True,
|
||||||
|
darf: Callable[[str], bool] | None = None,
|
||||||
|
) -> list[Method]:
|
||||||
"""Alle Wege, die bei diesem Geraet tatsaechlich zu etwas fuehren.
|
"""Alle Wege, die bei diesem Geraet tatsaechlich zu etwas fuehren.
|
||||||
|
|
||||||
Ein Weg, der nur *fast* geht -- Switch zugeordnet, aber kein Port --, gilt
|
Ein Weg, der nur *fast* geht -- Switch zugeordnet, aber kein Port --, gilt
|
||||||
als nicht verfuegbar. Ein Knopf, der verlaesslich nichts bewirkt, ist
|
als nicht verfuegbar. Ein Knopf, der verlaesslich nichts bewirkt, ist
|
||||||
schlimmer als kein Knopf.
|
schlimmer als kein Knopf.
|
||||||
|
|
||||||
|
``darf`` filtert zusaetzlich nach dem Recht des jeweiligen Weges (in der
|
||||||
|
Anwendung ``current_user.has``). Damit verschwindet der Knopf fuer den, der
|
||||||
|
ihn nicht druecken darf -- und zwar an allen Stellen zugleich, weil alle
|
||||||
|
dieselbe Liste benutzen. Ohne ``darf`` ist die Antwort rein technisch.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def field(name: str) -> Any:
|
def field(name: str) -> Any:
|
||||||
@@ -102,7 +122,10 @@ def available(device: Any, *, maintenance_active: bool = True) -> list[Method]:
|
|||||||
# der PoE-Weg, und der heisst dort "Einschalten": er schaltet den Port
|
# der PoE-Weg, und der heisst dort "Einschalten": er schaltet den Port
|
||||||
# wieder ein, statt ein laufendes System neu zu starten.
|
# wieder ein, statt ein laufendes System neu zu starten.
|
||||||
if str(field("state") or "") == "offline":
|
if str(field("state") or "") == "offline":
|
||||||
return [DEFINITIONS[POWER_ON]] if hat_poe else []
|
einschalten = [DEFINITIONS[POWER_ON]] if hat_poe else []
|
||||||
|
if darf is not None:
|
||||||
|
einschalten = [entry for entry in einschalten if darf(entry.recht)]
|
||||||
|
return einschalten
|
||||||
|
|
||||||
category = str(field("credential_category") or "")
|
category = str(field("credential_category") or "")
|
||||||
has_login = bool(field("credential_id"))
|
has_login = bool(field("credential_id"))
|
||||||
@@ -112,23 +135,39 @@ def available(device: Any, *, maintenance_active: bool = True) -> list[Method]:
|
|||||||
elif category == "linux":
|
elif category == "linux":
|
||||||
methods.append(DEFINITIONS[METHOD_SSH])
|
methods.append(DEFINITIONS[METHOD_SSH])
|
||||||
|
|
||||||
|
if darf is not None:
|
||||||
|
methods = [entry for entry in methods if darf(entry.recht)]
|
||||||
return sorted(methods, key=lambda entry: PREFERENCE.index(entry.key))
|
return sorted(methods, key=lambda entry: PREFERENCE.index(entry.key))
|
||||||
|
|
||||||
|
|
||||||
def preferred(device: Any, *, maintenance_active: bool = True) -> Method | None:
|
def preferred(
|
||||||
|
device: Any,
|
||||||
|
*,
|
||||||
|
maintenance_active: bool = True,
|
||||||
|
darf: Callable[[str], bool] | None = None,
|
||||||
|
) -> Method | None:
|
||||||
"""Der Weg, der vorausgewaehlt wird -- oder ``None``, wenn es keinen gibt."""
|
"""Der Weg, der vorausgewaehlt wird -- oder ``None``, wenn es keinen gibt."""
|
||||||
options = available(device, maintenance_active=maintenance_active)
|
options = available(device, maintenance_active=maintenance_active, darf=darf)
|
||||||
return options[0] if options else None
|
return options[0] if options else None
|
||||||
|
|
||||||
|
|
||||||
def resolve(device: Any, requested: str, *, maintenance_active: bool = True) -> Method | None:
|
def resolve(
|
||||||
|
device: Any,
|
||||||
|
requested: str,
|
||||||
|
*,
|
||||||
|
maintenance_active: bool = True,
|
||||||
|
darf: Callable[[str], bool] | None = None,
|
||||||
|
) -> Method | None:
|
||||||
"""Prueft eine Anforderung gegen das, was tatsaechlich moeglich ist.
|
"""Prueft eine Anforderung gegen das, was tatsaechlich moeglich ist.
|
||||||
|
|
||||||
Die Oberflaeche darf vorschlagen; entscheiden tut der Server. Ein Weg, der
|
Die Oberflaeche darf vorschlagen; entscheiden tut der Server. Ein Weg, der
|
||||||
fuer dieses Geraet nicht offensteht, wird nicht "irgendwie" ersetzt --
|
fuer dieses Geraet nicht offensteht, wird nicht "irgendwie" ersetzt --
|
||||||
sonst startet jemand ein Geraet anders neu, als er dachte.
|
sonst startet jemand ein Geraet anders neu, als er dachte.
|
||||||
"""
|
"""
|
||||||
options = {entry.key: entry for entry in available(device, maintenance_active=maintenance_active)}
|
options = {
|
||||||
|
entry.key: entry
|
||||||
|
for entry in available(device, maintenance_active=maintenance_active, darf=darf)
|
||||||
|
}
|
||||||
if not options:
|
if not options:
|
||||||
return None
|
return None
|
||||||
if not requested:
|
if not requested:
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ class ShellSession:
|
|||||||
elif time.monotonic() > deadline:
|
elif time.monotonic() > deadline:
|
||||||
channel.close()
|
channel.close()
|
||||||
raise SshError(
|
raise SshError(
|
||||||
f"Das Kommando hat das Zeitlimit von {timeout}s ueberschritten."
|
f"Das Kommando hat das Zeitlimit von {timeout}s überschritten."
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
time.sleep(0.05)
|
time.sleep(0.05)
|
||||||
|
|||||||
@@ -48,8 +48,8 @@ class UnknownHostKey(SshError):
|
|||||||
|
|
||||||
def __init__(self, host: str, port: int, key_type: str, fingerprint: str) -> None:
|
def __init__(self, host: str, port: int, key_type: str, fingerprint: str) -> None:
|
||||||
super().__init__(
|
super().__init__(
|
||||||
f"Der SSH-Host-Schluessel von {host}:{port} ist unbekannt "
|
f"Der SSH-Host-Schlüssel von {host}:{port} ist unbekannt "
|
||||||
f"({key_type}, {fingerprint}). Bitte pruefen und ausdruecklich freigeben."
|
f"({key_type}, {fingerprint}). Bitte prüfen und ausdrücklich freigeben."
|
||||||
)
|
)
|
||||||
self.host = host
|
self.host = host
|
||||||
self.port = port
|
self.port = port
|
||||||
@@ -62,9 +62,9 @@ class HostKeyChanged(SshError):
|
|||||||
|
|
||||||
def __init__(self, host: str, port: int, expected: str, received: str) -> None:
|
def __init__(self, host: str, port: int, expected: str, received: str) -> None:
|
||||||
super().__init__(
|
super().__init__(
|
||||||
f"Der SSH-Host-Schluessel von {host}:{port} hat sich geaendert "
|
f"Der SSH-Host-Schlüssel von {host}:{port} hat sich geändert "
|
||||||
f"(erwartet {expected}, erhalten {received}). Der Aufbau wurde abgebrochen. "
|
f"(erwartet {expected}, erhalten {received}). Der Aufbau wurde abgebrochen. "
|
||||||
"Das kann ein Geraetetausch sein -- oder ein Angriff."
|
"Das kann ein Gerätetausch sein – oder ein Angriff."
|
||||||
)
|
)
|
||||||
self.host = host
|
self.host = host
|
||||||
self.port = port
|
self.port = port
|
||||||
@@ -187,17 +187,17 @@ def describe_probe(
|
|||||||
if known is None:
|
if known is None:
|
||||||
return (
|
return (
|
||||||
"info",
|
"info",
|
||||||
f"Host-Schluessel gelesen: {record.key_type} {record.fingerprint}. "
|
f"Host-Schlüssel gelesen: {record.key_type} {record.fingerprint}. "
|
||||||
"Bitte gegen eine unabhaengige Quelle pruefen und dann freigeben.",
|
"Bitte gegen eine unabhängige Quelle prüfen und dann freigeben.",
|
||||||
)
|
)
|
||||||
if known.fingerprint != record.fingerprint:
|
if known.fingerprint != record.fingerprint:
|
||||||
return (
|
return (
|
||||||
"danger",
|
"danger",
|
||||||
f"Achtung: der Schluessel weicht vom gespeicherten ab. Gespeichert "
|
f"Achtung: der Schlüssel weicht vom gespeicherten ab. Gespeichert "
|
||||||
f"{known.fingerprint}, gelesen {record.fingerprint}. Das kann ein "
|
f"{known.fingerprint}, gelesen {record.fingerprint}. Das kann ein "
|
||||||
"Geraetetausch sein -- oder ein Angriff.",
|
"Gerätetausch sein – oder ein Angriff.",
|
||||||
)
|
)
|
||||||
return ("success", "Der Host-Schluessel stimmt mit dem gespeicherten ueberein.")
|
return ("success", "Der Host-Schlüssel stimmt mit dem gespeicherten überein.")
|
||||||
|
|
||||||
|
|
||||||
def probe_host_key(host: str, port: int = 22, *, timeout: int = CONNECT_TIMEOUT) -> HostKeyRecord:
|
def probe_host_key(host: str, port: int = 22, *, timeout: int = CONNECT_TIMEOUT) -> HostKeyRecord:
|
||||||
@@ -281,8 +281,8 @@ def open_transport(
|
|||||||
raise SshError(
|
raise SshError(
|
||||||
f"SSH-Handshake mit {host}:{port} fehlgeschlagen ({exc}). "
|
f"SSH-Handshake mit {host}:{port} fehlgeschlagen ({exc}). "
|
||||||
f"Freigegeben sind nur {', '.join(sorted(set(trusted_types)))} -- "
|
f"Freigegeben sind nur {', '.join(sorted(set(trusted_types)))} -- "
|
||||||
"bietet das Geraet inzwischen einen anderen Schluesseltyp an, muss der "
|
"bietet das Gerät inzwischen einen anderen Schlüsseltyp an, muss der "
|
||||||
"Schluessel neu ausgelesen und freigegeben werden."
|
"Schlüssel neu ausgelesen und freigegeben werden."
|
||||||
) from exc
|
) from exc
|
||||||
raise SshError(f"SSH-Handshake mit {host}:{port} fehlgeschlagen ({exc}).") from exc
|
raise SshError(f"SSH-Handshake mit {host}:{port} fehlgeschlagen ({exc}).") from exc
|
||||||
|
|
||||||
|
|||||||
@@ -30,14 +30,37 @@ from .ssh import SshError, open_transport
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
#: Steuerzeichen eines Terminals -- ANSI-Sequenzen, Glocke, Einzelbefehle.
|
||||||
|
#:
|
||||||
|
#: Ein ArubaOS-Switch an einem Pseudoterminal zeichnet seine Zeile neu und
|
||||||
|
#: haengt dabei Sequenzen wie ``ESC [ K`` ("bis Zeilenende loeschen") an. Im
|
||||||
|
#: Terminal sieht man davon **nichts** -- und genau das war der Fehler, der
|
||||||
|
#: Konsole und PoE-Neustart lahmgelegt hat: der Prompt stand da, aber hinter
|
||||||
|
#: ihm standen drei unsichtbare Bytes, und das verankerte Muster traf nicht
|
||||||
|
#: mehr. Im Protokoll las man "kein Prompt", waehrend der Prompt im Text
|
||||||
|
#: stand. Deshalb kommen die Steuerzeichen weg, bevor irgendetwas gesucht
|
||||||
|
#: wird -- das macht zugleich die Ausgabe in der Konsole lesbar.
|
||||||
|
ANSI_RE = re.compile(rb"\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b[@-Z\\-_]|[\x07\x00]")
|
||||||
|
|
||||||
#: Erkennt Operator- (``>``) und privilegierten Modus (``#``) sowie den
|
#: Erkennt Operator- (``>``) und privilegierten Modus (``#``) sowie den
|
||||||
#: Konfigurationsmodus (``switch(config)#``).
|
#: Konfigurationsmodus (``switch(config)#``).
|
||||||
PROMPT_RE = re.compile(rb"[\r\n][^\r\n]{0,80}?[>#]\s?$")
|
#:
|
||||||
|
#: Zwei Lockerungen gegenueber der ersten Fassung, beide aus dem Betrieb:
|
||||||
|
#: der Prompt darf am Anfang des Puffers stehen (nicht nur nach einem
|
||||||
|
#: Zeilenumbruch -- so kommt er nach einem geleerten Puffer), und hinter ihm
|
||||||
|
#: darf beliebiger Leerraum stehen (nicht genau eines).
|
||||||
|
PROMPT_RE = re.compile(rb"(?:^|[\r\n])[^\r\n]{0,120}?[>#]\s*$")
|
||||||
PAGER_RE = re.compile(rb"(--\s?MORE\s?--|Press any key to continue)", re.IGNORECASE)
|
PAGER_RE = re.compile(rb"(--\s?MORE\s?--|Press any key to continue)", re.IGNORECASE)
|
||||||
ERROR_RE = re.compile(
|
ERROR_RE = re.compile(
|
||||||
r"(invalid input|unknown command|incomplete|ambiguous|not supported|% *error)", re.IGNORECASE
|
r"(invalid input|unknown command|incomplete|ambiguous|not supported|% *error)", re.IGNORECASE
|
||||||
)
|
)
|
||||||
|
|
||||||
|
#: Der Weg nach draussen -- zweimal "exit" fuehrt aus jedem Modus in den
|
||||||
|
#: privilegierten zurueck, das dritte beendet die Sitzung. Genau so macht es
|
||||||
|
#: das bewaehrte Skript der Vorgaengerfassung.
|
||||||
|
ABMELDUNG = ("exit", "exit", "exit")
|
||||||
|
ABMELDE_PAUSE = 0.15
|
||||||
|
|
||||||
READ_CHUNK = 65536
|
READ_CHUNK = 65536
|
||||||
DEFAULT_COMMAND_TIMEOUT = 20
|
DEFAULT_COMMAND_TIMEOUT = 20
|
||||||
POE_SETTLE_SECONDS = 2
|
POE_SETTLE_SECONDS = 2
|
||||||
@@ -104,7 +127,42 @@ class SwitchCli:
|
|||||||
self.close()
|
self.close()
|
||||||
|
|
||||||
def close(self) -> None:
|
def close(self) -> None:
|
||||||
for closer in (self._channel, self._transport):
|
"""Meldet sich ab und legt dann erst auf.
|
||||||
|
|
||||||
|
Der entscheidende Teil, und der einzige, den das ``expect``-Skript der
|
||||||
|
Fassung 1.2.8 hatte und diese Fassung nicht: ``exit`` bis heraus aus
|
||||||
|
dem Konfigurationsmodus, dann ``exit``, und auf die Frage
|
||||||
|
"Do you want to log out (y/n)?" ein ``y``.
|
||||||
|
|
||||||
|
Warum das mehr ist als Hoeflichkeit: ArubaOS erlaubt nur eine Handvoll
|
||||||
|
gleichzeitiger SSH-Sitzungen (``show ip ssh``, Vorgabe fuenf) und
|
||||||
|
raeumt eine weggebrochene Verbindung **nicht sofort** ab. Wer die
|
||||||
|
Verbindung nur fallen laesst, belegt den Platz weiter. Nach ein paar
|
||||||
|
Vorgaengen -- ein Ueberwachungsdurchlauf mit mehreren Geraeten am
|
||||||
|
selben Switch reicht -- sind alle Plaetze belegt. Danach kommt eine
|
||||||
|
neue Sitzung noch durch Anmeldung und Authentisierung, bekommt aber
|
||||||
|
**keine Shell**: kein Prompt, keine Antwort, nichts. Im Betrieb sieht
|
||||||
|
das aus wie ein stummer Switch, und der Portzyklus passiert nicht.
|
||||||
|
|
||||||
|
Fehler werden hier geschluckt: eine Abmeldung, die nicht klappt, darf
|
||||||
|
das Ergebnis des Vorgangs nicht umwerfen -- aufgelegt wird ohnehin.
|
||||||
|
"""
|
||||||
|
kanal = self._channel
|
||||||
|
if kanal is not None:
|
||||||
|
try:
|
||||||
|
for zeile in ABMELDUNG:
|
||||||
|
kanal.send(zeile + "\r\n")
|
||||||
|
# Kurz warten, damit der Switch die Zeile verarbeitet --
|
||||||
|
# auf die Antwort zu warten wuerde beim Abmelden nur
|
||||||
|
# Zeitlimits sammeln.
|
||||||
|
time.sleep(ABMELDE_PAUSE)
|
||||||
|
# "Do you want to log out (y/n)?"
|
||||||
|
kanal.send("y\r\n")
|
||||||
|
time.sleep(ABMELDE_PAUSE)
|
||||||
|
except Exception: # noqa: BLE001 - die Verbindung ist ohnehin am Ende
|
||||||
|
logger.debug("Abmeldung von %s nicht mehr moeglich", self.host)
|
||||||
|
|
||||||
|
for closer in (kanal, self._transport):
|
||||||
try:
|
try:
|
||||||
if closer is not None:
|
if closer is not None:
|
||||||
closer.close()
|
closer.close()
|
||||||
@@ -116,28 +174,62 @@ class SwitchCli:
|
|||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
def _read_until_prompt(self, *, timeout: int, initial: bool = False) -> str:
|
def _read_until_prompt(self, *, timeout: int, initial: bool = False) -> str:
|
||||||
|
"""Liest bis zum Prompt. Ohne Prompt ist es ein Fehler, keine Antwort.
|
||||||
|
|
||||||
|
Bis 2.8.2 lief diese Schleife nach dem Zeitlimit einfach aus und gab
|
||||||
|
zurueck, was bis dahin gekommen war -- oft nichts. Der Aufrufer sah
|
||||||
|
eine leere Antwort und machte weiter: ``configure terminal`` ins
|
||||||
|
Nichts, ``no power-over-ethernet`` ins Nichts, und weil die
|
||||||
|
Zustandsabfrage dann "unknown" ergab und "unknown" als
|
||||||
|
"nicht widersprochen" durchging, meldete TESM am Ende **Erfolg** --
|
||||||
|
waehrend am Switch nichts geschaltet wurde. Genau das war im Betrieb
|
||||||
|
zu sehen: der Client blieb durchgehend erreichbar, das Protokoll sagte
|
||||||
|
"durchgefuehrt".
|
||||||
|
|
||||||
|
Ein Kommando ohne Prompt ist ein Kommando ohne Antwort. Das ist ein
|
||||||
|
Fehlschlag und wird als solcher gemeldet -- mit dem, was zuletzt
|
||||||
|
hereinkam, denn daran erkennt man, wo die Sitzung steht.
|
||||||
|
"""
|
||||||
assert self._channel is not None
|
assert self._channel is not None
|
||||||
deadline = time.monotonic() + timeout
|
deadline = time.monotonic() + timeout
|
||||||
collected = b""
|
# Zwei Puffer: das Rohe kommt herein, gesucht wird im Sichtbaren.
|
||||||
|
# Ohne diese Trennung entscheiden unsichtbare Steuerzeichen darueber,
|
||||||
|
# ob ein Prompt erkannt wird.
|
||||||
|
sichtbar = b""
|
||||||
|
prompt_gesehen = False
|
||||||
while time.monotonic() < deadline:
|
while time.monotonic() < deadline:
|
||||||
if self._channel.recv_ready():
|
if self._channel.recv_ready():
|
||||||
chunk = self._channel.recv(READ_CHUNK)
|
chunk = self._channel.recv(READ_CHUNK)
|
||||||
if not chunk:
|
if not chunk:
|
||||||
break
|
break
|
||||||
collected += chunk
|
sichtbar = ANSI_RE.sub(b"", sichtbar + chunk)
|
||||||
if PAGER_RE.search(collected[-200:]):
|
if PAGER_RE.search(sichtbar[-300:]):
|
||||||
self._channel.send(" ")
|
self._channel.send(" ")
|
||||||
collected = PAGER_RE.sub(b"", collected)
|
sichtbar = PAGER_RE.sub(b"", sichtbar)
|
||||||
continue
|
continue
|
||||||
if PROMPT_RE.search(collected[-200:]):
|
if PROMPT_RE.search(sichtbar[-300:]):
|
||||||
|
prompt_gesehen = True
|
||||||
break
|
break
|
||||||
# Der Login-Banner mancher Geraete verlangt eine Bestaetigung.
|
# Der Login-Banner mancher Geraete verlangt eine Bestaetigung.
|
||||||
if initial and collected.rstrip().endswith(b"continue"):
|
if initial and sichtbar.rstrip().endswith(b"continue"):
|
||||||
self._channel.send("\r\n")
|
self._channel.send("\r\n")
|
||||||
else:
|
else:
|
||||||
time.sleep(0.05)
|
time.sleep(0.05)
|
||||||
text = collected.decode("utf-8", errors="replace")
|
text = sichtbar.decode("utf-8", errors="replace")
|
||||||
self.transcript.append(text)
|
self.transcript.append(text)
|
||||||
|
if not prompt_gesehen:
|
||||||
|
letzte = " ".join(text.split())[-200:]
|
||||||
|
if initial:
|
||||||
|
raise SshError(
|
||||||
|
f"{self.host} hat sich nach der Anmeldung nicht gemeldet "
|
||||||
|
f"(kein Prompt innerhalb von {timeout}s)."
|
||||||
|
+ (f" Zuletzt kam: {letzte}" if letzte else " Es kam nichts.")
|
||||||
|
)
|
||||||
|
raise SshError(
|
||||||
|
f"{self.host} hat auf das Kommando nicht geantwortet "
|
||||||
|
f"(kein Prompt innerhalb von {timeout}s)."
|
||||||
|
+ (f" Zuletzt kam: {letzte}" if letzte else " Es kam nichts.")
|
||||||
|
)
|
||||||
return text
|
return text
|
||||||
|
|
||||||
def send(
|
def send(
|
||||||
@@ -149,7 +241,7 @@ class SwitchCli:
|
|||||||
``tolerate_error`` (z. B. ``no page``, das nicht jede Firmware kennt).
|
``tolerate_error`` (z. B. ``no page``, das nicht jede Firmware kennt).
|
||||||
"""
|
"""
|
||||||
if self._channel is None:
|
if self._channel is None:
|
||||||
raise SshError("Die Sitzung ist nicht geoeffnet.")
|
raise SshError("Die Sitzung ist nicht geöffnet.")
|
||||||
self.transcript.append(f"\n$ {command}\n")
|
self.transcript.append(f"\n$ {command}\n")
|
||||||
self._channel.send(command + "\r\n")
|
self._channel.send(command + "\r\n")
|
||||||
output = self._read_until_prompt(timeout=timeout)
|
output = self._read_until_prompt(timeout=timeout)
|
||||||
@@ -207,11 +299,22 @@ class SwitchCli:
|
|||||||
off_state = self.poe_state(port)
|
off_state = self.poe_state(port)
|
||||||
if off_state == "enabled":
|
if off_state == "enabled":
|
||||||
result.error = (
|
result.error = (
|
||||||
"Der Switch meldet den Port nach dem Abschalten weiter als aktiv -- "
|
"Der Switch meldet den Port nach dem Abschalten weiter als aktiv – "
|
||||||
"der Neustart hat nicht stattgefunden."
|
"der Neustart hat nicht stattgefunden."
|
||||||
)
|
)
|
||||||
result.transcript = self.full_transcript
|
result.transcript = self.full_transcript
|
||||||
return result
|
return result
|
||||||
|
if before == "enabled" and off_state == "unknown":
|
||||||
|
# Vorher lesbar, nachher nicht: dann ist nicht die Firmware
|
||||||
|
# unbekannt, sondern die Sitzung aus dem Tritt. Das als Erfolg
|
||||||
|
# zu zaehlen war der Fehler, der einen Neustart meldete, den
|
||||||
|
# es nicht gab.
|
||||||
|
result.error = (
|
||||||
|
"Der Zustand des Ports ließ sich nach dem Abschalten nicht mehr "
|
||||||
|
"lesen – vorher ging es. Der Portzyklus ist damit nicht belegt."
|
||||||
|
)
|
||||||
|
result.transcript = self.full_transcript
|
||||||
|
return result
|
||||||
result.add(f"Zustand im ausgeschalteten Zustand: {off_state}")
|
result.add(f"Zustand im ausgeschalteten Zustand: {off_state}")
|
||||||
|
|
||||||
self.set_poe(port, enabled=True)
|
self.set_poe(port, enabled=True)
|
||||||
@@ -224,6 +327,17 @@ class SwitchCli:
|
|||||||
result.transcript = self.full_transcript
|
result.transcript = self.full_transcript
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
if before == "unknown" and off_state == "unknown" and after == "unknown":
|
||||||
|
# Kein einziger Zustand lesbar: die Kommandos hat der Switch
|
||||||
|
# zwar mit einem Prompt bestaetigt (sonst waere die Sitzung
|
||||||
|
# oben abgebrochen), aber belegt ist der Zyklus nicht. Das
|
||||||
|
# gehoert in die Meldung -- "durchgefuehrt" waere zu viel
|
||||||
|
# gesagt.
|
||||||
|
result.add(
|
||||||
|
"Der Zustand war nicht lesbar – die Kommandos hat der Switch "
|
||||||
|
"angenommen, bestätigt ist der Zyklus damit nicht."
|
||||||
|
)
|
||||||
|
|
||||||
result.ok = True
|
result.ok = True
|
||||||
except SshError as exc:
|
except SshError as exc:
|
||||||
result.error = str(exc)
|
result.error = str(exc)
|
||||||
@@ -245,11 +359,11 @@ class SwitchCli:
|
|||||||
#: Kommandos, die die Oberflaeche ohne freies Eingabefeld anbietet.
|
#: Kommandos, die die Oberflaeche ohne freies Eingabefeld anbietet.
|
||||||
SAFE_COMMANDS: tuple[tuple[str, str], ...] = (
|
SAFE_COMMANDS: tuple[tuple[str, str], ...] = (
|
||||||
("show system information", "Systeminformationen"),
|
("show system information", "Systeminformationen"),
|
||||||
("show power-over-ethernet brief", "PoE-Uebersicht aller Ports"),
|
("show power-over-ethernet brief", "PoE-Übersicht aller Ports"),
|
||||||
("show interfaces brief", "Portstatus"),
|
("show interfaces brief", "Portstatus"),
|
||||||
("show vlans", "VLAN-Uebersicht"),
|
("show vlans", "VLAN-Uebersicht"),
|
||||||
("show mac-address", "MAC-Adresstabelle"),
|
("show mac-address", "MAC-Adresstabelle"),
|
||||||
("show lldp info remote-device", "Nachbargeraete (LLDP)"),
|
("show lldp info remote-device", "Nachbargeräte (LLDP)"),
|
||||||
("show version", "Firmware-Version"),
|
("show version", "Firmware-Version"),
|
||||||
("show logging", "Switch-Protokoll"),
|
("show logging", "Switch-Protokoll"),
|
||||||
)
|
)
|
||||||
@@ -14,6 +14,7 @@ from typing import Any, Callable, Iterable
|
|||||||
|
|
||||||
from tesm_core.db import Database
|
from tesm_core.db import Database
|
||||||
from tesm_core.keystore import Keystore
|
from tesm_core.keystore import Keystore
|
||||||
|
from tesm_core.rbac.legacy_permissions import umgeschrieben
|
||||||
from tesm_core.transfer import (
|
from tesm_core.transfer import (
|
||||||
MIN_PASSPHRASE_LENGTH,
|
MIN_PASSPHRASE_LENGTH,
|
||||||
PREVIEW_TTL_MINUTES,
|
PREVIEW_TTL_MINUTES,
|
||||||
@@ -50,20 +51,35 @@ def unseal(raw: bytes | str, passphrase: str) -> tuple[dict[str, Any], dict[str,
|
|||||||
|
|
||||||
|
|
||||||
CATEGORIES: tuple[Category, ...] = (
|
CATEGORIES: tuple[Category, ...] = (
|
||||||
Category("devices", "Clients", "Geraetestammdaten inklusive Switch- und Portzuordnung"),
|
Category("devices", "Clients", "Gerätestammdaten inklusive Switch- und Portzuordnung"),
|
||||||
Category("switches", "Switche", "Switch-Inventar"),
|
Category("switches", "Switche", "Switch-Inventar"),
|
||||||
Category(
|
Category(
|
||||||
"credentials",
|
"credentials",
|
||||||
"Zugangsdaten",
|
"Zugangsdaten",
|
||||||
"SSH-Logins. Die Passwoerter liegen im Export im Klartext -- geschuetzt "
|
"SSH-Logins. Die Passwörter liegen im Export im Klartext – geschützt "
|
||||||
"ausschliesslich durch die Passphrase.",
|
"ausschließlich durch die Passphrase.",
|
||||||
sensitive=True,
|
sensitive=True,
|
||||||
),
|
),
|
||||||
Category("dhcp", "DHCP-Konfiguration", "Subnetze, Optionen und manuelle Reservierungen"),
|
Category("dhcp", "DHCP-Konfiguration", "Subnetze, Optionen und manuelle Reservierungen"),
|
||||||
Category("fileshare", "Freigabe-Zuordnungen", "Verzeichnisgruppe zu UNC-Pfad"),
|
Category("fileshare", "Freigabe-Zuordnungen", "Verzeichnisgruppe zu UNC-Pfad"),
|
||||||
|
Category(
|
||||||
|
"directory",
|
||||||
|
"Verzeichnisdienst",
|
||||||
|
"LDAP-Anbindung, Bind-Konto und Gruppen-Zuordnungen. Das Passwort des "
|
||||||
|
"Bind-Kontos nur mit \"Geheimnisse einschliessen\".",
|
||||||
|
admin_only=True,
|
||||||
|
sensitive=True,
|
||||||
|
),
|
||||||
Category("users", "Benutzer", "Lokale Konten mit Passwort-Hash", admin_only=True),
|
Category("users", "Benutzer", "Lokale Konten mit Passwort-Hash", admin_only=True),
|
||||||
Category("groups", "Gruppen und Rechte", "Rechtematrix", admin_only=True),
|
Category("groups", "Gruppen und Rechte", "Rechtematrix", admin_only=True),
|
||||||
Category("settings", "Einstellungen", "Laufzeiteinstellungen der Anwendung", admin_only=True),
|
Category("settings", "Einstellungen", "Laufzeiteinstellungen der Anwendung", admin_only=True),
|
||||||
|
Category(
|
||||||
|
"signatures",
|
||||||
|
"Unterschriften",
|
||||||
|
"Pruefsummen unterschriebener Dokumente. Ohne sie sagt die Vorschau nach einer "
|
||||||
|
"Wiederherstellung zu jeder alten Fassung \"unbekannt\" – auch zu einer "
|
||||||
|
"veraenderten.",
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
CATEGORY_BY_KEY = {category.key: category for category in CATEGORIES}
|
CATEGORY_BY_KEY = {category.key: category for category in CATEGORIES}
|
||||||
@@ -123,7 +139,7 @@ def build_export(
|
|||||||
str(row["secret_encrypted"]), aad=aad_for_credential(int(row["id"]))
|
str(row["secret_encrypted"]), aad=aad_for_credential(int(row["id"]))
|
||||||
)
|
)
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
entry["secret_error"] = "nicht entschluesselbar"
|
entry["secret_error"] = "nicht entschlüsselbar"
|
||||||
entries.append(entry)
|
entries.append(entry)
|
||||||
payload["credentials"] = entries
|
payload["credentials"] = entries
|
||||||
if "dhcp" in wanted:
|
if "dhcp" in wanted:
|
||||||
@@ -178,6 +194,49 @@ def build_export(
|
|||||||
}
|
}
|
||||||
for row in Database.all(conn, "SELECT * FROM groups WHERE deleted_at IS NULL")
|
for row in Database.all(conn, "SELECT * FROM groups WHERE deleted_at IS NULL")
|
||||||
]
|
]
|
||||||
|
if "directory" in wanted:
|
||||||
|
# Die Anbindung an das Verzeichnis war das einzige Stueck Konfiguration,
|
||||||
|
# das man nach einer Wiederherstellung von Hand nachbauen musste --
|
||||||
|
# inklusive Bind-Passwort, das dabei jemand aus einem Kennwortspeicher
|
||||||
|
# holen muss. Es gehoert zur Anlage wie die Netzwerkeinstellung.
|
||||||
|
from tesm_core.auth import directory as verzeichnis
|
||||||
|
|
||||||
|
bind_benutzer, bind_passwort = verzeichnis.bind_credentials(conn)
|
||||||
|
gesetzt = {
|
||||||
|
str(row["key"]): str(row["value"])
|
||||||
|
for row in Database.all(conn, "SELECT key, value FROM settings")
|
||||||
|
}
|
||||||
|
payload["directory"] = {
|
||||||
|
# Nur die Schluessel der Anbindung, und nur die, die wirklich
|
||||||
|
# gesetzt sind: ein Vorgabewert in der Datei sieht aus wie eine
|
||||||
|
# Entscheidung und ueberschreibt beim Einlesen eine echte.
|
||||||
|
"settings": {
|
||||||
|
spec.key: gesetzt[spec.key] for spec in verzeichnis.SETTINGS
|
||||||
|
if spec.key in gesetzt
|
||||||
|
},
|
||||||
|
"bind_username": bind_benutzer,
|
||||||
|
# Ohne "Geheimnisse einschliessen" reist das Passwort nicht mit.
|
||||||
|
# Der Benutzername schon: er ist keine Geheimnis, und ohne ihn
|
||||||
|
# muesste man die halbe Anbindung doch wieder tippen.
|
||||||
|
"bind_password": bind_passwort if include_secrets else "",
|
||||||
|
# Die Zuordnung zeigt auf eine Gruppe **oder** auf das
|
||||||
|
# Administratorkennzeichen. Ueber eine Systemgrenze hinweg ist eine
|
||||||
|
# Gruppen-Id nichts wert -- der Name ist es.
|
||||||
|
"group_mappings": [
|
||||||
|
{
|
||||||
|
"ad_group_dn": str(row["ad_group_dn"]),
|
||||||
|
"ad_group_name": str(row["ad_group_name"]),
|
||||||
|
"group_name": str(row["group_name"] or ""),
|
||||||
|
"grants_admin": bool(row["grants_admin"]),
|
||||||
|
}
|
||||||
|
for row in Database.all(
|
||||||
|
conn,
|
||||||
|
"SELECT m.ad_group_dn, m.ad_group_name, m.grants_admin, g.name AS group_name "
|
||||||
|
"FROM ldap_group_mappings m "
|
||||||
|
"LEFT JOIN groups g ON g.id = m.app_group_id AND g.deleted_at IS NULL",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
}
|
||||||
if "settings" in wanted:
|
if "settings" in wanted:
|
||||||
payload["settings"] = {
|
payload["settings"] = {
|
||||||
str(row["key"]): str(row["value"])
|
str(row["key"]): str(row["value"])
|
||||||
@@ -186,6 +245,16 @@ def build_export(
|
|||||||
# Sicherung -- sonst wuerde ein Restore eine fremde Aktivierung erben.
|
# Sicherung -- sonst wuerde ein Restore eine fremde Aktivierung erben.
|
||||||
if not str(row["key"]).startswith("license_")
|
if not str(row["key"]).startswith("license_")
|
||||||
}
|
}
|
||||||
|
if "signatures" in wanted:
|
||||||
|
# Das Verzeichnis der Unterschriften. Der Beleg selbst bleibt das
|
||||||
|
# Aenderungsprotokoll (Hash-Kette) und das Journal neben dem Archiv --
|
||||||
|
# beides wandert nicht in die Sicherung. Ohne diese Zeilen waere nach
|
||||||
|
# einer Wiederherstellung jede unterschriebene Fassung "unbekannt".
|
||||||
|
payload["signatures"] = Database.all(
|
||||||
|
conn,
|
||||||
|
"SELECT sha256, share_label, path, signed_at, actor, names "
|
||||||
|
"FROM document_signatures ORDER BY id",
|
||||||
|
)
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|
||||||
@@ -237,6 +306,9 @@ def apply_import(
|
|||||||
"""
|
"""
|
||||||
from .inventory import ValidationError, aad_for_credential, normalize_mac
|
from .inventory import ValidationError, aad_for_credential, normalize_mac
|
||||||
|
|
||||||
|
#: Merkt, ob das Paket alte Rechteschluessel mitbringt (siehe unten).
|
||||||
|
alte_fassung: set[bool] = set()
|
||||||
|
|
||||||
def mac_or_none(value: Any) -> str | None:
|
def mac_or_none(value: Any) -> str | None:
|
||||||
"""MAC normalisieren, ohne den ganzen Import zu sprengen.
|
"""MAC normalisieren, ohne den ganzen Import zu sprengen.
|
||||||
|
|
||||||
@@ -513,19 +585,33 @@ def apply_import(
|
|||||||
|
|
||||||
if "fileshare" in wanted:
|
if "fileshare" in wanted:
|
||||||
for entry in payload.get("fileshare", []):
|
for entry in payload.get("fileshare", []):
|
||||||
|
gruppe = str(entry.get("ad_group_dn") or "")
|
||||||
|
pfad = str(entry.get("unc") or "")
|
||||||
|
if not gruppe or not pfad:
|
||||||
|
bump("fileshare", "skipped")
|
||||||
|
continue
|
||||||
|
# Vorher nachsehen, damit der Bericht die Wahrheit sagt: bisher
|
||||||
|
# stand dort auch bei einer frischen Installation "2 aktualisiert",
|
||||||
|
# obwohl zwei Zuordnungen **angelegt** wurden. Wer einen Import
|
||||||
|
# prueft, liest genau diese Zahlen.
|
||||||
|
vorhanden = Database.value(
|
||||||
|
conn,
|
||||||
|
"SELECT 1 FROM fileshare_mappings WHERE ad_group_dn=? AND unc=? LIMIT 1",
|
||||||
|
(gruppe, pfad),
|
||||||
|
)
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO fileshare_mappings (ad_group_dn, ad_group_name, label, unc, created_at) "
|
"INSERT INTO fileshare_mappings (ad_group_dn, ad_group_name, label, unc, created_at) "
|
||||||
"VALUES (?,?,?,?,?) ON CONFLICT(ad_group_dn, unc) DO UPDATE SET "
|
"VALUES (?,?,?,?,?) ON CONFLICT(ad_group_dn, unc) DO UPDATE SET "
|
||||||
"label=excluded.label, ad_group_name=excluded.ad_group_name",
|
"label=excluded.label, ad_group_name=excluded.ad_group_name",
|
||||||
(
|
(
|
||||||
str(entry.get("ad_group_dn") or ""),
|
gruppe,
|
||||||
str(entry.get("ad_group_name") or ""),
|
str(entry.get("ad_group_name") or ""),
|
||||||
str(entry.get("label") or ""),
|
str(entry.get("label") or ""),
|
||||||
str(entry.get("unc") or ""),
|
pfad,
|
||||||
stamp,
|
stamp,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
bump("fileshare", "updated")
|
bump("fileshare", "updated" if vorhanden else "created")
|
||||||
|
|
||||||
if "groups" in wanted:
|
if "groups" in wanted:
|
||||||
for entry in payload.get("groups", []):
|
for entry in payload.get("groups", []):
|
||||||
@@ -557,9 +643,21 @@ def apply_import(
|
|||||||
group_id = int(existing["id"])
|
group_id = int(existing["id"])
|
||||||
bump("groups", "updated")
|
bump("groups", "updated")
|
||||||
conn.execute("DELETE FROM group_permissions WHERE group_id=?", (group_id,))
|
conn.execute("DELETE FROM group_permissions WHERE group_id=?", (group_id,))
|
||||||
|
# Eine Sicherung aus 2.7.x nennt devices.view und devices.edit.
|
||||||
|
# Ohne die Umschreibung landen lauter unbekannte Schluessel in der
|
||||||
|
# Gruppe, und sanitize() wirft Unbekanntes still weg: die
|
||||||
|
# wiederhergestellte Gruppe waere leer, ohne eine Zeile Meldung.
|
||||||
|
mitgebracht = [str(recht) for recht in entry.get("permissions", []) or []]
|
||||||
|
neue = umgeschrieben(mitgebracht)
|
||||||
|
if sorted(set(mitgebracht)) != neue:
|
||||||
|
# Das Paket kennt die alten Schluessel -- es stammt also von
|
||||||
|
# vor 2.8.0. Ein Versionsfeld gibt es im Umschlag nicht, aber
|
||||||
|
# die Schluessel selbst sind das verlaesslichere Kennzeichen:
|
||||||
|
# sie stehen genau dann drin, wenn die Sicherung aelter ist.
|
||||||
|
alte_fassung.add(True)
|
||||||
conn.executemany(
|
conn.executemany(
|
||||||
"INSERT OR IGNORE INTO group_permissions (group_id, permission) VALUES (?,?)",
|
"INSERT OR IGNORE INTO group_permissions (group_id, permission) VALUES (?,?)",
|
||||||
[(group_id, str(permission)) for permission in entry.get("permissions", [])],
|
[(group_id, permission) for permission in neue],
|
||||||
)
|
)
|
||||||
|
|
||||||
if "users" in wanted:
|
if "users" in wanted:
|
||||||
@@ -625,6 +723,95 @@ def apply_import(
|
|||||||
(user_id, int(group_id)),
|
(user_id, int(group_id)),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if "signatures" in wanted:
|
||||||
|
for eintrag in payload.get("signatures") or []:
|
||||||
|
if not isinstance(eintrag, dict):
|
||||||
|
continue
|
||||||
|
summe = str(eintrag.get("sha256") or "").strip()
|
||||||
|
pfad = str(eintrag.get("path") or "").strip()
|
||||||
|
if not summe:
|
||||||
|
continue
|
||||||
|
# Zweimal einlesen darf nichts verdoppeln: verglichen wird ueber
|
||||||
|
# Pruefsumme und Ablageort, genau wie beim Journal.
|
||||||
|
vorhanden = Database.value(
|
||||||
|
conn,
|
||||||
|
"SELECT 1 FROM document_signatures WHERE sha256=? AND path=? LIMIT 1",
|
||||||
|
(summe, pfad),
|
||||||
|
)
|
||||||
|
if vorhanden:
|
||||||
|
bump("signatures", "skipped")
|
||||||
|
continue
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO document_signatures (sha256, share_label, path, signed_at, "
|
||||||
|
"actor, names, audit_id) VALUES (?,?,?,?,?,?,NULL)",
|
||||||
|
(
|
||||||
|
summe,
|
||||||
|
str(eintrag.get("share_label") or ""),
|
||||||
|
pfad,
|
||||||
|
str(eintrag.get("signed_at") or stamp),
|
||||||
|
str(eintrag.get("actor") or ""),
|
||||||
|
str(eintrag.get("names") or ""),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
bump("signatures", "created")
|
||||||
|
|
||||||
|
if "directory" in wanted:
|
||||||
|
from tesm_core.auth import directory as verzeichnis
|
||||||
|
|
||||||
|
abschnitt = payload.get("directory") or {}
|
||||||
|
bekannt = {spec.key for spec in verzeichnis.SETTINGS}
|
||||||
|
for key, value in (abschnitt.get("settings") or {}).items():
|
||||||
|
if str(key) not in bekannt:
|
||||||
|
# Ein fremder Schluessel in diesem Abschnitt gehoert nicht hier
|
||||||
|
# hin; die Kategorie "Einstellungen" nimmt alles andere.
|
||||||
|
bump("directory", "skipped")
|
||||||
|
continue
|
||||||
|
set_setting(str(key), value)
|
||||||
|
bump("directory", "updated")
|
||||||
|
|
||||||
|
benutzer = str(abschnitt.get("bind_username") or "").strip()
|
||||||
|
passwort = str(abschnitt.get("bind_password") or "")
|
||||||
|
if benutzer:
|
||||||
|
# Ohne Passwort bleibt ein vorhandenes stehen -- genau dafuer nimmt
|
||||||
|
# store_bind_credentials None. Eine Sicherung ohne Geheimnisse darf
|
||||||
|
# das Passwort nicht loeschen.
|
||||||
|
verzeichnis.store_bind_credentials(conn, benutzer, passwort or None)
|
||||||
|
bump("directory", "updated")
|
||||||
|
|
||||||
|
for eintrag in abschnitt.get("group_mappings") or []:
|
||||||
|
dn = str(eintrag.get("ad_group_dn") or "").strip()
|
||||||
|
if not dn:
|
||||||
|
bump("directory", "skipped")
|
||||||
|
continue
|
||||||
|
kennzeichen = bool(eintrag.get("grants_admin"))
|
||||||
|
gruppe = None
|
||||||
|
if not kennzeichen:
|
||||||
|
name = str(eintrag.get("group_name") or "").strip()
|
||||||
|
gruppe = Database.value(
|
||||||
|
conn, "SELECT id FROM groups WHERE name=? COLLATE NOCASE", (name,)
|
||||||
|
)
|
||||||
|
if gruppe is None:
|
||||||
|
# Eine Zuordnung auf eine Gruppe, die es hier nicht gibt,
|
||||||
|
# waere eine Zuordnung ins Leere: beim naechsten Login
|
||||||
|
# bekaeme das Konto nichts, und niemand wuesste warum.
|
||||||
|
bump("directory", "skipped")
|
||||||
|
continue
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO ldap_group_mappings "
|
||||||
|
"(ad_group_dn, ad_group_name, app_group_id, grants_admin, created_at) "
|
||||||
|
"VALUES (?,?,?,?,?) ON CONFLICT(ad_group_dn) DO UPDATE SET "
|
||||||
|
"ad_group_name=excluded.ad_group_name, app_group_id=excluded.app_group_id, "
|
||||||
|
"grants_admin=excluded.grants_admin",
|
||||||
|
(
|
||||||
|
dn,
|
||||||
|
str(eintrag.get("ad_group_name") or dn),
|
||||||
|
int(gruppe) if gruppe is not None else None,
|
||||||
|
int(kennzeichen),
|
||||||
|
stamp,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
bump("directory", "created")
|
||||||
|
|
||||||
if "settings" in wanted:
|
if "settings" in wanted:
|
||||||
for key, value in (payload.get("settings") or {}).items():
|
for key, value in (payload.get("settings") or {}).items():
|
||||||
if str(key).startswith("license_"):
|
if str(key).startswith("license_"):
|
||||||
@@ -634,4 +821,17 @@ def apply_import(
|
|||||||
set_setting(str(key), value)
|
set_setting(str(key), value)
|
||||||
bump("settings", "updated")
|
bump("settings", "updated")
|
||||||
|
|
||||||
|
if alte_fassung and "users" in wanted:
|
||||||
|
# Dieselbe Ueberlegung wie in der Migration: in der Welt, aus der
|
||||||
|
# diese Sicherung kommt, konnte ein Administrator alles. Ohne die
|
||||||
|
# Gruppe fehlt ihm nach dem Einlesen die halbe Anwendung -- und er
|
||||||
|
# haelt das fuer einen Fehler der Wiederherstellung.
|
||||||
|
from tesm_core.rbac import service as rbac
|
||||||
|
|
||||||
|
from ..permissions import FUNKTIONEN_VOLLZUGRIFF, PERMISSIONS
|
||||||
|
|
||||||
|
rbac.funktionsgruppe_fuer_administratoren(
|
||||||
|
conn, PERMISSIONS, FUNKTIONEN_VOLLZUGRIFF
|
||||||
|
)
|
||||||
|
|
||||||
return report
|
return report
|
||||||
@@ -0,0 +1,408 @@
|
|||||||
|
"""Bildschirmzugriff per VNC: Ziel bestimmen, Erreichbarkeit pruefen, Datei bauen.
|
||||||
|
|
||||||
|
Auf den HAPs (Raspberry Pis) laeuft ein VNC-Server. TESM kennt von jedem
|
||||||
|
Client Namen und Adresse -- daraus laesst sich die Verbindung erzeugen, statt
|
||||||
|
sie in jedem Viewer von Hand zu pflegen.
|
||||||
|
|
||||||
|
Zwei Wege, beide aus denselben zwei Angaben:
|
||||||
|
|
||||||
|
* eine **Verbindungsdatei** (``.vnc``), wie der RealVNC Viewer sie schreibt.
|
||||||
|
Sie enthaelt genau drei Zeilen -- ``ConnMethod``, ``FriendlyName``, ``Host``
|
||||||
|
-- und **nichts weiter**. Jede zusaetzliche Angabe ist eine Gelegenheit,
|
||||||
|
etwas zu verlangen, das hier niemand verlangen soll: keine
|
||||||
|
Verschluesselungsvorgabe, keine Identitaetspruefung, kein Benutzername, kein
|
||||||
|
Passwortfeld. Was am Ende gefragt wird, entscheidet ohnehin der VNC-Server
|
||||||
|
auf dem Geraet; die Datei fragt von sich aus nichts.
|
||||||
|
* eine **Adresse** (``vnc://host``) fuer den Fall, dass der Viewer als
|
||||||
|
Handler registriert ist -- ein Klick statt Herunterladen und Oeffnen.
|
||||||
|
|
||||||
|
Der Zugang erscheint nur dort, wo wirklich ein VNC-Server antwortet
|
||||||
|
(:func:`erreichbar`). Das ist der Unterschied zu einem Schalter am Geraet:
|
||||||
|
niemand muss ihn pflegen, und er zeigt nie ins Leere.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import socket
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
from .monitor import tcp_reachable
|
||||||
|
|
||||||
|
#: Vorgabeport von VNC (Anzeige :0). Ueberschreibbar in den Einstellungen.
|
||||||
|
STANDARD_PORT = 5900
|
||||||
|
|
||||||
|
#: So kurz, dass die Detailseite es nicht merkt, und so lang, dass ein Geraet
|
||||||
|
#: im selben Netz antworten kann. Gefragt wird ohnehin nur, wenn der Client
|
||||||
|
#: gerade als erreichbar gilt.
|
||||||
|
ZEITLIMIT = 0.6
|
||||||
|
|
||||||
|
#: Ein Name, den ein Rechner aufloesen kann: Buchstaben, Ziffern, Bindestrich,
|
||||||
|
#: Punkt als Trenner. "HAP 02" oder "Kamera Eingang" ist keiner -- dann bleibt
|
||||||
|
#: die IP-Adresse, die im Datensatz ohnehin steht.
|
||||||
|
HOSTNAME_RE = re.compile(r"^(?!-)[A-Za-z0-9-]{1,63}(?<!-)(\.(?!-)[A-Za-z0-9-]{1,63}(?<!-))*$")
|
||||||
|
|
||||||
|
#: Zeilenende der Datei: CRLF. Der Viewer liest beides, aber die Datei landet
|
||||||
|
#: auf einem Windows-Arbeitsplatz -- und dort soll sie in jedem Editor
|
||||||
|
#: vernuenftig aussehen, wenn jemand nachsieht, was drinsteht.
|
||||||
|
ZEILE = "\r\n"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class Zugang:
|
||||||
|
"""Was die Detailseite ueber den Bildschirmzugriff eines Clients weiss."""
|
||||||
|
|
||||||
|
ziel: str
|
||||||
|
port: int
|
||||||
|
erreichbar: bool
|
||||||
|
#: Die Sicherheitsverfahren, die der Server angeboten hat (leer: nicht
|
||||||
|
#: gemessen). Daraus entsteht der Satz, warum eine Sitzung im Browser
|
||||||
|
#: gehen wird oder nicht.
|
||||||
|
verfahren: tuple[int, ...] = ()
|
||||||
|
#: Der Text, mit dem der Server die Verbindung abgelehnt hat -- er nennt
|
||||||
|
#: dann kein Verfahren, sondern eine 0 und einen Grund.
|
||||||
|
absage: str = ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def browsertauglich(self) -> bool:
|
||||||
|
"""Ist mindestens ein angebotenes Verfahren eines, das noVNC kann?"""
|
||||||
|
return bool(set(self.verfahren) & BROWSER_VERFAHREN)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def hinderungsgrund(self) -> str:
|
||||||
|
"""Warum eine Sitzung im Browser nicht zustande kaeme -- im Klartext."""
|
||||||
|
if not self.erreichbar:
|
||||||
|
return f"Auf {self.ziel}:{self.port} antwortet kein VNC-Server."
|
||||||
|
if self.absage:
|
||||||
|
return (
|
||||||
|
f"Der VNC-Server auf {self.ziel} weist die Verbindung ab: "
|
||||||
|
f"„{self.absage}“. Das ist seine Auskunft, nicht unsere – "
|
||||||
|
"typisch sind eine Sperre nach zu vielen Fehlversuchen "
|
||||||
|
"(RealVNC: BlacklistTimeout), eine Adressfilterung oder eine "
|
||||||
|
"belegte Sitzung."
|
||||||
|
)
|
||||||
|
if not self.verfahren:
|
||||||
|
return ""
|
||||||
|
if self.browsertauglich:
|
||||||
|
return ""
|
||||||
|
angeboten = ", ".join(verfahren_name(v) for v in self.verfahren)
|
||||||
|
return (
|
||||||
|
f"Der VNC-Server bietet nur {angeboten} an. Der Browser-Client kann "
|
||||||
|
"davon keines: herstellereigene Verfahren sind nicht offengelegt, "
|
||||||
|
"und RA2 (5) verschlüsselt die ganze Strecke mit einem Verfahren, "
|
||||||
|
"das er nicht mitbringt. Auf dem Gerät hilft eine Zeile: "
|
||||||
|
"Encryption=AlwaysOff (dann bietet der Server RA2ne an) oder "
|
||||||
|
"Authentication=VncAuth samt vncpasswd (dann das klassische "
|
||||||
|
"VNC-Passwort)."
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def adresse(self) -> str:
|
||||||
|
"""``vnc://host`` -- den Standardport schreibt niemand dazu."""
|
||||||
|
if self.port == STANDARD_PORT:
|
||||||
|
return f"vnc://{self.ziel}"
|
||||||
|
return f"vnc://{self.ziel}:{self.port}"
|
||||||
|
|
||||||
|
|
||||||
|
#: Aufloesungen, die wir schon einmal versucht haben: ``name -> (Zeit, Ergebnis)``.
|
||||||
|
#:
|
||||||
|
#: Nicht wegen der Geschwindigkeit -- ein arbeitender Resolver antwortet in
|
||||||
|
#: Bruchteilen einer Millisekunde --, sondern wegen des Gegenteils: faellt der
|
||||||
|
#: Nameserver aus, laeuft jede Abfrage in sein eigenes Zeitlimit. Ohne diesen
|
||||||
|
#: Puffer haette das der Seitenaufbau ausgebadet, und zwar bei jedem Aufruf.
|
||||||
|
_AUFLOESUNG: dict[str, tuple[float, bool]] = {}
|
||||||
|
|
||||||
|
#: Fuenf Minuten. Lang genug, damit ein kaputter Resolver nicht jede Seite
|
||||||
|
#: aufhaelt; kurz genug, dass ein neuer DNS-Eintrag nicht bis zum Neustart
|
||||||
|
#: wartet.
|
||||||
|
AUFLOESUNG_GILT = 300.0
|
||||||
|
|
||||||
|
|
||||||
|
def aufloesbar(name: str) -> bool:
|
||||||
|
"""Kennt der Resolver diesen Namen?"""
|
||||||
|
jetzt = time.monotonic()
|
||||||
|
gemerkt = _AUFLOESUNG.get(name)
|
||||||
|
if gemerkt is not None and jetzt - gemerkt[0] < AUFLOESUNG_GILT:
|
||||||
|
return gemerkt[1]
|
||||||
|
try:
|
||||||
|
socket.getaddrinfo(name, None)
|
||||||
|
ergebnis = True
|
||||||
|
except OSError:
|
||||||
|
ergebnis = False
|
||||||
|
_AUFLOESUNG[name] = (jetzt, ergebnis)
|
||||||
|
return ergebnis
|
||||||
|
|
||||||
|
|
||||||
|
def ziel(device: Mapping[str, Any]) -> str:
|
||||||
|
"""Der Name des Clients, sonst seine IP-Adresse.
|
||||||
|
|
||||||
|
Der Name ist die bessere Wahl, wo er gilt: er ueberlebt einen
|
||||||
|
Adresswechsel, und genau so stehen die gewachsenen Verbindungen im Viewer
|
||||||
|
(``Host=hap02``). Zwei Dinge muessen dafuer stimmen -- er muss als Hostname
|
||||||
|
**taugen** ("Kamera Eingang" tut es nicht) und er muss sich **aufloesen**
|
||||||
|
lassen. Sonst zeigte die Datei auf einen Namen, den der Arbeitsplatz nicht
|
||||||
|
kennt, und niemand saehe, woran es liegt; dann ist die Adresse richtig, die
|
||||||
|
ohnehin im Datensatz steht.
|
||||||
|
|
||||||
|
Loest der Name auf eine **andere** Adresse auf als die gespeicherte, gewinnt
|
||||||
|
trotzdem der Name: dann ist der Eintrag im Bestand veraltet, nicht das DNS.
|
||||||
|
"""
|
||||||
|
name = str(device.get("name") or "").strip()
|
||||||
|
if name and HOSTNAME_RE.match(name) and aufloesbar(name):
|
||||||
|
return name.lower()
|
||||||
|
return str(device.get("ip") or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def erreichbar(host: str, port: int = STANDARD_PORT) -> bool:
|
||||||
|
"""Nimmt auf diesem Host ein VNC-Server Verbindungen an?"""
|
||||||
|
if not host:
|
||||||
|
return False
|
||||||
|
return tcp_reachable(host, port, timeout=ZEITLIMIT)
|
||||||
|
|
||||||
|
|
||||||
|
#: Was der Browser-Client (noVNC 1.6) aushandeln kann. Aus dessen
|
||||||
|
#: ``_isSupportedSecurityType`` uebernommen -- steht hier, weil die Oberflaeche
|
||||||
|
#: **vor** dem Verbinden sagen koennen soll, ob es klappen wird.
|
||||||
|
BROWSER_VERFAHREN: frozenset[int] = frozenset({1, 2, 6, 16, 19, 22, 30, 113})
|
||||||
|
|
||||||
|
#: Die Namen dazu. Was nicht drinsteht, ist herstellereigen -- die Nummern
|
||||||
|
#: 7 bis 15 gehoeren RealVNC, 3 und 4 ebenfalls.
|
||||||
|
VERFAHREN_NAMEN: dict[int, str] = {
|
||||||
|
1: "ohne Authentifizierung",
|
||||||
|
2: "VNC-Passwort",
|
||||||
|
5: "RA2 (RSA-AES, verschlüsselt)",
|
||||||
|
6: "RA2ne (RSA-AES, unverschlüsselt)",
|
||||||
|
16: "Tight",
|
||||||
|
18: "TLS",
|
||||||
|
19: "VeNCrypt",
|
||||||
|
22: "xvp",
|
||||||
|
30: "Apple ARD",
|
||||||
|
113: "MS-Logon II",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def verfahren_name(nummer: int) -> str:
|
||||||
|
"""Lesbarer Name eines Sicherheitsverfahrens."""
|
||||||
|
if nummer in VERFAHREN_NAMEN:
|
||||||
|
return f"{nummer} ({VERFAHREN_NAMEN[nummer]})"
|
||||||
|
if 3 <= nummer <= 15:
|
||||||
|
return f"{nummer} (RealVNC-eigen)"
|
||||||
|
return str(nummer)
|
||||||
|
|
||||||
|
|
||||||
|
def _absage(sock: Any) -> str:
|
||||||
|
"""Der Grund, den ein Server einer abgelehnten Verbindung mitgibt.
|
||||||
|
|
||||||
|
Auf eine 0 -- ob als Anzahl (ab 3.7) oder als bestimmtes Verfahren (3.3) --
|
||||||
|
folgt eine Laenge und ein Text. Genau dieser Satz ist die Auskunft, die
|
||||||
|
sonst niemand bekommt: "zu viele Fehlversuche", "Verbindung von dieser
|
||||||
|
Adresse nicht erlaubt", "kein gemeinsames Verfahren". Ohne ihn steht da
|
||||||
|
nur, dass es nicht geht.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
roh = sock.recv(4)
|
||||||
|
if len(roh) < 4:
|
||||||
|
return ""
|
||||||
|
laenge = int.from_bytes(roh, "big")
|
||||||
|
if not 0 < laenge <= 1024:
|
||||||
|
return ""
|
||||||
|
text = b""
|
||||||
|
while len(text) < laenge:
|
||||||
|
stueck = sock.recv(laenge - len(text))
|
||||||
|
if not stueck:
|
||||||
|
break
|
||||||
|
text += stueck
|
||||||
|
return text.decode("utf-8", "replace").strip()
|
||||||
|
except OSError:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def sicherheitsverfahren(
|
||||||
|
host: str, port: int = STANDARD_PORT
|
||||||
|
) -> tuple[str, tuple[int, ...], str]:
|
||||||
|
"""Fragt den Server, **wie** er sich authentifizieren will.
|
||||||
|
|
||||||
|
Der Anfang einer VNC-Verbindung ist fuer jeden Server derselbe: er nennt
|
||||||
|
seine Protokollfassung, der Client die seine, dann kommt die Liste der
|
||||||
|
Sicherheitsverfahren. Weiter gehen wir nicht -- die Verbindung wird sofort
|
||||||
|
geschlossen, es wird nichts angemeldet und nichts geraten.
|
||||||
|
|
||||||
|
Das ist die Angabe, an der eine Bildschirmsitzung im Browser scheitert oder
|
||||||
|
gelingt, und sie hier zu messen ist der Unterschied zwischen einer
|
||||||
|
schwarzen Flaeche und dem Satz "dieser Server bietet nur Verfahren an, die
|
||||||
|
der Browser nicht kennt".
|
||||||
|
|
||||||
|
Gibt ``(Fassung, Verfahren, Absage)`` zurueck. Die Absage ist der Text, den
|
||||||
|
ein Server mitschickt, wenn er die Verbindung **ablehnt** -- er meldet dann
|
||||||
|
kein Verfahren, sondern eine 0 und einen Grund. Bei einem Fehler
|
||||||
|
``("", (), "")``.
|
||||||
|
"""
|
||||||
|
import socket
|
||||||
|
|
||||||
|
try:
|
||||||
|
with socket.create_connection((host, int(port)), timeout=ZEITLIMIT) as sock:
|
||||||
|
sock.settimeout(ZEITLIMIT)
|
||||||
|
gruss = sock.recv(12)
|
||||||
|
if not gruss.startswith(b"RFB "):
|
||||||
|
return "", (), ""
|
||||||
|
fassung = gruss[4:11].decode("ascii", "replace")
|
||||||
|
# Wir antworten mit 3.8 -- der Fassung, die auch der Browser
|
||||||
|
# spricht. Ein Server, der mehr kann, faellt darauf zurueck und
|
||||||
|
# nennt trotzdem seine Verfahren.
|
||||||
|
# Antworten wird mit *seiner* Fassung, hoechstens 3.8. Wer einem
|
||||||
|
# 3.3-Server mit 3.8 antwortet, bekommt keine Liste, sondern eine
|
||||||
|
# 32-Bit-Zahl -- und liest sie als "keine Verfahren".
|
||||||
|
eigene = "003.003" if fassung == "003.003" else "003.008"
|
||||||
|
sock.sendall(b"RFB " + eigene.encode("ascii") + bytes([10]))
|
||||||
|
|
||||||
|
if eigene == "003.003":
|
||||||
|
# RFB 3.3: der Server bestimmt allein und schickt eine
|
||||||
|
# 32-Bit-Zahl. Das ist kein Sonderfall aus der Vorzeit --
|
||||||
|
# RealVNC faellt darauf zurueck, sobald keine Anmeldung
|
||||||
|
# verlangt wird.
|
||||||
|
rohwert = b""
|
||||||
|
while len(rohwert) < 4:
|
||||||
|
stueck = sock.recv(4 - len(rohwert))
|
||||||
|
if not stueck:
|
||||||
|
break
|
||||||
|
rohwert += stueck
|
||||||
|
if len(rohwert) < 4:
|
||||||
|
return fassung, (), ""
|
||||||
|
bestimmt = int.from_bytes(rohwert, "big")
|
||||||
|
if bestimmt == 0:
|
||||||
|
return fassung, (), _absage(sock)
|
||||||
|
return fassung, (bestimmt,), ""
|
||||||
|
|
||||||
|
anzahl = sock.recv(1)
|
||||||
|
if not anzahl:
|
||||||
|
return fassung, (), ""
|
||||||
|
wie_viele = anzahl[0]
|
||||||
|
if wie_viele == 0:
|
||||||
|
# Null heisst: der Server lehnt ab und schickt den Grund.
|
||||||
|
return fassung, (), _absage(sock)
|
||||||
|
liste = b""
|
||||||
|
while len(liste) < wie_viele:
|
||||||
|
stueck = sock.recv(wie_viele - len(liste))
|
||||||
|
if not stueck:
|
||||||
|
break
|
||||||
|
liste += stueck
|
||||||
|
return fassung, tuple(liste), ""
|
||||||
|
except OSError:
|
||||||
|
return "", (), ""
|
||||||
|
|
||||||
|
|
||||||
|
def zugang_kurz(device: Mapping[str, Any], *, port: int = STANDARD_PORT) -> Zugang:
|
||||||
|
"""Nur die Frage, ob dort jemand annimmt -- ohne ein Wort Protokoll.
|
||||||
|
|
||||||
|
Das ist die Fassung fuer den Ueberwachungsdurchlauf. Er laeuft alle paar
|
||||||
|
Minuten ueber jedes Geraet, und eine angefangene und sofort wieder
|
||||||
|
abgebrochene Protokollverhandlung ist nicht nur unhoeflich: ein RealVNC
|
||||||
|
Server zaehlt abgebrochene Versuche und sperrt die Adresse eine Weile aus
|
||||||
|
(``BlacklistTimeout``). Eine Ueberwachung, die ihr eigenes Ziel aussperrt,
|
||||||
|
waere ein schoener Eigentor -- also fragt sie nur, ob der Port annimmt.
|
||||||
|
|
||||||
|
Welche Anmeldeverfahren der Server kann, misst erst die Seite, die es
|
||||||
|
wirklich braucht -- und auch die nicht oefter als noetig.
|
||||||
|
"""
|
||||||
|
host = ziel(device)
|
||||||
|
return Zugang(ziel=host, port=port, erreichbar=erreichbar(host, port))
|
||||||
|
|
||||||
|
|
||||||
|
def zugang(device: Mapping[str, Any], *, port: int = STANDARD_PORT, pruefen: bool = True) -> Zugang:
|
||||||
|
"""Ziel, Erreichbarkeit und Verfahren in einem Stueck -- so nimmt es die Vorlage."""
|
||||||
|
host = ziel(device)
|
||||||
|
if not pruefen:
|
||||||
|
return Zugang(ziel=host, port=port, erreichbar=False)
|
||||||
|
fassung, verfahren, absage = sicherheitsverfahren(host, port)
|
||||||
|
return Zugang(
|
||||||
|
ziel=host,
|
||||||
|
port=port,
|
||||||
|
erreichbar=bool(fassung),
|
||||||
|
verfahren=verfahren,
|
||||||
|
absage=absage,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def zugang_gemerkt(device: Mapping[str, Any], *, port: int = STANDARD_PORT) -> Zugang:
|
||||||
|
"""Der Zugang, wie er in der Datenbank steht -- **ohne** das Geraet zu fragen.
|
||||||
|
|
||||||
|
Frueher hat diese Funktion selbst gemessen, wenn die letzte Messung alt war.
|
||||||
|
Das ist gestrichen, und zwar aus Erfahrung: jede Messung ist eine
|
||||||
|
angefangene und abgebrochene Protokollverhandlung, und ein RealVNC Server
|
||||||
|
zaehlt so etwas. Wer eine Bildschirmseite oeffnet, klopfte damit zweimal an
|
||||||
|
-- einmal zum Messen, einmal fuer die Sitzung --, und das zweite Klopfen
|
||||||
|
fiel dem ersten zum Opfer.
|
||||||
|
|
||||||
|
Jetzt beruehrt das Oeffnen einer Seite das Geraet gar nicht mehr. Die
|
||||||
|
Erreichbarkeit kommt aus dem Ueberwachungsdurchlauf (ein blosser
|
||||||
|
Verbindungsaufbau), die Anmeldeverfahren aus der letzten **ausdruecklichen**
|
||||||
|
Pruefung -- der Knopf dafuer steht auf der Seite und wird gedrueckt, wenn
|
||||||
|
etwas nicht geht.
|
||||||
|
"""
|
||||||
|
gespeichert = str(device.get("vnc_types") or "")
|
||||||
|
return Zugang(
|
||||||
|
ziel=ziel(device),
|
||||||
|
port=port,
|
||||||
|
erreichbar=bool(device.get("vnc_open")),
|
||||||
|
verfahren=tuple(
|
||||||
|
int(teil) for teil in gespeichert.split(",") if teil.strip().isdigit()
|
||||||
|
),
|
||||||
|
absage=str(device.get("vnc_reason") or ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def verbindungsdatei(
|
||||||
|
device: Mapping[str, Any], *, port: int = STANDARD_PORT, warnung_aus: bool = False
|
||||||
|
) -> str:
|
||||||
|
"""Der Inhalt der ``.vnc``-Datei -- drei Zeilen, keine vierte.
|
||||||
|
|
||||||
|
``ConnMethod=tcp`` ist die direkte Verbindung (nicht ueber den Cloud-Dienst
|
||||||
|
von RealVNC), ``FriendlyName`` der Name, den der Viewer anzeigt, ``Host``
|
||||||
|
das Ziel. Ein abweichender Port gehoert in dieser Datei an den Host, mit
|
||||||
|
Doppelpunkt -- einen eigenen Schluessel dafuer gibt es nicht.
|
||||||
|
|
||||||
|
``warnung_aus`` haengt eine vierte Zeile an: ``WarnUnencrypted=False``. Sie
|
||||||
|
betrifft **den Viewer**, nicht den Server -- er zeigt sonst bei jeder
|
||||||
|
Verbindung zu einem Geraet ohne Streckenverschluesselung einen roten
|
||||||
|
Hinweis, den man wegklicken muss. Wer ``Encryption=AlwaysOff`` auf dem
|
||||||
|
Geraet gesetzt hat (damit der Browser ueberhaupt verbinden kann), sieht
|
||||||
|
diesen Hinweis fortan bei jedem Start. Er bleibt in der Vorgabe stehen:
|
||||||
|
eine unverschluesselte Strecke soll man einmal bewusst hinnehmen, nicht
|
||||||
|
stillschweigend. Abschaltbar unter Verwaltung -> System.
|
||||||
|
"""
|
||||||
|
host = ziel(device)
|
||||||
|
if port != STANDARD_PORT:
|
||||||
|
host = f"{host}:{port}"
|
||||||
|
zeilen = [
|
||||||
|
"ConnMethod=tcp",
|
||||||
|
f"FriendlyName={str(device.get('name') or host).strip()}",
|
||||||
|
f"Host={host}",
|
||||||
|
]
|
||||||
|
if warnung_aus:
|
||||||
|
zeilen.append("WarnUnencrypted=False")
|
||||||
|
return ZEILE.join(zeilen) + ZEILE
|
||||||
|
|
||||||
|
|
||||||
|
def dateiname(device: Mapping[str, Any]) -> str:
|
||||||
|
"""``HAP02.vnc`` -- aus dem Namen, auf Unbedenkliches beschraenkt.
|
||||||
|
|
||||||
|
Auch der Punkt faellt weg, nicht nur der Schraegstrich: ein Geraetename ist
|
||||||
|
Freitext, und ein Dateiname, in dem ".." vorkommen kann, ist eine Frage,
|
||||||
|
die man sich gar nicht erst stellen muss.
|
||||||
|
"""
|
||||||
|
name = re.sub(r"[^A-Za-z0-9_-]+", "-", str(device.get("name") or "client")).strip("-")
|
||||||
|
return f"{name or 'client'}.vnc"
|
||||||
|
|
||||||
|
|
||||||
|
def port_aus_einstellungen(extension: Any) -> int:
|
||||||
|
"""Der eingestellte Port -- oder 0, wenn der Zugang abgeschaltet ist.
|
||||||
|
|
||||||
|
Nimmt die Erweiterung statt der Verbindung, weil der Ueberwachungsprozess
|
||||||
|
ausserhalb eines Anfragekontextes laeuft und seine eigene Sitzung oeffnet.
|
||||||
|
"""
|
||||||
|
with extension.database.session() as conn:
|
||||||
|
if not extension.settings.get(conn, "vnc_enabled"):
|
||||||
|
return 0
|
||||||
|
return int(extension.settings.get(conn, "vnc_port") or STANDARD_PORT)
|
||||||
@@ -51,28 +51,28 @@ def available() -> bool:
|
|||||||
HINTS: tuple[tuple[str, str], ...] = (
|
HINTS: tuple[tuple[str, str], ...] = (
|
||||||
(
|
(
|
||||||
"NT_STATUS_LOGON_FAILURE",
|
"NT_STATUS_LOGON_FAILURE",
|
||||||
"Benutzername oder Passwort wurden vom Windows-Geraet abgewiesen. Bei einem "
|
"Benutzername oder Passwort wurden vom Windows-Gerät abgewiesen. Bei einem "
|
||||||
"Domaenenkonto die Form DOMAENE\\benutzer verwenden -- mit *einem* "
|
"Domänenkonto die Form DOMAENE\\benutzer verwenden – mit *einem* "
|
||||||
"Backslash.",
|
"Backslash.",
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"NT_STATUS_ACCESS_DENIED",
|
"NT_STATUS_ACCESS_DENIED",
|
||||||
"Das Konto darf auf diesem Geraet keinen Neustart ausloesen. Es braucht das Recht "
|
"Das Konto darf auf diesem Gerät keinen Neustart auslösen. Es braucht das Recht "
|
||||||
"'Herunterfahren von einem Remotesystem aus' -- Mitglied der lokalen "
|
"'Herunterfahren von einem Remotesystem aus' | Mitglied der lokalen "
|
||||||
"Administratoren genuegt dafuer in der Regel.",
|
"Administratoren genügt dafür in der Regel.",
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"NT_STATUS_CONNECTION_REFUSED",
|
"NT_STATUS_CONNECTION_REFUSED",
|
||||||
"Das Geraet nimmt keine RPC-Verbindung an. Die Datei- und Druckerfreigabe muss "
|
"Das Gerät nimmt keine RPC-Verbindung an. Die Datei- und Druckerfreigabe muss "
|
||||||
"aktiv und Port 445 in der Windows-Firewall freigegeben sein.",
|
"aktiv und Port 445 in der Windows-Firewall freigegeben sein.",
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"NT_STATUS_IO_TIMEOUT",
|
"NT_STATUS_IO_TIMEOUT",
|
||||||
"Keine Antwort auf Port 445. Ist das Geraet erreichbar und die Firewall offen?",
|
"Keine Antwort auf Port 445. Ist das Gerät erreichbar und die Firewall offen?",
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"NT_STATUS_HOST_UNREACHABLE",
|
"NT_STATUS_HOST_UNREACHABLE",
|
||||||
"Das Geraet ist nicht erreichbar.",
|
"Das Gerät ist nicht erreichbar.",
|
||||||
),
|
),
|
||||||
# Diese Meldung liest sich wie ein technisches Detail, hat aber fast immer
|
# Diese Meldung liest sich wie ein technisches Detail, hat aber fast immer
|
||||||
# eine einzige Ursache: das Konto ist auf dem Zielgeraet kein Administrator.
|
# eine einzige Ursache: das Konto ist auf dem Zielgeraet kein Administrator.
|
||||||
@@ -81,14 +81,14 @@ HINTS: tuple[tuple[str, str], ...] = (
|
|||||||
# bekommt ein Standardkonto die winreg-Pipe gar nicht zu sehen.
|
# bekommt ein Standardkonto die winreg-Pipe gar nicht zu sehen.
|
||||||
(
|
(
|
||||||
"pipe winreg",
|
"pipe winreg",
|
||||||
"Das Konto hat auf diesem Geraet keine Administratorrechte. Ein Neustart ueber "
|
"Das Konto hat auf diesem Gerät keine Administratorrechte. Ein Neustart über "
|
||||||
"RPC braucht sie: die Pipe, ueber die er laeuft, ist fuer Standardkonten nicht "
|
"RPC braucht sie: die Pipe, über die er läuft, ist für Standardkonten nicht "
|
||||||
"sichtbar. Ein Konto der lokalen Administratoren hinterlegen -- pruefen laesst "
|
"sichtbar. Ein Konto der lokalen Administratoren hinterlegen – prüfen lässt "
|
||||||
"sich das mit 'net rpc service status RemoteRegistry'.",
|
"sich das mit 'net rpc service status RemoteRegistry'.",
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"NT_STATUS_OBJECT_NAME_NOT_FOUND",
|
"NT_STATUS_OBJECT_NAME_NOT_FOUND",
|
||||||
"Der benoetigte RPC-Dienst antwortet auf dem Geraet nicht. Meist fehlen dem "
|
"Der benötigte RPC-Dienst antwortet auf dem Gerät nicht. Meist fehlen dem "
|
||||||
"Konto die Administratorrechte; sonst ist der Dienst 'Remoteregistrierung' "
|
"Konto die Administratorrechte; sonst ist der Dienst 'Remoteregistrierung' "
|
||||||
"gestoppt.",
|
"gestoppt.",
|
||||||
),
|
),
|
||||||
@@ -143,8 +143,8 @@ def _net_with_credentials(
|
|||||||
return RemoteResult(
|
return RemoteResult(
|
||||||
False,
|
False,
|
||||||
"",
|
"",
|
||||||
f"Auf diesem Server fehlt das Paket {REQUIRED_PACKAGE}. Ohne 'net' laesst sich "
|
f"Auf diesem Server fehlt das Paket {REQUIRED_PACKAGE}. Ohne 'net' lässt sich "
|
||||||
"ein Windows-Geraet von hier nicht ansprechen.",
|
"ein Windows-Gerät von hier nicht ansprechen.",
|
||||||
)
|
)
|
||||||
if not username:
|
if not username:
|
||||||
return RemoteResult(False, "", "Es sind keine Zugangsdaten hinterlegt.")
|
return RemoteResult(False, "", "Es sind keine Zugangsdaten hinterlegt.")
|
||||||
@@ -176,7 +176,7 @@ def _net_with_credentials(
|
|||||||
check=False,
|
check=False,
|
||||||
)
|
)
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
return RemoteResult(False, "", f"Zeitlimit von {timeout}s ueberschritten.")
|
return RemoteResult(False, "", f"Zeitlimit von {timeout}s überschritten.")
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
return RemoteResult(False, "", str(exc))
|
return RemoteResult(False, "", str(exc))
|
||||||
return completed.returncode, (completed.stdout + completed.stderr).strip()
|
return completed.returncode, (completed.stdout + completed.stderr).strip()
|
||||||
@@ -190,7 +190,7 @@ def _net_with_credentials(
|
|||||||
stream.flush()
|
stream.flush()
|
||||||
os.fsync(stream.fileno())
|
os.fsync(stream.fileno())
|
||||||
except OSError: # pragma: no cover - defensiv
|
except OSError: # pragma: no cover - defensiv
|
||||||
logger.warning("Anmeldedatei konnte nicht ueberschrieben werden: %s", path)
|
logger.warning("Anmeldedatei konnte nicht überschrieben werden: %s", path)
|
||||||
try:
|
try:
|
||||||
os.unlink(path)
|
os.unlink(path)
|
||||||
except OSError: # pragma: no cover - defensiv
|
except OSError: # pragma: no cover - defensiv
|
||||||
@@ -236,10 +236,10 @@ def check(
|
|||||||
return RemoteResult(
|
return RemoteResult(
|
||||||
False,
|
False,
|
||||||
output,
|
output,
|
||||||
"Anmeldung erfolgreich, aber das Konto hat auf diesem Geraet keine "
|
"Anmeldung erfolgreich, aber das Konto hat auf diesem Gerät keine "
|
||||||
"Administratorrechte. Fuer einen Neustart ueber RPC braucht es sie.",
|
"Administratorrechte. Für einen Neustart über RPC braucht es sie.",
|
||||||
)
|
)
|
||||||
return RemoteResult(False, output, hint_for(output) or "Pruefung fehlgeschlagen.")
|
return RemoteResult(False, output, hint_for(output) or "Prüfung fehlgeschlagen.")
|
||||||
|
|
||||||
|
|
||||||
def reboot(
|
def reboot(
|
||||||
@@ -275,5 +275,5 @@ def reboot(
|
|||||||
return ergebnis
|
return ergebnis
|
||||||
code, output = ergebnis
|
code, output = ergebnis
|
||||||
if code == 0:
|
if code == 0:
|
||||||
return RemoteResult(True, output or "Neustart ausgeloest.")
|
return RemoteResult(True, output or "Neustart ausgelöst.")
|
||||||
return RemoteResult(False, output, hint_for(output))
|
return RemoteResult(False, output, hint_for(output))
|
||||||
@@ -12,8 +12,8 @@ TESM_SETTINGS: tuple[SettingSpec, ...] = (
|
|||||||
"monitor_interval_seconds",
|
"monitor_interval_seconds",
|
||||||
300,
|
300,
|
||||||
"int",
|
"int",
|
||||||
"Pruefintervall (Sekunden)",
|
"Prüfintervall (Sekunden)",
|
||||||
hint="Abstand zwischen zwei Erreichbarkeitspruefungen. Lizenzpflichtig.",
|
hint="Abstand zwischen zwei Erreichbarkeitsprüfungen. Lizenzpflichtig.",
|
||||||
section="monitoring",
|
section="monitoring",
|
||||||
minimum=30,
|
minimum=30,
|
||||||
maximum=86400,
|
maximum=86400,
|
||||||
@@ -23,8 +23,8 @@ TESM_SETTINGS: tuple[SettingSpec, ...] = (
|
|||||||
2,
|
2,
|
||||||
"int",
|
"int",
|
||||||
"Fehlversuche bis zum automatischen PoE-Neustart",
|
"Fehlversuche bis zum automatischen PoE-Neustart",
|
||||||
hint="Erst nach so vielen aufeinanderfolgenden Fehlschlaegen wird geschaltet. "
|
hint="Erst nach so vielen aufeinanderfolgenden Fehlschlägen wird geschaltet. "
|
||||||
"Der Vorgaenger schaltete sofort beim ersten Ausfall.",
|
"Der Vorgänger schaltete sofort beim ersten Ausfall.",
|
||||||
section="monitoring",
|
section="monitoring",
|
||||||
minimum=1,
|
minimum=1,
|
||||||
maximum=10,
|
maximum=10,
|
||||||
@@ -40,11 +40,42 @@ TESM_SETTINGS: tuple[SettingSpec, ...] = (
|
|||||||
"dashboard_public",
|
"dashboard_public",
|
||||||
True,
|
True,
|
||||||
"bool",
|
"bool",
|
||||||
"Statusuebersicht ohne Anmeldung erreichbar",
|
"Statusübersicht ohne Anmeldung erreichbar",
|
||||||
hint="Zeigt ausschliesslich Name und Erreichbarkeit aktiver Geraete -- keine "
|
hint="Zeigt ausschließlich Name und Erreichbarkeit aktiver Geräte – keine "
|
||||||
"IP-Adressen, keine Switch-Zuordnung, keine Verwaltungsfunktionen.",
|
"IP-Adressen, keine Switch-Zuordnung, keine Verwaltungsfunktionen.",
|
||||||
section="monitoring",
|
section="monitoring",
|
||||||
),
|
),
|
||||||
|
# -- Bildschirmzugriff (VNC) -------------------------------------------- #
|
||||||
|
SettingSpec(
|
||||||
|
"vnc_enabled",
|
||||||
|
True,
|
||||||
|
"bool",
|
||||||
|
"Bildschirmzugriff (VNC) anbieten",
|
||||||
|
hint="Der Überwachungsdurchlauf fragt bei jedem erreichbaren Client kurz, ob "
|
||||||
|
"auf diesem Port ein VNC-Server antwortet; nur dort erscheint der Knopf. "
|
||||||
|
"Sehen darf ihn trotzdem nur, wer das Recht dafür hat.",
|
||||||
|
section="monitoring",
|
||||||
|
),
|
||||||
|
SettingSpec(
|
||||||
|
"vnc_port",
|
||||||
|
5900,
|
||||||
|
"int",
|
||||||
|
"VNC-Port",
|
||||||
|
hint="5900 ist Anzeige :0 – die Vorgabe eines Raspberry Pi.",
|
||||||
|
section="monitoring",
|
||||||
|
minimum=1,
|
||||||
|
maximum=65535,
|
||||||
|
),
|
||||||
|
SettingSpec(
|
||||||
|
"vnc_warn_unencrypted_off",
|
||||||
|
False,
|
||||||
|
"bool",
|
||||||
|
"Warnung des Viewers vor unverschlüsselter Verbindung unterdrücken",
|
||||||
|
hint="Setzt WarnUnencrypted=False in die Verbindungsdatei. Betrifft nur den "
|
||||||
|
"RealVNC Viewer auf dem Arbeitsplatz, nicht die Verbindung selbst: die bleibt "
|
||||||
|
"unverschlüsselt, wenn das Gerät es so anbietet.",
|
||||||
|
section="monitoring",
|
||||||
|
),
|
||||||
# -- DHCP --------------------------------------------------------------- #
|
# -- DHCP --------------------------------------------------------------- #
|
||||||
SettingSpec("dhcp_domain", "local", "str", "Domainname", section="dhcp"),
|
SettingSpec("dhcp_domain", "local", "str", "Domainname", section="dhcp"),
|
||||||
SettingSpec(
|
SettingSpec(
|
||||||
@@ -74,20 +105,11 @@ TESM_SETTINGS: tuple[SettingSpec, ...] = (
|
|||||||
"fileshare_max_age_hours",
|
"fileshare_max_age_hours",
|
||||||
12,
|
12,
|
||||||
"int",
|
"int",
|
||||||
"Einbindungen automatisch loesen nach (Stunden)",
|
"Einbindungen automatisch lösen nach (Stunden)",
|
||||||
section="fileshare",
|
section="fileshare",
|
||||||
minimum=1,
|
minimum=1,
|
||||||
maximum=168,
|
maximum=168,
|
||||||
),
|
),
|
||||||
SettingSpec(
|
|
||||||
"fileshare_upload_max_mb",
|
|
||||||
512,
|
|
||||||
"int",
|
|
||||||
"Maximale Dateigroesse beim Hochladen (MB)",
|
|
||||||
section="fileshare",
|
|
||||||
minimum=1,
|
|
||||||
maximum=8192,
|
|
||||||
),
|
|
||||||
# -- Wartung ------------------------------------------------------------ #
|
# -- Wartung ------------------------------------------------------------ #
|
||||||
SettingSpec(
|
SettingSpec(
|
||||||
"maintenance_command_timeout",
|
"maintenance_command_timeout",
|
||||||
@@ -102,26 +124,50 @@ TESM_SETTINGS: tuple[SettingSpec, ...] = (
|
|||||||
"maintenance_reboot_wait",
|
"maintenance_reboot_wait",
|
||||||
300,
|
300,
|
||||||
"int",
|
"int",
|
||||||
"Wartezeit auf die Rueckkehr nach einem Neustart (Sekunden)",
|
"Wartezeit auf die Rückkehr nach einem Neustart (Sekunden)",
|
||||||
section="maintenance",
|
section="maintenance",
|
||||||
minimum=30,
|
minimum=30,
|
||||||
maximum=1800,
|
maximum=1800,
|
||||||
),
|
),
|
||||||
# -- Terminal ----------------------------------------------------------- #
|
# -- Unterschriften ------------------------------------------------------ #
|
||||||
SettingSpec(
|
SettingSpec(
|
||||||
"terminal_enabled",
|
"signature_witness_enabled",
|
||||||
True,
|
True,
|
||||||
"bool",
|
"bool",
|
||||||
"Browser-Terminal fuer Switche aktiv",
|
"Unterschriften mit dem ablegenden Konto bezeugen",
|
||||||
hint="Auch wenn aktiv: erfordert das Recht 'Ausfuehren' auf Switche, eine frische "
|
hint="Eine Zeile unter der **ersten** Unterschrift nennt das Konto, das das "
|
||||||
"Passwortbestaetigung und wird vollstaendig protokolliert.",
|
"Dokument in TESM abgelegt hat. In den Dokumenteigenschaften und im "
|
||||||
|
"Änderungsprotokoll steht es ohnehin, unabhängig von diesem Schalter. Der Name in "
|
||||||
|
"der Unterschrift ist eine Angabe des Unterschreibenden – wie auf Papier; das "
|
||||||
|
"Konto ist belegt. Eine Passwortabfrage gibt es dafür nicht.",
|
||||||
|
section="fileshare",
|
||||||
|
),
|
||||||
|
# -- Terminal ----------------------------------------------------------- #
|
||||||
|
SettingSpec(
|
||||||
|
"terminal_switches_enabled",
|
||||||
|
True,
|
||||||
|
"bool",
|
||||||
|
"Browser-Konsole für Switche zulassen",
|
||||||
|
hint="Auch wenn aktiv: erfordert das Recht 'Ausführen' auf Switche, eine frische "
|
||||||
|
"Passwortbestätigung und wird vollständig protokolliert. Das Freigeben von "
|
||||||
|
"Host-Schlüsseln bleibt davon unberührt – es wird für den PoE-Neustart "
|
||||||
|
"gebraucht.",
|
||||||
|
section="monitoring",
|
||||||
|
),
|
||||||
|
SettingSpec(
|
||||||
|
"terminal_clients_enabled",
|
||||||
|
True,
|
||||||
|
"bool",
|
||||||
|
"Browser-Konsole für Clients zulassen",
|
||||||
|
hint="Braucht zusätzlich das Modul Wartung und das Recht 'Ausführen' auf Wartung. "
|
||||||
|
"Das Freigeben von Host-Schlüsseln bleibt davon unberührt.",
|
||||||
section="monitoring",
|
section="monitoring",
|
||||||
),
|
),
|
||||||
SettingSpec(
|
SettingSpec(
|
||||||
"terminal_idle_timeout",
|
"terminal_idle_timeout",
|
||||||
300,
|
300,
|
||||||
"int",
|
"int",
|
||||||
"Terminal bei Untaetigkeit trennen nach (Sekunden)",
|
"Terminal bei Untätigkeit trennen nach (Sekunden)",
|
||||||
section="monitoring",
|
section="monitoring",
|
||||||
minimum=60,
|
minimum=60,
|
||||||
maximum=3600,
|
maximum=3600,
|
||||||
|
|||||||
@@ -84,7 +84,33 @@
|
|||||||
<a class="btn btn--sm btn--ghost" href="{{ url_for('devices.detail', device_id=tile.id) }}">
|
<a class="btn btn--sm btn--ghost" href="{{ url_for('devices.detail', device_id=tile.id) }}">
|
||||||
Details
|
Details
|
||||||
</a>
|
</a>
|
||||||
{% if current_user.has('devices.execute') and tile.restart_methods %}
|
{% if current_user.has('devices.write') and tile.state == 'disabled' %}
|
||||||
|
<form method="post" action="{{ url_for('devices.toggle_active', device_id=tile.id) }}"
|
||||||
|
data-no-guard>
|
||||||
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
|
<input type="hidden" name="active" value="1">
|
||||||
|
<button class="btn btn--sm" type="submit">Aktivieren</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{# Die beiden Eingriffe stehen unten und immer an derselben
|
||||||
|
Stelle: links der Bildschirm, rechts der Neustart. Wer
|
||||||
|
eine Reihe Kacheln abarbeitet, trifft denselben Knopf,
|
||||||
|
ohne hinzusehen -- und verwechselt ihn nicht mit dem
|
||||||
|
anderen. #}
|
||||||
|
{% if tile.vnc or tile.restart_methods %}
|
||||||
|
<div class="tile__actions">
|
||||||
|
{% if tile.vnc %}
|
||||||
|
{# Die Sitzung laeuft in TESM selbst: ein Klick, kein
|
||||||
|
Viewer auf dem Arbeitsplatz, keine Datei. #}
|
||||||
|
<a class="btn btn--sm" href="{{ url_for('screen.screen', device_id=tile.id) }}"
|
||||||
|
title="Bildschirm im Browser öffnen">
|
||||||
|
{{ icon('monitor', size=14) }} VNC
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<span class="tile__spacer" aria-hidden="true"></span>
|
||||||
|
{% endif %}
|
||||||
|
{% if tile.restart_methods %}
|
||||||
{% set method = tile.restart_methods[0] %}
|
{% set method = tile.restart_methods[0] %}
|
||||||
<form method="post" data-no-guard
|
<form method="post" data-no-guard
|
||||||
action="{{ url_for('devices.restart_device', device_id=tile.id) }}">
|
action="{{ url_for('devices.restart_device', device_id=tile.id) }}">
|
||||||
@@ -99,16 +125,9 @@
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if current_user.has('devices.edit') and tile.state == 'disabled' %}
|
|
||||||
<form method="post" action="{{ url_for('devices.toggle_active', device_id=tile.id) }}"
|
|
||||||
data-no-guard>
|
|
||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
|
||||||
<input type="hidden" name="active" value="1">
|
|
||||||
<button class="btn btn--sm" type="submit">Aktivieren</button>
|
|
||||||
</form>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
</article>
|
</article>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -149,10 +149,10 @@ sie getrennt von der Sicherung auf.', 'warning', 'Passphrase') }}
|
|||||||
</div>
|
</div>
|
||||||
<div class="card__body">
|
<div class="card__body">
|
||||||
<ul class="stack stack--tight fs-sm muted">
|
<ul class="stack stack--tight fs-sm muted">
|
||||||
<li>Verzeichniskonten werden nie exportiert -- sie entstehen am Ziel bei der ersten Anmeldung neu.</li>
|
<li>Verzeichniskonten werden nie exportiert – sie entstehen am Ziel bei der ersten Anmeldung neu.</li>
|
||||||
<li>Lizenz- und Aktivierungszustand bleiben ausgenommen: sie gehören zur Maschine, nicht zur Sicherung.</li>
|
<li>Lizenz- und Aktivierungszustand bleiben ausgenommen: sie gehören zur Maschine, nicht zur Sicherung.</li>
|
||||||
<li>Sitzungen, Ratenbegrenzung und das Änderungsprotokoll sind nicht Teil der Sicherung.</li>
|
<li>Sitzungen, Ratenbegrenzung und das Änderungsprotokoll sind nicht Teil der Sicherung.</li>
|
||||||
<li>Der Zwischenstand des Imports liegt in der Datenbank -- ein Dienst-Neustall verliert ihn nicht.</li>
|
<li>Der Zwischenstand des Imports liegt in der Datenbank – ein Dienst-Neustall verliert ihn nicht.</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ davon abhängt, welche Geräte auf der Wartungsseite erscheinen.', 'info') }}
|
|||||||
data-confirm-style="primary">{{ icon('eye', size=15) }}</button>
|
data-confirm-style="primary">{{ icon('eye', size=15) }}</button>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if current_user.has('credentials.edit') %}
|
{% if current_user.has('credentials.write') %}
|
||||||
<button class="btn btn--sm btn--ghost" type="button"
|
<button class="btn btn--sm btn--ghost" type="button"
|
||||||
data-dialog-open="credential-edit-{{ entry.id }}" aria-label="Bearbeiten">
|
data-dialog-open="credential-edit-{{ entry.id }}" aria-label="Bearbeiten">
|
||||||
{{ icon('pencil', size=15) }}
|
{{ icon('pencil', size=15) }}
|
||||||
@@ -121,7 +121,7 @@ davon abhängt, welche Geräte auf der Wartungsseite erscheinen.', 'info') }}
|
|||||||
</form>
|
</form>
|
||||||
{% endcall %}
|
{% endcall %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if current_user.has('credentials.edit') %}
|
{% if current_user.has('credentials.write') %}
|
||||||
{% for entry in credentials %}
|
{% for entry in credentials %}
|
||||||
{% call modal('credential-edit-' ~ entry.id, entry.name) %}
|
{% call modal('credential-edit-' ~ entry.id, entry.name) %}
|
||||||
<form method="post" action="{{ url_for('credentials.update', credential_id=entry.id) }}">
|
<form method="post" action="{{ url_for('credentials.update', credential_id=entry.id) }}">
|
||||||
|
|||||||
@@ -11,12 +11,18 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block topbar_extra %}
|
{% block topbar_extra %}
|
||||||
{% if terminal_enabled and current_user.has('maintenance.execute') and device.credential_id %}
|
{% if terminal_enabled and current_user.has('device_console.execute') and device.credential_id %}
|
||||||
<a class="btn btn--sm" href="{{ url_for('terminal.device_console', device_id=device.id) }}">
|
<a class="btn btn--sm" href="{{ url_for('terminal.device_console', device_id=device.id) }}">
|
||||||
{{ icon('terminal', size=15) }} Konsole
|
{{ icon('terminal', size=15) }} Konsole
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if current_user.has('devices.execute') and restart_methods %}
|
{% if vnc and vnc.erreichbar %}
|
||||||
|
<a class="btn btn--sm" href="{{ url_for('screen.screen', device_id=device.id) }}"
|
||||||
|
title="Bildschirm im Browser öffnen">
|
||||||
|
{{ icon('monitor', size=15) }} VNC
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if restart_methods %}
|
||||||
{% for method in restart_methods %}
|
{% for method in restart_methods %}
|
||||||
<form method="post" style="display:inline"
|
<form method="post" style="display:inline"
|
||||||
action="{{ url_for('devices.restart_device', device_id=device.id) }}" data-no-guard>
|
action="{{ url_for('devices.restart_device', device_id=device.id) }}" data-no-guard>
|
||||||
@@ -88,7 +94,7 @@
|
|||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="card__title">{{ icon('power', size=17) }} Neustarts</div>
|
<div class="card__title">{{ icon('power', size=17) }} Neustarts</div>
|
||||||
<div class="card__sub">
|
<div class="card__sub">
|
||||||
Die letzten {{ events | length }} Neustarts -- über den PoE-Port wie per SSH
|
Die letzten {{ events | length }} Neustarts – über den PoE-Port wie per SSH
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -115,7 +121,7 @@
|
|||||||
{% else %}{{ pill('fehlgeschlagen', 'danger') }}{% endif %}
|
{% else %}{{ pill('fehlgeschlagen', 'danger') }}{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="numeric fs-sm nowrap">{{ event.duration_ms | millis }}</td>
|
<td class="numeric fs-sm nowrap">{{ event.duration_ms | millis }}</td>
|
||||||
<td class="fs-xs muted break-all">{{ event.detail }}</td>
|
<td class="fs-xs muted break-all">{{ event.detail | audit_detail }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -138,7 +144,7 @@
|
|||||||
Ohne freigegebenen Schlüssel verweigern Wartung und Konsole die Verbindung.
|
Ohne freigegebenen Schlüssel verweigern Wartung und Konsole die Verbindung.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% if current_user.has('devices.edit') %}
|
{% if current_user.has('devices.write') %}
|
||||||
<form method="post" action="{{ url_for('devices.probe_host_key', device_id=device.id) }}"
|
<form method="post" action="{{ url_for('devices.probe_host_key', device_id=device.id) }}"
|
||||||
data-no-guard>
|
data-no-guard>
|
||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
@@ -151,7 +157,7 @@
|
|||||||
<div class="card__body stack">
|
<div class="card__body stack">
|
||||||
{% set probed_type = request.args.get('probed_type') %}
|
{% set probed_type = request.args.get('probed_type') %}
|
||||||
{% set probed_fingerprint = request.args.get('probed_fingerprint') %}
|
{% set probed_fingerprint = request.args.get('probed_fingerprint') %}
|
||||||
{% if probed_type and probed_fingerprint and current_user.has('devices.edit') %}
|
{% if probed_type and probed_fingerprint and current_user.has('devices.write') %}
|
||||||
<div class="alert alert--warning">
|
<div class="alert alert--warning">
|
||||||
{{ icon('alert', size=18) }}
|
{{ icon('alert', size=18) }}
|
||||||
<div class="alert__body">
|
<div class="alert__body">
|
||||||
@@ -182,7 +188,7 @@
|
|||||||
<dd class="mono fs-xs break-all">{{ key.fingerprint }}</dd>
|
<dd class="mono fs-xs break-all">{{ key.fingerprint }}</dd>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</dl>
|
</dl>
|
||||||
{% if current_user.has('devices.edit') %}
|
{% if current_user.has('devices.write') %}
|
||||||
<form method="post"
|
<form method="post"
|
||||||
action="{{ url_for('devices.forget_host_key', device_id=device.id) }}"
|
action="{{ url_for('devices.forget_host_key', device_id=device.id) }}"
|
||||||
data-no-guard>
|
data-no-guard>
|
||||||
@@ -206,7 +212,7 @@
|
|||||||
<section class="card">
|
<section class="card">
|
||||||
<div class="card__header">
|
<div class="card__header">
|
||||||
<div class="flex-1"><div class="card__title">{{ icon('info', size=17) }} Stammdaten</div></div>
|
<div class="flex-1"><div class="card__title">{{ icon('info', size=17) }} Stammdaten</div></div>
|
||||||
{% if current_user.has('devices.edit') %}
|
{% if current_user.has('devices.write') %}
|
||||||
<a class="btn btn--sm" href="{{ url_for('devices.overview') }}">Bearbeiten</a>
|
<a class="btn btn--sm" href="{{ url_for('devices.overview') }}">Bearbeiten</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
@@ -225,6 +231,29 @@
|
|||||||
<dt>Kategorie</dt><dd>{{ device.category or '–' | safe }}</dd>
|
<dt>Kategorie</dt><dd>{{ device.category or '–' | safe }}</dd>
|
||||||
<dt>Standort</dt><dd>{{ device.location or '–' | safe }}</dd>
|
<dt>Standort</dt><dd>{{ device.location or '–' | safe }}</dd>
|
||||||
<dt>Zugangsdaten</dt><dd>{{ device.credential_name or 'keine' }}</dd>
|
<dt>Zugangsdaten</dt><dd>{{ device.credential_name or 'keine' }}</dd>
|
||||||
|
{% if vnc %}
|
||||||
|
<dt>Bildschirm (VNC)</dt>
|
||||||
|
<dd>
|
||||||
|
{% if vnc.erreichbar %}
|
||||||
|
<span class="mono">{{ vnc.ziel }}:{{ vnc.port }}</span>
|
||||||
|
<div class="fs-xs faint">
|
||||||
|
<a href="{{ url_for('screen.screen', device_id=device.id) }}">
|
||||||
|
Im Browser öffnen</a> – oder die
|
||||||
|
<a href="{{ url_for('devices.vnc_connection', device_id=device.id) }}">
|
||||||
|
Verbindungsdatei</a> für einen Viewer auf dem Arbeitsplatz; sie
|
||||||
|
enthält nur Ziel und Namen.
|
||||||
|
{% if vnc.hinderungsgrund %}
|
||||||
|
<div class="mt-1">{{ vnc.hinderungsgrund }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<span class="faint">
|
||||||
|
kein VNC-Server auf {{ vnc.ziel }}:{{ vnc.port }}
|
||||||
|
{%- if device.state != 'online' %} (Client antwortet gerade nicht){% endif %}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</dd>
|
||||||
|
{% endif %}
|
||||||
<dt>Automatischer Neustart</dt><dd>{{ device.auto_restart | yes_no }}</dd>
|
<dt>Automatischer Neustart</dt><dd>{{ device.auto_restart | yes_no }}</dd>
|
||||||
<dt>Angelegt</dt><dd>{{ device.created_at | dt }}</dd>
|
<dt>Angelegt</dt><dd>{{ device.created_at | dt }}</dd>
|
||||||
<dt>Geändert</dt>
|
<dt>Geändert</dt>
|
||||||
@@ -232,7 +261,7 @@
|
|||||||
</dl>
|
</dl>
|
||||||
{% if device.notes %}<p class="fs-sm muted mt-3">{{ device.notes }}</p>{% endif %}
|
{% if device.notes %}<p class="fs-sm muted mt-3">{{ device.notes }}</p>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% if current_user.has('devices.edit') %}
|
{% if current_user.has('devices.write') %}
|
||||||
<div class="card__footer">
|
<div class="card__footer">
|
||||||
<form method="post" action="{{ url_for('devices.toggle_active', device_id=device.id) }}" data-no-guard>
|
<form method="post" action="{{ url_for('devices.toggle_active', device_id=device.id) }}" data-no-guard>
|
||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
|
|||||||
@@ -213,10 +213,10 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="actions">
|
<td class="actions">
|
||||||
{% if current_user.has('devices.execute') %}
|
{% if restart_methods.get(device.id) %}
|
||||||
{{ restart_control(device, restart_methods.get(device.id, []), compact=True) }}
|
{{ restart_control(device, restart_methods.get(device.id, []), compact=True) }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if current_user.has('devices.edit') %}
|
{% if current_user.has('devices.write') %}
|
||||||
<button class="btn btn--sm btn--ghost" type="button"
|
<button class="btn btn--sm btn--ghost" type="button"
|
||||||
data-dialog-open="device-edit-{{ device.id }}" aria-label="Bearbeiten">
|
data-dialog-open="device-edit-{{ device.id }}" aria-label="Bearbeiten">
|
||||||
{{ icon('pencil', size=15) }}
|
{{ icon('pencil', size=15) }}
|
||||||
@@ -263,7 +263,7 @@ nicht -- damit kein Neustart vorgetäuscht wird, der nichts bewirkt.', 'info') }
|
|||||||
{% endcall %}
|
{% endcall %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if current_user.has('devices.edit') %}
|
{% if current_user.has('devices.write') %}
|
||||||
{% for device in devices %}
|
{% for device in devices %}
|
||||||
{% call modal('device-edit-' ~ device.id, device.name, device.mac, 'wide') %}
|
{% call modal('device-edit-' ~ device.id, device.name, device.mac, 'wide') %}
|
||||||
<form method="post" action="{{ url_for('devices.update', device_id=device.id) }}">
|
<form method="post" action="{{ url_for('devices.update', device_id=device.id) }}">
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
<div class="stat">
|
<div class="stat">
|
||||||
<span class="stat__label">Reservierungen</span>
|
<span class="stat__label">Reservierungen</span>
|
||||||
<span class="stat__value">{{ reservations | length }}</span>
|
<span class="stat__value">{{ reservations | length }}</span>
|
||||||
<span class="stat__hint">automatisch + manüll</span>
|
<span class="stat__hint">automatisch + manuell</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
<span class="stat__label">Leases</span>
|
<span class="stat__label">Leases</span>
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if current_user.has('dhcp.execute') %}
|
{% if current_user.has('dhcp_service.execute') %}
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<div class="card__header">
|
<div class="card__header">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
@@ -72,7 +72,7 @@
|
|||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="card__title">{{ icon('network', size=17) }} Subnetze</div>
|
<div class="card__title">{{ icon('network', size=17) }} Subnetze</div>
|
||||||
<div class="card__sub">
|
<div class="card__sub">
|
||||||
Subnetz und Maske kommen vom laufenden System -- eingegeben wird nur der Adressbereich.
|
Subnetz und Maske kommen vom laufenden System – eingegeben wird nur der Adressbereich.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -89,7 +89,7 @@
|
|||||||
<td class="mono fs-sm">{{ subnet.dns or '–' | safe }}</td>
|
<td class="mono fs-sm">{{ subnet.dns or '–' | safe }}</td>
|
||||||
<td>{% if subnet.enabled %}{{ pill('aktiv', 'online') }}{% else %}{{ pill('pausiert', '') }}{% endif %}</td>
|
<td>{% if subnet.enabled %}{{ pill('aktiv', 'online') }}{% else %}{{ pill('pausiert', '') }}{% endif %}</td>
|
||||||
<td class="actions">
|
<td class="actions">
|
||||||
{% if current_user.has('dhcp.edit') %}
|
{% if current_user.has('dhcp_subnets.write') %}
|
||||||
<form method="post" action="{{ url_for('dhcp.delete_subnet', subnet_id=subnet.id) }}"
|
<form method="post" action="{{ url_for('dhcp.delete_subnet', subnet_id=subnet.id) }}"
|
||||||
style="display:inline" data-no-guard>
|
style="display:inline" data-no-guard>
|
||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
@@ -109,7 +109,7 @@
|
|||||||
{{ empty_state('Kein Subnetz', 'Ohne Subnetz startet Kea nicht.', 'network') }}
|
{{ empty_state('Kein Subnetz', 'Ohne Subnetz startet Kea nicht.', 'network') }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if current_user.has('dhcp.edit') %}
|
{% if current_user.has('dhcp_subnets.create') %}
|
||||||
<form method="post" action="{{ url_for('dhcp.add_subnet') }}">
|
<form method="post" action="{{ url_for('dhcp.add_subnet') }}">
|
||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
<div class="card__body">
|
<div class="card__body">
|
||||||
@@ -135,7 +135,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{# -- Globale Einstellungen --------------------------------------------- #}
|
{# -- Globale Einstellungen --------------------------------------------- #}
|
||||||
{% if current_user.has('dhcp.edit') %}
|
{% if current_user.has('dhcp_subnets.write') %}
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<div class="card__header">
|
<div class="card__header">
|
||||||
<div class="flex-1"><div class="card__title">{{ icon('sliders', size=17) }} Globale Einstellungen</div></div>
|
<div class="flex-1"><div class="card__title">{{ icon('sliders', size=17) }} Globale Einstellungen</div></div>
|
||||||
@@ -183,7 +183,7 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="fs-xs mono">{{ option.type }}</td>
|
<td class="fs-xs mono">{{ option.type }}</td>
|
||||||
<td>
|
<td>
|
||||||
{% if current_user.has('dhcp.edit') %}
|
{% if current_user.has('dhcp_options.write') %}
|
||||||
<form method="post" action="{{ url_for('dhcp.set_option_value') }}" class="row row--tight"
|
<form method="post" action="{{ url_for('dhcp.set_option_value') }}" class="row row--tight"
|
||||||
data-no-guard>
|
data-no-guard>
|
||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
@@ -197,7 +197,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="actions">
|
<td class="actions">
|
||||||
{% if current_user.has('dhcp.edit') and not option.is_standard %}
|
{% if current_user.has('dhcp_options.write') and not option.is_standard %}
|
||||||
<form method="post" action="{{ url_for('dhcp.delete_custom_option', option_id=option.id) }}"
|
<form method="post" action="{{ url_for('dhcp.delete_custom_option', option_id=option.id) }}"
|
||||||
data-no-guard>
|
data-no-guard>
|
||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
@@ -213,7 +213,7 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
{% if current_user.has('dhcp.edit') %}
|
{% if current_user.has('dhcp_options.write') %}
|
||||||
<form method="post" action="{{ url_for('dhcp.add_custom_option') }}">
|
<form method="post" action="{{ url_for('dhcp.add_custom_option') }}">
|
||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
<div class="card__body">
|
<div class="card__body">
|
||||||
@@ -339,7 +339,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="actions">
|
<td class="actions">
|
||||||
{% if reservation.source == 'manüll' and current_user.has('dhcp.edit') %}
|
{% if reservation.source == 'manuell' and current_user.has('dhcp_reservations.delete') %}
|
||||||
<form method="post" data-no-guard
|
<form method="post" data-no-guard
|
||||||
action="{{ url_for('dhcp.delete_reservation', reservation_id=reservation.reservation_id) }}">
|
action="{{ url_for('dhcp.delete_reservation', reservation_id=reservation.reservation_id) }}">
|
||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
@@ -355,7 +355,7 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
{% if current_user.has('dhcp.edit') %}
|
{% if current_user.has('dhcp_reservations.create') %}
|
||||||
<form method="post" action="{{ url_for('dhcp.add_reservation') }}">
|
<form method="post" action="{{ url_for('dhcp.add_reservation') }}">
|
||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
<div class="card__body">
|
<div class="card__body">
|
||||||
@@ -434,7 +434,7 @@
|
|||||||
{{ icon('chevron-down', size=16) }}
|
{{ icon('chevron-down', size=16) }}
|
||||||
</summary>
|
</summary>
|
||||||
<pre class="log-view">{{ preview }}</pre>
|
<pre class="log-view">{{ preview }}</pre>
|
||||||
{% if current_user.has('dhcp.edit') %}
|
{% if current_user.has('dhcp_config.execute') %}
|
||||||
<div class="card__footer">
|
<div class="card__footer">
|
||||||
<form method="post" action="{{ url_for('dhcp.write_config') }}" data-no-guard>
|
<form method="post" action="{{ url_for('dhcp.write_config') }}" data-no-guard>
|
||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
{% block page_sub %}{{ mounts | length }} eingebundene Freigabe(n){% endblock %}
|
{% block page_sub %}{{ mounts | length }} eingebundene Freigabe(n){% endblock %}
|
||||||
|
|
||||||
{% block topbar_extra %}
|
{% block topbar_extra %}
|
||||||
{% if current_user.has('fileshare.edit') %}
|
{% if current_user.has('fileshare.write') %}
|
||||||
<a class="btn btn--sm" href="{{ url_for('fileshare.mappings') }}">
|
<a class="btn btn--sm" href="{{ url_for('fileshare.mappings') }}">
|
||||||
{{ icon('settings', size=15) }} Zuordnungen
|
{{ icon('settings', size=15) }} Zuordnungen
|
||||||
</a>
|
</a>
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
<div class="tabs">
|
<div class="tabs">
|
||||||
{% for mount in mounts %}
|
{% for mount in mounts %}
|
||||||
<a class="tab {{ 'is-active' if mount.token == token else '' }}"
|
<a class="tab {{ 'is-active' if mount.token == token else '' }}"
|
||||||
href="{{ url_for('fileshare.browse', freigabe=mount.token) }}">{{ mount.label }}</a>
|
href="{{ url_for('fileshare.browse', share=mount.token) }}">{{ mount.label }}</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -33,9 +33,9 @@
|
|||||||
<div class="card__header">
|
<div class="card__header">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<nav class="breadcrumb">
|
<nav class="breadcrumb">
|
||||||
<span><a href="{{ url_for('fileshare.browse', freigabe=token) }}">{{ current.label }}</a></span>
|
<span><a href="{{ url_for('fileshare.browse', share=token) }}">{{ current.label }}</a></span>
|
||||||
{% for crumb in listing.breadcrumb %}
|
{% for crumb in listing.breadcrumb %}
|
||||||
<span><a href="{{ url_for('fileshare.browse', freigabe=token, pfad=crumb.relative) }}">{{ crumb.name }}</a></span>
|
<span><a href="{{ url_for('fileshare.browse', share=token, path=crumb.relative) }}">{{ crumb.name }}</a></span>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</nav>
|
</nav>
|
||||||
<div class="card__sub mono fs-xs">{{ current.unc }}</div>
|
<div class="card__sub mono fs-xs">{{ current.unc }}</div>
|
||||||
@@ -69,8 +69,8 @@
|
|||||||
<tr>
|
<tr>
|
||||||
{% if current_user.has('fileshare.delete') %}<td></td>{% endif %}
|
{% if current_user.has('fileshare.delete') %}<td></td>{% endif %}
|
||||||
<td colspan="4">
|
<td colspan="4">
|
||||||
<a class="link-icon" href="{{ url_for('fileshare.browse', freigabe=token,
|
<a class="link-icon" href="{{ url_for('fileshare.browse', share=token,
|
||||||
pfad=(listing.breadcrumb[-2].relative if listing.breadcrumb | length > 1 else '')) }}">
|
path=(listing.breadcrumb[-2].relative if listing.breadcrumb | length > 1 else '')) }}">
|
||||||
{{ icon('chevron-right', size=14) }} eine Ebene höher
|
{{ icon('chevron-right', size=14) }} eine Ebene höher
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
@@ -79,13 +79,26 @@
|
|||||||
{% for entry in listing.directories %}
|
{% for entry in listing.directories %}
|
||||||
<tr>
|
<tr>
|
||||||
{% if current_user.has('fileshare.delete') %}
|
{% if current_user.has('fileshare.delete') %}
|
||||||
<td><label class="check"><input type="checkbox" name="pfade" value="{{ entry.relative }}"><span class="sr-only">auswählen</span></label></td>
|
<td><label class="check"><input type="checkbox" name="paths" value="{{ entry.relative }}"><span class="sr-only">auswählen</span></label></td>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<td>
|
{# Symbol und Name in einer Zeile: der Zeilenumbruch dazwischen
|
||||||
|
liess den Ordner aussehen wie zwei halbe Zeilen. #}
|
||||||
|
<td class="file-cell">
|
||||||
{{ icon('folder', size=15) }}
|
{{ icon('folder', size=15) }}
|
||||||
<a href="{{ url_for('fileshare.browse', freigabe=token, pfad=entry.relative) }}">{{ entry.name }}</a>
|
<a href="{{ url_for('fileshare.browse', share=token, path=entry.relative) }}">{{ entry.name }}</a>
|
||||||
|
</td>
|
||||||
|
{# An der Stelle der Dateigrösse: wie viel liegt darin. #}
|
||||||
|
<td class="numeric faint fs-sm">
|
||||||
|
{%- if entry.entries is not defined or entry.entries < 0 -%}
|
||||||
|
–
|
||||||
|
{%- elif entry.entries >= 500 -%}
|
||||||
|
500+ Einträge
|
||||||
|
{%- elif entry.entries == 1 -%}
|
||||||
|
1 Eintrag
|
||||||
|
{%- else -%}
|
||||||
|
{{ entry.entries }} Einträge
|
||||||
|
{%- endif -%}
|
||||||
</td>
|
</td>
|
||||||
<td class="numeric faint">–</td>
|
|
||||||
<td class="fs-sm">{{ entry.modified | dt }}</td>
|
<td class="fs-sm">{{ entry.modified | dt }}</td>
|
||||||
<td class="actions"></td>
|
<td class="actions"></td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -93,7 +106,7 @@
|
|||||||
{% for entry in listing.files %}
|
{% for entry in listing.files %}
|
||||||
<tr>
|
<tr>
|
||||||
{% if current_user.has('fileshare.delete') %}
|
{% if current_user.has('fileshare.delete') %}
|
||||||
<td><label class="check"><input type="checkbox" name="pfade" value="{{ entry.relative }}"><span class="sr-only">auswählen</span></label></td>
|
<td><label class="check"><input type="checkbox" name="paths" value="{{ entry.relative }}"><span class="sr-only">auswählen</span></label></td>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{# Der Name ist die grösste Fläche der Zeile -- er soll auch
|
{# Der Name ist die grösste Fläche der Zeile -- er soll auch
|
||||||
das Naheliegende tun. Was sich anzeigen lässt, öffnet die
|
das Naheliegende tun. Was sich anzeigen lässt, öffnet die
|
||||||
@@ -102,16 +115,27 @@
|
|||||||
{% if entry.previewable %}
|
{% if entry.previewable %}
|
||||||
<button class="link-icon file-name" type="button"
|
<button class="link-icon file-name" type="button"
|
||||||
data-dialog-open="file-preview"
|
data-dialog-open="file-preview"
|
||||||
data-fill-Src="{{ url_for('fileshare.preview', token=token, pfad=entry.relative) }}"
|
data-fill-Src="{{ url_for('fileshare.preview', token=token, path=entry.relative) }}"
|
||||||
|
data-fill-Pruefung="{{ url_for('fileshare.signature_state', token=token, path=entry.relative) }}"
|
||||||
|
data-fill-Kind="{{ entry.preview_kind }}"
|
||||||
|
data-fill-Pfad="{{ entry.relative }}"
|
||||||
data-fill-Name="{{ entry.name }}"
|
data-fill-Name="{{ entry.name }}"
|
||||||
data-fill-Download="{{ url_for('fileshare.download', token=token, pfad=entry.relative) }}">
|
{% if current_user.has('fileshare.export') %}data-fill-Download="{{ url_for('fileshare.download', token=token, path=entry.relative) }}"{% endif %}>
|
||||||
{{ icon('file-text', size=15) }} {{ entry.name }}
|
{{ icon('file-text', size=15) }} {{ entry.name }}
|
||||||
</button>
|
</button>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
{% if current_user.has('fileshare.export') %}
|
||||||
<a class="link-icon"
|
<a class="link-icon"
|
||||||
href="{{ url_for('fileshare.download', token=token, pfad=entry.relative) }}">
|
href="{{ url_for('fileshare.download', token=token, path=entry.relative) }}">
|
||||||
{{ icon('file-text', size=15) }} {{ entry.name }}
|
{{ icon('file-text', size=15) }} {{ entry.name }}
|
||||||
</a>
|
</a>
|
||||||
|
{% else %}
|
||||||
|
{# Ohne Exportrecht bleibt der Name Text: es gibt hier
|
||||||
|
nichts zu holen, und ein toter Link waere Spott. #}
|
||||||
|
<span class="link-icon faint">
|
||||||
|
{{ icon('file-text', size=15) }} {{ entry.name }}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="numeric fs-sm">{{ entry.size | bytes }}</td>
|
<td class="numeric fs-sm">{{ entry.size | bytes }}</td>
|
||||||
@@ -120,21 +144,27 @@
|
|||||||
{% if entry.previewable %}
|
{% if entry.previewable %}
|
||||||
<button class="btn btn--sm btn--ghost" type="button"
|
<button class="btn btn--sm btn--ghost" type="button"
|
||||||
data-dialog-open="file-preview"
|
data-dialog-open="file-preview"
|
||||||
data-fill-Src="{{ url_for('fileshare.preview', token=token, pfad=entry.relative) }}"
|
data-fill-Src="{{ url_for('fileshare.preview', token=token, path=entry.relative) }}"
|
||||||
|
data-fill-Pruefung="{{ url_for('fileshare.signature_state', token=token, path=entry.relative) }}"
|
||||||
|
data-fill-Kind="{{ entry.preview_kind }}"
|
||||||
|
data-fill-Pfad="{{ entry.relative }}"
|
||||||
data-fill-Name="{{ entry.name }}"
|
data-fill-Name="{{ entry.name }}"
|
||||||
data-fill-Download="{{ url_for('fileshare.download', token=token, pfad=entry.relative) }}"
|
{% if current_user.has('fileshare.export') %}data-fill-Download="{{ url_for('fileshare.download', token=token, path=entry.relative) }}"{% endif %}
|
||||||
aria-label="Vorschau">{{ icon('eye', size=15) }}</button>
|
aria-label="Vorschau">{{ icon('eye', size=15) }}</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if current_user.has('fileshare.edit') %}
|
{% if current_user.has('fileshare.write') %}
|
||||||
<button class="btn btn--sm btn--ghost" type="button"
|
<button class="btn btn--sm btn--ghost" type="button"
|
||||||
data-dialog-open="file-rename"
|
data-dialog-open="file-rename"
|
||||||
data-fill-Pfad="{{ entry.relative }}"
|
data-fill-Pfad="{{ entry.relative }}"
|
||||||
data-fill-Name="{{ entry.name }}"
|
data-fill-Name="{{ entry.stem }}"
|
||||||
|
data-fill-Endung="{{ entry.suffix or '' }}"
|
||||||
aria-label="Umbenennen">{{ icon('pencil', size=15) }}</button>
|
aria-label="Umbenennen">{{ icon('pencil', size=15) }}</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if current_user.has('fileshare.export') %}
|
||||||
<a class="btn btn--sm btn--ghost"
|
<a class="btn btn--sm btn--ghost"
|
||||||
href="{{ url_for('fileshare.download', token=token, pfad=entry.relative) }}"
|
href="{{ url_for('fileshare.download', token=token, path=entry.relative) }}"
|
||||||
aria-label="Herunterladen">{{ icon('download', size=15) }}</a>
|
aria-label="Herunterladen">{{ icon('download', size=15) }}</a>
|
||||||
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -167,7 +197,7 @@
|
|||||||
<form method="post" enctype="multipart/form-data" data-no-guard
|
<form method="post" enctype="multipart/form-data" data-no-guard
|
||||||
action="{{ url_for('fileshare.upload', token=token) }}">
|
action="{{ url_for('fileshare.upload', token=token) }}">
|
||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
<input type="hidden" name="pfad" value="{{ listing.relative }}">
|
<input type="hidden" name="path" value="{{ listing.relative }}">
|
||||||
<div class="card__body row">
|
<div class="card__body row">
|
||||||
<input class="input" type="file" name="files" multiple required style="flex:1">
|
<input class="input" type="file" name="files" multiple required style="flex:1">
|
||||||
<button class="btn btn--primary" type="submit" data-busy-label="Lädt ...">Hochladen</button>
|
<button class="btn btn--primary" type="submit" data-busy-label="Lädt ...">Hochladen</button>
|
||||||
@@ -184,7 +214,7 @@
|
|||||||
{% call modal('folder-new', 'Ordner anlegen', listing.relative or 'Wurzelverzeichnis', 'narrow') %}
|
{% call modal('folder-new', 'Ordner anlegen', listing.relative or 'Wurzelverzeichnis', 'narrow') %}
|
||||||
<form method="post" action="{{ url_for('fileshare.make_directory', token=token) }}">
|
<form method="post" action="{{ url_for('fileshare.make_directory', token=token) }}">
|
||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
<input type="hidden" name="pfad" value="{{ listing.relative }}">
|
<input type="hidden" name="path" value="{{ listing.relative }}">
|
||||||
<div class="modal__body">{{ field('name', 'Name', required=True) }}</div>
|
<div class="modal__body">{{ field('name', 'Name', required=True) }}</div>
|
||||||
<div class="modal__footer">
|
<div class="modal__footer">
|
||||||
<button class="btn" type="button" data-dialog-close>Abbrechen</button>
|
<button class="btn" type="button" data-dialog-close>Abbrechen</button>
|
||||||
@@ -201,29 +231,182 @@
|
|||||||
Dialog für alle Dateien statt einer je Zeile. #}
|
Dialog für alle Dateien statt einer je Zeile. #}
|
||||||
{% call modal('file-preview', 'Vorschau', '', 'wide') %}
|
{% call modal('file-preview', 'Vorschau', '', 'wide') %}
|
||||||
<div class="modal__caption" data-fill-target="Name"></div>
|
<div class="modal__caption" data-fill-target="Name"></div>
|
||||||
<div class="modal__body modal__body--flush">
|
{# Drei Anzeigen, eine je Art. Ein Rahmen für alles zeigte ein Foto vom
|
||||||
<iframe class="file-preview" title="Vorschau" data-fill-target="Src" data-fill-as="src"
|
iPad in Originalgrösse (also einen Ausschnitt) und ein PDF auf iPadOS
|
||||||
src="about:blank" referrerpolicy="same-origin"></iframe>
|
nur als Standbild -- Formularfelder liessen sich nicht anfassen. #}
|
||||||
|
<div class="modal__body modal__body--flush" data-viewer
|
||||||
|
{# Ohne das Recht zum Ausfuellen zeigt die Vorschau das Dokument so,
|
||||||
|
wie es ist -- mit seinen eigenen Marken, ohne Eingabefelder. #}
|
||||||
|
data-viewer-formulare="{{ '1' if current_user.has('fileshare_pdf.read') else '' }}"
|
||||||
|
data-viewer-pdfjs="{{ asset_url('vendor/pdfjs/pdf.min.js') }}"
|
||||||
|
data-viewer-pdflib="{{ asset_url('vendor/pdflib/pdf-lib.min.js') }}"
|
||||||
|
data-viewer-konto="{{ current_user.username }}"
|
||||||
|
data-viewer-zeuge="{{ '1' if signature_witness else '0' }}"
|
||||||
|
data-viewer-zeit="{{ serverzeit }}"
|
||||||
|
data-viewer-ordnerliste="{{ verzeichnisse | tojson }}">
|
||||||
|
<div class="viewer">
|
||||||
|
<div class="viewer__stage" data-viewer-bildbuehne hidden>
|
||||||
|
<img class="viewer__img" alt="" data-viewer-bild>
|
||||||
|
</div>
|
||||||
|
<div class="viewer__pdf" data-viewer-pdf hidden>
|
||||||
|
{# Zwei Ebenen: aussen ein normaler Kasten, der sich die Hoehe mit
|
||||||
|
Leiste und Speicherzeile teilt, innen der absolute Scrollbereich,
|
||||||
|
den pdf.js verlangt. #}
|
||||||
|
<div class="viewer__pdfscroll" data-viewer-pdfscroll>
|
||||||
|
<div class="pdfViewer" data-viewer-pdfseiten></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<iframe class="file-preview" title="Vorschau" data-viewer-rahmen
|
||||||
|
src="about:blank" referrerpolicy="same-origin" hidden></iframe>
|
||||||
|
{# Gefragt wird erst, wenn gespeichert werden soll -- vorher waere es
|
||||||
|
eine Zeile Formular über jeder Bildvorschau. Und nur, wer Formulare
|
||||||
|
ablegen darf, sieht sie überhaupt. #}
|
||||||
|
{% if current_user.has('fileshare_pdf.create') %}
|
||||||
|
{# Unterschreiben: Name tippen, mit dem Finger zeichnen, ins Dokument
|
||||||
|
setzen. Der Name daneben ist eine Angabe des Unterschreibenden – wie
|
||||||
|
auf Papier; wer abgelegt hat, steht im Änderungsprotokoll. #}
|
||||||
|
<div class="viewer__sign" data-viewer-unterschriftpanel hidden>
|
||||||
|
<div class="viewer__signfelder">
|
||||||
|
<label class="viewer__feld">
|
||||||
|
<span class="field__label">Vorname</span>
|
||||||
|
<input class="input" data-viewer-vorname autocomplete="off">
|
||||||
|
</label>
|
||||||
|
<label class="viewer__feld">
|
||||||
|
<span class="field__label">Nachname</span>
|
||||||
|
<input class="input" data-viewer-nachname autocomplete="off">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<canvas class="viewer__signpad" data-viewer-signpad width="700" height="200"
|
||||||
|
aria-label="Unterschriftsfeld"></canvas>
|
||||||
|
<div class="viewer__signaktionen">
|
||||||
|
<span class="fs-xs faint" data-viewer-signhinweis></span>
|
||||||
|
<button class="btn btn--sm btn--ghost" type="button" data-viewer-signleeren>Leeren</button>
|
||||||
|
<button class="btn btn--sm btn--ghost" type="button" data-viewer-signabbrechen>Abbrechen</button>
|
||||||
|
<button class="btn btn--sm btn--primary" type="button" data-viewer-signsetzen>
|
||||||
|
Unterschrift setzen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="fs-xs faint">
|
||||||
|
Nach „Unterschrift setzen“ auf die Stelle im Dokument tippen. Mit Unterschrift
|
||||||
|
wird immer abgeschlossen gespeichert – die Felder sind danach gesperrt.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="viewer__save" data-viewer-speichernzeile hidden>
|
||||||
|
{# Zwei eingebundene Freigaben, ein Formular: die Vorlage liegt in der
|
||||||
|
einen, die ausgefüllte Fassung gehört in die andere. #}
|
||||||
|
<label class="viewer__feld">
|
||||||
|
<span class="field__label">Freigabe</span>
|
||||||
|
<select class="select" data-viewer-ziel
|
||||||
|
data-url-vorlage="{{ url_for('fileshare.folders', token='__token__') }}">
|
||||||
|
{% for eintrag in mounts %}
|
||||||
|
<option value="{{ eintrag.token }}" {% if eintrag.token == token %}selected{% endif %}>
|
||||||
|
{{ eintrag.label }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="viewer__feld">
|
||||||
|
<span class="field__label">Ordner</span>
|
||||||
|
{# Auswahl statt Eingabe mit Vorschlagsliste: auf einem iPad laesst
|
||||||
|
sich ein <datalist> kaum bedienen -- das Freigabefeld daneben
|
||||||
|
zeigt, wie es sein soll. #}
|
||||||
|
<select class="select" data-viewer-ordner>
|
||||||
|
<option value="">Wurzelverzeichnis der Freigabe</option>
|
||||||
|
{% for eintrag in verzeichnisse %}
|
||||||
|
<option value="{{ eintrag }}">{{ eintrag }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="viewer__feld">
|
||||||
|
<span class="field__label">Dateiname (ohne Endung)</span>
|
||||||
|
<input class="input" data-viewer-name autocomplete="off">
|
||||||
|
</label>
|
||||||
|
<label class="viewer__feld">
|
||||||
|
<span class="field__label">Speichern als</span>
|
||||||
|
{# Zwei Wege, beide gewollt: weiter ausfüllbar (das Bild wird das
|
||||||
|
Aussehen des Knopfes und lässt sich später austauschen) oder
|
||||||
|
abgeschlossen (alle Felder werden Teil der Seite). #}
|
||||||
|
{# Nur, was das Recht hergibt: "weiter ausfüllbar" hängt an
|
||||||
|
PDF-Formulare/Bearbeiten, "abgeschlossen" an Anlegen. Wer nur
|
||||||
|
anlegen darf, sieht eine Zeile statt einer Auswahl. #}
|
||||||
|
<select class="select" data-viewer-modus>
|
||||||
|
{% if current_user.has('fileshare_pdf.write') %}
|
||||||
|
<option value="formular">Formular – weiter ausfüllbar</option>
|
||||||
|
{% endif %}
|
||||||
|
<option value="fertig">Abgeschlossen – Felder gesperrt</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{# Haken über den Knöpfen und beides rechts: gelesen wird die Zeile von
|
||||||
|
links nach rechts (wohin, wie es heißt, wie gespeichert), und die
|
||||||
|
Entscheidung steht am Ende. #}
|
||||||
|
<div class="viewer__saveaktionen">
|
||||||
|
<label class="check" data-viewer-ersetzenfeld>
|
||||||
|
<input type="checkbox" data-viewer-ersetzen>
|
||||||
|
<span>Vorhandene ersetzen</span>
|
||||||
|
</label>
|
||||||
|
<div class="viewer__saveknoepfe">
|
||||||
|
<button class="btn btn--sm btn--ghost" type="button" data-viewer-abbrechen>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button class="btn btn--sm btn--primary" type="button" data-viewer-jetzt>
|
||||||
|
Speichern
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{# Das Ergebnis der Pruefung steht über der Leiste – wer eine
|
||||||
|
unterschriebene Fassung öffnet, soll es sehen, bevor er liest. #}
|
||||||
|
<p class="viewer__pruefung" data-viewer-pruefung hidden></p>
|
||||||
|
<div class="viewer__bar" data-viewer-leiste hidden>
|
||||||
|
<button class="btn btn--sm btn--ghost" type="button" data-viewer-zoom="aus"
|
||||||
|
aria-label="Verkleinern">{{ icon('minus', size=15) }}</button>
|
||||||
|
<button class="btn btn--sm btn--ghost" type="button" data-viewer-zoom="fit">Anpassen</button>
|
||||||
|
<button class="btn btn--sm btn--ghost" type="button" data-viewer-zoom="ein"
|
||||||
|
aria-label="Vergrössern">{{ icon('plus', size=15) }}</button>
|
||||||
|
<span class="viewer__hint fs-xs faint" data-viewer-hinweis></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal__footer">
|
<div class="modal__footer">
|
||||||
|
{% if current_user.has('fileshare.export') %}
|
||||||
<a class="btn btn--sm" data-fill-target="Download" data-fill-as="href" href="#" download>
|
<a class="btn btn--sm" data-fill-target="Download" data-fill-as="href" href="#" download>
|
||||||
{{ icon('download', size=15) }} Herunterladen
|
{{ icon('download', size=15) }} Herunterladen
|
||||||
</a>
|
</a>
|
||||||
<button class="btn btn--primary" type="button" data-dialog-close>Schliessen</button>
|
{% endif %}
|
||||||
|
{# Unterschreiben ist eine eigene Zeile der Matrix: wer ausgefuellte
|
||||||
|
Formulare ablegen darf, darf damit nicht unterschreiben. Der Server
|
||||||
|
weist es ohnehin ab -- aber ein Knopf, der immer in eine Meldung
|
||||||
|
laeuft, ist keiner. #}
|
||||||
|
{% if current_user.has('fileshare_signature.execute') %}
|
||||||
|
<button class="btn btn--sm" type="button" data-viewer-unterschreiben hidden>
|
||||||
|
{{ icon('pencil', size=15) }} Unterschreiben
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
{% if current_user.has('fileshare_pdf.create') %}
|
||||||
|
<button class="btn btn--sm" type="button" data-viewer-speichern hidden
|
||||||
|
data-url="{{ url_for('fileshare.save_form', token=token) }}">
|
||||||
|
{{ icon('save', size=15) }} Ausgefüllt speichern
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
<button class="btn btn--primary" type="button" data-dialog-close>Schließen</button>
|
||||||
</div>
|
</div>
|
||||||
{% endcall %}
|
{% endcall %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if listing and current_user.has('fileshare.edit') %}
|
{% if listing and current_user.has('fileshare.write') %}
|
||||||
{% call modal('file-rename', 'Umbenennen', '', 'narrow') %}
|
{% call modal('file-rename', 'Umbenennen', '', 'narrow') %}
|
||||||
<form method="post" action="{{ url_for('fileshare.rename', token=token) }}">
|
<form method="post" action="{{ url_for('fileshare.rename', token=token) }}">
|
||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
<input type="hidden" name="parent" value="{{ listing.relative }}">
|
<input type="hidden" name="parent" value="{{ listing.relative }}">
|
||||||
<input type="hidden" name="pfad" data-fill-target="Pfad" value="">
|
<input type="hidden" name="path" data-fill-target="Pfad" value="">
|
||||||
<div class="modal__body">
|
<div class="modal__body">
|
||||||
{{ field('name', 'Neuer Name', required=True, attrs='data-fill-target="Name"') }}
|
{{ field('name', 'Neuer Name (ohne Endung)', required=True,
|
||||||
|
attrs='data-fill-target="Name"') }}
|
||||||
<p class="fs-xs faint">
|
<p class="fs-xs faint">
|
||||||
Umbenannt wird auf der Freigabe selbst -- es gibt dort keinen Papierkorb.
|
Die Endung <code data-fill-target="Endung"></code> bleibt, wie sie ist --
|
||||||
|
eine Datei ohne passende Endung öffnet auf der Freigabe nichts mehr.
|
||||||
|
Umbenannt wird dort direkt; einen Papierkorb gibt es nicht.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal__footer">
|
<div class="modal__footer">
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ Einbindungen.', 'info') }}
|
|||||||
<td class="fs-sm">{{ entry.ad_group_name }}
|
<td class="fs-sm">{{ entry.ad_group_name }}
|
||||||
<div class="fs-xs faint break-all">{{ entry.ad_group_dn }}</div></td>
|
<div class="fs-xs faint break-all">{{ entry.ad_group_dn }}</div></td>
|
||||||
<td class="actions">
|
<td class="actions">
|
||||||
{% if current_user.has('fileshare.edit') %}
|
{% if current_user.has('fileshare.write') %}
|
||||||
<button class="btn btn--sm btn--ghost" type="button"
|
<button class="btn btn--sm btn--ghost" type="button"
|
||||||
data-dialog-open="mapping-edit-{{ entry.id }}"
|
data-dialog-open="mapping-edit-{{ entry.id }}"
|
||||||
aria-label="Bearbeiten">{{ icon('pencil', size=15) }}</button>
|
aria-label="Bearbeiten">{{ icon('pencil', size=15) }}</button>
|
||||||
@@ -80,7 +80,7 @@ Einbindungen.', 'info') }}
|
|||||||
{# Bearbeiten statt neu anlegen: bei einem Tippfehler im UNC-Pfad oder im
|
{# Bearbeiten statt neu anlegen: bei einem Tippfehler im UNC-Pfad oder im
|
||||||
Anzeigenamen soll man ihn korrigieren können. Sonst legt man daneben einen
|
Anzeigenamen soll man ihn korrigieren können. Sonst legt man daneben einen
|
||||||
zweiten Eintrag an und vergisst, den ersten zu löschen. #}
|
zweiten Eintrag an und vergisst, den ersten zu löschen. #}
|
||||||
{% if current_user.has('fileshare.edit') %}
|
{% if current_user.has('fileshare.write') %}
|
||||||
{% for entry in mappings %}
|
{% for entry in mappings %}
|
||||||
{% call modal('mapping-edit-' ~ entry.id, entry.label, 'Zuordnung ändern') %}
|
{% call modal('mapping-edit-' ~ entry.id, entry.label, 'Zuordnung ändern') %}
|
||||||
<form method="post" action="{{ url_for('fileshare.mapping_update', mapping_id=entry.id) }}">
|
<form method="post" action="{{ url_for('fileshare.mapping_update', mapping_id=entry.id) }}">
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ wird nie stillschweigend übernommen: das ist der Fall, der bei einem Angriff au
|
|||||||
<div class="alert__title">Gelesener Schlüssel von {{ probed_host }}:{{ probed_port }}</div>
|
<div class="alert__title">Gelesener Schlüssel von {{ probed_host }}:{{ probed_port }}</div>
|
||||||
<div class="mono fs-sm break-all">{{ probed_type }} {{ probed_fingerprint }}</div>
|
<div class="mono fs-sm break-all">{{ probed_type }} {{ probed_fingerprint }}</div>
|
||||||
<p class="fs-sm mt-2">
|
<p class="fs-sm mt-2">
|
||||||
Vergleichen Sie den Fingerprint mit einer unabhängigen Quelle -- Konsolenzugang am
|
Vergleichen Sie den Fingerprint mit einer unabhängigen Quelle | Konsolenzugang am
|
||||||
Gerät, Inventardokumentation --, bevor Sie ihn unten freigeben.
|
Gerät, Inventardokumentation --, bevor Sie ihn unten freigeben.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -145,7 +145,7 @@ wird nie stillschweigend übernommen: das ist der Fall, der bei einem Angriff au
|
|||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="card__title">{{ icon('alert', size=17) }} Verwaiste Freigaben</div>
|
<div class="card__title">{{ icon('alert', size=17) }} Verwaiste Freigaben</div>
|
||||||
<div class="card__sub">
|
<div class="card__sub">
|
||||||
Zu diesen Einträgen gibt es kein Ziel mehr -- gelöscht oder mit neuer IP.
|
Zu diesen Einträgen gibt es kein Ziel mehr – gelöscht oder mit neuer IP.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -160,7 +160,7 @@ wird nie stillschweigend übernommen: das ist der Fall, der bei einem Angriff au
|
|||||||
<td class="mono fs-xs break-all">{{ row.fingerprint }}</td>
|
<td class="mono fs-xs break-all">{{ row.fingerprint }}</td>
|
||||||
<td class="fs-sm">{{ row.added_at | dt }} <span class="faint">von {{ row.added_by }}</span></td>
|
<td class="fs-sm">{{ row.added_at | dt }} <span class="faint">von {{ row.added_by }}</span></td>
|
||||||
<td>
|
<td>
|
||||||
{% if current_user.has('devices.edit') or current_user.has('switches.edit') %}
|
{% if current_user.has('devices.write') or current_user.has('switches.write') %}
|
||||||
<form method="post" action="{{ url_for('hostkeys.forget_orphan') }}" data-no-guard>
|
<form method="post" action="{{ url_for('hostkeys.forget_orphan') }}" data-no-guard>
|
||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
<input type="hidden" name="host" value="{{ row.host }}">
|
<input type="hidden" name="host" value="{{ row.host }}">
|
||||||
|
|||||||
@@ -60,9 +60,18 @@
|
|||||||
<dd>
|
<dd>
|
||||||
{% if state.modules %}
|
{% if state.modules %}
|
||||||
{% for module in modules %}
|
{% for module in modules %}
|
||||||
{% if module in state.modules %}{{ pill(module_labels[module], 'online') }}
|
{% if module in state.modules and module in state.modules_off %}
|
||||||
|
{{ pill(module_labels[module] ~ ' (abgeschaltet)', 'warning') }}
|
||||||
|
{% elif module in state.modules %}{{ pill(module_labels[module], 'online') }}
|
||||||
{% else %}{{ pill(module_labels[module], '') }}{% endif %}
|
{% else %}{{ pill(module_labels[module], '') }}{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
{% if state.modules_off %}
|
||||||
|
<div class="fs-xs faint">
|
||||||
|
Abgeschaltet wurde in
|
||||||
|
<a href="{{ url_for('settings.system') }}">Verwaltung → System</a>;
|
||||||
|
die Lizenz ist davon unberührt.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% else %}<span class="faint">keine Zusatzmodule</span>{% endif %}
|
{% else %}<span class="faint">keine Zusatzmodule</span>{% endif %}
|
||||||
</dd>
|
</dd>
|
||||||
<dt>Lizenzserver</dt>
|
<dt>Lizenzserver</dt>
|
||||||
@@ -76,7 +85,7 @@
|
|||||||
</dd>
|
</dd>
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
{% if current_user.has('settings_license.edit') %}
|
{% if current_user.has('settings_license.write') %}
|
||||||
<div class="card__footer">
|
<div class="card__footer">
|
||||||
{% if not state.activated %}
|
{% if not state.activated %}
|
||||||
<form method="post" action="{{ url_for('license.activate') }}" data-no-guard>
|
<form method="post" action="{{ url_for('license.activate') }}" data-no-guard>
|
||||||
@@ -125,7 +134,7 @@
|
|||||||
{% if pending.code %}
|
{% if pending.code %}
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<span class="field__label">
|
<span class="field__label">
|
||||||
Anfragecode -- an den Anbieter übermitteln
|
Anfragecode – an den Anbieter übermitteln
|
||||||
</span>
|
</span>
|
||||||
<pre class="log-view" id="offline-request" style="max-height:14dvh">{{ pending.code }}</pre>
|
<pre class="log-view" id="offline-request" style="max-height:14dvh">{{ pending.code }}</pre>
|
||||||
<div class="row row--end mt-2">
|
<div class="row row--end mt-2">
|
||||||
@@ -155,7 +164,7 @@
|
|||||||
</section>
|
</section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if current_user.has('settings_license.edit') %}
|
{% if current_user.has('settings_license.write') %}
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<div class="card__header">
|
<div class="card__header">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
@@ -190,7 +199,7 @@
|
|||||||
</section>
|
</section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if current_user.has('settings_license.edit') %}
|
{% if current_user.has('settings_license.write') %}
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<div class="card__header">
|
<div class="card__header">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
@@ -234,7 +243,7 @@
|
|||||||
</dl>
|
</dl>
|
||||||
<p class="fs-xs faint mt-3">
|
<p class="fs-xs faint mt-3">
|
||||||
Der Fingerprint stammt aus der stabilen Maschinenidentität. Eine Umbenennung des Hosts
|
Der Fingerprint stammt aus der stabilen Maschinenidentität. Eine Umbenennung des Hosts
|
||||||
ändert ihn <strong>nicht</strong> mehr -- im Vorgänger führte genau das zu
|
ändert ihn <strong>nicht</strong> mehr – im Vorgänger führte genau das zu
|
||||||
„bereits anderweitig gebunden“.
|
„bereits anderweitig gebunden“.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{% extends "tesm_core/base.html" %}
|
{% extends "tesm_core/base.html" %}
|
||||||
{% from "tesm_core/macros.html" import alert %}
|
{% from "tesm_core/macros.html" import alert %}
|
||||||
{% block page_title %}Kea-DHCP-Protokoll{% endblock %}
|
{% block page_title %}DHCP-Protokoll{% endblock %}
|
||||||
{% block page_sub %}{{ path }}{% endblock %}
|
{% block page_sub %}{{ path }}{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{% if error %}{{ alert(error, 'warning') }}{% endif %}
|
{% if error %}{{ alert(error, 'warning') }}{% endif %}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
{% block topbar_extra %}{{ search_box('restart-table', 'Gerät, Ergebnis, Methode ...') }}{% endblock %}
|
{% block topbar_extra %}{{ search_box('restart-table', 'Gerät, Ergebnis, Methode ...') }}{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{{ alert('Jeder Neustart wird mit Methode, Ergebnis, Dauer und Auslöser festgehalten -- automatisch
|
{{ alert('Jeder Neustart wird mit Methode, Ergebnis, Dauer und Auslöser festgehalten -- automatisch
|
||||||
wie manüll, über den PoE-Port des Switches ebenso wie per Neustart-Befehl aus der Wartung.
|
wie manuell, über den PoE-Port des Switches ebenso wie per Neustart-Befehl aus der Wartung.
|
||||||
Der Vorgänger protokollierte Neustarts ausdrücklich nicht.', 'info') }}
|
Der Vorgänger protokollierte Neustarts ausdrücklich nicht.', 'info') }}
|
||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
@@ -15,7 +15,7 @@ Der Vorgänger protokollierte Neustarts ausdrücklich nicht.', 'info') }}
|
|||||||
<div class="card__sub">
|
<div class="card__sub">
|
||||||
{% for value, label in methods %}
|
{% for value, label in methods %}
|
||||||
<a class="btn btn--sm {{ 'btn--ghost' if method != value else '' }}"
|
<a class="btn btn--sm {{ 'btn--ghost' if method != value else '' }}"
|
||||||
href="{{ url_for('tesmlogs.restarts', methode=value) }}">{{ label }}</a>
|
href="{{ url_for('tesmlogs.restarts', method=value) }}">{{ label }}</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
<a class="btn btn--sm {{ 'btn--ghost' if method else '' }}"
|
<a class="btn btn--sm {{ 'btn--ghost' if method else '' }}"
|
||||||
href="{{ url_for('tesmlogs.restarts') }}">Alle</a>
|
href="{{ url_for('tesmlogs.restarts') }}">Alle</a>
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
|
|||||||
<span>Alle auswählen</span>
|
<span>Alle auswählen</span>
|
||||||
</label>
|
</label>
|
||||||
<span class="table-toolbar__spacer"></span>
|
<span class="table-toolbar__spacer"></span>
|
||||||
{% if current_user.has('maintenance.execute') %}
|
{% if current_user.has('servicing_update.execute') %}
|
||||||
<button class="btn btn--sm btn--primary" type="submit"
|
<button class="btn btn--sm btn--primary" type="submit"
|
||||||
formaction="{{ url_for('maintenance.start_update') }}"
|
formaction="{{ url_for('maintenance.start_update') }}"
|
||||||
title="Nur für Linux-Clients -- Windows-Geräte werden dabei übersprungen."
|
title="Nur für Linux-Clients -- Windows-Geräte werden dabei übersprungen."
|
||||||
@@ -91,7 +91,7 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
|
|||||||
<label class="check">
|
<label class="check">
|
||||||
<input type="checkbox" name="device_ids" value="{{ device.id }}"
|
<input type="checkbox" name="device_ids" value="{{ device.id }}"
|
||||||
class="device-select" data-category="{{ device.category }}"
|
class="device-select" data-category="{{ device.category }}"
|
||||||
{% if not current_user.has('maintenance.execute') %}disabled{% endif %}>
|
{% if not current_user.has('servicing_update.execute') %}disabled{% endif %}>
|
||||||
<span class="sr-only">{{ device.name }} auswählen</span>
|
<span class="sr-only">{{ device.name }} auswählen</span>
|
||||||
</label>
|
</label>
|
||||||
</td>
|
</td>
|
||||||
@@ -121,7 +121,7 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
|
|||||||
</td>
|
</td>
|
||||||
<td class="nowrap">
|
<td class="nowrap">
|
||||||
{% if terminal_enabled and device.ssh_managed
|
{% if terminal_enabled and device.ssh_managed
|
||||||
and current_user.has('maintenance.execute') %}
|
and current_user.has('servicing_update.execute') %}
|
||||||
<a class="btn btn--sm btn--ghost"
|
<a class="btn btn--sm btn--ghost"
|
||||||
href="{{ url_for('terminal.device_console', device_id=device.id) }}">
|
href="{{ url_for('terminal.device_console', device_id=device.id) }}">
|
||||||
{{ icon('terminal', size=14) }} Konsole
|
{{ icon('terminal', size=14) }} Konsole
|
||||||
@@ -151,7 +151,7 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
|
|||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="card__title">{{ icon('clock', size=17) }} Aufträge</div>
|
<div class="card__title">{{ icon('clock', size=17) }} Aufträge</div>
|
||||||
<div class="card__sub">
|
<div class="card__sub">
|
||||||
Zustand liegt in der Datenbank -- ein Dienst-Neustart verliert ihn nicht mehr
|
Zustand liegt in der Datenbank – ein Dienst-Neustart verliert ihn nicht mehr
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -194,7 +194,7 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
|
|||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script type="module" nonce="{{ csp_nonce }}">
|
<script type="module" nonce="{{ csp_nonce }}">
|
||||||
// Auswahl beim Laden bewusst zurücksetzen: Browser stellen den Checked-Zustand
|
// Auswahl beim Laden bewusst zurücksetzen: Browser stellen den Checked-Zustand
|
||||||
// sonst aus dem Verlauf wieder her -- und dann wird bei "Neustarten"
|
// sonst aus dem Verlauf wieder her – und dann wird bei "Neustarten"
|
||||||
// versehentlich das falsche Gerät erwischt.
|
// versehentlich das falsche Gerät erwischt.
|
||||||
const boxes = Array.from(document.querySelectorAll(".device-select"));
|
const boxes = Array.from(document.querySelectorAll(".device-select"));
|
||||||
boxes.forEach((box) => (box.checked = false));
|
boxes.forEach((box) => (box.checked = false));
|
||||||
@@ -211,7 +211,7 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
|
|||||||
const state = await window.tesm.api("{{ url_for('maintenance.status') }}");
|
const state = await window.tesm.api("{{ url_for('maintenance.status') }}");
|
||||||
if (state.running) window.setTimeout(() => location.reload(), state.poll_ms);
|
if (state.running) window.setTimeout(() => location.reload(), state.poll_ms);
|
||||||
} catch {
|
} catch {
|
||||||
/* stumm bleiben -- die Seite ist auch ohne Aktualisierung nutzbar */
|
/* stumm bleiben – die Seite ist auch ohne Aktualisierung nutzbar */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
poll();
|
poll();
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
{% extends "tesm_core/base.html" %}
|
||||||
|
{% from "tesm_core/macros.html" import alert, pill %}
|
||||||
|
|
||||||
|
{% block page_title %}Bildschirm: {{ device.name }}{% endblock %}
|
||||||
|
{% block page_sub %}{{ zugang.ziel }}:{{ zugang.port }}{% endblock %}
|
||||||
|
|
||||||
|
{% block breadcrumb %}
|
||||||
|
<nav class="breadcrumb">
|
||||||
|
<span><a href="{{ url_for('devices.overview') }}">Clients</a></span>
|
||||||
|
<span><a href="{{ url_for('devices.detail', device_id=device.id) }}">{{ device.name }}</a></span>
|
||||||
|
<span>Bildschirm</span>
|
||||||
|
</nav>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{# Die Zugangsdaten stehen in einem eigenen JSON-Block statt in einem
|
||||||
|
data-Attribut: ein Attribut steht im Quelltext der Seite zwischen der
|
||||||
|
Beschriftung und wird beim Kopieren einer Zeile mitgenommen. Beides ist
|
||||||
|
derselbe Inhalt -- der Unterschied ist, wie leicht er einem versehentlich
|
||||||
|
unter die Augen kommt. #}
|
||||||
|
<script type="application/json" id="screen-credentials-data">{{ vorgaben | tojson }}</script>
|
||||||
|
|
||||||
|
<div class="stack">
|
||||||
|
{% if zugang.hinderungsgrund %}
|
||||||
|
{{ alert(zugang.hinderungsgrund, 'warning') }}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<div class="card__header">
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="card__title">
|
||||||
|
{{ icon('monitor', size=17) }} {{ device.name }}
|
||||||
|
{% if zugang.absage %}{{ pill('Verbindung abgelehnt', 'warning') }}
|
||||||
|
{% elif zugang.verfahren %}
|
||||||
|
{% if zugang.browsertauglich %}{{ pill('Browser-Sitzung möglich', 'online') }}
|
||||||
|
{% else %}{{ pill('Verfahren nicht unterstützt', 'warning') }}{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="card__sub">
|
||||||
|
Tastatur und Maus gehen an das Gerät, sobald das Bild steht. Die Zwischenablage
|
||||||
|
bleibt getrennt.
|
||||||
|
{% if zugang.verfahren %}
|
||||||
|
Zuletzt angebotene Anmeldeverfahren:
|
||||||
|
{% for nummer in zugang.verfahren %}{{ verfahren_name(nummer) }}{% if not loop.last %}, {% endif %}{% endfor %}.
|
||||||
|
{% else %}
|
||||||
|
Welche Anmeldeverfahren das Gerät anbietet, ist nicht bekannt – die Seite
|
||||||
|
fragt es nicht von sich aus.
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row row--tight">
|
||||||
|
<button class="btn btn--sm" type="button" id="screen-cad" hidden
|
||||||
|
title="Strg+Alt+Entf an das Gerät senden">Strg+Alt+Entf</button>
|
||||||
|
<button class="btn btn--sm" type="button" id="screen-disconnect" hidden>Trennen</button>
|
||||||
|
{# Ausdrücklich ein Knopf und keine Nebenwirkung des Seitenaufbaus: die
|
||||||
|
Prüfung bricht eine Protokollverhandlung ab, und RealVNC zählt das.
|
||||||
|
Wer sucht, drückt ihn; wer nur arbeiten will, merkt nichts davon. #}
|
||||||
|
<form method="post" data-no-guard
|
||||||
|
action="{{ url_for('screen.probe', device_id=device.id) }}">
|
||||||
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
|
<button class="btn btn--sm btn--ghost" type="submit"
|
||||||
|
title="Fragt das Gerät, welche Anmeldeverfahren es anbietet">
|
||||||
|
Verfahren prüfen
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<a class="btn btn--sm btn--ghost"
|
||||||
|
href="{{ url_for('devices.vnc_connection', device_id=device.id) }}">
|
||||||
|
{{ icon('download', size=14) }} Verbindungsdatei
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card__body stack">
|
||||||
|
<div id="screen-status" class="screen__status">Wird vorbereitet …</div>
|
||||||
|
|
||||||
|
<form id="screen-credentials" class="row row--tight" hidden onsubmit="return false">
|
||||||
|
<label class="field">
|
||||||
|
<span class="field__label">Benutzer</span>
|
||||||
|
<input class="input" type="text" id="screen-username" autocomplete="off"
|
||||||
|
value="{{ vorgaben.username }}">
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span class="field__label">Passwort</span>
|
||||||
|
<input class="input" type="password" id="screen-password" autocomplete="off">
|
||||||
|
</label>
|
||||||
|
<button class="btn btn--primary" type="button" id="screen-send">Anmelden</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div id="screen" class="screen"
|
||||||
|
data-socket="{{ websocket_path }}"
|
||||||
|
data-ziel="{{ zugang.ziel }}:{{ zugang.port }}"
|
||||||
|
data-verfahren="{{ zugang.verfahren | join(',') }}"
|
||||||
|
data-blockiert="{{ '1' if zugang.hinderungsgrund else '0' }}"
|
||||||
|
data-grund="{{ zugang.hinderungsgrund }}">
|
||||||
|
<div id="screen-canvas" class="screen__canvas" hidden></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if not darf_geheimnis and device.credential_id %}
|
||||||
|
<p class="fs-xs faint">
|
||||||
|
Für diesen Client sind Zugangsdaten hinterlegt. TESM gibt sie nur an Konten
|
||||||
|
heraus, die Geheimnisse ohnehin einsehen dürfen – sonst wären sie über den
|
||||||
|
Umweg einer Bildschirmsitzung für jeden lesbar, der sie öffnen darf. Tragen
|
||||||
|
Sie sie oben ein; sie werden nicht gespeichert.
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script type="module" src="{{ asset_url('js/screen.js') }}" nonce="{{ csp_nonce }}"></script>
|
||||||
|
{% endblock %}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
{% extends "tesm_core/base.html" %}
|
{% extends "tesm_core/base.html" %}
|
||||||
{% from "tesm_core/macros.html" import field, select_field, checkbox, alert, pill %}
|
{% from "tesm_core/macros.html" import field, select_field, checkbox, alert, pill, nav_order_card %}
|
||||||
|
|
||||||
{% block page_title %}Systemeinstellungen{% endblock %}
|
{% block page_title %}Systemeinstellungen{% endblock %}
|
||||||
{% block page_sub %}Überwachung, Protokolle, Host, Navigation{% endblock %}
|
{% block page_sub %}Überwachung, Protokolle, Host, Navigation{% endblock %}
|
||||||
@@ -41,11 +41,77 @@
|
|||||||
</div>
|
</div>
|
||||||
{{ checkbox('monitor_auto_restart', 'Automatischen PoE-Neustart bei Ausfall aktivieren',
|
{{ checkbox('monitor_auto_restart', 'Automatischen PoE-Neustart bei Ausfall aktivieren',
|
||||||
checked=values.monitor_auto_restart,
|
checked=values.monitor_auto_restart,
|
||||||
hint_text='Zusätzlich lizenzpflichtig. Der manülle Knopf im Dashboard
|
hint_text='Zusätzlich lizenzpflichtig. Der manuelle Knopf im Dashboard
|
||||||
funktioniert unabhängig davon.') }}
|
funktioniert unabhängig davon.') }}
|
||||||
{{ checkbox('dashboard_public', 'Statusübersicht ohne Anmeldung erreichbar',
|
{{ checkbox('dashboard_public', 'Statusübersicht ohne Anmeldung erreichbar',
|
||||||
checked=values.dashboard_public,
|
checked=values.dashboard_public,
|
||||||
hint_text=specs['dashboard_public'].hint) }}
|
hint_text=specs['dashboard_public'].hint) }}
|
||||||
|
{{ checkbox('vnc_enabled', 'Bildschirmzugriff (VNC) anbieten',
|
||||||
|
checked=values.vnc_enabled,
|
||||||
|
hint_text=specs['vnc_enabled'].hint) }}
|
||||||
|
<div class="form-grid">
|
||||||
|
{{ field('vnc_port', 'VNC-Port', value=values.vnc_port, type='number',
|
||||||
|
hint_text=specs['vnc_port'].hint) }}
|
||||||
|
</div>
|
||||||
|
{{ checkbox('vnc_warn_unencrypted_off',
|
||||||
|
'Warnung des Viewers vor unverschlüsselter Verbindung unterdrücken',
|
||||||
|
checked=values.vnc_warn_unencrypted_off,
|
||||||
|
hint_text=specs['vnc_warn_unencrypted_off'].hint) }}
|
||||||
|
</div>
|
||||||
|
<div class="card__footer"><button class="btn btn--primary" type="submit">Speichern</button></div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% if module_rows %}
|
||||||
|
<section class="card">
|
||||||
|
<div class="card__header">
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="card__title">{{ icon('puzzle', size=17) }} Module</div>
|
||||||
|
<div class="card__sub">
|
||||||
|
Die Lizenz sagt, was erlaubt ist – hier steht, was dieses Haus benutzt.
|
||||||
|
Ein abgeschaltetes Modul verschwindet aus der Navigation, und seine Seiten
|
||||||
|
antworten wie ohne Lizenz. Bestehende Daten bleiben unverändert stehen und
|
||||||
|
sind nach dem Wiedereinschalten wieder da.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form method="post" action="{{ url_for('settings.modules_save') }}">
|
||||||
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
|
<div class="card__body stack">
|
||||||
|
{% for modul in module_rows %}
|
||||||
|
{{ checkbox('module_' ~ modul.key, modul.label, checked=modul.enabled,
|
||||||
|
hint_text=('In der Lizenz enthalten.' if modul.licensed
|
||||||
|
else 'Nicht lizenziert – bleibt aus, bis die Lizenz es
|
||||||
|
freischaltet. Der Haken gilt dann sofort.')) }}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div class="card__footer"><button class="btn btn--primary" type="submit">Speichern</button></div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<div class="card__header">
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="card__title">{{ icon('terminal', size=17) }} Terminal</div>
|
||||||
|
<div class="card__sub">
|
||||||
|
Auch bei aktiver Konsole gilt: eigenes Recht, frische Passwortbestätigung,
|
||||||
|
vollständige Mitschrift. Abgeschaltet bleibt das Freigeben von
|
||||||
|
SSH-Host-Schlüsseln möglich – es wird für den PoE-Neustart gebraucht.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form method="post" action="{{ url_for('settings.system_save') }}">
|
||||||
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
|
<div class="card__body stack">
|
||||||
|
{{ checkbox('terminal_switches_enabled', 'Browser-Konsole für Switche zulassen',
|
||||||
|
checked=values.terminal_switches_enabled,
|
||||||
|
hint_text=specs['terminal_switches_enabled'].hint) }}
|
||||||
|
{{ checkbox('terminal_clients_enabled', 'Browser-Konsole für Clients zulassen',
|
||||||
|
checked=values.terminal_clients_enabled,
|
||||||
|
hint_text=specs['terminal_clients_enabled'].hint) }}
|
||||||
|
{{ field('terminal_idle_timeout', 'Trennen bei Untätigkeit (Sekunden)',
|
||||||
|
value=values.terminal_idle_timeout, type='number') }}
|
||||||
</div>
|
</div>
|
||||||
<div class="card__footer"><button class="btn btn--primary" type="submit">Speichern</button></div>
|
<div class="card__footer"><button class="btn btn--primary" type="submit">Speichern</button></div>
|
||||||
</form>
|
</form>
|
||||||
@@ -54,20 +120,52 @@
|
|||||||
<section class="card">
|
<section class="card">
|
||||||
<div class="card__header">
|
<div class="card__header">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="card__title">{{ icon('terminal', size=17) }} Switch-Konsole</div>
|
<div class="card__title">{{ icon('pencil', size=17) }} Unterschriften</div>
|
||||||
<div class="card__sub">
|
<div class="card__sub">
|
||||||
Auch bei aktiver Konsole gilt: eigenes Recht, frische Passwortbestätigung,
|
Der Name in der Unterschrift ist eine Angabe des Unterschreibenden – wie auf
|
||||||
vollständige Mitschrift
|
Papier. Wer das Dokument abgelegt hat, ist dagegen belegt: in den
|
||||||
|
Dokumenteigenschaften und im Änderungsprotokoll, auf Wunsch zusätzlich
|
||||||
|
sichtbar unter der ersten Unterschrift.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<form method="post" action="{{ url_for('settings.system_save') }}">
|
<form method="post" action="{{ url_for('settings.system_save') }}">
|
||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
<div class="card__body stack">
|
<div class="card__body stack">
|
||||||
{{ checkbox('terminal_enabled', 'Browser-Konsole für Switche zulassen',
|
{{ checkbox('signature_witness_enabled', 'Unterschriften mit dem ablegenden Konto bezeugen',
|
||||||
checked=values.terminal_enabled) }}
|
checked=values.signature_witness_enabled,
|
||||||
{{ field('terminal_idle_timeout', 'Trennen bei Untätigkeit (Sekunden)',
|
hint_text=specs['signature_witness_enabled'].hint) }}
|
||||||
value=values.terminal_idle_timeout, type='number') }}
|
</div>
|
||||||
|
<div class="card__footer"><button class="btn btn--primary" type="submit">Speichern</button></div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{# Eigene Karte: die Abmeldung stand zwischen Protokollrotation und
|
||||||
|
Papierkorb -- drei Dinge ohne Zusammenhang. Wer die Sitzungsdauer sucht,
|
||||||
|
sucht sie nicht unter "Protokolle". #}
|
||||||
|
<section class="card">
|
||||||
|
<div class="card__header">
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="card__title">{{ icon('clock', size=17) }} Sitzungen</div>
|
||||||
|
<div class="card__sub">
|
||||||
|
Wie lange eine Anmeldung gilt. Beides bestimmt auch, wie lange eine
|
||||||
|
eingebundene Dateifreigabe bestehen bleibt – sie endet mit der Sitzung.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form method="post" action="{{ url_for('settings.system_save') }}">
|
||||||
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
|
<div class="card__body">
|
||||||
|
<div class="form-grid">
|
||||||
|
{{ field('session_idle_minutes', 'Automatische Abmeldung nach Leerlauf (Minuten)',
|
||||||
|
value=values.session_idle_minutes, type='number',
|
||||||
|
attrs='min="5" max="1440" step="1"',
|
||||||
|
hint_text=specs['session_idle_minutes'].hint) }}
|
||||||
|
{{ field('session_absolute_hours', 'Längstmögliche Sitzungsdauer (Stunden)',
|
||||||
|
value=values.session_absolute_hours, type='number',
|
||||||
|
attrs='min="1" max="168" step="1"',
|
||||||
|
hint_text=specs['session_absolute_hours'].hint) }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card__footer"><button class="btn btn--primary" type="submit">Speichern</button></div>
|
<div class="card__footer"><button class="btn btn--primary" type="submit">Speichern</button></div>
|
||||||
</form>
|
</form>
|
||||||
@@ -87,9 +185,6 @@
|
|||||||
specs['log_rotate_interval'].choices, selected=values.log_rotate_interval) }}
|
specs['log_rotate_interval'].choices, selected=values.log_rotate_interval) }}
|
||||||
{{ field('log_rotate_keep', 'Aufbewahrte Rotationen', value=values.log_rotate_keep,
|
{{ field('log_rotate_keep', 'Aufbewahrte Rotationen', value=values.log_rotate_keep,
|
||||||
type='number') }}
|
type='number') }}
|
||||||
{{ field('session_idle_minutes', 'Automatische Abmeldung (Minuten)',
|
|
||||||
value=values.session_idle_minutes, type='number',
|
|
||||||
hint_text=specs['session_idle_minutes'].hint) }}
|
|
||||||
{{ field('trash_retention_days', 'Papierkorb-Aufbewahrung (Tage)',
|
{{ field('trash_retention_days', 'Papierkorb-Aufbewahrung (Tage)',
|
||||||
value=values.trash_retention_days, type='number',
|
value=values.trash_retention_days, type='number',
|
||||||
hint_text=specs['trash_retention_days'].hint) }}
|
hint_text=specs['trash_retention_days'].hint) }}
|
||||||
@@ -133,39 +228,8 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{% if current_user.is_admin %}
|
{% if current_user.is_admin %}
|
||||||
<section class="card">
|
{{ nav_order_card(nav_baum, nav_order, nav_child_order,
|
||||||
<div class="card__header">
|
url_for('admin.nav_order'), csrf_field_name, csrf_token()) }}
|
||||||
<div class="flex-1">
|
|
||||||
<div class="card__title">{{ icon('sliders', size=17) }} Navigationsreihenfolge</div>
|
|
||||||
<div class="card__sub">Gilt für alle Benutzer; jeder sieht weiter nur, was er darf</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<form method="post" action="{{ url_for('admin.nav_order') }}">
|
|
||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
|
||||||
<div class="card__body">
|
|
||||||
<ol class="stack stack--tight">
|
|
||||||
{% for key in nav_order %}
|
|
||||||
{% set item = nav_items | selectattr('key', 'equalto', key) | list | first %}
|
|
||||||
{% if item %}
|
|
||||||
<li class="row row--tight">
|
|
||||||
<input class="input" type="number" name="position_{{ item.key }}" value="{{ loop.index }}"
|
|
||||||
min="1" style="width:70px" disabled>
|
|
||||||
<input type="hidden" name="order" value="{{ item.key }}">
|
|
||||||
<span class="flex-1">{{ icon(item.icon, size=15) }} {{ item.label }}</span>
|
|
||||||
</li>
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
</ol>
|
|
||||||
<p class="fs-xs faint mt-3">
|
|
||||||
Die Reihenfolge folgt der Liste. Zum Umsortieren die Einträge in der gewünschten
|
|
||||||
Reihenfolge speichern (Drag-and-drop folgt in einer späteren Version).
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="card__footer">
|
|
||||||
<button class="btn btn--sm" type="submit">Reihenfolge speichern</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block topbar_extra %}
|
{% block topbar_extra %}
|
||||||
{% if terminal_enabled and current_user.has('switches.execute') and host_keys %}
|
{% if terminal_enabled and current_user.has('switch_console.execute') and host_keys %}
|
||||||
<a class="btn btn--sm btn--primary" href="{{ url_for('terminal.switch_console', switch_id=switch.id) }}">
|
<a class="btn btn--sm btn--primary" href="{{ url_for('terminal.switch_console', switch_id=switch.id) }}">
|
||||||
{{ icon('terminal', size=15) }} Konsole
|
{{ icon('terminal', size=15) }} Konsole
|
||||||
</a>
|
</a>
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
Ohne freigegebenen Schlüssel verweigert die Anwendung jeden Zugriff auf diesen Switch.
|
Ohne freigegebenen Schlüssel verweigert die Anwendung jeden Zugriff auf diesen Switch.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% if current_user.has('switches.edit') %}
|
{% if current_user.has('switches.write') %}
|
||||||
<form method="post" action="{{ url_for('switches.probe_host_key', switch_id=switch.id) }}"
|
<form method="post" action="{{ url_for('switches.probe_host_key', switch_id=switch.id) }}"
|
||||||
data-no-guard>
|
data-no-guard>
|
||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
@@ -53,7 +53,7 @@
|
|||||||
Vergleichen Sie den Fingerprint mit einer unabhängigen Quelle (Konsolenzugang am
|
Vergleichen Sie den Fingerprint mit einer unabhängigen Quelle (Konsolenzugang am
|
||||||
Switch, Inventardokumentation), bevor Sie ihn freigeben.
|
Switch, Inventardokumentation), bevor Sie ihn freigeben.
|
||||||
</p>
|
</p>
|
||||||
{% if current_user.has('switches.edit') %}
|
{% if current_user.has('switches.write') %}
|
||||||
<form method="post" class="mt-2"
|
<form method="post" class="mt-2"
|
||||||
action="{{ url_for('switches.trust_host_key', switch_id=switch.id) }}">
|
action="{{ url_for('switches.trust_host_key', switch_id=switch.id) }}">
|
||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
@@ -78,7 +78,7 @@
|
|||||||
</dd>
|
</dd>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</dl>
|
</dl>
|
||||||
{% if current_user.has('switches.edit') %}
|
{% if current_user.has('switches.write') %}
|
||||||
<form method="post" action="{{ url_for('switches.forget_host_key', switch_id=switch.id) }}"
|
<form method="post" action="{{ url_for('switches.forget_host_key', switch_id=switch.id) }}"
|
||||||
data-no-guard>
|
data-no-guard>
|
||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
@@ -95,7 +95,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{% if current_user.has('switches.execute') %}
|
{% if current_user.has('switch_console.execute') %}
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<div class="card__header">
|
<div class="card__header">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
|
|||||||
@@ -83,13 +83,13 @@ stillschweigend übernommen.', 'info', 'Host-Schlüssel') }}
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="actions">
|
<td class="actions">
|
||||||
{% if terminal_enabled and current_user.has('switches.execute') and switch.host_key_known %}
|
{% if terminal_enabled and current_user.has('switch_console.execute') and switch.host_key_known %}
|
||||||
<a class="btn btn--sm btn--ghost" title="Konsole"
|
<a class="btn btn--sm btn--ghost" title="Konsole"
|
||||||
href="{{ url_for('terminal.switch_console', switch_id=switch.id) }}">
|
href="{{ url_for('terminal.switch_console', switch_id=switch.id) }}">
|
||||||
{{ icon('terminal', size=15) }}
|
{{ icon('terminal', size=15) }}
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if current_user.has('switches.edit') %}
|
{% if current_user.has('switches.write') %}
|
||||||
<button class="btn btn--sm btn--ghost" type="button"
|
<button class="btn btn--sm btn--ghost" type="button"
|
||||||
data-dialog-open="switch-edit-{{ switch.id }}" aria-label="Bearbeiten">
|
data-dialog-open="switch-edit-{{ switch.id }}" aria-label="Bearbeiten">
|
||||||
{{ icon('pencil', size=15) }}
|
{{ icon('pencil', size=15) }}
|
||||||
@@ -135,7 +135,7 @@ stillschweigend übernommen.', 'info', 'Host-Schlüssel') }}
|
|||||||
</form>
|
</form>
|
||||||
{% endcall %}
|
{% endcall %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if current_user.has('switches.edit') %}
|
{% if current_user.has('switches.write') %}
|
||||||
{% for switch in switches %}
|
{% for switch in switches %}
|
||||||
{% call modal('switch-edit-' ~ switch.id, switch.hostname, switch.ip) %}
|
{% call modal('switch-edit-' ~ switch.id, switch.hostname, switch.ip) %}
|
||||||
<form method="post" action="{{ url_for('switches.update', switch_id=switch.id) }}">
|
<form method="post" action="{{ url_for('switches.update', switch_id=switch.id) }}">
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ from types import FrameType
|
|||||||
from tesm_core.extension import core
|
from tesm_core.extension import core
|
||||||
|
|
||||||
from . import create_app
|
from . import create_app
|
||||||
from .services import inventory, monitor
|
from .services import inventory, monitor, vnc
|
||||||
|
|
||||||
logger = logging.getLogger("tesm.monitor")
|
logger = logging.getLogger("tesm.monitor")
|
||||||
|
|
||||||
@@ -36,7 +36,7 @@ class MonitorDaemon:
|
|||||||
self.running = True
|
self.running = True
|
||||||
|
|
||||||
def stop(self, signum: int, _frame: FrameType | None) -> None:
|
def stop(self, signum: int, _frame: FrameType | None) -> None:
|
||||||
logger.info("Signal %s empfangen -- beende nach dem laufenden Durchlauf.", signum)
|
logger.info("Signal %s empfangen – beende nach dem laufenden Durchlauf.", signum)
|
||||||
self.running = False
|
self.running = False
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
@@ -68,8 +68,8 @@ class MonitorDaemon:
|
|||||||
|
|
||||||
def run(self) -> int:
|
def run(self) -> int:
|
||||||
live = self.extension.registry["live_log"]
|
live = self.extension.registry["live_log"]
|
||||||
live.info("monitor", "Ueberwachungsprozess gestartet")
|
live.info("monitor", "Überwachungsprozess gestartet")
|
||||||
logger.info("Ueberwachung laeuft, Datenbank: %s", self.extension.config.db_path)
|
logger.info("Überwachung läuft, Datenbank: %s", self.extension.config.db_path)
|
||||||
|
|
||||||
while self.running:
|
while self.running:
|
||||||
interval = self._interval()
|
interval = self._interval()
|
||||||
@@ -82,10 +82,11 @@ class MonitorDaemon:
|
|||||||
keystore=self.extension.keystore,
|
keystore=self.extension.keystore,
|
||||||
license_allows_auto_restart=self._auto_restart_allowed(),
|
license_allows_auto_restart=self._auto_restart_allowed(),
|
||||||
failures_before_restart=self._failures_before_restart(),
|
failures_before_restart=self._failures_before_restart(),
|
||||||
|
vnc_port=vnc.port_aus_einstellungen(self.extension),
|
||||||
)
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
"Durchlauf: %s geprueft, %s online, %s offline, %s Neustarts (%s erfolgreich), "
|
"Durchlauf: %s geprüft, %s online, %s offline, %s Neustarts (%s erfolgreich), "
|
||||||
"%s ohne Lizenz uebersprungen, %sms",
|
"%s ohne Lizenz übersprungen, %sms",
|
||||||
report.checked,
|
report.checked,
|
||||||
report.online,
|
report.online,
|
||||||
report.offline,
|
report.offline,
|
||||||
@@ -96,7 +97,7 @@ class MonitorDaemon:
|
|||||||
)
|
)
|
||||||
except Exception: # noqa: BLE001 - der Dienst darf nie sterben
|
except Exception: # noqa: BLE001 - der Dienst darf nie sterben
|
||||||
logger.exception("Durchlauf fehlgeschlagen")
|
logger.exception("Durchlauf fehlgeschlagen")
|
||||||
live.error("monitor", "Durchlauf fehlgeschlagen -- siehe app.log")
|
live.error("monitor", "Durchlauf fehlgeschlagen – siehe app.log")
|
||||||
|
|
||||||
# Das Aufraeumen abgelaufener Freigabe-Einbindungen laeuft **nicht**
|
# Das Aufraeumen abgelaufener Freigabe-Einbindungen laeuft **nicht**
|
||||||
# hier, sondern im Webprozess: eingebunden wird in dessen
|
# hier, sondern im Webprozess: eingebunden wird in dessen
|
||||||
@@ -112,7 +113,7 @@ class MonitorDaemon:
|
|||||||
time.sleep(min(1.0, remaining))
|
time.sleep(min(1.0, remaining))
|
||||||
remaining -= 1.0
|
remaining -= 1.0
|
||||||
|
|
||||||
live.info("monitor", "Ueberwachungsprozess beendet")
|
live.info("monitor", "Überwachungsprozess beendet")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+283
-20
@@ -28,6 +28,9 @@ set -euo pipefail
|
|||||||
|
|
||||||
APP=""
|
APP=""
|
||||||
INSTANCE=""
|
INSTANCE=""
|
||||||
|
#: Verzeichnis der Umgebungsdateien. Ueberschreibbar, damit sich die
|
||||||
|
#: Portsuche in einem Test wirklich ausfuehren laesst.
|
||||||
|
ETC_TESM="${TESM_ETC_DIR:-/etc/tesm}"
|
||||||
PORT=""
|
PORT=""
|
||||||
HTTP_PORT=""
|
HTTP_PORT=""
|
||||||
SKIP_PACKAGES=0
|
SKIP_PACKAGES=0
|
||||||
@@ -36,6 +39,7 @@ HTTPS=0
|
|||||||
HTTPS_PORT=""
|
HTTPS_PORT=""
|
||||||
DOMAIN=""
|
DOMAIN=""
|
||||||
ACME_EMAIL=""
|
ACME_EMAIL=""
|
||||||
|
ERZWINGEN=0
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
@@ -46,6 +50,95 @@ warn() { printf '%s !!%s %s\n' "$COLOR_WARN" "$COLOR_OFF" "$*"; }
|
|||||||
fail() { printf '%s !!%s %s\n' "$COLOR_ERR" "$COLOR_OFF" "$*" >&2; exit 1; }
|
fail() { printf '%s !!%s %s\n' "$COLOR_ERR" "$COLOR_OFF" "$*" >&2; exit 1; }
|
||||||
info() { printf ' %s\n' "$*"; }
|
info() { printf ' %s\n' "$*"; }
|
||||||
|
|
||||||
|
# -- Welche Fassung liegt hier, und welche laeuft schon? ---------------------
|
||||||
|
#
|
||||||
|
# Ein Installationslauf auf dieselbe Fassung macht nichts besser: er baut die
|
||||||
|
# Python-Umgebung neu, kopiert dieselben Dateien, laedt nginx neu und startet
|
||||||
|
# den Dienst -- mit der einzigen Wirkung, dass die Anwendung ein paar Sekunden
|
||||||
|
# weg ist. Wer "update.sh" in einen Zeitplan haengt, bekaeme das taeglich.
|
||||||
|
#
|
||||||
|
# Verglichen wird die Anwendung **und** der Kern: die beiden Nummern laufen
|
||||||
|
# absichtlich auseinander (siehe pyproject.toml), und eine Auslieferung kann
|
||||||
|
# denselben Anwendungsstand mit neuem Kern enthalten.
|
||||||
|
|
||||||
|
#: Die Nummer aus einer pyproject.toml. Die erste Zeile "version = ..." gilt --
|
||||||
|
#: weitere Vorkommen stehen in Abhaengigkeiten und gehen uns nichts an.
|
||||||
|
paket_version() {
|
||||||
|
sed -n 's/^version = "\([^"]*\)"/\1/p' "$1" 2>/dev/null | head -1
|
||||||
|
}
|
||||||
|
|
||||||
|
#: Was in dieser Installation laeuft: "<anwendung> <kern>", sonst leer.
|
||||||
|
laufende_version() {
|
||||||
|
local wurzel="$1" modul="$2"
|
||||||
|
[[ -x "$wurzel/venv/bin/python" ]] || return 0
|
||||||
|
"$wurzel/venv/bin/python" -c \
|
||||||
|
"import $modul, tesm_core; print($modul.__version__, tesm_core.__version__)" \
|
||||||
|
2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
#: Ist das, was hier liegt, schon installiert? Eine eigene Funktion, weil genau
|
||||||
|
#: diese Frage sich pruefen lassen muss -- ohne einen Server aufzusetzen.
|
||||||
|
gleiche_version() {
|
||||||
|
local laeuft="$1" liegt="$2"
|
||||||
|
[[ -n "$laeuft" && "$laeuft" == "$liegt" ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
#: Merker fuer die EXIT-Falle. Zurueckgerollt wird nur, was auch gesichert ist.
|
||||||
|
SICHERUNG_STEHT=0
|
||||||
|
INSTALL_FERTIG=0
|
||||||
|
ZURUECKGEROLLT=0
|
||||||
|
|
||||||
|
zurueckrollen_install() {
|
||||||
|
[[ $SICHERUNG_STEHT -eq 1 && $ZURUECKGEROLLT -eq 0 ]] || return 0
|
||||||
|
# Kommt nur zum Zug, wenn die Sicherung steht -- dann sind diese Variablen
|
||||||
|
# gesetzt. Der Vollstaendigkeit halber trotzdem gepruefte Namen.
|
||||||
|
[[ -n "${BACKUP_DIR:-}" && -d "$BACKUP_DIR" ]] || { warn "Keine Sicherung gefunden -- nichts wiederhergestellt."; return 0; }
|
||||||
|
ZURUECKGEROLLT=1
|
||||||
|
warn "Alten Stand wiederherstellen."
|
||||||
|
systemctl stop "$UNIT" 2>/dev/null || true
|
||||||
|
[[ "$APP" == "tesm" ]] && systemctl stop "$MONITOR_UNIT" 2>/dev/null || true
|
||||||
|
rm -rf "$FAILED_DIR"
|
||||||
|
mv "$ROOT" "$FAILED_DIR" 2>/dev/null || true
|
||||||
|
cp -a "$BACKUP_DIR" "$ROOT"
|
||||||
|
systemctl start "$UNIT" 2>/dev/null || true
|
||||||
|
[[ "$APP" == "tesm" ]] && systemctl start "$MONITOR_UNIT" 2>/dev/null || true
|
||||||
|
warn "Wiederhergestellt aus $BACKUP_DIR"
|
||||||
|
warn "Fehlgeschlagener Stand liegt unter $FAILED_DIR"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Bei **jedem** Fehlschlag, nicht nur bei einem, an den ich gedacht habe:
|
||||||
|
# Systempakete, venv, sudoers, nginx, ein Ctrl-C. Vorher blieb der Dienst
|
||||||
|
# gestoppt und das Verzeichnis teils ersetzt zurueck.
|
||||||
|
bei_fehler() {
|
||||||
|
local code=$?
|
||||||
|
[[ $INSTALL_FERTIG -eq 1 || "$code" == "0" ]] && return "$code"
|
||||||
|
if [[ $SICHERUNG_STEHT -eq 1 ]]; then
|
||||||
|
zurueckrollen_install
|
||||||
|
else
|
||||||
|
# Neuinstallation: es gibt keinen alten Stand. Nichts loeschen -- im
|
||||||
|
# Zielverzeichnis kann eine Fassung 1.x liegen, deren Sicherung ein
|
||||||
|
# anderes Skript haelt (upgrade.sh).
|
||||||
|
# Vorsichtige Ersetzung: bricht es schon beim Auswerten der Argumente ab,
|
||||||
|
# gibt es $ROOT noch nicht -- und "set -u" wuerde die Falle selbst zum
|
||||||
|
# Fehler machen.
|
||||||
|
if [[ ${IS_UPDATE:-0} -eq 1 ]]; then
|
||||||
|
# Abbruch vor dem Sicherungsschritt: die Installation ist unangetastet.
|
||||||
|
warn "Abgebrochen (Code $code), bevor etwas geaendert wurde."
|
||||||
|
warn "Die bestehende Installation ist unberuehrt -- nichts zurueckzurollen."
|
||||||
|
else
|
||||||
|
warn "Abgebrochen (Code $code). Es gab keinen vorherigen Stand dieser Fassung,"
|
||||||
|
warn "also wurde nichts wiederhergestellt und nichts geloescht."
|
||||||
|
# Nur sagen, was es wirklich gibt: bei einem Abbruch an der Rueckfrage
|
||||||
|
# existiert das Verzeichnis noch nicht.
|
||||||
|
if [[ -n "${ROOT:-}" && -d "${ROOT:-}" ]]; then
|
||||||
|
warn "Angelegt wurde bis hierhin: $ROOT (Daten unter data/ unberuehrt)."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
return "$code"
|
||||||
|
}
|
||||||
|
trap bei_fehler EXIT
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
cat <<'USAGE'
|
cat <<'USAGE'
|
||||||
Verwendung: install.sh --app <tesm|tesm-license> [Optionen]
|
Verwendung: install.sh --app <tesm|tesm-license> [Optionen]
|
||||||
@@ -70,6 +163,7 @@ Verwendung: install.sh --app <tesm|tesm-license> [Optionen]
|
|||||||
--acme-email <a> Kontaktadresse fuer Let's Encrypt.
|
--acme-email <a> Kontaktadresse fuer Let's Encrypt.
|
||||||
--skip-packages Keine Systempakete installieren
|
--skip-packages Keine Systempakete installieren
|
||||||
--yes Rueckfragen ueberspringen
|
--yes Rueckfragen ueberspringen
|
||||||
|
--force Auch dann einrichten, wenn dieselbe Fassung schon laeuft
|
||||||
USAGE
|
USAGE
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,6 +179,7 @@ while [[ $# -gt 0 ]]; do
|
|||||||
--acme-email) ACME_EMAIL="${2:?}"; shift 2 ;;
|
--acme-email) ACME_EMAIL="${2:?}"; shift 2 ;;
|
||||||
--skip-packages) SKIP_PACKAGES=1; shift ;;
|
--skip-packages) SKIP_PACKAGES=1; shift ;;
|
||||||
--yes|-y) ASSUME_YES=1; shift ;;
|
--yes|-y) ASSUME_YES=1; shift ;;
|
||||||
|
--force) ERZWINGEN=1; shift ;;
|
||||||
-h|--help) usage; exit 0 ;;
|
-h|--help) usage; exit 0 ;;
|
||||||
*) fail "unbekannte Option: $1" ;;
|
*) fail "unbekannte Option: $1" ;;
|
||||||
esac
|
esac
|
||||||
@@ -109,16 +204,35 @@ LOG_DIR="/var/log/$NAME"
|
|||||||
SERVICE_USER="$APP"
|
SERVICE_USER="$APP"
|
||||||
UNIT="$NAME.service"
|
UNIT="$NAME.service"
|
||||||
MONITOR_UNIT="$NAME-monitor.service"
|
MONITOR_UNIT="$NAME-monitor.service"
|
||||||
|
CLEANUP_UNIT="$NAME-cleanup.service"
|
||||||
|
CLEANUP_TIMER="$NAME-cleanup.timer"
|
||||||
NGINX_SITE="/etc/nginx/sites-available/$NAME"
|
NGINX_SITE="/etc/nginx/sites-available/$NAME"
|
||||||
ENV_PREFIX="TESM_"
|
ENV_PREFIX="TESM_"
|
||||||
[[ "$APP" == "tesm-license" ]] && ENV_PREFIX="TESM_LICENSE_"
|
[[ "$APP" == "tesm-license" ]] && ENV_PREFIX="TESM_LICENSE_"
|
||||||
|
|
||||||
|
# Ausgabe des Lizenzservers: die Master-Ausgabe bringt das Teilpaket
|
||||||
|
# tesm_license/master mit -- den einzigen Weg, auf dem eine Lizenzdatei die
|
||||||
|
# Rolle "master" bekommt. Das Release fuer Sub-Lizenzserver hat es nicht.
|
||||||
|
# Die Ausgabe haengt an der Anwesenheit des Verzeichnisses, nicht an einer
|
||||||
|
# Einstellung: eine Einstellung liesse sich vor Ort umstellen.
|
||||||
|
MASTER_TEILPAKET="apps/tesm-license/src/tesm_license/master"
|
||||||
|
EDITION="standard"
|
||||||
|
if [[ "$APP" == "tesm-license" ]]; then
|
||||||
|
if [[ -d "$REPO_DIR/$MASTER_TEILPAKET" ]]; then EDITION="master"; else EDITION="sub"; fi
|
||||||
|
fi
|
||||||
# Bei einer Aktualisierung gilt zuerst, was schon eingerichtet ist. Sonst
|
# Bei einer Aktualisierung gilt zuerst, was schon eingerichtet ist. Sonst
|
||||||
# verschiebt ein Update ohne Portangaben die Anwendung: eine benannte Instanz
|
# verschiebt ein Update ohne Portangaben die Anwendung: eine benannte Instanz
|
||||||
# faellt auf 8080/8443 zurueck, auch wenn sie seit der Installation auf Port 80
|
# faellt auf 8080/8443 zurueck, auch wenn sie seit der Installation auf Port 80
|
||||||
# lief -- und ein Reverse Proxy davor zeigt dann ins Leere. Dieselbe Regel wie
|
# lief -- und ein Reverse Proxy davor zeigt dann ins Leere. Dieselbe Regel wie
|
||||||
# beim Secure-Flag weiter unten: eine Aktualisierung nimmt nichts weg, was
|
# beim Secure-Flag weiter unten: eine Aktualisierung nimmt nichts weg, was
|
||||||
# nicht ausdruecklich neu gesetzt wird.
|
# nicht ausdruecklich neu gesetzt wird.
|
||||||
ENV_FILE="/etc/tesm/$NAME.env"
|
ENV_FILE="$ETC_TESM/$NAME.env"
|
||||||
|
# Wo die Daten liegen, steht in der Umgebungsdatei; ohne sie beim Vorgabeort.
|
||||||
|
# "|| true": bei einer Neuinstallation gibt es die Datei noch nicht, und ein
|
||||||
|
# scheiterndes sed in einer Zuweisung beendet unter "set -e" das ganze Skript.
|
||||||
|
DATEN_DIR="$(sed -n "s/^${ENV_PREFIX}DATA_DIR=//p" "$ENV_FILE" 2>/dev/null || true)"
|
||||||
|
DATEN_DIR="$(printf '%s' "$DATEN_DIR" | tail -1)"
|
||||||
|
DATEN_DIR="${DATEN_DIR:-$ROOT/data}"
|
||||||
if [[ -z "$PORT" && -f "$ENV_FILE" ]]; then
|
if [[ -z "$PORT" && -f "$ENV_FILE" ]]; then
|
||||||
PORT="$(sed -n "s/^${ENV_PREFIX}BIND=127\.0\.0\.1:\([0-9]\{1,5\}\)$/\1/p" "$ENV_FILE" | head -1)"
|
PORT="$(sed -n "s/^${ENV_PREFIX}BIND=127\.0\.0\.1:\([0-9]\{1,5\}\)$/\1/p" "$ENV_FILE" | head -1)"
|
||||||
fi
|
fi
|
||||||
@@ -127,10 +241,30 @@ if [[ -f "$NGINX_SITE" ]]; then
|
|||||||
[[ -z "$HTTPS_PORT" ]] && HTTPS_PORT="$(sed -n 's/^[[:space:]]*listen[[:space:]]\([0-9]\{1,5\}\)[[:space:]]\+ssl.*/\1/p' "$NGINX_SITE" | head -1)"
|
[[ -z "$HTTPS_PORT" ]] && HTTPS_PORT="$(sed -n 's/^[[:space:]]*listen[[:space:]]\([0-9]\{1,5\}\)[[:space:]]\+ssl.*/\1/p' "$NGINX_SITE" | head -1)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
port_belegt() {
|
||||||
|
local p="$1"
|
||||||
|
# Schon von einer anderen Installation vergeben? Die Umgebungsdateien sind
|
||||||
|
# die Wahrheit -- ein Dienst kann gerade gestoppt sein und den Port trotzdem
|
||||||
|
# beanspruchen.
|
||||||
|
grep -qs "BIND=127\.0\.0\.1:$p\$" "$ETC_TESM"/*.env 2>/dev/null &&
|
||||||
|
return 0
|
||||||
|
# Oder gerade in Benutzung, von was auch immer.
|
||||||
|
command -v ss >/dev/null 2>&1 && [[ -n "$(ss -Hltn "sport = :$p" 2>/dev/null)" ]] && return 0
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
if [[ -z "$PORT" ]]; then
|
if [[ -z "$PORT" ]]; then
|
||||||
PORT=5000
|
BASISPORT=5000
|
||||||
[[ "$APP" == "tesm-license" ]] && PORT=5001
|
[[ "$APP" == "tesm-license" ]] && BASISPORT=5001
|
||||||
[[ -n "$INSTANCE" ]] && PORT=$((PORT + 100))
|
if [[ -n "$INSTANCE" ]]; then
|
||||||
|
# Frueher bekam **jede** Instanz Basis+100. Die zweite startete nie
|
||||||
|
# ("Address already in use") -- und die Gesundheitspruefung merkte es
|
||||||
|
# nicht, weil die erste auf demselben Port antwortete.
|
||||||
|
PORT=$((BASISPORT + 100))
|
||||||
|
while port_belegt "$PORT"; do PORT=$((PORT + 1)); done
|
||||||
|
else
|
||||||
|
PORT=$BASISPORT
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
if [[ -z "$HTTP_PORT" ]]; then
|
if [[ -z "$HTTP_PORT" ]]; then
|
||||||
HTTP_PORT=80
|
HTTP_PORT=80
|
||||||
@@ -163,6 +297,23 @@ FAILED_DIR="/srv/$NAME-failed-$TIMESTAMP"
|
|||||||
IS_UPDATE=0
|
IS_UPDATE=0
|
||||||
[[ -f "$ROOT/data/app.db" ]] && IS_UPDATE=1
|
[[ -f "$ROOT/data/app.db" ]] && IS_UPDATE=1
|
||||||
|
|
||||||
|
# Laeuft hier schon genau das, was hier liegt? Dann ist Schluss, bevor
|
||||||
|
# irgendetwas angefasst wird -- und zwar mit Ausstiegscode 0: es ist nichts
|
||||||
|
# schiefgegangen, es war nur nichts zu tun.
|
||||||
|
MODUL="tesm"
|
||||||
|
[[ "$APP" == "tesm-license" ]] && MODUL="tesm_license"
|
||||||
|
LIEGT="$(paket_version "$REPO_DIR/apps/$APP/pyproject.toml") $(
|
||||||
|
paket_version "$REPO_DIR/packages/tesm-core/pyproject.toml")"
|
||||||
|
LAEUFT="$(laufende_version "$ROOT" "$MODUL")"
|
||||||
|
if [[ $IS_UPDATE -eq 1 && $ERZWINGEN -eq 0 ]] && gleiche_version "$LAEUFT" "$LIEGT"; then
|
||||||
|
step "Nichts zu tun"
|
||||||
|
info "$APP ${LIEGT%% *} (tesm-core ${LIEGT##* }) laeuft hier bereits."
|
||||||
|
info "Ein zweiter Durchlauf wuerde nur die Umgebung neu bauen und den"
|
||||||
|
info "Dienst neu starten -- ohne dass sich etwas aendert."
|
||||||
|
info "Trotzdem neu einrichten: --force"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
step "TESM-Installation"
|
step "TESM-Installation"
|
||||||
info "Anwendung : $APP${INSTANCE:+ (Instanz $INSTANCE)}"
|
info "Anwendung : $APP${INSTANCE:+ (Instanz $INSTANCE)}"
|
||||||
info "Ziel : $ROOT"
|
info "Ziel : $ROOT"
|
||||||
@@ -180,6 +331,7 @@ else
|
|||||||
info "nginx : Port $HTTP_PORT (nur HTTP)"
|
info "nginx : Port $HTTP_PORT (nur HTTP)"
|
||||||
fi
|
fi
|
||||||
info "Modus : $([[ $IS_UPDATE -eq 1 ]] && echo Aktualisierung || echo Neuinstallation)"
|
info "Modus : $([[ $IS_UPDATE -eq 1 ]] && echo Aktualisierung || echo Neuinstallation)"
|
||||||
|
info "Fassung : ${LIEGT%% *} (tesm-core ${LIEGT##* })${LAEUFT:+, bisher ${LAEUFT%% *}}"
|
||||||
|
|
||||||
if [[ $ASSUME_YES -eq 0 ]]; then
|
if [[ $ASSUME_YES -eq 0 ]]; then
|
||||||
read -r -p "Fortfahren? [j/N] " answer
|
read -r -p "Fortfahren? [j/N] " answer
|
||||||
@@ -212,12 +364,41 @@ fi
|
|||||||
# Der Vorgaenger lief als root. Hier laeuft der Dienst unprivilegiert; Rootrechte
|
# Der Vorgaenger lief als root. Hier laeuft der Dienst unprivilegiert; Rootrechte
|
||||||
# gibt es ausschliesslich ueber den Helfer.
|
# gibt es ausschliesslich ueber den Helfer.
|
||||||
|
|
||||||
|
# Ein Sub-Paket auf eine Master-Installation waere ein stiller Rueckbau: der
|
||||||
|
# Abgleich unten laeuft mit --delete und wuerde das Teilpaket entfernen. Danach
|
||||||
|
# gaebe es auf dem Master keinen Weg mehr, eine Master-Lizenz auszustellen --
|
||||||
|
# und niemand haette es gesagt.
|
||||||
|
# Die Rolle steht in der eingespielten Lizenz. Das ist die zuverlaessigere
|
||||||
|
# Haelfte der Pruefung: eine Master-Installation aus einer aelteren Fassung
|
||||||
|
# kennt das Teilpaket noch nicht, ist aber trotzdem eine Wurzelinstanz.
|
||||||
|
ROLLE_ALT=""
|
||||||
|
if [[ "$EDITION" == "sub" && -s "$DATEN_DIR/license.json" ]]; then
|
||||||
|
ROLLE_ALT="$(python3 -c '
|
||||||
|
import json, sys
|
||||||
|
try:
|
||||||
|
print(json.load(open(sys.argv[1])).get("role", ""))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
' "$DATEN_DIR/license.json" 2>/dev/null || true)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$EDITION" == "sub" && ( -d "$ROOT/$MASTER_TEILPAKET" || "$ROLLE_ALT" == "master" ) ]]; then
|
||||||
|
fail "Hier laeuft eine Wurzelinstanz${ROLLE_ALT:+ (Lizenz mit Rolle $ROLLE_ALT)}, dieses Paket
|
||||||
|
ist die Ausgabe fuer Sub-Lizenzserver.
|
||||||
|
Eine Aktualisierung damit wuerde die Master-Ausgabe entfernen.
|
||||||
|
Passendes Paket: Repository tesm-license-master (privat).
|
||||||
|
Bewusster Rueckbau: eine vom Master ausgestellte Lizenz einspielen (Rolle
|
||||||
|
license_server) und $ROOT/$MASTER_TEILPAKET entfernen -- danach ist dies ein
|
||||||
|
Sub-Lizenzserver und dieses Paket das richtige."
|
||||||
|
fi
|
||||||
|
|
||||||
# -- Backup ------------------------------------------------------------------
|
# -- Backup ------------------------------------------------------------------
|
||||||
if [[ $IS_UPDATE -eq 1 ]]; then
|
if [[ $IS_UPDATE -eq 1 ]]; then
|
||||||
step "Sicherung vor der Aktualisierung"
|
step "Sicherung vor der Aktualisierung"
|
||||||
systemctl stop "$UNIT" 2>/dev/null || true
|
systemctl stop "$UNIT" 2>/dev/null || true
|
||||||
[[ "$APP" == "tesm" ]] && systemctl stop "$MONITOR_UNIT" 2>/dev/null || true
|
[[ "$APP" == "tesm" ]] && systemctl stop "$MONITOR_UNIT" 2>/dev/null || true
|
||||||
cp -a "$ROOT" "$BACKUP_DIR"
|
cp -a "$ROOT" "$BACKUP_DIR"
|
||||||
|
SICHERUNG_STEHT=1
|
||||||
info "Sicherung: $BACKUP_DIR"
|
info "Sicherung: $BACKUP_DIR"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -295,6 +476,42 @@ install -o root -g root -m 0440 "$TMP_SUDO" "/etc/sudoers.d/$APP"
|
|||||||
rm -f "$TMP_SUDO"
|
rm -f "$TMP_SUDO"
|
||||||
info "sudoers: $SERVICE_USER darf ausschliesslich tesm-helper als root aufrufen"
|
info "sudoers: $SERVICE_USER darf ausschliesslich tesm-helper als root aufrufen"
|
||||||
|
|
||||||
|
# -- Anmeldebanner (MOTD) ----------------------------------------------------
|
||||||
|
# Das Banner liest die Datenbank dieser Installation und laeuft dabei mit dem
|
||||||
|
# System-Python, ohne das venv und ohne die Anwendung zu importieren. Der
|
||||||
|
# Vorgaenger importierte dafuer die ganze Flask-App -- bei jeder Anmeldung, und
|
||||||
|
# nach einem Wechsel des Quellverzeichnisses zeigte er stillschweigend die
|
||||||
|
# Zahlen einer Datenbank, die niemand mehr fuellte.
|
||||||
|
#
|
||||||
|
# "timeout" verhindert, dass ein haengender Dateizugriff die Anmeldung
|
||||||
|
# blockiert; "|| true" sorgt dafuer, dass ein Fehler hier den Login nie stoert.
|
||||||
|
step "Anmeldebanner"
|
||||||
|
if [[ -d /etc/update-motd.d ]]; then
|
||||||
|
install_if_changed "$ROOT/deploy/tesm-motd" /usr/local/lib/tesm/tesm-motd 0755 >/dev/null \
|
||||||
|
&& info "Bannerskript aktualisiert" || info "Bannerskript unveraendert"
|
||||||
|
TMP_MOTD="$(mktemp)"
|
||||||
|
{
|
||||||
|
echo "#!/bin/sh"
|
||||||
|
echo "# Von TESM erzeugt ($NAME). Aenderungen werden bei der naechsten"
|
||||||
|
echo "# Installation ueberschrieben."
|
||||||
|
echo "timeout 3 /usr/local/lib/tesm/tesm-motd --app $APP --root $ROOT 2>/dev/null || true"
|
||||||
|
} > "$TMP_MOTD"
|
||||||
|
install -o root -g root -m 0755 "$TMP_MOTD" "/etc/update-motd.d/50-$NAME"
|
||||||
|
rm -f "$TMP_MOTD"
|
||||||
|
info "Banner: /etc/update-motd.d/50-$NAME"
|
||||||
|
# Ein Banner der Vorgaengerfassung zeigt Zahlen aus einer Datenbank, die
|
||||||
|
# niemand mehr fuellt. Er wird nur entfernt, wenn er genau darauf zeigt --
|
||||||
|
# nichts anderes wird angefasst.
|
||||||
|
ALT_MOTD="/etc/update-motd.d/50-tesm"
|
||||||
|
if [[ "$ALT_MOTD" != "/etc/update-motd.d/50-$NAME" && -f "$ALT_MOTD" ]] \
|
||||||
|
&& grep -q "motd_banner.py" "$ALT_MOTD" 2>/dev/null; then
|
||||||
|
rm -f "$ALT_MOTD"
|
||||||
|
info "Banner der Vorgaengerfassung entfernt ($ALT_MOTD)"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
info "Kein /etc/update-motd.d -- Banner uebersprungen"
|
||||||
|
fi
|
||||||
|
|
||||||
# -- Rechte ------------------------------------------------------------------
|
# -- Rechte ------------------------------------------------------------------
|
||||||
step "Dateirechte"
|
step "Dateirechte"
|
||||||
chown -R "$SERVICE_USER:$SERVICE_USER" "$ROOT/data" "$LOG_DIR"
|
chown -R "$SERVICE_USER:$SERVICE_USER" "$ROOT/data" "$LOG_DIR"
|
||||||
@@ -311,8 +528,8 @@ step "Umgebung"
|
|||||||
# EnvironmentFile, die Kommandozeilenwerkzeuge ueber ihren Wrapper. Ohne das
|
# EnvironmentFile, die Kommandozeilenwerkzeuge ueber ihren Wrapper. Ohne das
|
||||||
# wuesste "tesm-admin" nicht, wo die Instanz liegt, und wuerde sich eine leere
|
# wuesste "tesm-admin" nicht, wo die Instanz liegt, und wuerde sich eine leere
|
||||||
# Datenbank im Arbeitsverzeichnis anlegen.
|
# Datenbank im Arbeitsverzeichnis anlegen.
|
||||||
install -d -o root -g root -m 0755 /etc/tesm
|
install -d -o root -g root -m 0755 "$ETC_TESM"
|
||||||
ENV_FILE="/etc/tesm/$NAME.env"
|
ENV_FILE="$ETC_TESM/$NAME.env"
|
||||||
TMP_ENV="$(mktemp)"
|
TMP_ENV="$(mktemp)"
|
||||||
{
|
{
|
||||||
echo "# Von install.sh erzeugt fuer $NAME -- nicht von Hand bearbeiten."
|
echo "# Von install.sh erzeugt fuer $NAME -- nicht von Hand bearbeiten."
|
||||||
@@ -378,6 +595,11 @@ render_unit "$ROOT/deploy/systemd/$APP.service" "/etc/systemd/system/$UNIT"
|
|||||||
if [[ "$APP" == "tesm" ]]; then
|
if [[ "$APP" == "tesm" ]]; then
|
||||||
render_unit "$ROOT/deploy/systemd/tesm-monitor.service" "/etc/systemd/system/$MONITOR_UNIT"
|
render_unit "$ROOT/deploy/systemd/tesm-monitor.service" "/etc/systemd/system/$MONITOR_UNIT"
|
||||||
fi
|
fi
|
||||||
|
# Aufraeumen lief bisher nur von Hand -- also praktisch nie. Auf einer
|
||||||
|
# laufenden Installation lagen deshalb hunderte abgelaufene Sitzungszeilen in
|
||||||
|
# der Datenbank, und das Aenderungsprotokoll waere nie archiviert worden.
|
||||||
|
render_unit "$ROOT/deploy/systemd/$APP-cleanup.service" "/etc/systemd/system/$CLEANUP_UNIT"
|
||||||
|
render_unit "$ROOT/deploy/systemd/$APP-cleanup.timer" "/etc/systemd/system/$CLEANUP_TIMER"
|
||||||
[[ $UNITS_CHANGED -eq 1 ]] && systemctl daemon-reload && info "Units neu geladen" \
|
[[ $UNITS_CHANGED -eq 1 ]] && systemctl daemon-reload && info "Units neu geladen" \
|
||||||
|| info "Units unveraendert"
|
|| info "Units unveraendert"
|
||||||
|
|
||||||
@@ -450,12 +672,18 @@ systemctl enable --now "$UNIT" >/dev/null
|
|||||||
if [[ "$APP" == "tesm" ]]; then
|
if [[ "$APP" == "tesm" ]]; then
|
||||||
systemctl enable --now "$MONITOR_UNIT" >/dev/null
|
systemctl enable --now "$MONITOR_UNIT" >/dev/null
|
||||||
fi
|
fi
|
||||||
|
# Der Timer, nicht der Dienst: der Dienst laeuft einmal und ist fertig.
|
||||||
|
systemctl enable --now "$CLEANUP_TIMER" >/dev/null 2>&1 && info "Aufraeumen: taeglich 03:30" || info "Aufraeum-Timer nicht aktiviert -- 'systemctl status $CLEANUP_TIMER' zeigt warum"
|
||||||
|
|
||||||
# -- Health-Check und Rollback ----------------------------------------------
|
# -- Health-Check und Rollback ----------------------------------------------
|
||||||
step "Gesundheitspruefung"
|
step "Gesundheitspruefung"
|
||||||
HEALTHY=0
|
HEALTHY=0
|
||||||
for attempt in $(seq 1 20); do
|
for attempt in $(seq 1 20); do
|
||||||
if curl -fsS --max-time 3 "http://127.0.0.1:$PORT/gesundheit" >/dev/null 2>&1; then
|
# Beides: der Dienst muss laufen **und** antworten. Nur die Antwort zu
|
||||||
|
# pruefen ging schon einmal schief -- auf einem gemeinsamen Port antwortete
|
||||||
|
# eine andere Instanz, und ein toter Dienst galt als gesund.
|
||||||
|
if systemctl is-active --quiet "$UNIT" &&
|
||||||
|
curl -fsS --max-time 3 "http://127.0.0.1:$PORT/health" >/dev/null 2>&1; then
|
||||||
HEALTHY=1
|
HEALTHY=1
|
||||||
info "Antwort nach ${attempt}x"
|
info "Antwort nach ${attempt}x"
|
||||||
break
|
break
|
||||||
@@ -466,13 +694,9 @@ done
|
|||||||
if [[ $HEALTHY -eq 0 ]]; then
|
if [[ $HEALTHY -eq 0 ]]; then
|
||||||
warn "Die Anwendung antwortet nicht."
|
warn "Die Anwendung antwortet nicht."
|
||||||
if [[ $IS_UPDATE -eq 1 ]]; then
|
if [[ $IS_UPDATE -eq 1 ]]; then
|
||||||
warn "Rollback wird ausgefuehrt."
|
# Dieselbe Funktion wie die EXIT-Falle -- zwei Wege, die dasselbe tun
|
||||||
systemctl stop "$UNIT" 2>/dev/null || true
|
# sollen, laufen sonst irgendwann auseinander.
|
||||||
[[ "$APP" == "tesm" ]] && systemctl stop "$MONITOR_UNIT" 2>/dev/null || true
|
zurueckrollen_install
|
||||||
mv "$ROOT" "$FAILED_DIR"
|
|
||||||
cp -a "$BACKUP_DIR" "$ROOT"
|
|
||||||
systemctl start "$UNIT" || true
|
|
||||||
[[ "$APP" == "tesm" ]] && systemctl start "$MONITOR_UNIT" || true
|
|
||||||
fail "Aktualisierung fehlgeschlagen. Alter Stand wiederhergestellt.
|
fail "Aktualisierung fehlgeschlagen. Alter Stand wiederhergestellt.
|
||||||
Fehlgeschlagene Version : $FAILED_DIR
|
Fehlgeschlagene Version : $FAILED_DIR
|
||||||
Sicherung : $BACKUP_DIR
|
Sicherung : $BACKUP_DIR
|
||||||
@@ -532,12 +756,41 @@ if [[ $HTTPS -eq 1 ]]; then
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# -- Lizenzserver einrichten -------------------------------------------------
|
||||||
|
# Bei einer Neuinstallation: Signaturschluessel anlegen (braucht jeder
|
||||||
|
# Lizenzserver, um Kundenlizenzen zu signieren) und in der Master-Ausgabe
|
||||||
|
# gleich die eigene Master-Lizenz. Bei einer Aktualisierung nichts davon --
|
||||||
|
# ein bestehender Schluessel darf nie angetastet werden.
|
||||||
|
if [[ "$APP" == "tesm-license" && $IS_UPDATE -eq 0 ]]; then
|
||||||
|
step "Signaturschluessel"
|
||||||
|
if sudo -u "$SERVICE_USER" "/usr/local/bin/$NAME-admin" init-key; then
|
||||||
|
info "Bitte die Datei $DATEN_DIR/master_signing_key.json SOFORT sichern."
|
||||||
|
else
|
||||||
|
fail "Der Signaturschluessel liess sich nicht erzeugen. Ohne ihn kann dieser Server
|
||||||
|
keine Lizenz ausstellen. Protokoll: journalctl -u $UNIT -n 50"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$EDITION" == "master" ]]; then
|
||||||
|
step "Eigene Master-Lizenz"
|
||||||
|
if [[ -s "$DATEN_DIR/license.json" ]]; then
|
||||||
|
info "Es liegt schon eine Lizenzdatei -- unangetastet."
|
||||||
|
elif sudo -u "$SERVICE_USER" "/usr/local/bin/$NAME-admin" bootstrap; then
|
||||||
|
systemctl restart "$UNIT"
|
||||||
|
info "Dienst neu gestartet, damit die Lizenz gelesen wird."
|
||||||
|
else
|
||||||
|
fail "Die Master-Lizenz liess sich nicht ausstellen.
|
||||||
|
Nachtraeglich: sudo -u $SERVICE_USER $NAME-admin bootstrap"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
if [[ $IS_UPDATE -eq 1 ]]; then
|
if [[ $IS_UPDATE -eq 1 ]]; then
|
||||||
rm -rf "$BACKUP_DIR"
|
rm -rf "$BACKUP_DIR"
|
||||||
info "Sicherung entfernt (Aktualisierung erfolgreich)"
|
info "Sicherung entfernt (Aktualisierung erfolgreich)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# -- Abschluss ---------------------------------------------------------------
|
# -- Abschluss ---------------------------------------------------------------
|
||||||
|
INSTALL_FERTIG=1
|
||||||
step "Fertig"
|
step "Fertig"
|
||||||
ADMIN_CMD="tesm-admin"
|
ADMIN_CMD="tesm-admin"
|
||||||
[[ "$APP" == "tesm-license" ]] && ADMIN_CMD="tesm-license-admin"
|
[[ "$APP" == "tesm-license" ]] && ADMIN_CMD="tesm-license-admin"
|
||||||
@@ -553,14 +806,24 @@ cat <<SUMMARY
|
|||||||
Naechste Schritte:
|
Naechste Schritte:
|
||||||
SUMMARY
|
SUMMARY
|
||||||
|
|
||||||
if [[ "$APP" == "tesm-license" ]]; then
|
if [[ "$APP" == "tesm-license" && "$EDITION" == "master" ]]; then
|
||||||
cat <<SUMMARY
|
cat <<SUMMARY
|
||||||
1. Signaturschluessel erzeugen und sofort sichern:
|
1. Administrator anlegen:
|
||||||
sudo -u $SERVICE_USER $NAME-admin init-key
|
|
||||||
2. Eigene Master-Lizenz ausstellen:
|
|
||||||
sudo -u $SERVICE_USER $NAME-admin bootstrap
|
|
||||||
3. Administrator anlegen:
|
|
||||||
sudo -u $SERVICE_USER $NAME-admin create-admin
|
sudo -u $SERVICE_USER $NAME-admin create-admin
|
||||||
|
2. Signaturschluessel sichern -- ohne ihn ist keine ausgestellte Lizenz
|
||||||
|
mehr pruefbar:
|
||||||
|
$ROOT/data/master_signing_key.json
|
||||||
|
SUMMARY
|
||||||
|
elif [[ "$APP" == "tesm-license" ]]; then
|
||||||
|
cat <<SUMMARY
|
||||||
|
1. Administrator anlegen:
|
||||||
|
sudo -u $SERVICE_USER $NAME-admin create-admin
|
||||||
|
2. Beim Master-Lizenzserver eine Lizenz fuer diesen Server anfordern und
|
||||||
|
unter "Eigene Lizenz" einspielen. Diese Ausgabe ist ein Sub-Lizenzserver
|
||||||
|
und stellt keine eigene Master-Lizenz aus.
|
||||||
|
3. Signaturschluessel sichern -- mit ihm signiert dieser Server seine
|
||||||
|
Kundenlizenzen:
|
||||||
|
$ROOT/data/master_signing_key.json
|
||||||
SUMMARY
|
SUMMARY
|
||||||
else
|
else
|
||||||
cat <<SUMMARY
|
cat <<SUMMARY
|
||||||
|
|||||||
+10
-1
@@ -14,13 +14,22 @@ TAG="${1:?Verwendung: release.sh vX.Y.Z [name]}"
|
|||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
|
||||||
# Vorgabe: der Name der einzigen Anwendung im Baum, sonst der Verzeichnisname.
|
# Vorgabe: der Name des Repositoriums. Genau den erwartet der Bootstrap von
|
||||||
|
# update.sh als Anhangsnamen ("$repo-$TAG.tar.gz"). Die Anwendung heisst nicht
|
||||||
|
# immer so: im Repositorium tesm-license-master liegt die Anwendung
|
||||||
|
# "tesm-license", und ein Anhang unter deren Namen waere nie zu finden.
|
||||||
if [[ -n "${2:-}" ]]; then
|
if [[ -n "${2:-}" ]]; then
|
||||||
NAME="$2"
|
NAME="$2"
|
||||||
else
|
else
|
||||||
|
HERKUNFT="$(git -C "$REPO_DIR" config --get remote.origin.url 2>/dev/null || true)"
|
||||||
|
if [[ -n "$HERKUNFT" ]]; then
|
||||||
|
NAME="$(basename "$HERKUNFT" .git)"
|
||||||
|
else
|
||||||
|
# Ohne Gegenseite: die einzige Anwendung im Baum, sonst das Verzeichnis.
|
||||||
mapfile -t APPS < <(ls -1 "$REPO_DIR/apps" 2>/dev/null || true)
|
mapfile -t APPS < <(ls -1 "$REPO_DIR/apps" 2>/dev/null || true)
|
||||||
if [[ ${#APPS[@]} -eq 1 ]]; then NAME="${APPS[0]}"; else NAME="$(basename "$REPO_DIR")"; fi
|
if [[ ${#APPS[@]} -eq 1 ]]; then NAME="${APPS[0]}"; else NAME="$(basename "$REPO_DIR")"; fi
|
||||||
fi
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
OUT="$NAME-$TAG.tar.gz"
|
OUT="$NAME-$TAG.tar.gz"
|
||||||
git -c core.autocrlf=false -C "$REPO_DIR" archive --format=tar.gz --prefix="$NAME/" -o "$OUT" "$TAG"
|
git -c core.autocrlf=false -C "$REPO_DIR" archive --format=tar.gz --prefix="$NAME/" -o "$OUT" "$TAG"
|
||||||
|
|||||||
@@ -0,0 +1,359 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# =============================================================================
|
||||||
|
# Benennt eine Installation um -- aus einer Instanz wird die Hauptinstallation
|
||||||
|
# (oder umgekehrt).
|
||||||
|
#
|
||||||
|
# sudo bash deploy/rename.sh --app tesm --from tesm-test --to tesm
|
||||||
|
# sudo bash deploy/rename.sh --app tesm-license --from tesm-license-test \
|
||||||
|
# --to tesm-license
|
||||||
|
#
|
||||||
|
# Der Name steckt an mehr Stellen, als man beim Verschieben eines Verzeichnisses
|
||||||
|
# erwartet, und drei davon sind leicht zu vergessen:
|
||||||
|
#
|
||||||
|
# * ``SITE_KEY`` in der Umgebungsdatei -- daraus entsteht der Name des
|
||||||
|
# Sitzungscookies. Bleibt er alt, heisst das Cookie weiter wie die alte
|
||||||
|
# Instanz.
|
||||||
|
# * ``web_cert_path``/``web_key_path``/``web_acme_webroot`` in der Datenbank.
|
||||||
|
# Sie enthalten den Namen; ein Zertifikat unter dem alten Pfad findet nginx
|
||||||
|
# nach dem Umzug nicht mehr.
|
||||||
|
# * ``server_name`` der nginx-Site. install.sh schreibt die Datei neu, sobald
|
||||||
|
# sich die alias-Pfade aendern -- und die aendern sich hier zwangslaeufig.
|
||||||
|
# Ohne den Namen aus der Datenbank stuenden danach zwei Sites mit ``_`` auf
|
||||||
|
# Port 80 und die erste gewinnt.
|
||||||
|
#
|
||||||
|
# Belegt der Zielname noch eine Installation der Fassung 1.x, wird sie beiseite
|
||||||
|
# geschoben: Verzeichnis nach ``<ziel>-<suffix>``, ihre systemd-Units und ihre
|
||||||
|
# nginx-Site als Kopie hinein. Danach ist der Name frei und nichts auf dem
|
||||||
|
# System zeigt mehr ins Leere.
|
||||||
|
#
|
||||||
|
# Alle angemeldeten Benutzer werden abgemeldet: das Sitzungscookie heisst
|
||||||
|
# danach anders.
|
||||||
|
# =============================================================================
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
APP=""
|
||||||
|
FROM=""
|
||||||
|
TO=""
|
||||||
|
SUFFIX="sonnet5"
|
||||||
|
ASSUME_YES=0
|
||||||
|
DRY_RUN=0
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
|
||||||
|
COLOR_OK=$'\033[32m'; COLOR_WARN=$'\033[33m'; COLOR_ERR=$'\033[31m'; COLOR_OFF=$'\033[0m'
|
||||||
|
step() { printf '\n%s==>%s %s\n' "$COLOR_OK" "$COLOR_OFF" "$*"; }
|
||||||
|
warn() { printf '%s !!%s %s\n' "$COLOR_WARN" "$COLOR_OFF" "$*"; }
|
||||||
|
fail() { printf '%s !!%s %s\n' "$COLOR_ERR" "$COLOR_OFF" "$*" >&2; exit 1; }
|
||||||
|
info() { printf ' %s\n' "$*"; }
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'USAGE'
|
||||||
|
Verwendung: rename.sh --app <tesm|tesm-license> --from <alt> --to <neu> [Optionen]
|
||||||
|
|
||||||
|
--app <name> Welche Anwendung (Pflicht)
|
||||||
|
--from <name> Bisheriger Installationsname, z.B. tesm-test (Pflicht)
|
||||||
|
--to <name> Neuer Installationsname, z.B. tesm (Pflicht)
|
||||||
|
--suffix <text> Namenszusatz fuer eine beiseite geschobene Installation
|
||||||
|
der Fassung 1.x (Vorgabe: sonnet5)
|
||||||
|
--dry-run Nur zeigen, was geschehen wuerde
|
||||||
|
--yes Keine Rueckfrage
|
||||||
|
USAGE
|
||||||
|
}
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--app) APP="${2:?}"; shift 2 ;;
|
||||||
|
--from) FROM="${2:?}"; shift 2 ;;
|
||||||
|
--to) TO="${2:?}"; shift 2 ;;
|
||||||
|
--suffix) SUFFIX="${2:?}"; shift 2 ;;
|
||||||
|
--dry-run) DRY_RUN=1; shift ;;
|
||||||
|
--yes|-y) ASSUME_YES=1; shift ;;
|
||||||
|
-h|--help) usage; exit 0 ;;
|
||||||
|
*) usage; fail "unbekannte Option: $1" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
[[ "$APP" == "tesm" || "$APP" == "tesm-license" ]] || { usage; fail "--app fehlt oder ist unbekannt"; }
|
||||||
|
[[ -n "$FROM" && -n "$TO" ]] || { usage; fail "--from und --to sind Pflicht"; }
|
||||||
|
[[ "$FROM" != "$TO" ]] || fail "--from und --to sind gleich"
|
||||||
|
[[ "$TO" =~ ^[a-z0-9-]{1,32}$ ]] || fail "ungueltiger Zielname: $TO"
|
||||||
|
[[ "$TO" == "$APP" || "$TO" == "$APP-"* ]] || fail "der Zielname muss mit '$APP' beginnen"
|
||||||
|
[[ "$(id -u)" -eq 0 ]] || fail "bitte mit sudo ausfuehren"
|
||||||
|
[[ -f "$SCRIPT_DIR/install.sh" ]] || fail "install.sh fehlt neben diesem Skript"
|
||||||
|
[[ -d "/srv/$FROM" ]] || fail "/srv/$FROM gibt es nicht"
|
||||||
|
[[ -f "/srv/$FROM/data/app.db" ]] || fail "/srv/$FROM sieht nicht nach einer Installation dieser Fassung aus"
|
||||||
|
|
||||||
|
ENV_PREFIX="TESM_"
|
||||||
|
[[ "$APP" == "tesm-license" ]] && ENV_PREFIX="TESM_LICENSE_"
|
||||||
|
|
||||||
|
# Alles, was den Namen traegt. Reihenfolge: erst Daten, dann Konfiguration.
|
||||||
|
VERSCHIEBEN=(
|
||||||
|
"/srv/$FROM:/srv/$TO"
|
||||||
|
"/var/log/$FROM:/var/log/$TO"
|
||||||
|
"/etc/tesm/$FROM.env:/etc/tesm/$TO.env"
|
||||||
|
"/var/www/$FROM-acme:/var/www/$TO-acme"
|
||||||
|
"/etc/tesm/certs/$FROM:/etc/tesm/certs/$TO"
|
||||||
|
)
|
||||||
|
ENTFERNEN=(
|
||||||
|
"/etc/systemd/system/$FROM.service"
|
||||||
|
"/etc/systemd/system/$FROM-monitor.service"
|
||||||
|
"/etc/nginx/sites-enabled/$FROM"
|
||||||
|
"/etc/nginx/sites-available/$FROM"
|
||||||
|
"/usr/local/bin/$FROM-admin"
|
||||||
|
"/etc/logrotate.d/$FROM"
|
||||||
|
"/etc/update-motd.d/50-$FROM"
|
||||||
|
)
|
||||||
|
|
||||||
|
# -- Webeinstellungen auslesen, bevor irgendetwas umzieht -------------------- #
|
||||||
|
# Mit dem System-Python und der Standardbibliothek: das venv zieht gleich mit
|
||||||
|
# um, und ein zusaetzliches Werkzeug (sqlite3) ist auf einem schlanken Server
|
||||||
|
# nicht garantiert.
|
||||||
|
lese_einstellung() {
|
||||||
|
python3 - "$1" "/srv/$FROM/data/app.db" <<'PY'
|
||||||
|
import sqlite3, sys
|
||||||
|
schluessel, datenbank = sys.argv[1], sys.argv[2]
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(f"file:{datenbank}?mode=ro", uri=True)
|
||||||
|
row = conn.execute("SELECT value FROM settings WHERE key=?", (schluessel,)).fetchone()
|
||||||
|
conn.close()
|
||||||
|
except sqlite3.Error:
|
||||||
|
row = None
|
||||||
|
print((row[0] if row and row[0] is not None else "").strip())
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
DOMAIN="$(lese_einstellung web_domain || true)"
|
||||||
|
HTTPS_AN="$(lese_einstellung web_https_enabled || true)"
|
||||||
|
HSTS_AN="$(lese_einstellung web_hsts_enabled || true)"
|
||||||
|
CERT_ALT="$(lese_einstellung web_cert_path || true)"
|
||||||
|
|
||||||
|
step "Vorhaben"
|
||||||
|
info "Anwendung : $APP"
|
||||||
|
info "Alt : $FROM"
|
||||||
|
info "Neu : $TO"
|
||||||
|
info "Domain : ${DOMAIN:-(keine)}"
|
||||||
|
info "HTTPS : $([[ "$HTTPS_AN" == "1" ]] && echo "ja${CERT_ALT:+, Zertifikat $CERT_ALT}" || echo nein)"
|
||||||
|
for paar in "${VERSCHIEBEN[@]}"; do
|
||||||
|
quelle="${paar%%:*}"; ziel="${paar##*:}"
|
||||||
|
[[ -e "$quelle" ]] && info "verschieben: $quelle -> $ziel"
|
||||||
|
done
|
||||||
|
for pfad in "${ENTFERNEN[@]}"; do
|
||||||
|
[[ -e "$pfad" ]] && info "entfernen : $pfad (install.sh legt es unter dem neuen Namen neu an)"
|
||||||
|
done
|
||||||
|
|
||||||
|
# -- Was belegt den Zielnamen? ----------------------------------------------- #
|
||||||
|
# Nicht nur das Verzeichnis unter /srv: eine Installation der Fassung 1.x
|
||||||
|
# hinterlaesst auch /var/log/<name>, /etc/logrotate.d/<name> und den
|
||||||
|
# ACME-Pfad. Der Umzug bricht sonst mitten drin ab -- live genau so passiert.
|
||||||
|
BELEGT=()
|
||||||
|
for paar in "${VERSCHIEBEN[@]}"; do
|
||||||
|
ziel="${paar##*:}"
|
||||||
|
[[ -e "$ziel" ]] && BELEGT+=("$ziel")
|
||||||
|
done
|
||||||
|
for weiteres in "/etc/logrotate.d/$TO" "/etc/update-motd.d/50-$TO"; do
|
||||||
|
[[ -e "$weiteres" ]] && BELEGT+=("$weiteres")
|
||||||
|
done
|
||||||
|
|
||||||
|
BEISEITE=""
|
||||||
|
if [[ ${#BELEGT[@]} -gt 0 ]]; then
|
||||||
|
if [[ -f "/srv/$TO/data/app.db" ]]; then
|
||||||
|
fail "/srv/$TO ist bereits eine Installation dieser Fassung. Welche gewinnt, entscheidet niemand automatisch."
|
||||||
|
fi
|
||||||
|
BEISEITE="/srv/$TO-$SUFFIX"
|
||||||
|
if [[ -e "$BEISEITE" ]]; then
|
||||||
|
# Ein vorhandenes Archiv derselben Verdraengung wird weiterbenutzt -- die
|
||||||
|
# Reste einer Installation gehoeren zusammen und nicht in ein zweites
|
||||||
|
# Verzeichnis. Alles andere unter diesem Namen bleibt unangetastet.
|
||||||
|
if [[ -f "$BEISEITE/sqlite.db" || -d "$BEISEITE/systemd-alt" ]]; then
|
||||||
|
info "Archiv $BEISEITE ist vorhanden und wird weiterbenutzt."
|
||||||
|
else
|
||||||
|
fail "$BEISEITE existiert und sieht nicht nach einem solchen Archiv aus -- bitte --suffix waehlen"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
info "Der Zielname ist belegt. Alles davon wandert nach $BEISEITE:"
|
||||||
|
for pfad in "${BELEGT[@]}"; do info " $pfad"; done
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ $DRY_RUN -eq 1 ]]; then
|
||||||
|
step "--dry-run: hier ist Schluss."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if [[ $ASSUME_YES -eq 0 ]]; then
|
||||||
|
read -r -p "Fortfahren? Alle angemeldeten Benutzer werden abgemeldet. [j/N] " answer
|
||||||
|
[[ "$answer" =~ ^[jJyY]$ ]] || { echo "Abgebrochen."; exit 0; }
|
||||||
|
fi
|
||||||
|
|
||||||
|
# -- 1. Dienste anhalten ----------------------------------------------------- #
|
||||||
|
step "Dienste anhalten"
|
||||||
|
for unit in "$FROM.service" "$FROM-monitor.service"; do
|
||||||
|
if systemctl list-unit-files "$unit" --no-legend 2>/dev/null | grep -q .; then
|
||||||
|
systemctl disable --now "$unit" >/dev/null 2>&1 || true
|
||||||
|
info "$unit gestoppt und deaktiviert"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# -- 2. Zielnamen freimachen ------------------------------------------------- #
|
||||||
|
if [[ -n "$BEISEITE" ]]; then
|
||||||
|
step "Zielnamen freimachen"
|
||||||
|
# Alles wandert ins Archiv, nichts wird geloescht: unter /var/log/<name>
|
||||||
|
# liegen Protokolle, unter /srv/<name> im Zweifel eine ganze Installation.
|
||||||
|
# Die Units und die Site kommen mit -- sie zeigen auf das alte Verzeichnis
|
||||||
|
# und waeren auf dem System nur noch Fehlerquellen, im Archiv aber ein Beleg.
|
||||||
|
mkdir -p "$BEISEITE"
|
||||||
|
for pfad in "${BELEGT[@]}"; do
|
||||||
|
ziel="$BEISEITE/$(basename "$pfad")"
|
||||||
|
if [[ "$pfad" == "/srv/$TO" ]]; then
|
||||||
|
# Das Verzeichnis selbst wird das Archiv -- sein Inhalt bleibt, wie er ist.
|
||||||
|
for eintrag in "$pfad"/* "$pfad"/.[!.]*; do
|
||||||
|
[[ -e "$eintrag" ]] || continue
|
||||||
|
mv "$eintrag" "$BEISEITE/"
|
||||||
|
done
|
||||||
|
rmdir "$pfad"
|
||||||
|
info "/srv/$TO -> $BEISEITE/"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
if [[ -e "$ziel" ]]; then
|
||||||
|
ziel="$ziel-$(date +%Y%m%d-%H%M%S)"
|
||||||
|
warn "Im Archiv lag schon etwas mit diesem Namen -- ablegen als $(basename "$ziel")"
|
||||||
|
fi
|
||||||
|
mv "$pfad" "$ziel"
|
||||||
|
info "$pfad -> $ziel"
|
||||||
|
done
|
||||||
|
mkdir -p "$BEISEITE/systemd-alt"
|
||||||
|
for unit in "/etc/systemd/system/$TO.service" "/etc/systemd/system/$TO-check.service" \
|
||||||
|
"/etc/systemd/system/$TO-check.timer" "/etc/systemd/system/$TO-check-restart.service" \
|
||||||
|
"/etc/systemd/system/$TO-check-restart.timer"; do
|
||||||
|
[[ -f "$unit" ]] || continue
|
||||||
|
systemctl disable --now "$(basename "$unit")" >/dev/null 2>&1 || true
|
||||||
|
mv "$unit" "$BEISEITE/systemd-alt/"
|
||||||
|
info "$(basename "$unit") archiviert"
|
||||||
|
done
|
||||||
|
for site in "/etc/nginx/sites-enabled/$TO" "/etc/nginx/sites-available/$TO"; do
|
||||||
|
[[ -e "$site" ]] || continue
|
||||||
|
[[ -L "$site" ]] && { rm -f "$site"; continue; }
|
||||||
|
mv "$site" "$BEISEITE/systemd-alt/nginx-site"
|
||||||
|
info "nginx-Site archiviert"
|
||||||
|
done
|
||||||
|
for motd in "/etc/update-motd.d/50-$TO"; do
|
||||||
|
[[ -f "$motd" ]] && grep -q "motd_banner.py" "$motd" 2>/dev/null && rm -f "$motd" \
|
||||||
|
&& info "Banner der Fassung 1.x entfernt"
|
||||||
|
done
|
||||||
|
info "Alles Verdraengte liegt unter $BEISEITE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# -- 3. Umziehen ------------------------------------------------------------- #
|
||||||
|
step "Umziehen"
|
||||||
|
for paar in "${VERSCHIEBEN[@]}"; do
|
||||||
|
quelle="${paar%%:*}"; ziel="${paar##*:}"
|
||||||
|
[[ -e "$quelle" ]] || continue
|
||||||
|
[[ -e "$ziel" ]] && fail "$ziel existiert bereits -- Abbruch, damit nichts vermischt wird"
|
||||||
|
mkdir -p "$(dirname "$ziel")"
|
||||||
|
mv "$quelle" "$ziel"
|
||||||
|
info "$quelle -> $ziel"
|
||||||
|
done
|
||||||
|
|
||||||
|
# -- 4. Namen in den Dateien nachziehen -------------------------------------- #
|
||||||
|
step "Namen nachziehen"
|
||||||
|
ENV_FILE="/etc/tesm/$TO.env"
|
||||||
|
if [[ -f "$ENV_FILE" ]]; then
|
||||||
|
# Der SITE_KEY bestimmt den Namen des Sitzungscookies. Bleibt er alt, heisst
|
||||||
|
# das Cookie weiter wie die alte Instanz -- unauffaellig und falsch.
|
||||||
|
sed -i "s#^${ENV_PREFIX}SITE_KEY=.*#${ENV_PREFIX}SITE_KEY=$TO#" "$ENV_FILE"
|
||||||
|
info "SITE_KEY=$TO"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Pfade in der Datenbank, die den Namen enthalten.
|
||||||
|
python3 - "$TO" "$FROM" "/srv/$TO/data/app.db" <<'PY'
|
||||||
|
import sqlite3, sys
|
||||||
|
neu, alt, datenbank = sys.argv[1], sys.argv[2], sys.argv[3]
|
||||||
|
schluessel = ("web_cert_path", "web_key_path", "web_acme_webroot")
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(datenbank, timeout=5.0)
|
||||||
|
except sqlite3.Error as fehler:
|
||||||
|
print(f" Datenbank nicht schreibbar: {fehler}")
|
||||||
|
raise SystemExit(0)
|
||||||
|
try:
|
||||||
|
with conn:
|
||||||
|
for key in schluessel:
|
||||||
|
row = conn.execute("SELECT value FROM settings WHERE key=?", (key,)).fetchone()
|
||||||
|
if not row or not row[0]:
|
||||||
|
continue
|
||||||
|
wert = str(row[0])
|
||||||
|
ersetzt = wert.replace(alt, neu)
|
||||||
|
if ersetzt != wert:
|
||||||
|
conn.execute("UPDATE settings SET value=? WHERE key=?", (ersetzt, key))
|
||||||
|
print(f" {key}: {wert} -> {ersetzt}")
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
PY
|
||||||
|
|
||||||
|
# -- 5. Alte Konfiguration entfernen ----------------------------------------- #
|
||||||
|
step "Alte Konfiguration entfernen"
|
||||||
|
for pfad in "${ENTFERNEN[@]}"; do
|
||||||
|
[[ -e "$pfad" ]] || continue
|
||||||
|
rm -f "$pfad"
|
||||||
|
info "$pfad"
|
||||||
|
done
|
||||||
|
systemctl daemon-reload
|
||||||
|
|
||||||
|
# -- 6. Unter dem neuen Namen einrichten ------------------------------------- #
|
||||||
|
# Das venv wird verworfen und neu gebaut. Es ist abgeleiteter Zustand, und
|
||||||
|
# seine Konsolenskripte tragen den absoluten Pfad im Shebang: nach dem Umzug
|
||||||
|
# zeigt "venv/bin/gunicorn" auf einen Interpreter, den es nicht mehr gibt, und
|
||||||
|
# systemd meldet nur "No such file or directory" -- fuer eine Datei, die
|
||||||
|
# sichtbar da ist. Live auf POETEST genau so passiert.
|
||||||
|
if [[ -d "/srv/$TO/venv" ]]; then
|
||||||
|
step "venv verwerfen (Shebangs tragen den alten Pfad)"
|
||||||
|
rm -rf "/srv/$TO/venv"
|
||||||
|
info "wird von install.sh neu gebaut"
|
||||||
|
fi
|
||||||
|
|
||||||
|
step "Unter dem neuen Namen einrichten"
|
||||||
|
INSTALL_ARGS=(--app "$APP" --yes)
|
||||||
|
[[ "$TO" != "$APP" ]] && INSTALL_ARGS+=(--instance "${TO#"$APP"-}")
|
||||||
|
bash "$SCRIPT_DIR/install.sh" "${INSTALL_ARGS[@]}"
|
||||||
|
|
||||||
|
# -- 7. nginx-Site aus den Einstellungen neu schreiben ----------------------- #
|
||||||
|
# install.sh schreibt die Site aus seiner Vorlage, sobald sich die alias-Pfade
|
||||||
|
# aendern -- und die aendern sich hier zwangslaeufig. Der Name der Site steht
|
||||||
|
# aber in der Datenbank, nicht in der Vorlage. Ohne diesen Schritt stuenden
|
||||||
|
# zwei Sites mit "_" auf Port 80 und die erste gewinnt.
|
||||||
|
if [[ -n "$DOMAIN" ]]; then
|
||||||
|
step "Webserver-Konfiguration erneuern"
|
||||||
|
# --apply nicht vergessen: ohne ihn speichert web-setup nur die Einstellungen
|
||||||
|
# und laesst die Site-Datei stehen, die install.sh aus seiner Vorlage
|
||||||
|
# geschrieben hat -- mit "server_name _" und ohne TLS.
|
||||||
|
WEB_ARGS=(web-setup --apply --domain "$DOMAIN")
|
||||||
|
if [[ "$HTTPS_AN" == "1" ]]; then
|
||||||
|
WEB_ARGS+=(--https)
|
||||||
|
[[ "$HSTS_AN" == "1" ]] && WEB_ARGS+=(--hsts)
|
||||||
|
# Das Zertifikat ist mitgezogen und sein Pfad in der Datenbank nachgefuehrt
|
||||||
|
# -- "web-setup --https" laesst es dann unangetastet. Ein neues erzeugen
|
||||||
|
# wuerde nur eine weitere Browserwarnung produzieren. Nur wenn nach dem
|
||||||
|
# Umzug wirklich keines da ist, wird eines erzeugt.
|
||||||
|
CERT_NEU="${CERT_ALT//$FROM/$TO}"
|
||||||
|
if [[ -n "$CERT_NEU" && -f "$CERT_NEU" ]]; then
|
||||||
|
info "HTTPS mit dem vorhandenen Zertifikat: $CERT_NEU"
|
||||||
|
else
|
||||||
|
WEB_ARGS+=(--self-signed)
|
||||||
|
warn "Zertifikat nicht gefunden (${CERT_NEU:-keins hinterlegt}) -- es wird ein neues"
|
||||||
|
warn "selbstsigniertes erzeugt. Der Browser warnt dann einmal erneut."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if sudo -u "$APP" "/usr/local/bin/$TO-admin" "${WEB_ARGS[@]}"; then
|
||||||
|
info "Site neu geschrieben: server_name $DOMAIN"
|
||||||
|
else
|
||||||
|
warn "web-setup ist fehlgeschlagen -- bitte in der Anwendung unter"
|
||||||
|
warn "'Verwaltung -> Webserver und TLS' die Domain eintragen und uebernehmen."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# -- 8. Ergebnis ------------------------------------------------------------- #
|
||||||
|
step "Fertig"
|
||||||
|
info "Verzeichnis : /srv/$TO"
|
||||||
|
info "Dienst : systemctl status $TO.service"
|
||||||
|
info "Werkzeug : $TO-admin"
|
||||||
|
info "Aktualisieren: sudo bash /srv/$TO/deploy/update.sh $APP$([[ "$TO" != "$APP" ]] && echo " latest --instance ${TO#"$APP"-}")"
|
||||||
|
[[ -n "$BEISEITE" ]] && info "Fassung 1.x : $BEISEITE (Units und Site darin unter systemd-alt/)"
|
||||||
|
echo
|
||||||
|
warn "Alle Benutzer sind abgemeldet: das Sitzungscookie heisst jetzt anders."
|
||||||
+7
-1
@@ -5,5 +5,11 @@
|
|||||||
#
|
#
|
||||||
# Bewusst KEIN "ALL=(ALL) NOPASSWD: ALL". Der Vorgaenger loeste dasselbe
|
# Bewusst KEIN "ALL=(ALL) NOPASSWD: ALL". Der Vorgaenger loeste dasselbe
|
||||||
# Problem, indem die gesamte Anwendung als root lief.
|
# Problem, indem die gesamte Anwendung als root lief.
|
||||||
Defaults:tesm !requiretty
|
#
|
||||||
|
# Hier stand "Defaults:tesm !requiretty". Auf Debian und Ubuntu ist
|
||||||
|
# requiretty nie gesetzt, die Zeile war also von Anfang an wirkungslos -- und
|
||||||
|
# ab sudo 1.9.16 kennt visudo die Einstellung nicht mehr und lehnt die ganze
|
||||||
|
# Datei ab ("unknown setting: 'requiretty'"). Auf einem frisch aufgesetzten
|
||||||
|
# Server brach die Installation deshalb mitten drin ab. Nicht wieder
|
||||||
|
# hinzufuegen; ein Hygienetest haelt das fest.
|
||||||
tesm ALL=(root) NOPASSWD: /usr/local/lib/tesm/tesm-helper
|
tesm ALL=(root) NOPASSWD: /usr/local/lib/tesm/tesm-helper
|
||||||
@@ -5,5 +5,11 @@
|
|||||||
#
|
#
|
||||||
# Bewusst KEIN "ALL=(ALL) NOPASSWD: ALL". Der Vorgaenger loeste dasselbe
|
# Bewusst KEIN "ALL=(ALL) NOPASSWD: ALL". Der Vorgaenger loeste dasselbe
|
||||||
# Problem, indem die gesamte Anwendung als root lief.
|
# Problem, indem die gesamte Anwendung als root lief.
|
||||||
Defaults:tesm-license !requiretty
|
#
|
||||||
|
# Hier stand "Defaults:tesm-license !requiretty". Auf Debian und Ubuntu ist
|
||||||
|
# requiretty nie gesetzt, die Zeile war also von Anfang an wirkungslos -- und
|
||||||
|
# ab sudo 1.9.16 kennt visudo die Einstellung nicht mehr und lehnt die ganze
|
||||||
|
# Datei ab ("unknown setting: 'requiretty'"). Auf einem frisch aufgesetzten
|
||||||
|
# Server brach die Installation deshalb mitten drin ab. Nicht wieder
|
||||||
|
# hinzufuegen; ein Hygienetest haelt das fest.
|
||||||
tesm-license ALL=(root) NOPASSWD: /usr/local/lib/tesm/tesm-helper
|
tesm-license ALL=(root) NOPASSWD: /usr/local/lib/tesm/tesm-helper
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=TESM -- Aufraeumen: Sitzungen, Ratenereignisse, Protokollarchiv
|
||||||
|
After=tesm.service
|
||||||
|
# Ohne die laufende Anwendung ist nichts kaputt -- der Befehl arbeitet direkt
|
||||||
|
# auf der Datenbank. "After" sorgt nur fuer eine sinnvolle Reihenfolge beim
|
||||||
|
# Hochfahren.
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
User=tesm
|
||||||
|
Group=tesm
|
||||||
|
WorkingDirectory=/srv/tesm
|
||||||
|
Environment=TESM_BASE_DIR=/srv/tesm
|
||||||
|
Environment=TESM_DATA_DIR=/srv/tesm/data
|
||||||
|
Environment=TESM_LOG_DIR=/var/log/tesm
|
||||||
|
Environment=PYTHONUNBUFFERED=1
|
||||||
|
ExecStart=/srv/tesm/venv/bin/tesm-admin housekeeping
|
||||||
|
|
||||||
|
# Hier darf voll gehaertet werden: dieser Aufruf fasst nur die Datenbank und
|
||||||
|
# das Protokollverzeichnis an, keine Systemdateien und keinen Helfer per sudo.
|
||||||
|
NoNewPrivileges=yes
|
||||||
|
PrivateTmp=yes
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=yes
|
||||||
|
ProtectKernelTunables=yes
|
||||||
|
ProtectKernelModules=yes
|
||||||
|
ProtectControlGroups=yes
|
||||||
|
RestrictSUIDSGID=yes
|
||||||
|
RestrictNamespaces=yes
|
||||||
|
RestrictAddressFamilies=AF_UNIX
|
||||||
|
LockPersonality=yes
|
||||||
|
SystemCallArchitectures=native
|
||||||
|
UMask=0027
|
||||||
|
ReadWritePaths=/srv/tesm/data /var/log/tesm
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=TESM -- taegliches Aufraeumen
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
# Nachts, wenn niemand arbeitet: abgelaufene Sitzungen, alte Ratenereignisse
|
||||||
|
# und die Archivierung des Aenderungsprotokolls.
|
||||||
|
#
|
||||||
|
# Vorher lief das nur, wenn jemand "tesm-admin housekeeping" aufrief oder in
|
||||||
|
# der Diagnose darauf klickte -- also praktisch nie. Gemessen auf einer
|
||||||
|
# Installation nach vier Wochen: 747 Sitzungszeilen, 682 davon abgelaufen.
|
||||||
|
OnCalendar=*-*-* 03:30:00
|
||||||
|
# Streuung, damit nicht jede Installation im Netz zur selben Sekunde loslegt.
|
||||||
|
RandomizedDelaySec=15m
|
||||||
|
# Nachgeholt, wenn die Maschine nachts aus war.
|
||||||
|
Persistent=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=TESM-Lizenzserver -- Aufraeumen: Sitzungen, Ratenereignisse, Protokollarchiv
|
||||||
|
After=tesm-license.service
|
||||||
|
# Ohne die laufende Anwendung ist nichts kaputt -- der Befehl arbeitet direkt
|
||||||
|
# auf der Datenbank. "After" sorgt nur fuer eine sinnvolle Reihenfolge beim
|
||||||
|
# Hochfahren.
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
User=tesm-license
|
||||||
|
Group=tesm-license
|
||||||
|
WorkingDirectory=/srv/tesm-license
|
||||||
|
Environment=TESM_LICENSE_BASE_DIR=/srv/tesm-license
|
||||||
|
Environment=TESM_LICENSE_DATA_DIR=/srv/tesm-license/data
|
||||||
|
Environment=TESM_LICENSE_LOG_DIR=/var/log/tesm-license
|
||||||
|
Environment=PYTHONUNBUFFERED=1
|
||||||
|
ExecStart=/srv/tesm-license/venv/bin/tesm-license-admin housekeeping
|
||||||
|
|
||||||
|
# Hier darf voll gehaertet werden: dieser Aufruf fasst nur die Datenbank und
|
||||||
|
# das Protokollverzeichnis an, keine Systemdateien und keinen Helfer per sudo.
|
||||||
|
NoNewPrivileges=yes
|
||||||
|
PrivateTmp=yes
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=yes
|
||||||
|
ProtectKernelTunables=yes
|
||||||
|
ProtectKernelModules=yes
|
||||||
|
ProtectControlGroups=yes
|
||||||
|
RestrictSUIDSGID=yes
|
||||||
|
RestrictNamespaces=yes
|
||||||
|
RestrictAddressFamilies=AF_UNIX
|
||||||
|
LockPersonality=yes
|
||||||
|
SystemCallArchitectures=native
|
||||||
|
UMask=0027
|
||||||
|
ReadWritePaths=/srv/tesm-license/data /var/log/tesm-license
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=TESM-Lizenzserver -- taegliches Aufraeumen
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
# Nachts, wenn niemand arbeitet: abgelaufene Sitzungen, alte Ratenereignisse
|
||||||
|
# und die Archivierung des Aenderungsprotokolls.
|
||||||
|
#
|
||||||
|
# Vorher lief das nur, wenn jemand "tesm-license-admin housekeeping" aufrief oder in
|
||||||
|
# der Diagnose darauf klickte -- also praktisch nie. Gemessen auf einer
|
||||||
|
# Installation nach vier Wochen: 747 Sitzungszeilen, 682 davon abgelaufen.
|
||||||
|
OnCalendar=*-*-* 03:30:00
|
||||||
|
# Streuung, damit nicht jede Installation im Netz zur selben Sekunde loslegt.
|
||||||
|
RandomizedDelaySec=15m
|
||||||
|
# Nachgeholt, wenn die Maschine nachts aus war.
|
||||||
|
Persistent=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
+1
-1
@@ -47,7 +47,7 @@ check_in_list() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Name einer Installation auf diesem Host: entweder die Hauptinstallation
|
# Name einer Installation auf diesem Host: entweder die Hauptinstallation
|
||||||
# ("tesm") oder eine benannte Instanz daneben ("tesm-opus"). Mehr als diese
|
# ("tesm") oder eine benannte Instanz daneben ("tesm-test"). Mehr als diese
|
||||||
# beiden Formen gibt es nicht -- so kann keine erfundene Zeichenkette einen
|
# beiden Formen gibt es nicht -- so kann keine erfundene Zeichenkette einen
|
||||||
# Pfad ausserhalb des vorgesehenen Namensraums erzeugen.
|
# Pfad ausserhalb des vorgesehenen Namensraums erzeugen.
|
||||||
check_app_key() {
|
check_app_key() {
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Live-Banner fuer die SSH-Anmeldung (MOTD).
|
||||||
|
|
||||||
|
Eingebunden ueber ``/etc/update-motd.d/50-<name>``, das ``install.sh``
|
||||||
|
erzeugt. Aufruf mit ``--app`` und ``--root``; alles andere steht in der
|
||||||
|
Datenbank der Installation.
|
||||||
|
|
||||||
|
**Nur Standardbibliothek, kein Flask, kein venv.** Der Vorgaenger importierte
|
||||||
|
dafuer die ganze Anwendung -- bei jeder SSH-Anmeldung. Das ist teuer, und es
|
||||||
|
faellt aus, sobald das venv nicht mehr passt: nach dem Umstieg auf diese
|
||||||
|
Fassung zeigte das alte Banner monatelang eingefrorene Zahlen aus einer
|
||||||
|
Datenbank, die niemand mehr fuellte. Hier wird gelesen, was auf der Platte
|
||||||
|
liegt, und zwar **nur lesend** (``mode=ro``).
|
||||||
|
|
||||||
|
Ein Banner darf eine Anmeldung niemals stoeren: jede Auskunft, die nicht zu
|
||||||
|
holen ist, wird zu einem kurzen Hinweis, und am Ende faengt ``main`` alles ab.
|
||||||
|
Der Aufrufer setzt zusaetzlich ein Zeitlimit.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
AMBER = "\033[38;2;226;166;60m"
|
||||||
|
DIM = "\033[2m"
|
||||||
|
BOLD = "\033[1m"
|
||||||
|
RESET = "\033[0m"
|
||||||
|
|
||||||
|
#: Kea schreibt seine Leases als CSV; die letzte Zeile je Adresse gilt.
|
||||||
|
#: Dieselbe Datei liest die Anwendung (``services/kea.py``: ``LEASE_FILE``).
|
||||||
|
LEASE_FILE = "/var/lib/kea/kea-leases4.csv"
|
||||||
|
|
||||||
|
#: Zeile unter der Wortmarke. Wortlaut wie in der jeweiligen Anwendung
|
||||||
|
#: (``AUTH_TAGLINE`` in deren ``__init__.py``) -- ein Hygienetest haelt beides
|
||||||
|
#: zusammen.
|
||||||
|
TAGLINE = {
|
||||||
|
"tesm": "ÜBERWACHEN · BOOTEN · ANBINDEN",
|
||||||
|
"tesm-license": "LIZENZIEREN",
|
||||||
|
}
|
||||||
|
|
||||||
|
TITEL = {"tesm": "TimEShepManager", "tesm-license": "Lizenzserver"}
|
||||||
|
|
||||||
|
#: Wortgleich mit ``services/monitor.py``: ``DASHBOARD_COUNT_SQL``. Die
|
||||||
|
#: Statusuebersicht und dieses Banner sollen nicht zwei Wahrheiten haben; ein
|
||||||
|
#: Hygienetest vergleicht die beiden Zeichenketten.
|
||||||
|
DASHBOARD_COUNT_SQL = """
|
||||||
|
SELECT
|
||||||
|
COUNT(*) AS total,
|
||||||
|
SUM(CASE WHEN d.is_active=0 THEN 1 ELSE 0 END) AS disabled,
|
||||||
|
SUM(CASE WHEN d.is_active=1 AND COALESCE(st.state,'unknown')='online' THEN 1 ELSE 0 END) AS online,
|
||||||
|
SUM(CASE WHEN d.is_active=1 AND COALESCE(st.state,'unknown')='offline' THEN 1 ELSE 0 END) AS offline,
|
||||||
|
SUM(CASE WHEN d.is_active=1 AND COALESCE(st.state,'unknown')='unknown' THEN 1 ELSE 0 END) AS unknown,
|
||||||
|
SUM(CASE WHEN d.is_active=1 AND st.state='restarting' THEN 1 ELSE 0 END) AS restarting
|
||||||
|
FROM devices d LEFT JOIN device_status st ON st.device_id = d.id
|
||||||
|
WHERE d.deleted_at IS NULL
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _zahl(anzahl: int, singular: str, plural: str) -> str:
|
||||||
|
"""Zahl mit passendem Wort. "1 Clients" liest man jeden Tag einmal zu oft."""
|
||||||
|
return f"{BOLD}{anzahl}{RESET} {singular if anzahl == 1 else plural}"
|
||||||
|
|
||||||
|
|
||||||
|
def _oeffnen(root: Path) -> sqlite3.Connection:
|
||||||
|
pfad = root / "data" / "app.db"
|
||||||
|
conn = sqlite3.connect(f"file:{pfad.as_posix()}?mode=ro", uri=True, timeout=1.0)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def _flotte(conn: sqlite3.Connection) -> str:
|
||||||
|
row = conn.execute(DASHBOARD_COUNT_SQL).fetchone()
|
||||||
|
online = int(row["online"] or 0)
|
||||||
|
offline = int(row["offline"] or 0)
|
||||||
|
teile = [
|
||||||
|
_zahl(online, "Client online", "Clients online"),
|
||||||
|
f"{BOLD}{offline}{RESET} nicht erreichbar",
|
||||||
|
]
|
||||||
|
# Nur nennen, wenn es sie gibt: eine Null bei "startet neu" ist keine
|
||||||
|
# Auskunft, sondern Fuellmaterial.
|
||||||
|
restarting = int(row["restarting"] or 0)
|
||||||
|
if restarting:
|
||||||
|
teile.append(_zahl(restarting, "startet neu", "starten neu"))
|
||||||
|
unbekannt = int(row["unknown"] or 0)
|
||||||
|
if unbekannt:
|
||||||
|
teile.append(f"{DIM}{unbekannt} ohne Pruefung{RESET}")
|
||||||
|
return " · ".join(teile)
|
||||||
|
|
||||||
|
|
||||||
|
def _dhcp() -> str:
|
||||||
|
"""Aktive Leases aus der Kea-CSV. Laeuft als root, braucht keinen Helfer."""
|
||||||
|
datei = Path(LEASE_FILE)
|
||||||
|
if not datei.is_file():
|
||||||
|
return "keine Lease-Datei"
|
||||||
|
try:
|
||||||
|
zeilen = datei.read_text(encoding="utf-8", errors="replace").splitlines()
|
||||||
|
except OSError:
|
||||||
|
return "Lease-Datei nicht lesbar"
|
||||||
|
if len(zeilen) < 2:
|
||||||
|
return "0 Leases aktiv"
|
||||||
|
kopf = [name.strip().strip('"') for name in zeilen[0].split(",")]
|
||||||
|
try:
|
||||||
|
i_adresse = kopf.index("address")
|
||||||
|
i_ablauf = kopf.index("expire")
|
||||||
|
i_zustand = kopf.index("state")
|
||||||
|
except ValueError:
|
||||||
|
return "Lease-Datei mit unerwarteten Spalten"
|
||||||
|
|
||||||
|
jetzt = int(time.time())
|
||||||
|
letzte: dict[str, tuple[int, str]] = {}
|
||||||
|
for zeile in zeilen[1:]:
|
||||||
|
felder = [feld.strip().strip('"') for feld in zeile.split(",")]
|
||||||
|
if len(felder) < len(kopf):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
letzte[felder[i_adresse]] = (int(felder[i_ablauf] or 0), felder[i_zustand] or "0")
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
aktiv = sum(1 for ablauf, zustand in letzte.values() if zustand == "0" and ablauf > jetzt)
|
||||||
|
return f"{aktiv} {'Lease' if aktiv == 1 else 'Leases'} aktiv"
|
||||||
|
|
||||||
|
|
||||||
|
def _lizenzen(conn: sqlite3.Connection) -> str:
|
||||||
|
kunden = int(conn.execute("SELECT COUNT(*) FROM license_customers").fetchone()[0] or 0)
|
||||||
|
lizenzen = int(
|
||||||
|
conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM licenses WHERE status IN ('issued','active')"
|
||||||
|
).fetchone()[0]
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
_zahl(kunden, "Kunde", "Kunden")
|
||||||
|
+ " · "
|
||||||
|
+ _zahl(lizenzen, "Lizenz ausgestellt", "Lizenzen ausgestellt")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
parser = argparse.ArgumentParser(prog="tesm-motd", description="Banner fuer die SSH-Anmeldung")
|
||||||
|
parser.add_argument("--app", required=True, choices=sorted(TITEL))
|
||||||
|
parser.add_argument("--root", required=True, help="Verzeichnis der Installation")
|
||||||
|
parser.add_argument("--no-color", action="store_true")
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
if args.no_color:
|
||||||
|
globals().update(AMBER="", DIM="", BOLD="", RESET="")
|
||||||
|
|
||||||
|
root = Path(args.root)
|
||||||
|
# Kein isatty()-Test: dieses Skript schreibt beim Login nicht auf ein
|
||||||
|
# Terminal, sondern ueber run-parts in eine Datei. Der Betrachter ist
|
||||||
|
# trotzdem ein echtes Terminal -- deshalb immer Farbe.
|
||||||
|
print(f"{BOLD}{AMBER}TESM{RESET} · {TITEL[args.app]}")
|
||||||
|
if TAGLINE[args.app]:
|
||||||
|
print(f"{DIM}{TAGLINE[args.app]}{RESET}")
|
||||||
|
print()
|
||||||
|
try:
|
||||||
|
conn = _oeffnen(root)
|
||||||
|
except sqlite3.Error:
|
||||||
|
print(f"{DIM}Datenbank nicht lesbar: {root}{RESET}")
|
||||||
|
print()
|
||||||
|
return 0
|
||||||
|
try:
|
||||||
|
if args.app == "tesm":
|
||||||
|
print(f"{_flotte(conn)} · DHCP: {_dhcp()}")
|
||||||
|
else:
|
||||||
|
print(_lizenzen(conn))
|
||||||
|
except sqlite3.Error as fehler:
|
||||||
|
print(f"{DIM}Zustand nicht verfuegbar ({fehler}){RESET}")
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
print()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
raise SystemExit(main())
|
||||||
|
except SystemExit:
|
||||||
|
raise
|
||||||
|
except Exception: # noqa: BLE001 - ein Banner darf keinen Login stoeren
|
||||||
|
sys.exit(0)
|
||||||
+182
-44
@@ -1,89 +1,227 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Bootstrapper: laedt ein Release herunter und ruft dessen install.sh auf.
|
# Aktualisierung: laedt das aktuelle Release und ruft dessen install.sh auf.
|
||||||
# Enthaelt selbst keinerlei Installationslogik.
|
# Enthaelt selbst keinerlei Installationslogik.
|
||||||
#
|
#
|
||||||
# Zwei Fallen des Vorgaengers sind hier bewusst vermieden:
|
# Eine einzelne Datei genuegt -- sie holt sich alles Weitere:
|
||||||
# * Der Tag "latest" wird ueber die API aufgeloest, nie direkt in die
|
#
|
||||||
# Download-URL geschrieben. Gitea liefert sonst mit HTTP 200 ein
|
# curl -fsSLO https://gitea.int.eertmoed.net/alientim/tesm/raw/branch/main/deploy/update.sh
|
||||||
# automatisch erzeugtes Quell-Snapshot statt des echten Release-Assets.
|
# sudo bash update.sh tesm
|
||||||
# * Nach dem Entpacken wird geprueft, dass ein install.sh existiert -- faengt
|
#
|
||||||
# denselben Fehler ein zweites Mal ab.
|
# Auf einer bestehenden Installation liegt sie schon bereit:
|
||||||
|
#
|
||||||
|
# sudo bash /srv/tesm/deploy/update.sh tesm
|
||||||
|
#
|
||||||
|
# Laeuft hier die Master-Ausgabe des Lizenzservers, kommt das Release aus dem
|
||||||
|
# privaten Repositorium tesm-license-master -- dafuer braucht es einen
|
||||||
|
# Zugriffstoken in TESM_RELEASE_TOKEN oder in /etc/tesm-release.token.
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
# Ein Wagenruecklauf im Paket macht jedes Shell-Skript auf dem Ziel
|
|
||||||
# unbrauchbar ("bad interpreter"). Die Pruefung darf dabei nicht selbst
|
|
||||||
# von einem Zusatzpaket abhaengen: mit "file" -- das auf einem schlanken
|
|
||||||
# Server fehlt -- endete sie in "command not found", grep bekam nichts zu
|
|
||||||
# sehen, und das Skript machte weiter, als waere alles in Ordnung.
|
|
||||||
hat_crlf() {
|
|
||||||
local cr
|
|
||||||
cr="$(printf '\r')"
|
|
||||||
# -U (binaer lesen) ist unter Linux ohne Wirkung, auf einer Windows-Buildmaschine
|
|
||||||
# aber entscheidend: dort entfernt grep den Wagenruecklauf beim Lesen und
|
|
||||||
# findet deshalb nie einen.
|
|
||||||
LC_ALL=C grep -qU "$cr" "$1"
|
|
||||||
}
|
|
||||||
|
|
||||||
APP="${1:-}"
|
APP="${1:-}"
|
||||||
TAG="${2:-latest}"
|
TAG="${2:-latest}"
|
||||||
|
|
||||||
# Alles nach dem Tag geht unveraendert an install.sh weiter. Ohne das liesse
|
# Alles nach dem Tag geht unveraendert an install.sh weiter. Ohne das liesse
|
||||||
# sich eine benannte Instanz nicht aktualisieren: install.sh leitet den Namen
|
# sich eine benannte Instanz nicht aktualisieren: install.sh leitet den Namen
|
||||||
# aus "--instance" ab, und ohne den Schalter entstuende neben "tesm-opus" eine
|
# aus "--instance" ab, und ohne den Schalter entstuende neben "tesm-test" eine
|
||||||
# zweite Installation namens "tesm" -- mit eigener Datenbank, eigener
|
# zweite Installation namens "tesm" -- mit eigener Datenbank, eigener
|
||||||
# nginx-Site und demselben Port.
|
# nginx-Site und demselben Port.
|
||||||
shift $(( $# > 2 ? 2 : $# ))
|
shift $(( $# > 2 ? 2 : $# ))
|
||||||
EXTRA=("$@")
|
EXTRA=("$@")
|
||||||
# Bezugsquelle. Die Vorgaben zeigen dorthin, wo die Releases tatsaechlich
|
|
||||||
# liegen -- ein Platzhalter wie "gitea.example.invalid" macht das Skript auf
|
|
||||||
# jedem Host unbrauchbar, und genau das war hier der Fall.
|
|
||||||
#
|
|
||||||
# Das Repository heisst wie die Anwendung; getrennte Repositorien, getrennte
|
|
||||||
# Releases. Der Anhang heisst "<repo>-<tag>.tar.gz", siehe unten.
|
|
||||||
BASE="${TESM_RELEASE_BASE:-https://gitea.int.eertmoed.net}"
|
|
||||||
OWNER="${TESM_RELEASE_OWNER:-alientim}"
|
|
||||||
|
|
||||||
[[ "$APP" == "tesm" || "$APP" == "tesm-license" ]] || {
|
[[ "$APP" == "tesm" || "$APP" == "tesm-license" ]] || {
|
||||||
echo "Verwendung: update.sh <tesm|tesm-license> [tag] [install.sh-Argumente ...]" >&2
|
echo "Verwendung: update.sh <tesm|tesm-license> [tag] [install.sh-Argumente ...]" >&2
|
||||||
echo "Beispiel : update.sh tesm latest --instance opus" >&2
|
echo "Beispiel : update.sh tesm latest --instance test" >&2
|
||||||
|
echo "Ohne Rueckfrage: --yes anhaengen (ohne Terminal zwingend)." >&2
|
||||||
|
echo "Laeuft die Fassung schon, endet der Aufruf ohne Arbeit; --force setzt" >&2
|
||||||
|
echo "sie trotzdem neu auf." >&2
|
||||||
exit 2
|
exit 2
|
||||||
}
|
}
|
||||||
|
|
||||||
REPO="${TESM_RELEASE_REPO:-$APP}"
|
# Repositorium: die Master-Ausgabe des Lizenzservers liegt in einem eigenen,
|
||||||
|
# privaten Repositorium, heisst als Installation aber weiter "tesm-license".
|
||||||
|
# Erkannt wird sie an der Installation und nicht an einem Schalter -- wer
|
||||||
|
# aktualisiert, soll nicht erst wissen muessen, welche Ausgabe hier laeuft.
|
||||||
|
# Sonst holte der Aufruf die Ausgabe fuer Sub-Lizenzserver, und der Installer
|
||||||
|
# muesste sie abweisen.
|
||||||
|
REPO="$APP"
|
||||||
|
if [[ "$APP" == "tesm-license" ]]; then
|
||||||
|
NAME="$APP"
|
||||||
|
for ((i = 0; i < ${#EXTRA[@]}; i++)); do
|
||||||
|
[[ "${EXTRA[i]}" == "--instance" ]] && NAME="$APP-${EXTRA[i + 1]:-}"
|
||||||
|
done
|
||||||
|
# Die Wurzel ist ueberschreibbar, damit ein Test die Erkennung wirklich
|
||||||
|
# ausfuehren kann und nicht nur den Quelltext liest.
|
||||||
|
if [[ -d "${TESM_SRV_ROOT:-/srv}/$NAME/apps/tesm-license/src/tesm_license/master" ]]; then
|
||||||
|
REPO="tesm-license-master"
|
||||||
|
echo "==> Master-Ausgabe erkannt -- Release aus $REPO."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
WORK="$(mktemp -d /tmp/tesm-update-XXXXXX)"
|
# --8<-- Bootstrap: Release holen --------------------------------------------
|
||||||
trap 'rm -rf "$WORK"' EXIT # anders als im Vorgaenger wird aufgeraeumt
|
# Wortgleich in update.sh und upgrade.sh; ein Hygienetest vergleicht die beiden
|
||||||
|
# Bloecke. Zwei eigenstaendige Skripte koennen sich keine Datei teilen, und
|
||||||
|
# beide sollen als **einzelne Datei** genuegen: auf das Zielsystem legen,
|
||||||
|
# ausfuehren, fertig -- auf einer 1.2.8 liegt nichts von diesem Projekt.
|
||||||
|
#
|
||||||
|
# Erwartet: $APP und $TAG, wahlweise $REPO (Vorgabe: $APP). Setzt: $TAG
|
||||||
|
# (aufgeloest) und $SRC (entpacktes Release). Raeumt sein Arbeitsverzeichnis
|
||||||
|
# beim Beenden auf.
|
||||||
|
#
|
||||||
|
# $REPO gibt es, weil Installationsname und Repositorium auseinanderfallen
|
||||||
|
# koennen: die Master-Ausgabe des Lizenzservers liegt in
|
||||||
|
# "tesm-license-master", die Installation heisst weiter "tesm-license".
|
||||||
|
#
|
||||||
|
# Zwei Fallen des Vorgaengers sind hier vermieden:
|
||||||
|
# * Der Tag "latest" wird ueber die API aufgeloest, nie direkt in die
|
||||||
|
# Download-URL geschrieben. Gitea liefert sonst mit HTTP 200 ein
|
||||||
|
# automatisch erzeugtes Quell-Snapshot statt des echten Release-Anhangs.
|
||||||
|
# * Nach dem Entpacken wird geprueft, dass ein install.sh existiert.
|
||||||
|
BASE="${TESM_RELEASE_BASE:-https://gitea.int.eertmoed.net}"
|
||||||
|
OWNER="${TESM_RELEASE_OWNER:-alientim}"
|
||||||
|
|
||||||
|
# Ein privates Repositorium -- tesm-license-master -- gibt sein Release nur mit
|
||||||
|
# Zugriffstoken heraus. Reihenfolge: Umgebungsvariable, dann eine Datei, die
|
||||||
|
# der Betreiber selbst anlegt. Der Installer legt sie nie an: ob ein Token auf
|
||||||
|
# dem Server liegt, ist keine Entscheidung eines Skripts.
|
||||||
|
TOKEN="${TESM_RELEASE_TOKEN:-}"
|
||||||
|
if [[ -z "$TOKEN" && -r /etc/tesm-release.token ]]; then
|
||||||
|
TOKEN="$(tr -d '[:space:]' < /etc/tesm-release.token)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Ein Wagenruecklauf im Paket macht jedes Shell-Skript auf dem Ziel unbrauchbar
|
||||||
|
# ("bad interpreter"). Die Pruefung darf dabei nicht selbst von einem
|
||||||
|
# Zusatzpaket abhaengen: mit "file" -- das auf einem schlanken Server fehlt --
|
||||||
|
# endete sie in "command not found", grep bekam nichts zu sehen, und das Skript
|
||||||
|
# machte weiter, als waere alles in Ordnung.
|
||||||
|
hat_crlf() {
|
||||||
|
local cr
|
||||||
|
cr="$(printf '\r')"
|
||||||
|
# -U (binaer lesen) ist unter Linux ohne Wirkung, auf einer
|
||||||
|
# Windows-Buildmaschine aber entscheidend: dort entfernt grep den
|
||||||
|
# Wagenruecklauf beim Lesen und findet deshalb nie einen.
|
||||||
|
LC_ALL=C grep -qU "$cr" "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
BOOTSTRAP_WORK=""
|
||||||
|
bootstrap_aufraeumen() {
|
||||||
|
# Den Ausstiegscode zuerst festhalten: alles danach ueberschreibt ihn.
|
||||||
|
local code=$?
|
||||||
|
[[ -n "$BOOTSTRAP_WORK" ]] && rm -rf "$BOOTSTRAP_WORK"
|
||||||
|
# Wer einen eigenen Abschluss braucht -- etwa einen Rueckweg nach einem
|
||||||
|
# Fehlschlag --, definiert "beim_beenden". Ein zweiter "trap ... EXIT" waere
|
||||||
|
# der falsche Weg: er wuerde diesen hier ersetzen, und das entpackte Release
|
||||||
|
# bliebe unter /tmp liegen.
|
||||||
|
declare -F beim_beenden >/dev/null 2>&1 && beim_beenden "$code"
|
||||||
|
return "$code"
|
||||||
|
}
|
||||||
|
trap bootstrap_aufraeumen EXIT
|
||||||
|
|
||||||
|
release_holen() {
|
||||||
|
local repo="${TESM_RELEASE_REPO:-${REPO:-$APP}}" asset
|
||||||
|
# Leeres Array und "set -u" vertragen sich nur mit dieser Schreibweise.
|
||||||
|
local -a kopf=()
|
||||||
|
[[ -n "$TOKEN" ]] && kopf=(-H "Authorization: token $TOKEN")
|
||||||
|
for werkzeug in curl tar python3 find; do
|
||||||
|
command -v "$werkzeug" >/dev/null 2>&1 || {
|
||||||
|
echo "Auf diesem System fehlt $werkzeug -- ohne das geht es nicht." >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
done
|
||||||
|
BOOTSTRAP_WORK="$(mktemp -d /tmp/tesm-release-XXXXXX)"
|
||||||
|
|
||||||
if [[ "$TAG" == "latest" ]]; then
|
if [[ "$TAG" == "latest" ]]; then
|
||||||
echo "==> Ermittle aktuellen Release-Tag ..."
|
echo "==> Ermittle aktuellen Release-Tag ..."
|
||||||
TAG="$(curl -fsS "$BASE/api/v1/repos/$OWNER/$REPO/releases/latest" \
|
TAG="$(curl -fsS ${kopf[0]+"${kopf[@]}"} "$BASE/api/v1/repos/$OWNER/$repo/releases/latest" \
|
||||||
| python3 -c 'import json,sys; print(json.load(sys.stdin)["tag_name"])')"
|
| python3 -c 'import json,sys; print(json.load(sys.stdin)["tag_name"])')" || {
|
||||||
|
echo "Kein Release gefunden unter $BASE/$OWNER/$repo." >&2
|
||||||
|
[[ -z "$TOKEN" ]] &&
|
||||||
|
echo "Ist das Repositorium privat? Dann TESM_RELEASE_TOKEN setzen." >&2
|
||||||
|
echo "Andere Quelle: TESM_RELEASE_BASE, TESM_RELEASE_OWNER, TESM_RELEASE_REPO." >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
echo " $TAG"
|
echo " $TAG"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
ASSET="$REPO-$TAG.tar.gz"
|
asset="$repo-$TAG.tar.gz"
|
||||||
echo "==> Lade $ASSET ..."
|
echo "==> Lade $asset ..."
|
||||||
curl -fsSL -o "$WORK/$ASSET" "$BASE/$OWNER/$REPO/releases/download/$TAG/$ASSET"
|
curl -fsSL ${kopf[0]+"${kopf[@]}"} -o "$BOOTSTRAP_WORK/$asset" \
|
||||||
|
"$BASE/$OWNER/$repo/releases/download/$TAG/$asset" || {
|
||||||
|
echo "Download fehlgeschlagen: $BASE/$OWNER/$repo/releases/download/$TAG/$asset" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
echo "==> Entpacke ..."
|
echo "==> Entpacke ..."
|
||||||
tar -xzf "$WORK/$ASSET" -C "$WORK"
|
tar -xzf "$BOOTSTRAP_WORK/$asset" -C "$BOOTSTRAP_WORK"
|
||||||
# Drei Ebenen, nicht zwei: release.sh packt mit dem Praefix "<name>/", also
|
# Drei Ebenen, nicht zwei: release.sh packt mit dem Praefix "<name>/", also
|
||||||
# liegt das Skript unter <name>/deploy/install.sh. Mit "-maxdepth 2" fand die
|
# liegt das Skript unter <name>/deploy/install.sh. Mit "-maxdepth 2" fand die
|
||||||
# Suche nie etwas -- und die Fehlermeldung schob es auf den Download.
|
# Suche nie etwas -- und die Fehlermeldung schob es auf den Download.
|
||||||
SRC="$(find "$WORK" -maxdepth 3 -name install.sh -path '*/deploy/*' -printf '%h/..\n' | head -1)"
|
SRC="$(find "$BOOTSTRAP_WORK" -maxdepth 3 -name install.sh -path '*/deploy/*' \
|
||||||
[[ -n "$SRC" ]] || { echo "Im Paket fehlt deploy/install.sh -- Download unbrauchbar." >&2; exit 1; }
|
-printf '%h/..\n' | head -1)"
|
||||||
|
[[ -n "$SRC" ]] || {
|
||||||
|
echo "Im Paket fehlt deploy/install.sh -- Download unbrauchbar." >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
SRC="$(cd "$SRC" && pwd)"
|
SRC="$(cd "$SRC" && pwd)"
|
||||||
|
|
||||||
echo "==> Pruefe auf CRLF (Windows-Buildmaschinen) ..."
|
echo "==> Pruefe auf CRLF (Windows-Buildmaschinen) ..."
|
||||||
if hat_crlf "$SRC/deploy/install.sh"; then
|
if hat_crlf "$SRC/deploy/install.sh"; then
|
||||||
echo "Das Paket enthaelt CRLF-Zeilenenden. Beim Paketieren fehlte" >&2
|
echo "Das Paket enthaelt CRLF-Zeilenenden. Beim Paketieren fehlte" >&2
|
||||||
echo "'git -c core.autocrlf=false archive'." >&2
|
echo "'git -c core.autocrlf=false archive'." >&2
|
||||||
exit 1
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
# --8<-- Ende Bootstrap ------------------------------------------------------
|
||||||
|
|
||||||
|
release_holen
|
||||||
|
|
||||||
|
# Schon installiert? Dann gar nicht erst fragen.
|
||||||
|
#
|
||||||
|
# Dieselbe Pruefung steht in install.sh und gilt dort endgueltig (sie
|
||||||
|
# vergleicht auch den Kern). Hier steht sie noch einmal, weil ein Update, das
|
||||||
|
# ohne Wirkung bleibt, gar nicht erst nach einer Bestaetigung fragen soll --
|
||||||
|
# und weil "es lief durch" sonst wie eine Aktualisierung aussieht.
|
||||||
|
ERZWINGEN=0
|
||||||
|
for ((i = 0; i < ${#EXTRA[@]}; i++)); do
|
||||||
|
[[ "${EXTRA[i]}" == "--force" ]] && ERZWINGEN=1
|
||||||
|
done
|
||||||
|
INSTALLATION="$APP"
|
||||||
|
for ((i = 0; i < ${#EXTRA[@]}; i++)); do
|
||||||
|
[[ "${EXTRA[i]}" == "--instance" ]] && INSTALLATION="$APP-${EXTRA[i + 1]:-}"
|
||||||
|
done
|
||||||
|
MODUL="tesm"
|
||||||
|
[[ "$APP" == "tesm-license" ]] && MODUL="tesm_license"
|
||||||
|
if [[ $ERZWINGEN -eq 0 && -x "/srv/$INSTALLATION/venv/bin/python" ]]; then
|
||||||
|
LAEUFT="$("/srv/$INSTALLATION/venv/bin/python" -c \
|
||||||
|
"import $MODUL; print($MODUL.__version__)" 2>/dev/null || true)"
|
||||||
|
if [[ -n "$LAEUFT" && "v$LAEUFT" == "$TAG" ]]; then
|
||||||
|
echo "==> $APP $LAEUFT laeuft hier bereits -- nichts zu tun."
|
||||||
|
echo " Trotzdem neu einrichten: --force"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# --yes darf auch hier stehen, nicht nur bei install.sh und upgrade.sh. Es
|
||||||
|
# bleibt in EXTRA: install.sh bekommt es ohnehin, doppelt schadet nicht.
|
||||||
|
OHNE_RUECKFRAGE=0
|
||||||
|
for ((i = 0; i < ${#EXTRA[@]}; i++)); do
|
||||||
|
[[ "${EXTRA[i]}" == "--yes" || "${EXTRA[i]}" == "-y" ]] && OHNE_RUECKFRAGE=1
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ $OHNE_RUECKFRAGE -eq 1 ]]; then
|
||||||
|
echo "==> Version $TAG wird ohne Rueckfrage installiert."
|
||||||
|
elif [[ -t 0 ]]; then
|
||||||
read -r -p "Version $TAG fuer $APP${EXTRA[0]+ (${EXTRA[*]})} installieren? [j/N] " answer
|
read -r -p "Version $TAG fuer $APP${EXTRA[0]+ (${EXTRA[*]})} installieren? [j/N] " answer
|
||||||
[[ "$answer" =~ ^[jJyY]$ ]] || exit 0
|
[[ "$answer" =~ ^[jJyY]$ ]] || exit 0
|
||||||
|
else
|
||||||
|
# Ohne Terminal las "read" hier EOF, die Antwort blieb leer, und das Skript
|
||||||
|
# endete mit 0 -- wer es aus einem anderen Skript aufrief, hielt das Update
|
||||||
|
# fuer erledigt. Lieber laut abbrechen.
|
||||||
|
echo "Kein Terminal fuer die Rueckfrage, und --yes fehlt -- es wurde nichts" >&2
|
||||||
|
echo "installiert. Aufruf mit --yes wiederholen." >&2
|
||||||
|
exit 3
|
||||||
|
fi
|
||||||
|
|
||||||
exec sudo bash "$SRC/deploy/install.sh" --app "$APP" --yes ${EXTRA[0]+"${EXTRA[@]}"}
|
# Kein exec: sonst laeuft die Aufraeumfalle nie, und jedes Update liesse ein
|
||||||
|
# entpacktes Release unter /tmp liegen.
|
||||||
|
bash "$SRC/deploy/install.sh" --app "$APP" --yes ${EXTRA[0]+"${EXTRA[@]}"}
|
||||||
@@ -0,0 +1,687 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# =============================================================================
|
||||||
|
# Upgrade vom Stand SONNET5 (Version 1.x) auf diese Fassung -- mit Datenuebernahme.
|
||||||
|
#
|
||||||
|
# Eine einzelne Datei genuegt -- sie holt sich das Release selbst:
|
||||||
|
#
|
||||||
|
# curl -fsSLO https://gitea.int.eertmoed.net/alientim/tesm/raw/branch/main/deploy/upgrade.sh
|
||||||
|
# sudo bash upgrade.sh --app tesm
|
||||||
|
#
|
||||||
|
# Auf einer 1.2.8 liegt nichts von diesem Projekt; alles andere waere die
|
||||||
|
# falsche Voraussetzung. Liegt das Skript in einem entpackten Release, wird
|
||||||
|
# dessen install.sh benutzt statt eines Downloads:
|
||||||
|
#
|
||||||
|
# sudo bash deploy/upgrade.sh --app tesm
|
||||||
|
# sudo bash deploy/upgrade.sh --app tesm --instance test --source /srv/tesm
|
||||||
|
#
|
||||||
|
# Der Ablauf ist die Vorgabe und braucht keine Schalter:
|
||||||
|
#
|
||||||
|
# 1. **Sichern.** Vollstaendige Kopie der alten Installation nach
|
||||||
|
# /srv/<name>-upgrade-<zeitstempel>/alt. Erst danach wird irgendetwas
|
||||||
|
# angefasst -- und aus dieser Kopie stammen auch alle weiteren Schritte.
|
||||||
|
# 2. **Altes System abschalten.** Dienst und Timer werden gestoppt und
|
||||||
|
# deaktiviert, damit sie nach einem Neustart nicht zurueckkommen.
|
||||||
|
# 3. **Neu installieren.** install.sh dieses Verzeichnisses.
|
||||||
|
# 4. **Exportieren.** Aus der Kopie entsteht eine Exportdatei im **alten**
|
||||||
|
# Format -- genau die, die der Vorgaenger selbst geschrieben haette.
|
||||||
|
# 5. **Uebersetzen.** Daraus eine Datei im neuen Format.
|
||||||
|
# 6. **Einspielen.** Diese Datei geht durch denselben Import wie eine
|
||||||
|
# Sicherung.
|
||||||
|
#
|
||||||
|
# Warum die Sicherung vor dem Export steht und nicht danach: laeuft das
|
||||||
|
# Upgrade **in dasselbe Verzeichnis** (also ohne --instance), ueberschreibt
|
||||||
|
# install.sh die alte Installation. Wer erst exportiert und dann sichert,
|
||||||
|
# sichert im schlechtesten Fall nichts mehr. Gelesen wird ausschliesslich aus
|
||||||
|
# der Kopie -- das Ergebnis ist dasselbe, die Reihenfolge ist die sichere.
|
||||||
|
#
|
||||||
|
# Der Lizenzserver kennt Schritt 4 und 5 nicht: seine Uebernahme liest die alte
|
||||||
|
# Datenbank unmittelbar und **signiert jede Lizenz neu**. Dabei wird der alte
|
||||||
|
# Signaturschluessel uebernommen -- sonst muesste jeder Kunde einen
|
||||||
|
# Ausstellerwechsel bestaetigen. Wer das nicht will: --new-key. Dort ist die
|
||||||
|
# Kopie aus Schritt 1 der Export.
|
||||||
|
# =============================================================================
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
APP=""
|
||||||
|
INSTANCE=""
|
||||||
|
SOURCE=""
|
||||||
|
PASSPHRASE=""
|
||||||
|
ASSUME_YES=0
|
||||||
|
DRY_RUN=0
|
||||||
|
WITH_DATA=1
|
||||||
|
STOP_OLD=1
|
||||||
|
ADOPT_KEY=1
|
||||||
|
TAG="latest"
|
||||||
|
ROLLBACK=""
|
||||||
|
|
||||||
|
#: Steht die Sicherung schon, und ist der Lauf durch? Daran entscheidet
|
||||||
|
#: die EXIT-Falle, ob sie zurueckrollt.
|
||||||
|
SICHERUNG_STEHT=0
|
||||||
|
UPGRADE_FERTIG=0
|
||||||
|
#: Namenszusatz, unter dem ``rename.sh`` eine verdraengte Installation der
|
||||||
|
#: Fassung 1.x ablegt. Beide Skripte muessen denselben Wert benutzen; ein
|
||||||
|
#: Hygienetest vergleicht sie.
|
||||||
|
#:
|
||||||
|
#: Auf einer echten 1.2.8 gibt es das **nicht** -- dort liegt die Installation
|
||||||
|
#: unter ihrem Hauptnamen. Der Zusatz entsteht erst, wenn ``rename.sh`` den
|
||||||
|
#: Hauptnamen fuer die neue Fassung braucht. Deshalb ist er der zweite Ort, an
|
||||||
|
#: dem gesucht wird, nicht der erste.
|
||||||
|
ARCHIV_SUFFIX="sonnet5"
|
||||||
|
|
||||||
|
#: Nur fuer die Ausgabe: wurde die Quelle selbst gefunden oder angegeben?
|
||||||
|
info_quelle=""
|
||||||
|
EXTRA=()
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
|
||||||
|
COLOR_OK=$'\033[32m'; COLOR_WARN=$'\033[33m'; COLOR_ERR=$'\033[31m'; COLOR_OFF=$'\033[0m'
|
||||||
|
step() { printf '\n%s==>%s %s\n' "$COLOR_OK" "$COLOR_OFF" "$*"; }
|
||||||
|
warn() { printf '%s !!%s %s\n' "$COLOR_WARN" "$COLOR_OFF" "$*"; }
|
||||||
|
fail() { printf '%s !!%s %s\n' "$COLOR_ERR" "$COLOR_OFF" "$*" >&2; exit 1; }
|
||||||
|
info() { printf ' %s\n' "$*"; }
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'USAGE'
|
||||||
|
Verwendung: upgrade.sh --app <tesm|tesm-license> [Optionen]
|
||||||
|
|
||||||
|
--app <name> Welche Anwendung (Pflicht)
|
||||||
|
--source <pfad> Verzeichnis der alten Installation
|
||||||
|
(Vorgabe: /srv/tesm bzw. /srv/tesm-license)
|
||||||
|
--instance <name> Neue Installation als benannte Instanz anlegen, statt
|
||||||
|
das alte Verzeichnis zu ueberschreiben. Damit bleibt die
|
||||||
|
alte Installation liegen und ist zurueckholbar.
|
||||||
|
--passphrase <text> Passphrase der Zwischendateien. Ohne Angabe wird eine
|
||||||
|
erzeugt und in der Sicherung abgelegt.
|
||||||
|
--no-data Nur installieren, keine Datenuebernahme
|
||||||
|
--keep-old-running Alten Dienst nicht stoppen (nur mit --instance sinnvoll)
|
||||||
|
--new-key Nur Lizenzserver: einen neuen Signaturschluessel
|
||||||
|
erzeugen, statt den alten zu uebernehmen. Dann muessen
|
||||||
|
alle Kunden den Ausstellerwechsel bestaetigen -- die
|
||||||
|
Vorgabe ist deshalb die Uebernahme.
|
||||||
|
--tag <vX.Y.Z> Bestimmte Version installieren (Vorgabe: latest).
|
||||||
|
Wird nur beachtet, wenn das Release geladen wird.
|
||||||
|
--rollback [pfad] Einen fehlgeschlagenen Lauf zurueckdrehen: die alte
|
||||||
|
Installation aus der Sicherung wiederherstellen und
|
||||||
|
ihren Dienst wieder einschalten. Ohne Pfad wird die
|
||||||
|
neueste Sicherung dieses Namens genommen.
|
||||||
|
--dry-run Nur zeigen, was geschehen wuerde
|
||||||
|
--yes Keine Rueckfrage
|
||||||
|
-h, --help Diese Hilfe
|
||||||
|
|
||||||
|
Alles weitere wird unveraendert an install.sh weitergegeben, etwa --https
|
||||||
|
oder --domain.
|
||||||
|
|
||||||
|
Beispiele:
|
||||||
|
# In dasselbe Verzeichnis, alte Installation wird ersetzt:
|
||||||
|
sudo bash deploy/upgrade.sh --app tesm
|
||||||
|
|
||||||
|
# Daneben, zum Vergleich, alte Installation bleibt unangetastet:
|
||||||
|
sudo bash deploy/upgrade.sh --app tesm --instance test --keep-old-running
|
||||||
|
USAGE
|
||||||
|
}
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--app) APP="${2:?}"; shift 2 ;;
|
||||||
|
--source) SOURCE="${2:?}"; shift 2 ;;
|
||||||
|
--instance) INSTANCE="${2:?}"; shift 2 ;;
|
||||||
|
--passphrase) PASSPHRASE="${2:?}"; shift 2 ;;
|
||||||
|
--no-data) WITH_DATA=0; shift ;;
|
||||||
|
--keep-old-running) STOP_OLD=0; shift ;;
|
||||||
|
--new-key) ADOPT_KEY=0; shift ;;
|
||||||
|
--adopt-key) ADOPT_KEY=1; shift ;; # Vorgabe, hier nur zur Deutlichkeit
|
||||||
|
--tag) TAG="${2:?}"; shift 2 ;;
|
||||||
|
--rollback)
|
||||||
|
# Der Pfad ist optional: allein genommen heisst es "die neueste".
|
||||||
|
if [[ -n "${2:-}" && "${2:-}" != --* ]]; then ROLLBACK="$2"; shift 2
|
||||||
|
else ROLLBACK="auto"; shift; fi
|
||||||
|
;;
|
||||||
|
--dry-run) DRY_RUN=1; shift ;;
|
||||||
|
--yes|-y) ASSUME_YES=1; shift ;;
|
||||||
|
-h|--help) usage; exit 0 ;;
|
||||||
|
*) EXTRA+=("$1"); shift ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
[[ "$APP" == "tesm" || "$APP" == "tesm-license" ]] || { usage; fail "--app fehlt oder ist unbekannt"; }
|
||||||
|
[[ "$(id -u)" -eq 0 ]] || fail "bitte mit sudo ausfuehren"
|
||||||
|
|
||||||
|
NAME="$APP"
|
||||||
|
[[ -n "$INSTANCE" ]] && NAME="$APP-$INSTANCE"
|
||||||
|
ADMIN="$NAME-admin"
|
||||||
|
NEW_ROOT="/srv/$NAME"
|
||||||
|
STAMP="$(date +%Y%m%d-%H%M%S)"
|
||||||
|
BACKUP="/srv/$NAME-upgrade-$STAMP"
|
||||||
|
ALT_KOPIE="$BACKUP/alt"
|
||||||
|
SYSTEM_KOPIE="$BACKUP/system"
|
||||||
|
EXPORT_ALT="$BACKUP/export-alt.json"
|
||||||
|
IMPORT_NEU="$BACKUP/import-neu.json"
|
||||||
|
|
||||||
|
# Alte Einheiten der Vorgaengerfassung. Der Timer gehoert dazu: nur den Dienst
|
||||||
|
# zu stoppen liesse ihn beim naechsten Ablauf wieder anlaufen.
|
||||||
|
# -- Rueckweg ---------------------------------------------------------------- #
|
||||||
|
# Alles, was install.sh unter demselben Namen ueberschreiben wuerde. Bei einem
|
||||||
|
# Upgrade in dasselbe Verzeichnis sind das die Dateien der alten Fassung.
|
||||||
|
SYSTEMDATEIEN=(
|
||||||
|
"/etc/systemd/system/$NAME.service"
|
||||||
|
"/etc/systemd/system/$NAME-monitor.service"
|
||||||
|
"/etc/nginx/sites-available/$NAME"
|
||||||
|
"/etc/logrotate.d/$NAME"
|
||||||
|
"/etc/update-motd.d/50-$NAME"
|
||||||
|
"/etc/tesm/$NAME.env"
|
||||||
|
)
|
||||||
|
|
||||||
|
zurueckrollen() {
|
||||||
|
local sicherung="$1" grund="${2:-}"
|
||||||
|
local alt="$sicherung/alt" system="$sicherung/system"
|
||||||
|
[[ -d "$alt" ]] || fail "In $sicherung fehlt das Verzeichnis 'alt' -- das ist keine Sicherung dieses Skripts."
|
||||||
|
|
||||||
|
# Quelle und Art des Umzugs stehen im Manifest. Aus dem laufenden Aufruf
|
||||||
|
# abzuleiten waere falsch: ein Rueckweg kann Tage spaeter und mit anderen
|
||||||
|
# Schaltern aufgerufen werden -- und beim automatischen Rueckweg steht
|
||||||
|
# IN_PLACE zwar fest, beim spaeteren --rollback aber nicht.
|
||||||
|
local ziel="${SOURCE:-}" gleiches_verzeichnis="${IN_PLACE:-0}" vermerkt
|
||||||
|
if [[ -f "$system/manifest" ]]; then
|
||||||
|
vermerkt="$(awk '$1=="quelle" {print $2}' "$system/manifest" | head -1)"
|
||||||
|
[[ -n "$vermerkt" ]] && ziel="$vermerkt"
|
||||||
|
vermerkt="$(awk '$1=="in-place" {print $2}' "$system/manifest" | head -1)"
|
||||||
|
[[ -n "$vermerkt" ]] && gleiches_verzeichnis="$vermerkt"
|
||||||
|
fi
|
||||||
|
[[ -n "$ziel" ]] || fail "Im Manifest fehlt die Quelle -- bitte --source angeben."
|
||||||
|
|
||||||
|
[[ -n "$grund" ]] && warn "$grund"
|
||||||
|
step "Rueckweg: alten Stand wiederherstellen"
|
||||||
|
|
||||||
|
# Der neue Dienst zuerst -- er haelt sonst Dateien offen, die gleich ersetzt
|
||||||
|
# werden.
|
||||||
|
for unit in "$NAME.service" "$NAME-monitor.service"; do
|
||||||
|
if systemctl list-unit-files "$unit" --no-legend 2>/dev/null | grep -q .; then
|
||||||
|
systemctl disable --now "$unit" >/dev/null 2>&1 || true
|
||||||
|
info "$unit gestoppt"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Verzeichnis: nur bei gleichem Ziel -- bei einer eigenen Instanz war die
|
||||||
|
# alte Installation nie angefasst.
|
||||||
|
if [[ "$gleiches_verzeichnis" == "1" ]]; then
|
||||||
|
rm -rf "$ziel"
|
||||||
|
mkdir -p "$ziel"
|
||||||
|
tar -C "$alt" -cf - . | tar -C "$ziel" -xf -
|
||||||
|
info "$ziel aus der Sicherung wiederhergestellt"
|
||||||
|
[[ -d "$ziel/venv" ]] || warn "Im Backup war kein venv -- der alte Dienst braucht dann noch 'python3 -m venv venv' und seine requirements.txt."
|
||||||
|
else
|
||||||
|
info "$ziel war nicht betroffen (eigene Instanz) -- bleibt, wie es ist"
|
||||||
|
if [[ -d "$NEW_ROOT" && "$NEW_ROOT" != "$ziel" ]]; then
|
||||||
|
rm -rf "$NEW_ROOT"
|
||||||
|
info "$NEW_ROOT entfernt"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Systemdateien zurueck, alles andere entfernen, was der Lauf angelegt hat.
|
||||||
|
local site_wieder=0
|
||||||
|
if [[ -f "$system/manifest" ]]; then
|
||||||
|
while read -r zeile; do
|
||||||
|
case "$zeile" in
|
||||||
|
unit-enabled\ *|site-enabled\ *) continue ;;
|
||||||
|
esac
|
||||||
|
local name; name="$(basename "$zeile")"
|
||||||
|
if [[ -e "$system/$name" ]]; then
|
||||||
|
cp -a "$system/$name" "$zeile"
|
||||||
|
info "wiederhergestellt: $zeile"
|
||||||
|
[[ "$zeile" == /etc/nginx/* ]] && site_wieder=1
|
||||||
|
fi
|
||||||
|
done < <(grep -v '^\(unit\|site\)-enabled ' "$system/manifest" || true)
|
||||||
|
fi
|
||||||
|
|
||||||
|
systemctl daemon-reload
|
||||||
|
if [[ $site_wieder -eq 1 ]] || [[ -e "/etc/nginx/sites-enabled/$NAME" ]]; then
|
||||||
|
nginx -t >/dev/null 2>&1 && systemctl reload nginx >/dev/null 2>&1 \
|
||||||
|
&& info "nginx neu geladen" || warn "nginx meldet einen Fehler -- bitte 'nginx -t' ansehen"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Und die Dienste wieder einschalten, die vorher liefen.
|
||||||
|
if [[ -f "$system/manifest" ]]; then
|
||||||
|
while read -r _ unit; do
|
||||||
|
systemctl enable --now "$unit" >/dev/null 2>&1 \
|
||||||
|
&& info "$unit wieder eingeschaltet" || warn "$unit liess sich nicht starten"
|
||||||
|
done < <(grep '^unit-enabled ' "$system/manifest" || true)
|
||||||
|
fi
|
||||||
|
|
||||||
|
step "Zurueckgerollt"
|
||||||
|
info "Sicherung bleibt liegen: $sicherung"
|
||||||
|
# Nicht ueber ALTE_UNITS: die Liste entsteht erst nach der Quellensuche, und
|
||||||
|
# bei "--rollback" laeuft diese Funktion vorher. Das Manifest weiss es.
|
||||||
|
local hinweis
|
||||||
|
hinweis="$(awk '$1=="unit-enabled" {print $2; exit}' "$system/manifest" 2>/dev/null || true)"
|
||||||
|
info "Zustand pruefen: systemctl status ${hinweis:-$APP.service}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Wird von der EXIT-Falle des Bootstraps aufgerufen -- mit dem Ausstiegscode.
|
||||||
|
# Damit gilt der Rueckweg fuer **jeden** Fehlschlag und nicht nur fuer den, an
|
||||||
|
# den ich gedacht habe: Export, Uebersetzen, Einspielen, nginx, ein Ctrl-C.
|
||||||
|
beim_beenden() {
|
||||||
|
local code="${1:-0}"
|
||||||
|
[[ "$code" == "0" ]] && return 0
|
||||||
|
[[ $UPGRADE_FERTIG -eq 1 ]] && return 0
|
||||||
|
if [[ $SICHERUNG_STEHT -eq 0 ]]; then
|
||||||
|
warn "Abgebrochen (Code $code), bevor eine Sicherung stand -- nichts zurueckzurollen."
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
zurueckrollen "$BACKUP" "Abgebrochen (Code $code)."
|
||||||
|
}
|
||||||
|
|
||||||
|
neueste_sicherung() {
|
||||||
|
local kandidat
|
||||||
|
kandidat="$(ls -1dt /srv/"$NAME"-upgrade-*/ 2>/dev/null | head -1)"
|
||||||
|
[[ -n "$kandidat" ]] || return 1
|
||||||
|
printf '%s' "${kandidat%/}"
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ -n "$ROLLBACK" ]]; then
|
||||||
|
if [[ "$ROLLBACK" == "auto" ]]; then
|
||||||
|
ROLLBACK="$(neueste_sicherung)" || fail "Keine Sicherung unter /srv/$NAME-upgrade-* gefunden. Pfad angeben."
|
||||||
|
info "Neueste Sicherung: $ROLLBACK"
|
||||||
|
fi
|
||||||
|
[[ -d "$ROLLBACK" ]] || fail "$ROLLBACK gibt es nicht"
|
||||||
|
if [[ $DRY_RUN -eq 1 ]]; then
|
||||||
|
step "--dry-run: es wuerde $ROLLBACK zurueckgerollt."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if [[ $ASSUME_YES -eq 0 ]]; then
|
||||||
|
read -r -p "Stand aus $ROLLBACK wiederherstellen? [j/N] " answer
|
||||||
|
[[ "$answer" =~ ^[jJyY]$ ]] || { echo "Abgebrochen."; exit 0; }
|
||||||
|
fi
|
||||||
|
zurueckrollen "$ROLLBACK"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Ab hier wird wirklich installiert -- ein Rueckweg hat oben schon
|
||||||
|
# beendet. Erst jetzt braucht es ein Release und eine Quelle.
|
||||||
|
|
||||||
|
# --8<-- Bootstrap: Release holen --------------------------------------------
|
||||||
|
# Wortgleich in update.sh und upgrade.sh; ein Hygienetest vergleicht die beiden
|
||||||
|
# Bloecke. Zwei eigenstaendige Skripte koennen sich keine Datei teilen, und
|
||||||
|
# beide sollen als **einzelne Datei** genuegen: auf das Zielsystem legen,
|
||||||
|
# ausfuehren, fertig -- auf einer 1.2.8 liegt nichts von diesem Projekt.
|
||||||
|
#
|
||||||
|
# Erwartet: $APP und $TAG, wahlweise $REPO (Vorgabe: $APP). Setzt: $TAG
|
||||||
|
# (aufgeloest) und $SRC (entpacktes Release). Raeumt sein Arbeitsverzeichnis
|
||||||
|
# beim Beenden auf.
|
||||||
|
#
|
||||||
|
# $REPO gibt es, weil Installationsname und Repositorium auseinanderfallen
|
||||||
|
# koennen: die Master-Ausgabe des Lizenzservers liegt in
|
||||||
|
# "tesm-license-master", die Installation heisst weiter "tesm-license".
|
||||||
|
#
|
||||||
|
# Zwei Fallen des Vorgaengers sind hier vermieden:
|
||||||
|
# * Der Tag "latest" wird ueber die API aufgeloest, nie direkt in die
|
||||||
|
# Download-URL geschrieben. Gitea liefert sonst mit HTTP 200 ein
|
||||||
|
# automatisch erzeugtes Quell-Snapshot statt des echten Release-Anhangs.
|
||||||
|
# * Nach dem Entpacken wird geprueft, dass ein install.sh existiert.
|
||||||
|
BASE="${TESM_RELEASE_BASE:-https://gitea.int.eertmoed.net}"
|
||||||
|
OWNER="${TESM_RELEASE_OWNER:-alientim}"
|
||||||
|
|
||||||
|
# Ein privates Repositorium -- tesm-license-master -- gibt sein Release nur mit
|
||||||
|
# Zugriffstoken heraus. Reihenfolge: Umgebungsvariable, dann eine Datei, die
|
||||||
|
# der Betreiber selbst anlegt. Der Installer legt sie nie an: ob ein Token auf
|
||||||
|
# dem Server liegt, ist keine Entscheidung eines Skripts.
|
||||||
|
TOKEN="${TESM_RELEASE_TOKEN:-}"
|
||||||
|
if [[ -z "$TOKEN" && -r /etc/tesm-release.token ]]; then
|
||||||
|
TOKEN="$(tr -d '[:space:]' < /etc/tesm-release.token)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Ein Wagenruecklauf im Paket macht jedes Shell-Skript auf dem Ziel unbrauchbar
|
||||||
|
# ("bad interpreter"). Die Pruefung darf dabei nicht selbst von einem
|
||||||
|
# Zusatzpaket abhaengen: mit "file" -- das auf einem schlanken Server fehlt --
|
||||||
|
# endete sie in "command not found", grep bekam nichts zu sehen, und das Skript
|
||||||
|
# machte weiter, als waere alles in Ordnung.
|
||||||
|
hat_crlf() {
|
||||||
|
local cr
|
||||||
|
cr="$(printf '\r')"
|
||||||
|
# -U (binaer lesen) ist unter Linux ohne Wirkung, auf einer
|
||||||
|
# Windows-Buildmaschine aber entscheidend: dort entfernt grep den
|
||||||
|
# Wagenruecklauf beim Lesen und findet deshalb nie einen.
|
||||||
|
LC_ALL=C grep -qU "$cr" "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
BOOTSTRAP_WORK=""
|
||||||
|
bootstrap_aufraeumen() {
|
||||||
|
# Den Ausstiegscode zuerst festhalten: alles danach ueberschreibt ihn.
|
||||||
|
local code=$?
|
||||||
|
[[ -n "$BOOTSTRAP_WORK" ]] && rm -rf "$BOOTSTRAP_WORK"
|
||||||
|
# Wer einen eigenen Abschluss braucht -- etwa einen Rueckweg nach einem
|
||||||
|
# Fehlschlag --, definiert "beim_beenden". Ein zweiter "trap ... EXIT" waere
|
||||||
|
# der falsche Weg: er wuerde diesen hier ersetzen, und das entpackte Release
|
||||||
|
# bliebe unter /tmp liegen.
|
||||||
|
declare -F beim_beenden >/dev/null 2>&1 && beim_beenden "$code"
|
||||||
|
return "$code"
|
||||||
|
}
|
||||||
|
trap bootstrap_aufraeumen EXIT
|
||||||
|
|
||||||
|
release_holen() {
|
||||||
|
local repo="${TESM_RELEASE_REPO:-${REPO:-$APP}}" asset
|
||||||
|
# Leeres Array und "set -u" vertragen sich nur mit dieser Schreibweise.
|
||||||
|
local -a kopf=()
|
||||||
|
[[ -n "$TOKEN" ]] && kopf=(-H "Authorization: token $TOKEN")
|
||||||
|
for werkzeug in curl tar python3 find; do
|
||||||
|
command -v "$werkzeug" >/dev/null 2>&1 || {
|
||||||
|
echo "Auf diesem System fehlt $werkzeug -- ohne das geht es nicht." >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
done
|
||||||
|
BOOTSTRAP_WORK="$(mktemp -d /tmp/tesm-release-XXXXXX)"
|
||||||
|
|
||||||
|
if [[ "$TAG" == "latest" ]]; then
|
||||||
|
echo "==> Ermittle aktuellen Release-Tag ..."
|
||||||
|
TAG="$(curl -fsS ${kopf[0]+"${kopf[@]}"} "$BASE/api/v1/repos/$OWNER/$repo/releases/latest" \
|
||||||
|
| python3 -c 'import json,sys; print(json.load(sys.stdin)["tag_name"])')" || {
|
||||||
|
echo "Kein Release gefunden unter $BASE/$OWNER/$repo." >&2
|
||||||
|
[[ -z "$TOKEN" ]] &&
|
||||||
|
echo "Ist das Repositorium privat? Dann TESM_RELEASE_TOKEN setzen." >&2
|
||||||
|
echo "Andere Quelle: TESM_RELEASE_BASE, TESM_RELEASE_OWNER, TESM_RELEASE_REPO." >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
echo " $TAG"
|
||||||
|
fi
|
||||||
|
|
||||||
|
asset="$repo-$TAG.tar.gz"
|
||||||
|
echo "==> Lade $asset ..."
|
||||||
|
curl -fsSL ${kopf[0]+"${kopf[@]}"} -o "$BOOTSTRAP_WORK/$asset" \
|
||||||
|
"$BASE/$OWNER/$repo/releases/download/$TAG/$asset" || {
|
||||||
|
echo "Download fehlgeschlagen: $BASE/$OWNER/$repo/releases/download/$TAG/$asset" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "==> Entpacke ..."
|
||||||
|
tar -xzf "$BOOTSTRAP_WORK/$asset" -C "$BOOTSTRAP_WORK"
|
||||||
|
# Drei Ebenen, nicht zwei: release.sh packt mit dem Praefix "<name>/", also
|
||||||
|
# liegt das Skript unter <name>/deploy/install.sh. Mit "-maxdepth 2" fand die
|
||||||
|
# Suche nie etwas -- und die Fehlermeldung schob es auf den Download.
|
||||||
|
SRC="$(find "$BOOTSTRAP_WORK" -maxdepth 3 -name install.sh -path '*/deploy/*' \
|
||||||
|
-printf '%h/..\n' | head -1)"
|
||||||
|
[[ -n "$SRC" ]] || {
|
||||||
|
echo "Im Paket fehlt deploy/install.sh -- Download unbrauchbar." >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
SRC="$(cd "$SRC" && pwd)"
|
||||||
|
|
||||||
|
echo "==> Pruefe auf CRLF (Windows-Buildmaschinen) ..."
|
||||||
|
if hat_crlf "$SRC/deploy/install.sh"; then
|
||||||
|
echo "Das Paket enthaelt CRLF-Zeilenenden. Beim Paketieren fehlte" >&2
|
||||||
|
echo "'git -c core.autocrlf=false archive'." >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
# --8<-- Ende Bootstrap ------------------------------------------------------
|
||||||
|
|
||||||
|
# Woher kommt install.sh? Liegt dieses Skript in einem entpackten Release,
|
||||||
|
# nimm dessen Verzeichnis -- wer ein Paket ausgepackt hat, will nicht, dass
|
||||||
|
# ungefragt ein anderes geladen wird. Sonst hol das Release.
|
||||||
|
if [[ -f "$SCRIPT_DIR/install.sh" ]]; then
|
||||||
|
SRC="$REPO_DIR"
|
||||||
|
RELEASE_HERKUNFT="$SRC (entpacktes Release neben diesem Skript)"
|
||||||
|
else
|
||||||
|
release_holen || fail "Das Release liess sich nicht holen."
|
||||||
|
RELEASE_HERKUNFT="$TAG von $BASE/$OWNER"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# -- Wo liegt die alte Fassung? ---------------------------------------------- #
|
||||||
|
# Zwei Orte kommen in Frage, und der zweite ist der Grund fuer diesen Block:
|
||||||
|
# ``rename.sh`` schiebt eine Installation der Fassung 1.x nach
|
||||||
|
# ``/srv/<app>-sonnet5``, sobald der Hauptname fuer die neue gebraucht wird.
|
||||||
|
# Danach liegt unter ``/srv/<app>`` die **laufende** Installation, und die
|
||||||
|
# Vorgabe zeigte ins Leere. Erkannt wird an ``sqlite.db`` im Wurzelverzeichnis
|
||||||
|
# -- die neue Fassung legt ihre Datenbank unter ``data/app.db`` ab.
|
||||||
|
ist_alte_fassung() { [[ -f "$1/sqlite.db" ]]; }
|
||||||
|
|
||||||
|
if [[ -z "$SOURCE" ]]; then
|
||||||
|
for kandidat in "/srv/$APP" "/srv/$APP-$ARCHIV_SUFFIX"; do
|
||||||
|
if ist_alte_fassung "$kandidat"; then SOURCE="$kandidat"; break; fi
|
||||||
|
done
|
||||||
|
if [[ -z "$SOURCE" ]]; then
|
||||||
|
warn "Keine Installation der Fassung 1.x gefunden. Gesucht wurde in:"
|
||||||
|
warn " /srv/$APP (dort liegt sie normalerweise)"
|
||||||
|
warn " /srv/$APP-$ARCHIV_SUFFIX (dorthin verschiebt rename.sh sie, falls"
|
||||||
|
warn " der Hauptname schon vergeben war)"
|
||||||
|
[[ -f "/srv/$APP/data/app.db" ]] && warn "Unter /srv/$APP liegt die laufende Installation dieser Fassung."
|
||||||
|
fail "Bitte --source setzen."
|
||||||
|
fi
|
||||||
|
info_quelle="gefunden: $SOURCE"
|
||||||
|
fi
|
||||||
|
[[ -d "$SOURCE" ]] || fail "Die alte Installation $SOURCE gibt es nicht (--source setzen)"
|
||||||
|
ist_alte_fassung "$SOURCE" || fail "In $SOURCE liegt keine sqlite.db -- das ist keine Installation der Vorgaengerfassung"
|
||||||
|
|
||||||
|
IN_PLACE=0
|
||||||
|
[[ "$(cd "$SOURCE" && pwd)" == "$NEW_ROOT" ]] && IN_PLACE=1
|
||||||
|
|
||||||
|
# Die Units der alten Fassung erkennt man daran, dass sie in genau dieses
|
||||||
|
# Verzeichnis zeigen -- nicht am Namen der Anwendung. Nach einem rename.sh
|
||||||
|
# heisst die *laufende* Installation "tesm", und eine Namensliste haette sie
|
||||||
|
# gestoppt und deaktiviert, sobald jemand die archivierte 1.x in eine eigene
|
||||||
|
# Instanz hochzieht.
|
||||||
|
#
|
||||||
|
# Timer und Hilfsdienste tragen keinen Pfad (der Restart-Timer ruft nur
|
||||||
|
# "systemctl restart ..."). Sie kommen ueber den Namen der gefundenen Unit mit.
|
||||||
|
alte_units_finden() {
|
||||||
|
local quelle="$1" gefunden=() datei name runde
|
||||||
|
shopt -s nullglob
|
||||||
|
for datei in /etc/systemd/system/*.service; do
|
||||||
|
grep -qs -- "$quelle/" "$datei" && gefunden+=("$(basename "$datei")")
|
||||||
|
done
|
||||||
|
# Zwei Runden: erst Timer und Helfer, die eine gefundene Unit nennen, dann
|
||||||
|
# noch einmal fuer deren eigene Timer.
|
||||||
|
for runde in 1 2; do
|
||||||
|
for datei in /etc/systemd/system/*.service /etc/systemd/system/*.timer; do
|
||||||
|
name="$(basename "$datei")"
|
||||||
|
[[ " ${gefunden[*]-} " == *" $name "* ]] && continue
|
||||||
|
for treffer in ${gefunden[*]-}; do
|
||||||
|
if grep -qs -- "$treffer" "$datei"; then gefunden+=("$name"); break; fi
|
||||||
|
done
|
||||||
|
done
|
||||||
|
done
|
||||||
|
shopt -u nullglob
|
||||||
|
printf '%s\n' ${gefunden[*]-}
|
||||||
|
}
|
||||||
|
|
||||||
|
mapfile -t ALTE_UNITS < <(alte_units_finden "$SOURCE")
|
||||||
|
if [[ ${#ALTE_UNITS[@]} -eq 0 ]]; then
|
||||||
|
if [[ "$SOURCE" == "/srv/$APP" ]]; then
|
||||||
|
# Kein Fund, aber die Quelle ist der Hauptpfad: die historischen Namen der
|
||||||
|
# Fassung 1.x als Rueckfall.
|
||||||
|
if [[ "$APP" == "tesm" ]]; then
|
||||||
|
ALTE_UNITS=(tesm.service tesm-check.timer tesm-check.service
|
||||||
|
tesm-check-restart.timer tesm-check-restart.service)
|
||||||
|
else
|
||||||
|
ALTE_UNITS=(tesm-license.service)
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
ALTE_UNITS=()
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
step "Vorhaben"
|
||||||
|
info "Anwendung : $APP${INSTANCE:+ (Instanz $INSTANCE)}"
|
||||||
|
info "Neue Fassung : $RELEASE_HERKUNFT"
|
||||||
|
info "Alt : $SOURCE${info_quelle:+ (automatisch gefunden)}"
|
||||||
|
info "Neu : $NEW_ROOT$([[ $IN_PLACE -eq 1 ]] && echo ' << dasselbe Verzeichnis')"
|
||||||
|
info "Sicherung : $ALT_KOPIE"
|
||||||
|
info "Datenuebernahme: $([[ $WITH_DATA -eq 1 ]] && echo ja || echo nein)"
|
||||||
|
info "Altes System : $([[ $STOP_OLD -eq 1 ]] && echo 'stoppen und deaktivieren' || echo 'laeuft weiter')"
|
||||||
|
if [[ $IN_PLACE -eq 1 && $STOP_OLD -eq 0 ]]; then
|
||||||
|
warn "--keep-old-running bei gleichem Verzeichnis: der alte Dienst laeuft auf Dateien,"
|
||||||
|
warn "die gleich ersetzt werden. Das geht schief. Bitte --instance verwenden."
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ $DRY_RUN -eq 1 ]]; then
|
||||||
|
step "--dry-run: hier ist Schluss."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ $ASSUME_YES -eq 0 ]]; then
|
||||||
|
read -r -p "Fortfahren? [j/N] " answer
|
||||||
|
[[ "$answer" =~ ^[jJyY]$ ]] || { echo "Abgebrochen."; exit 0; }
|
||||||
|
fi
|
||||||
|
|
||||||
|
# -- 1. Sichern -------------------------------------------------------------- #
|
||||||
|
step "Sichern"
|
||||||
|
mkdir -p "$ALT_KOPIE" "$SYSTEM_KOPIE"
|
||||||
|
chmod 700 "$BACKUP"
|
||||||
|
|
||||||
|
# Das venv kommt nur mit, wenn in dasselbe Verzeichnis installiert wird -- dort
|
||||||
|
# ersetzt install.sh es, und ohne das alte startet der alte Dienst nach einem
|
||||||
|
# Rueckweg nicht mehr. Bei einer eigenen Instanz bleibt die alte Installation
|
||||||
|
# unangetastet, dann waere es nur Ballast (und seine Shebangs zeigen auf einen
|
||||||
|
# anderen Pfad).
|
||||||
|
if [[ $IN_PLACE -eq 1 ]]; then
|
||||||
|
tar -C "$SOURCE" --exclude=__pycache__ -cf - . | tar -C "$ALT_KOPIE" -xf -
|
||||||
|
info "$(du -sh "$ALT_KOPIE" | cut -f1) kopiert (mit venv, weil dasselbe Verzeichnis)"
|
||||||
|
else
|
||||||
|
tar -C "$SOURCE" --exclude=venv --exclude=__pycache__ -cf - . | tar -C "$ALT_KOPIE" -xf -
|
||||||
|
info "$(du -sh "$ALT_KOPIE" | cut -f1) kopiert (ohne venv)"
|
||||||
|
fi
|
||||||
|
[[ -f "$ALT_KOPIE/sqlite.db" ]] || fail "In der Kopie fehlt die Datenbank -- Abbruch"
|
||||||
|
|
||||||
|
# Und die Systemdateien: bei gleichem Verzeichnis schreibt install.sh Unit,
|
||||||
|
# nginx-Site, logrotate und Banner neu. Ohne Kopie liesse sich der alte Stand
|
||||||
|
# nicht wiederherstellen -- das Verzeichnis allein genuegt nicht.
|
||||||
|
: > "$SYSTEM_KOPIE/manifest"
|
||||||
|
# Woher kam der Stand, und ging es in dasselbe Verzeichnis? Ein spaeterer
|
||||||
|
# Rueckweg darf das nicht raten muessen -- er laeuft womoeglich Tage danach
|
||||||
|
# und mit anderen Schaltern.
|
||||||
|
printf 'quelle %s\n' "$SOURCE" >> "$SYSTEM_KOPIE/manifest"
|
||||||
|
printf 'in-place %s\n' "$IN_PLACE" >> "$SYSTEM_KOPIE/manifest"
|
||||||
|
for pfad in "${SYSTEMDATEIEN[@]}"; do
|
||||||
|
[[ -e "$pfad" ]] || continue
|
||||||
|
cp -a "$pfad" "$SYSTEM_KOPIE/$(basename "$pfad")"
|
||||||
|
printf '%s\n' "$pfad" >> "$SYSTEM_KOPIE/manifest"
|
||||||
|
done
|
||||||
|
# Welche Units liefen, und war die Site eingeschaltet?
|
||||||
|
for unit in "${ALTE_UNITS[@]}"; do
|
||||||
|
systemctl is-enabled "$unit" >/dev/null 2>&1 && printf 'unit-enabled %s\n' "$unit" >> "$SYSTEM_KOPIE/manifest"
|
||||||
|
done
|
||||||
|
[[ -L "/etc/nginx/sites-enabled/$NAME" ]] && printf 'site-enabled %s\n' "$NAME" >> "$SYSTEM_KOPIE/manifest"
|
||||||
|
info "$(grep -c . "$SYSTEM_KOPIE/manifest") Systemdatei(en) und Zustaende vermerkt"
|
||||||
|
SICHERUNG_STEHT=1
|
||||||
|
|
||||||
|
# -- 2. Altes System abschalten ---------------------------------------------- #
|
||||||
|
if [[ $STOP_OLD -eq 1 ]]; then
|
||||||
|
step "Altes System abschalten"
|
||||||
|
for unit in "${ALTE_UNITS[@]}"; do
|
||||||
|
if systemctl list-unit-files "$unit" --no-legend 2>/dev/null | grep -q .; then
|
||||||
|
systemctl disable --now "$unit" >/dev/null 2>&1 || true
|
||||||
|
info "$unit gestoppt und deaktiviert"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
else
|
||||||
|
step "Altes System bleibt in Betrieb"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# -- 3. Neu installieren ----------------------------------------------------- #
|
||||||
|
step "Neu installieren"
|
||||||
|
INSTALL_ARGS=(--app "$APP" --yes)
|
||||||
|
[[ -n "$INSTANCE" ]] && INSTALL_ARGS+=(--instance "$INSTANCE")
|
||||||
|
# Kein eigener Fehlerzweig mehr: die EXIT-Falle rollt bei jedem Fehlschlag
|
||||||
|
# zurueck -- hier, beim Export, beim Einspielen oder bei einem Ctrl-C.
|
||||||
|
bash "$SRC/deploy/install.sh" "${INSTALL_ARGS[@]}" ${EXTRA[0]+"${EXTRA[@]}"}
|
||||||
|
|
||||||
|
if [[ $WITH_DATA -eq 0 ]]; then
|
||||||
|
UPGRADE_FERTIG=1
|
||||||
|
step "Fertig -- ohne Datenuebernahme"
|
||||||
|
info "Sicherung der alten Installation: $ALT_KOPIE"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
command -v "$ADMIN" >/dev/null 2>&1 || fail "$ADMIN nicht gefunden -- die Installation ist unvollstaendig"
|
||||||
|
|
||||||
|
if [[ -z "$PASSPHRASE" ]]; then
|
||||||
|
PASSPHRASE="$(head -c 24 /dev/urandom | base64 | tr -d '=+/' | cut -c1-24)"
|
||||||
|
# Die Passphrase liegt neben den Dateien, die sie schuetzt. Das ist hier kein
|
||||||
|
# Widerspruch: das alte Format verlangt eine, das Verzeichnis gehoert root
|
||||||
|
# allein (0700), und ohne sie waeren die beiden Dateien spaeter unbrauchbar --
|
||||||
|
# auch als Rueckweg.
|
||||||
|
printf '%s\n' "$PASSPHRASE" > "$BACKUP/passphrase.txt"
|
||||||
|
chmod 600 "$BACKUP/passphrase.txt"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$APP" == "tesm" ]]; then
|
||||||
|
# -- 4. Exportieren -------------------------------------------------------- #
|
||||||
|
step "Daten der alten Installation lesen"
|
||||||
|
"$ADMIN" legacy-export --source "$ALT_KOPIE" --out "$EXPORT_ALT" \
|
||||||
|
--passphrase "$PASSPHRASE" --force
|
||||||
|
chmod 600 "$EXPORT_ALT"
|
||||||
|
|
||||||
|
# -- 5. Uebersetzen -------------------------------------------------------- #
|
||||||
|
step "Uebersetzen"
|
||||||
|
"$ADMIN" convert-legacy "$EXPORT_ALT" --ausgabe "$IMPORT_NEU" \
|
||||||
|
--passphrase "$PASSPHRASE" --neue-passphrase "$PASSPHRASE" --force
|
||||||
|
chmod 600 "$IMPORT_NEU"
|
||||||
|
|
||||||
|
# -- 6. Einspielen --------------------------------------------------------- #
|
||||||
|
step "Einspielen"
|
||||||
|
"$ADMIN" legacy-import "$IMPORT_NEU" --passphrase "$PASSPHRASE" --actor upgrade
|
||||||
|
else
|
||||||
|
# -- 4.-6. in einem: die Uebernahme liest die alte Datenbank unmittelbar --- #
|
||||||
|
step "Lizenzbestand uebernehmen"
|
||||||
|
# Der alte Signaturschluessel wird uebernommen. Fuer ein Upgrade ist das
|
||||||
|
# die einzige sinnvolle Vorgabe: es ist derselbe Server mit neuerer Software,
|
||||||
|
# und ohne den alten Schluessel muesste jeder Kunde den Ausstellerwechsel
|
||||||
|
# bestaetigen. Ueberschrieben wird dabei nichts -- die Uebernahme lehnt ab,
|
||||||
|
# wenn die neue Installation schon einen eigenen Schluessel hat.
|
||||||
|
MIGRATE_ARGS=(migrate-legacy --source "$ALT_KOPIE")
|
||||||
|
[[ $ADOPT_KEY -eq 1 ]] && MIGRATE_ARGS+=(--adopt-key)
|
||||||
|
"$ADMIN" "${MIGRATE_ARGS[@]}"
|
||||||
|
if [[ $ADOPT_KEY -eq 0 ]]; then
|
||||||
|
warn "--new-key: es gilt ein neuer Signaturschluessel. Die Kunden muessen den"
|
||||||
|
warn "Ausstellerwechsel einmal bestaetigen."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# -- Abschluss --------------------------------------------------------------- #
|
||||||
|
UPGRADE_FERTIG=1
|
||||||
|
step "Fertig"
|
||||||
|
info "Sicherung der alten Installation : $ALT_KOPIE"
|
||||||
|
if [[ "$APP" == "tesm" ]]; then
|
||||||
|
info "Export im alten Format : $EXPORT_ALT"
|
||||||
|
info "Uebersetzte Datei : $IMPORT_NEU"
|
||||||
|
[[ -f "$BACKUP/passphrase.txt" ]] && info "Passphrase beider Dateien : $BACKUP/passphrase.txt"
|
||||||
|
fi
|
||||||
|
cat <<TEXT
|
||||||
|
|
||||||
|
Von Hand nachzuziehen -- das laesst sich nicht uebersetzen:
|
||||||
|
* Lokale Konten: der alte Hash ist bcrypt, hier gilt Argon2id. Ein Hash
|
||||||
|
ist nicht umrechenbar; das ist sein Zweck. Erstes Konto anlegen mit
|
||||||
|
sudo -u $APP $ADMIN create-admin
|
||||||
|
* Gruppen und Rechte: die Rechteschluessel wurden neu geschnitten.
|
||||||
|
* Verzeichnisdienst (LDAP), Webserver und Zertifikat.
|
||||||
|
TEXT
|
||||||
|
|
||||||
|
# Der Rueckweg sieht in den beiden Faellen verschieden aus. Ein Hinweis, der
|
||||||
|
# das alte Verzeichnis wiederherstellen laesst, obwohl es nie angefasst wurde,
|
||||||
|
# waere schlimmer als keiner: er wuerde die Daten der neuen Installation mit
|
||||||
|
# einem alten Stand ueberschreiben.
|
||||||
|
if [[ $IN_PLACE -eq 1 ]]; then
|
||||||
|
cat <<TEXT
|
||||||
|
|
||||||
|
Zurueck auf den alten Stand -- das Verzeichnis wurde ersetzt, die Kopie
|
||||||
|
bringt es zurueck:
|
||||||
|
sudo systemctl disable --now $NAME
|
||||||
|
sudo rm -rf $SOURCE && sudo mkdir -p $SOURCE
|
||||||
|
sudo tar -C $ALT_KOPIE -cf - . | sudo tar -C $SOURCE -xf -
|
||||||
|
# venv der alten Installation neu bauen:
|
||||||
|
sudo python3 -m venv $SOURCE/venv
|
||||||
|
sudo $SOURCE/venv/bin/python -m pip install -r $SOURCE/requirements.txt
|
||||||
|
sudo systemctl enable --now ${ALTE_UNITS[0]}
|
||||||
|
|
||||||
|
TEXT
|
||||||
|
else
|
||||||
|
cat <<TEXT
|
||||||
|
|
||||||
|
Zurueck auf den alten Stand -- $SOURCE wurde nicht angefasst, es genuegt,
|
||||||
|
die neue Instanz abzuschalten:
|
||||||
|
sudo systemctl disable --now $NAME $NAME-monitor
|
||||||
|
sudo rm -f /etc/nginx/sites-enabled/$NAME && sudo systemctl reload nginx
|
||||||
|
sudo systemctl enable --now ${ALTE_UNITS[0]}
|
||||||
|
|
||||||
|
TEXT
|
||||||
|
fi
|
||||||
+196
-6
@@ -105,19 +105,34 @@ Eine bereits eingerichtete HTTPS-Konfiguration bleibt erhalten, auch ohne
|
|||||||
|
|
||||||
### Ohne Quellbaum: `update.sh`
|
### Ohne Quellbaum: `update.sh`
|
||||||
|
|
||||||
Liegt kein entpacktes Release vor, holt `update.sh` es selbst -- es lädt das
|
`update.sh` genügt als **einzelne Datei**. Auf einem System, auf dem noch nichts
|
||||||
Release-Asset, entpackt es und ruft dessen `install.sh` auf:
|
von diesem Projekt liegt:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo bash /opt/tesm/deploy/update.sh tesm
|
curl -fsSLO https://gitea.int.eertmoed.net/alientim/tesm/raw/branch/main/deploy/update.sh
|
||||||
sudo bash /opt/tesm-license/deploy/update.sh tesm-license
|
sudo bash update.sh tesm
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Auf einer bestehenden Installation liegt sie schon bereit:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo bash /srv/tesm/deploy/update.sh tesm
|
||||||
|
sudo bash /srv/tesm-license/deploy/update.sh tesm-license
|
||||||
|
```
|
||||||
|
|
||||||
|
**Läuft die Fassung schon, passiert nichts.** `update.sh` vergleicht den
|
||||||
|
aufgelösten Tag mit dem, was installiert ist, und endet mit `0`, bevor es eine
|
||||||
|
Rückfrage stellt; `install.sh` prüft es noch einmal genauer (Anwendung **und**
|
||||||
|
Kern) und bricht vor dem ersten Eingriff ab. Ein zweiter Lauf auf dieselbe
|
||||||
|
Fassung baut sonst die Python-Umgebung neu, kopiert dieselben Dateien und
|
||||||
|
startet den Dienst neu -- ein paar Sekunden Ausfall ohne jede Wirkung. Wer das
|
||||||
|
trotzdem will (eine beschädigte Installation etwa), hängt `--force` an.
|
||||||
|
|
||||||
Alles nach dem Tag geht unverändert an `install.sh` weiter -- das ist der Weg,
|
Alles nach dem Tag geht unverändert an `install.sh` weiter -- das ist der Weg,
|
||||||
eine **benannte Instanz** zu aktualisieren:
|
eine **benannte Instanz** zu aktualisieren:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo bash /opt/tesm-opus/deploy/update.sh tesm latest --instance opus
|
sudo bash /srv/tesm/deploy/update.sh tesm
|
||||||
```
|
```
|
||||||
|
|
||||||
Ohne `--instance` entstünde daneben eine zweite Installation namens `tesm`, mit
|
Ohne `--instance` entstünde daneben eine zweite Installation namens `tesm`, mit
|
||||||
@@ -141,6 +156,104 @@ etwa auf eine Spiegelung:
|
|||||||
|
|
||||||
Der erwartete Anhang heisst `<repo>-<tag>.tar.gz`.
|
Der erwartete Anhang heisst `<repo>-<tag>.tar.gz`.
|
||||||
|
|
||||||
|
### Upgrade vom Stand 1.x (Branch `SONNET5`)
|
||||||
|
|
||||||
|
Ein eigenes Skript, weil dabei mehr passiert als ein Dateiabgleich: die alte
|
||||||
|
Fassung hat ein anderes Schema, andere Rechteschlüssel und ein anderes
|
||||||
|
Exportformat.
|
||||||
|
|
||||||
|
Auch `upgrade.sh` genügt als **einzelne Datei** -- und das ist hier der
|
||||||
|
Regelfall: auf einer 1.2.8 liegt nichts von dieser Fassung.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSLO https://gitea.int.eertmoed.net/alientim/tesm/raw/branch/main/deploy/upgrade.sh
|
||||||
|
sudo bash upgrade.sh --app tesm
|
||||||
|
```
|
||||||
|
|
||||||
|
Sie lädt das aktuelle Release, entpackt es und benutzt dessen `install.sh`.
|
||||||
|
Liegt das Skript in einem entpackten Release, wird dieses benutzt statt eines
|
||||||
|
Downloads -- wer ein Paket ausgepackt hat, will nicht, dass ungefragt ein
|
||||||
|
anderes geladen wird:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo bash deploy/upgrade.sh --app tesm
|
||||||
|
sudo bash deploy/upgrade.sh --app tesm-license
|
||||||
|
```
|
||||||
|
|
||||||
|
Eine bestimmte Version statt `latest`: `--tag v2.0.8`.
|
||||||
|
|
||||||
|
**Wenn etwas schiefgeht.** Schlägt `install.sh` fehl, dreht `upgrade.sh` den
|
||||||
|
Lauf selbst zurück: der neue Dienst wird gestoppt, das Verzeichnis aus der
|
||||||
|
Sicherung wiederhergestellt und der alte Dienst wieder eingeschaltet. Das
|
||||||
|
Verzeichnis allein genügt dafür nicht -- bei einem Upgrade in dasselbe
|
||||||
|
Verzeichnis schreibt `install.sh` auch systemd-Unit, nginx-Site, logrotate und
|
||||||
|
Banner neu und ersetzt das venv. Die Sicherung enthält deshalb alles davon,
|
||||||
|
und ein Manifest hält fest, woher der Stand kam.
|
||||||
|
|
||||||
|
Später von Hand zurückdrehen, etwa nach einem Lauf, der durchlief aber nicht
|
||||||
|
gefallen hat:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo bash upgrade.sh --app tesm --rollback # neueste Sicherung
|
||||||
|
sudo bash upgrade.sh --app tesm --rollback /srv/tesm-upgrade-20260908-225428
|
||||||
|
```
|
||||||
|
|
||||||
|
Die Sicherung bleibt danach liegen; gelöscht wird nichts.
|
||||||
|
|
||||||
|
Ohne `--source` wird die alte Fassung an zwei Stellen gesucht: unter
|
||||||
|
`/srv/<app>` und unter `/srv/<app>-sonnet5`. Der zweite Ort ist der, an den
|
||||||
|
`rename.sh` sie verschiebt, sobald der Hauptname für die neue Installation
|
||||||
|
gebraucht wird -- danach liegt unter `/srv/<app>` die **laufende** Installation.
|
||||||
|
Erkannt wird eine 1.x an der `sqlite.db` im Wurzelverzeichnis; die neue Fassung
|
||||||
|
legt ihre Datenbank unter `data/app.db` ab.
|
||||||
|
|
||||||
|
Der Ablauf ist die Vorgabe und braucht keine Schalter:
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| 1. Sichern | Vollständige Kopie der alten Installation nach `/srv/<name>-upgrade-<zeitstempel>/alt` (ohne `venv`, Verzeichnis 0700) |
|
||||||
|
| 2. Abschalten | Alter Dienst **und seine Timer** werden gestoppt und deaktiviert |
|
||||||
|
| 3. Installieren | `install.sh` dieses Verzeichnisses |
|
||||||
|
| 4. Exportieren | Aus der Kopie entsteht `export-alt.json` im **alten** Format |
|
||||||
|
| 5. Übersetzen | Daraus `import-neu.json` im neuen Format |
|
||||||
|
| 6. Einspielen | Diese Datei geht durch denselben Import wie eine Sicherung |
|
||||||
|
|
||||||
|
Warum die Sicherung **vor** dem Export steht: ohne `--instance` installiert
|
||||||
|
`install.sh` in dasselbe Verzeichnis und ersetzt die alte Installation. Wer
|
||||||
|
erst exportiert und dann sichert, sichert im schlechtesten Fall nichts mehr.
|
||||||
|
Gelesen wird ausschliesslich aus der Kopie -- die alte Installation wird nie
|
||||||
|
verändert.
|
||||||
|
|
||||||
|
Die beiden Zwischendateien bleiben liegen; ihre Passphrase steht daneben in
|
||||||
|
`passphrase.txt` (0600, Verzeichnis 0700). `export-alt.json` ist ein echter
|
||||||
|
Export des Vorgängers und taugt deshalb auch als Rückweg.
|
||||||
|
|
||||||
|
**Daneben statt darüber.** Mit `--instance` entsteht eine zweite Installation,
|
||||||
|
und die alte bleibt vollständig liegen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo bash deploy/upgrade.sh --app tesm --instance test --keep-old-running --port 5100 --http-port 8080
|
||||||
|
```
|
||||||
|
|
||||||
|
**Was nicht mitkommt** -- und warum:
|
||||||
|
|
||||||
|
* **Lokale Konten.** Der alte Hash ist bcrypt, hier gilt Argon2id. Ein Hash
|
||||||
|
ist nicht umrechenbar; das ist sein Zweck. Erstes Konto mit
|
||||||
|
`tesm-admin create-admin`.
|
||||||
|
* **Gruppen und Rechte.** Die Rechteschlüssel wurden neu geschnitten.
|
||||||
|
* **LDAP, Webserver, Zertifikat.** Von Hand nachziehen.
|
||||||
|
|
||||||
|
Beim **Lizenzserver** gibt es Schritt 4 und 5 nicht: die Übernahme liest die
|
||||||
|
alte Datenbank unmittelbar, holt Endpunkt und Anbieterdaten aus ihren
|
||||||
|
Einstellungen und **signiert jede Lizenz neu**. Der alte Signaturschlüssel wird
|
||||||
|
dabei übernommen -- sonst müsste jeder Kunde einen Ausstellerwechsel
|
||||||
|
bestätigen; `--new-key` erzwingt das Gegenteil. Der Bericht nennt namentlich,
|
||||||
|
wer eine neue Lizenzdatei braucht, denn die Bindung an eine Maschine gilt nur
|
||||||
|
für die alte Datei.
|
||||||
|
|
||||||
|
Nur die Anwendung, ohne Daten: `--no-data`. Nur zeigen, was geschähe:
|
||||||
|
`--dry-run`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. HTTPS
|
## 4. HTTPS
|
||||||
@@ -193,6 +306,83 @@ würde). Der ACME-Pfad bleibt auch bei aktiver Umleitung erreichbar.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 4a. Anmeldebanner (MOTD)
|
||||||
|
|
||||||
|
`install.sh` legt `/etc/update-motd.d/50-<name>` an. Bei jeder SSH-Anmeldung
|
||||||
|
steht dann der Zustand der Installation im Login-Banner:
|
||||||
|
|
||||||
|
```
|
||||||
|
TESM · TimEShepManager
|
||||||
|
UEBERWACHEN · BOOTEN · ANBINDEN (DHCP) · BETRIEBSSYSTEM GEBEN (PXE)
|
||||||
|
|
||||||
|
3 Clients online · 0 nicht erreichbar · DHCP: 2 Leases aktiv
|
||||||
|
```
|
||||||
|
|
||||||
|
Das Skript liegt unter `/usr/local/lib/tesm/tesm-motd` und benutzt **nur die
|
||||||
|
Standardbibliothek**: kein venv, kein Import der Anwendung, die Datenbank wird
|
||||||
|
nur lesend geöffnet. Ein Lauf dauert Millisekunden. Der Vorgänger importierte
|
||||||
|
dafür die ganze Flask-Anwendung -- bei jeder Anmeldung, und nach einem Wechsel
|
||||||
|
des Quellverzeichnisses zeigte er stillschweigend die Zahlen einer Datenbank,
|
||||||
|
die niemand mehr füllte.
|
||||||
|
|
||||||
|
Von Hand prüfen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo /usr/local/lib/tesm/tesm-motd --app tesm --root /srv/tesm-test
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Aufruf im Banner ist mit `timeout 3` und `|| true` abgesichert: ein
|
||||||
|
hängender oder fehlschlagender Bannerlauf darf eine Anmeldung nie blockieren.
|
||||||
|
Ein Banner der Vorgängerfassung (`50-tesm`, das `motd_banner.py` aufruft) wird
|
||||||
|
entfernt -- nur dieses, nichts anderes in dem Verzeichnis.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4b. Installation umbenennen
|
||||||
|
|
||||||
|
Aus einer Instanz die Hauptinstallation machen (oder umgekehrt):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo bash deploy/rename.sh --app tesm --from tesm-test --to tesm
|
||||||
|
sudo bash deploy/rename.sh --app tesm-license --from tesm-license-test --to tesm-license
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Name steckt an mehr Stellen als im Verzeichnisnamen, und drei davon sind
|
||||||
|
leicht zu übersehen:
|
||||||
|
|
||||||
|
* **`SITE_KEY`** in der Umgebungsdatei -- daraus entsteht der Name des
|
||||||
|
Sitzungscookies. Bleibt er alt, heisst das Cookie weiter wie die alte
|
||||||
|
Instanz.
|
||||||
|
* **`web_cert_path`, `web_key_path`, `web_acme_webroot`** in der Datenbank. Sie
|
||||||
|
enthalten den Namen; ein Zertifikat unter dem alten Pfad findet nginx nach
|
||||||
|
dem Umzug nicht mehr.
|
||||||
|
* **`server_name`** der nginx-Site. `install.sh` schreibt die Datei aus seiner
|
||||||
|
Vorlage neu, sobald sich die `alias`-Pfade ändern -- und die ändern sich hier
|
||||||
|
zwangsläufig. Ohne den Namen aus der Datenbank stünden danach zwei Sites mit
|
||||||
|
`server_name _` auf Port 80, und die erste gewinnt.
|
||||||
|
|
||||||
|
Ausserdem wird das **venv verworfen und neu gebaut**: seine Konsolenskripte
|
||||||
|
tragen den absoluten Pfad im Shebang, und nach dem Umzug zeigt
|
||||||
|
`venv/bin/gunicorn` auf einen Interpreter, den es nicht mehr gibt. systemd
|
||||||
|
meldet dann `No such file or directory` -- für eine Datei, die sichtbar da ist.
|
||||||
|
|
||||||
|
Belegt der Zielname noch eine Installation der Fassung 1.x, wandert **alles**,
|
||||||
|
was ihn belegt, in ein Archiv `/srv/<ziel>-sonnet5`: das Verzeichnis, die
|
||||||
|
Protokolle unter `/var/log/<ziel>`, der ACME-Pfad, die logrotate-Datei, und die
|
||||||
|
systemd-Units samt nginx-Site unter `systemd-alt/`. Gelöscht wird nichts.
|
||||||
|
|
||||||
|
Ports und HTTPS bleiben, wie sie waren; ein vorhandenes Zertifikat zieht mit
|
||||||
|
und wird weiterverwendet. **Alle angemeldeten Benutzer werden abgemeldet** --
|
||||||
|
das Sitzungscookie heisst danach anders.
|
||||||
|
|
||||||
|
Danach ist auch das Aktualisieren einfacher, weil der Instanzname wegfällt:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo bash /srv/tesm/deploy/update.sh tesm
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 5. Dienste
|
## 5. Dienste
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -264,7 +454,7 @@ Unverzichtbar:
|
|||||||
**Die Anwendung antwortet nicht.**
|
**Die Anwendung antwortet nicht.**
|
||||||
```bash
|
```bash
|
||||||
journalctl -u tesm -n 200 --no-pager
|
journalctl -u tesm -n 200 --no-pager
|
||||||
curl -s http://127.0.0.1:5000/gesundheit
|
curl -s http://127.0.0.1:5000/health
|
||||||
```
|
```
|
||||||
Antwortet gunicorn, aber der Browser nicht, liegt es an nginx.
|
Antwortet gunicorn, aber der Browser nicht, liegt es an nginx.
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ Was geschützt wird, womit, und wo bewusst Grenzen gezogen sind.
|
|||||||
| Speicherung | Argon2id (argon2-cffi) |
|
| Speicherung | Argon2id (argon2-cffi) |
|
||||||
| Altbestand | bcrypt wird noch geprüft und bei der nächsten erfolgreichen Anmeldung stillschweigend auf Argon2id umgestellt |
|
| Altbestand | bcrypt wird noch geprüft und bei der nächsten erfolgreichen Anmeldung stillschweigend auf Argon2id umgestellt |
|
||||||
| Zweiter Faktor | TOTP, optional erzwingbar für Administratoren |
|
| Zweiter Faktor | TOTP, optional erzwingbar für Administratoren |
|
||||||
|
| Rechte | Eine Zeile je Funktion; das Administratorkennzeichen gibt den Verwaltungsbereich, nicht alles |
|
||||||
| Sperre je Konto | nach `LOGIN_MAX_ATTEMPTS` Versuchen |
|
| Sperre je Konto | nach `LOGIN_MAX_ATTEMPTS` Versuchen |
|
||||||
| Sperre je Adresse | zusätzlich `LOGIN_MAX_ATTEMPTS_PER_IP` -- sonst lässt sich die Kontosperre umgehen, indem man viele Konten je einmal probiert |
|
| Sperre je Adresse | zusätzlich `LOGIN_MAX_ATTEMPTS_PER_IP` -- sonst lässt sich die Kontosperre umgehen, indem man viele Konten je einmal probiert |
|
||||||
| Verzeichnisdienst | LDAP nur über TLS, Zertifikat wird geprüft. Ohne gültiges Zertifikat keine Anmeldung -- kein Schalter, der das abschaltet |
|
| Verzeichnisdienst | LDAP nur über TLS, Zertifikat wird geprüft. Ohne gültiges Zertifikat keine Anmeldung -- kein Schalter, der das abschaltet |
|
||||||
@@ -171,6 +172,10 @@ braucht der Dienst kein `--workers 1` mehr.
|
|||||||
* **Kein CSRF-Freibrief für cookie-autorisierte Routen.**
|
* **Kein CSRF-Freibrief für cookie-autorisierte Routen.**
|
||||||
* **Kein `unsafe-inline` für Skripte**, auch nicht "vorübergehend".
|
* **Kein `unsafe-inline` für Skripte**, auch nicht "vorübergehend".
|
||||||
* **Keine Rechteausweitung**, auch nicht für Administratoren untereinander.
|
* **Keine Rechteausweitung**, auch nicht für Administratoren untereinander.
|
||||||
|
Ein echter Administrator darf sich Funktionsrechte über eine Gruppe geben --
|
||||||
|
das ist gewollt und hinterlässt eine Zeile im Änderungsprotokoll. Was es
|
||||||
|
nicht gibt, ist der stille Weg: ein Kennzeichen, das ohne Eintrag alles
|
||||||
|
aufschließt.
|
||||||
* **Kein Hostname im Lizenz-Fingerabdruck** -- Umbenennen darf die Bindung
|
* **Kein Hostname im Lizenz-Fingerabdruck** -- Umbenennen darf die Bindung
|
||||||
nicht brechen.
|
nicht brechen.
|
||||||
* **Keine von Hand gepflegte nginx-Site.** Sie wird immer neu erzeugt.
|
* **Keine von Hand gepflegte nginx-Site.** Sie wird immer neu erzeugt.
|
||||||
|
|||||||
+181
@@ -0,0 +1,181 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# =============================================================================
|
||||||
|
# Installation -- der kurze Weg.
|
||||||
|
#
|
||||||
|
# Diese Datei liegt oben im Paket und ruft den Installer unter deploy/ auf:
|
||||||
|
#
|
||||||
|
# tar xzf tesm-vX.Y.Z.tar.gz && cd tesm
|
||||||
|
# sudo bash install.sh --app tesm --https
|
||||||
|
#
|
||||||
|
# Sie genuegt aber auch **allein**: liegt kein Paket um sie herum, holt sie
|
||||||
|
# sich das aktuelle Release selbst.
|
||||||
|
#
|
||||||
|
# curl -fsSLO https://gitea.int.eertmoed.net/alientim/tesm/raw/branch/main/install.sh
|
||||||
|
# sudo bash install.sh --app tesm --https
|
||||||
|
#
|
||||||
|
# Die Master-Ausgabe des Lizenzservers liegt im privaten Repositorium
|
||||||
|
# tesm-license-master. Allein-Betrieb dafuer:
|
||||||
|
#
|
||||||
|
# export TESM_RELEASE_REPO=tesm-license-master
|
||||||
|
# export TESM_RELEASE_TOKEN=<Gitea-Token>
|
||||||
|
# sudo -E bash install.sh --app tesm-license --https
|
||||||
|
#
|
||||||
|
# Aus einem entpackten Paket braucht es das nicht: dort entscheidet, welches
|
||||||
|
# Paket es ist. Die Master-Ausgabe legt beim Installieren Signaturschluessel
|
||||||
|
# und eigene Master-Lizenz selbst an.
|
||||||
|
#
|
||||||
|
# Alle Argumente gehen unveraendert an deploy/install.sh weiter; dessen Hilfe:
|
||||||
|
#
|
||||||
|
# bash install.sh --help
|
||||||
|
# =============================================================================
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
|
||||||
|
# --app wird nur gelesen, nicht verbraucht: der Bootstrap braucht es fuer den
|
||||||
|
# Namen des Repositoriums, der Installer bekommt die Argumente unveraendert.
|
||||||
|
APP=""
|
||||||
|
TAG="latest"
|
||||||
|
ARGUMENTE=("$@")
|
||||||
|
for ((i = 0; i < $#; i++)); do
|
||||||
|
case "${ARGUMENTE[i]}" in
|
||||||
|
--app) APP="${ARGUMENTE[i + 1]:-}" ;;
|
||||||
|
--tag) TAG="${ARGUMENTE[i + 1]:-latest}" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# Ohne Paket drumherum brauchen wir den Namen, um das richtige Release zu
|
||||||
|
# finden -- die beiden Anwendungen liegen in getrennten Repositorien.
|
||||||
|
if [[ ! -f "$SCRIPT_DIR/deploy/install.sh" ]]; then
|
||||||
|
[[ "$APP" == "tesm" || "$APP" == "tesm-license" ]] || {
|
||||||
|
echo "Diese Datei liegt allein hier und muss sich das Release holen --" >&2
|
||||||
|
echo "dafuer braucht sie --app <tesm|tesm-license>." >&2
|
||||||
|
echo "Verwendung: install.sh --app <tesm|tesm-license> [weitere Optionen]" >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --8<-- Bootstrap: Release holen --------------------------------------------
|
||||||
|
# Wortgleich in update.sh und upgrade.sh; ein Hygienetest vergleicht die beiden
|
||||||
|
# Bloecke. Zwei eigenstaendige Skripte koennen sich keine Datei teilen, und
|
||||||
|
# beide sollen als **einzelne Datei** genuegen: auf das Zielsystem legen,
|
||||||
|
# ausfuehren, fertig -- auf einer 1.2.8 liegt nichts von diesem Projekt.
|
||||||
|
#
|
||||||
|
# Erwartet: $APP und $TAG, wahlweise $REPO (Vorgabe: $APP). Setzt: $TAG
|
||||||
|
# (aufgeloest) und $SRC (entpacktes Release). Raeumt sein Arbeitsverzeichnis
|
||||||
|
# beim Beenden auf.
|
||||||
|
#
|
||||||
|
# $REPO gibt es, weil Installationsname und Repositorium auseinanderfallen
|
||||||
|
# koennen: die Master-Ausgabe des Lizenzservers liegt in
|
||||||
|
# "tesm-license-master", die Installation heisst weiter "tesm-license".
|
||||||
|
#
|
||||||
|
# Zwei Fallen des Vorgaengers sind hier vermieden:
|
||||||
|
# * Der Tag "latest" wird ueber die API aufgeloest, nie direkt in die
|
||||||
|
# Download-URL geschrieben. Gitea liefert sonst mit HTTP 200 ein
|
||||||
|
# automatisch erzeugtes Quell-Snapshot statt des echten Release-Anhangs.
|
||||||
|
# * Nach dem Entpacken wird geprueft, dass ein install.sh existiert.
|
||||||
|
BASE="${TESM_RELEASE_BASE:-https://gitea.int.eertmoed.net}"
|
||||||
|
OWNER="${TESM_RELEASE_OWNER:-alientim}"
|
||||||
|
|
||||||
|
# Ein privates Repositorium -- tesm-license-master -- gibt sein Release nur mit
|
||||||
|
# Zugriffstoken heraus. Reihenfolge: Umgebungsvariable, dann eine Datei, die
|
||||||
|
# der Betreiber selbst anlegt. Der Installer legt sie nie an: ob ein Token auf
|
||||||
|
# dem Server liegt, ist keine Entscheidung eines Skripts.
|
||||||
|
TOKEN="${TESM_RELEASE_TOKEN:-}"
|
||||||
|
if [[ -z "$TOKEN" && -r /etc/tesm-release.token ]]; then
|
||||||
|
TOKEN="$(tr -d '[:space:]' < /etc/tesm-release.token)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Ein Wagenruecklauf im Paket macht jedes Shell-Skript auf dem Ziel unbrauchbar
|
||||||
|
# ("bad interpreter"). Die Pruefung darf dabei nicht selbst von einem
|
||||||
|
# Zusatzpaket abhaengen: mit "file" -- das auf einem schlanken Server fehlt --
|
||||||
|
# endete sie in "command not found", grep bekam nichts zu sehen, und das Skript
|
||||||
|
# machte weiter, als waere alles in Ordnung.
|
||||||
|
hat_crlf() {
|
||||||
|
local cr
|
||||||
|
cr="$(printf '\r')"
|
||||||
|
# -U (binaer lesen) ist unter Linux ohne Wirkung, auf einer
|
||||||
|
# Windows-Buildmaschine aber entscheidend: dort entfernt grep den
|
||||||
|
# Wagenruecklauf beim Lesen und findet deshalb nie einen.
|
||||||
|
LC_ALL=C grep -qU "$cr" "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
BOOTSTRAP_WORK=""
|
||||||
|
bootstrap_aufraeumen() {
|
||||||
|
# Den Ausstiegscode zuerst festhalten: alles danach ueberschreibt ihn.
|
||||||
|
local code=$?
|
||||||
|
[[ -n "$BOOTSTRAP_WORK" ]] && rm -rf "$BOOTSTRAP_WORK"
|
||||||
|
# Wer einen eigenen Abschluss braucht -- etwa einen Rueckweg nach einem
|
||||||
|
# Fehlschlag --, definiert "beim_beenden". Ein zweiter "trap ... EXIT" waere
|
||||||
|
# der falsche Weg: er wuerde diesen hier ersetzen, und das entpackte Release
|
||||||
|
# bliebe unter /tmp liegen.
|
||||||
|
declare -F beim_beenden >/dev/null 2>&1 && beim_beenden "$code"
|
||||||
|
return "$code"
|
||||||
|
}
|
||||||
|
trap bootstrap_aufraeumen EXIT
|
||||||
|
|
||||||
|
release_holen() {
|
||||||
|
local repo="${TESM_RELEASE_REPO:-${REPO:-$APP}}" asset
|
||||||
|
# Leeres Array und "set -u" vertragen sich nur mit dieser Schreibweise.
|
||||||
|
local -a kopf=()
|
||||||
|
[[ -n "$TOKEN" ]] && kopf=(-H "Authorization: token $TOKEN")
|
||||||
|
for werkzeug in curl tar python3 find; do
|
||||||
|
command -v "$werkzeug" >/dev/null 2>&1 || {
|
||||||
|
echo "Auf diesem System fehlt $werkzeug -- ohne das geht es nicht." >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
done
|
||||||
|
BOOTSTRAP_WORK="$(mktemp -d /tmp/tesm-release-XXXXXX)"
|
||||||
|
|
||||||
|
if [[ "$TAG" == "latest" ]]; then
|
||||||
|
echo "==> Ermittle aktuellen Release-Tag ..."
|
||||||
|
TAG="$(curl -fsS ${kopf[0]+"${kopf[@]}"} "$BASE/api/v1/repos/$OWNER/$repo/releases/latest" \
|
||||||
|
| python3 -c 'import json,sys; print(json.load(sys.stdin)["tag_name"])')" || {
|
||||||
|
echo "Kein Release gefunden unter $BASE/$OWNER/$repo." >&2
|
||||||
|
[[ -z "$TOKEN" ]] &&
|
||||||
|
echo "Ist das Repositorium privat? Dann TESM_RELEASE_TOKEN setzen." >&2
|
||||||
|
echo "Andere Quelle: TESM_RELEASE_BASE, TESM_RELEASE_OWNER, TESM_RELEASE_REPO." >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
echo " $TAG"
|
||||||
|
fi
|
||||||
|
|
||||||
|
asset="$repo-$TAG.tar.gz"
|
||||||
|
echo "==> Lade $asset ..."
|
||||||
|
curl -fsSL ${kopf[0]+"${kopf[@]}"} -o "$BOOTSTRAP_WORK/$asset" \
|
||||||
|
"$BASE/$OWNER/$repo/releases/download/$TAG/$asset" || {
|
||||||
|
echo "Download fehlgeschlagen: $BASE/$OWNER/$repo/releases/download/$TAG/$asset" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "==> Entpacke ..."
|
||||||
|
tar -xzf "$BOOTSTRAP_WORK/$asset" -C "$BOOTSTRAP_WORK"
|
||||||
|
# Drei Ebenen, nicht zwei: release.sh packt mit dem Praefix "<name>/", also
|
||||||
|
# liegt das Skript unter <name>/deploy/install.sh. Mit "-maxdepth 2" fand die
|
||||||
|
# Suche nie etwas -- und die Fehlermeldung schob es auf den Download.
|
||||||
|
SRC="$(find "$BOOTSTRAP_WORK" -maxdepth 3 -name install.sh -path '*/deploy/*' \
|
||||||
|
-printf '%h/..\n' | head -1)"
|
||||||
|
[[ -n "$SRC" ]] || {
|
||||||
|
echo "Im Paket fehlt deploy/install.sh -- Download unbrauchbar." >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
SRC="$(cd "$SRC" && pwd)"
|
||||||
|
|
||||||
|
echo "==> Pruefe auf CRLF (Windows-Buildmaschinen) ..."
|
||||||
|
if hat_crlf "$SRC/deploy/install.sh"; then
|
||||||
|
echo "Das Paket enthaelt CRLF-Zeilenenden. Beim Paketieren fehlte" >&2
|
||||||
|
echo "'git -c core.autocrlf=false archive'." >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
# --8<-- Ende Bootstrap ------------------------------------------------------
|
||||||
|
|
||||||
|
if [[ -f "$SCRIPT_DIR/deploy/install.sh" ]]; then
|
||||||
|
# Im Paket: der Installer liegt daneben.
|
||||||
|
exec bash "$SCRIPT_DIR/deploy/install.sh" ${ARGUMENTE[0]+"${ARGUMENTE[@]}"}
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Allein: Release holen und dessen Installer benutzen. Kein exec -- sonst
|
||||||
|
# laeuft die Aufraeumfalle nie und das entpackte Release bliebe unter /tmp.
|
||||||
|
release_holen || { echo "Das Release liess sich nicht holen." >&2; exit 1; }
|
||||||
|
bash "$SRC/deploy/install.sh" ${ARGUMENTE[0]+"${ARGUMENTE[@]}"}
|
||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "tesm-core"
|
name = "tesm-core"
|
||||||
version = "2.0.2"
|
version = "2.8.9"
|
||||||
description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver"
|
description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -8,7 +8,20 @@ Design-System.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
__version__ = "2.0.2"
|
#: Version aus den Metadaten des installierten Pakets -- die einzige Quelle
|
||||||
|
#: ist ``pyproject.toml``. Vorher stand hier eine feste Zahl, und die
|
||||||
|
#: Fusszeile zeigte vier Releases lang 2.0.2.
|
||||||
|
#:
|
||||||
|
#: Im Quellbaum ohne ``pip install`` gibt es keine Metadaten; dann steht dort
|
||||||
|
#: "unbekannt" statt einer erfundenen Nummer. In einem editierbaren Install
|
||||||
|
#: zeigt sie den Stand des letzten ``pip install -e`` -- also die Version, die
|
||||||
|
#: tatsaechlich installiert ist.
|
||||||
|
try: # pragma: no cover - haengt an der Installationsart
|
||||||
|
from importlib.metadata import PackageNotFoundError, version as _paketversion
|
||||||
|
|
||||||
|
__version__ = _paketversion("tesm-core")
|
||||||
|
except (ImportError, PackageNotFoundError): # pragma: no cover
|
||||||
|
__version__ = "unbekannt"
|
||||||
|
|
||||||
from .app_factory import create_base_app
|
from .app_factory import create_base_app
|
||||||
from .config import CoreConfig, SecurityConfig, load_core_config
|
from .config import CoreConfig, SecurityConfig, load_core_config
|
||||||
|
|||||||
@@ -13,10 +13,12 @@ import secrets
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any, Sequence
|
from typing import Any, Sequence
|
||||||
|
|
||||||
from flask import Blueprint, Flask, g, render_template, request
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
from flask import Blueprint, Flask, g, redirect, render_template, request
|
||||||
from werkzeug.exceptions import HTTPException
|
from werkzeug.exceptions import HTTPException
|
||||||
|
|
||||||
from . import audit, logging_setup, ui
|
from . import audit, legacy_urls, logging_setup, ui
|
||||||
from .config import CoreConfig
|
from .config import CoreConfig
|
||||||
from .core_schema import CORE_MIGRATIONS
|
from .core_schema import CORE_MIGRATIONS
|
||||||
from .db import Database
|
from .db import Database
|
||||||
@@ -31,6 +33,7 @@ from .security import csrf
|
|||||||
from .security import sessions as session_store
|
from .security import sessions as session_store
|
||||||
from .security.sessions import SqliteSessionInterface
|
from .security.sessions import SqliteSessionInterface
|
||||||
from .settings_store import SettingSpec, SettingsStore
|
from .settings_store import SettingSpec, SettingsStore
|
||||||
|
from . import uploads
|
||||||
from .sysops import SysOps
|
from .sysops import SysOps
|
||||||
from .auth import directory
|
from .auth import directory
|
||||||
from .auth import routes as auth_routes
|
from .auth import routes as auth_routes
|
||||||
@@ -56,18 +59,29 @@ CORE_SETTINGS: tuple[SettingSpec, ...] = (
|
|||||||
30,
|
30,
|
||||||
"int",
|
"int",
|
||||||
"Aufbewahrung im Papierkorb (Tage)",
|
"Aufbewahrung im Papierkorb (Tage)",
|
||||||
hint="Nach dieser Frist werden Eintraege beim naechsten Aufruf endgueltig entfernt.",
|
hint="Nach dieser Frist werden Einträge beim nächsten Aufruf endgültig entfernt.",
|
||||||
section="system",
|
section="system",
|
||||||
minimum=1,
|
minimum=1,
|
||||||
maximum=3650,
|
maximum=3650,
|
||||||
),
|
),
|
||||||
|
SettingSpec(
|
||||||
|
"session_absolute_hours",
|
||||||
|
12,
|
||||||
|
"int",
|
||||||
|
"Längstmögliche Sitzungsdauer (Stunden)",
|
||||||
|
hint="Danach ist Schluss, auch bei ständiger Arbeit – eine Sitzung soll nicht "
|
||||||
|
"ewig leben. Die eingebundenen Dateifreigaben enden mit ihr.",
|
||||||
|
section="system",
|
||||||
|
minimum=1,
|
||||||
|
maximum=168,
|
||||||
|
),
|
||||||
SettingSpec(
|
SettingSpec(
|
||||||
"session_idle_minutes",
|
"session_idle_minutes",
|
||||||
30,
|
30,
|
||||||
"int",
|
"int",
|
||||||
"Automatische Abmeldung nach Leerlauf (Minuten)",
|
"Automatische Abmeldung nach Leerlauf (Minuten)",
|
||||||
hint="Jede Anfrage setzt den Zaehler zurueck -- gezaehlt wird nur Untaetigkeit. "
|
hint="Jede Anfrage setzt den Zähler zurück – gezählt wird nur Untätigkeit. "
|
||||||
"Gilt auch fuer die eingebundenen Dateifreigaben: mit der Sitzung endet die "
|
"Gilt auch für die eingebundenen Dateifreigaben: mit der Sitzung endet die "
|
||||||
"Einbindung.",
|
"Einbindung.",
|
||||||
section="system",
|
section="system",
|
||||||
minimum=5,
|
minimum=5,
|
||||||
@@ -88,6 +102,14 @@ CORE_SETTINGS: tuple[SettingSpec, ...] = (
|
|||||||
),
|
),
|
||||||
# -- Webserver/TLS (Host-Ebene, in beiden Anwendungen gleich) ----------- #
|
# -- Webserver/TLS (Host-Ebene, in beiden Anwendungen gleich) ----------- #
|
||||||
# Lizenzserver: eigenes Zertifikat statt abgeschalteter Pruefung.
|
# Lizenzserver: eigenes Zertifikat statt abgeschalteter Pruefung.
|
||||||
|
SettingSpec(
|
||||||
|
"modules_disabled",
|
||||||
|
[],
|
||||||
|
"json",
|
||||||
|
"Vor Ort abgeschaltete Module",
|
||||||
|
hint="Die Lizenz sagt, was erlaubt ist; das hier sagt, was benutzt wird.",
|
||||||
|
section="system",
|
||||||
|
),
|
||||||
SettingSpec(
|
SettingSpec(
|
||||||
"license_server_ca",
|
"license_server_ca",
|
||||||
"",
|
"",
|
||||||
@@ -100,8 +122,8 @@ CORE_SETTINGS: tuple[SettingSpec, ...] = (
|
|||||||
SettingSpec("web_hsts_enabled", False, "bool", "HSTS senden", section="web"),
|
SettingSpec("web_hsts_enabled", False, "bool", "HSTS senden", section="web"),
|
||||||
SettingSpec("web_redirect_http", True, "bool", "HTTP auf HTTPS umleiten", section="web"),
|
SettingSpec("web_redirect_http", True, "bool", "HTTP auf HTTPS umleiten", section="web"),
|
||||||
SettingSpec("web_cert_path", "", "str", "Zertifikat (Pfad)", section="web"),
|
SettingSpec("web_cert_path", "", "str", "Zertifikat (Pfad)", section="web"),
|
||||||
SettingSpec("web_key_path", "", "str", "Privater Schluessel (Pfad)", section="web"),
|
SettingSpec("web_key_path", "", "str", "Privater Schlüssel (Pfad)", section="web"),
|
||||||
SettingSpec("web_acme_email", "", "str", "E-Mail fuer Let's Encrypt", section="web"),
|
SettingSpec("web_acme_email", "", "str", "E-Mail für Let's Encrypt", section="web"),
|
||||||
SettingSpec("web_acme_webroot", "", "str", "ACME-Webroot", section="web"),
|
SettingSpec("web_acme_webroot", "", "str", "ACME-Webroot", section="web"),
|
||||||
# Abweichende Ports braucht nur eine Testinstanz neben der Produktion --
|
# Abweichende Ports braucht nur eine Testinstanz neben der Produktion --
|
||||||
# im Regelbetrieb bleibt es bei 80 und 443.
|
# im Regelbetrieb bleibt es bei 80 und 443.
|
||||||
@@ -112,7 +134,7 @@ CORE_SETTINGS: tuple[SettingSpec, ...] = (
|
|||||||
"web_https_port", 443, "int", "HTTPS-Port", section="web", minimum=1, maximum=65535
|
"web_https_port", 443, "int", "HTTPS-Port", section="web", minimum=1, maximum=65535
|
||||||
),
|
),
|
||||||
SettingSpec(
|
SettingSpec(
|
||||||
"web_max_body_mb", 128, "int", "Maximale Anfragegroesse (MB)", section="web",
|
"web_max_body_mb", 128, "int", "Maximale Anfragegröße (MB)", section="web",
|
||||||
minimum=1, maximum=8192,
|
minimum=1, maximum=8192,
|
||||||
),
|
),
|
||||||
*directory.SETTINGS,
|
*directory.SETTINGS,
|
||||||
@@ -130,11 +152,15 @@ def create_base_app(
|
|||||||
import_name: str = "tesm_core",
|
import_name: str = "tesm_core",
|
||||||
app_short: str = "TESM",
|
app_short: str = "TESM",
|
||||||
app_edition: str = "",
|
app_edition: str = "",
|
||||||
#: Zeile unter der Wortmarke auf den Anmeldeseiten. Leer = kein
|
#: Zeile unter der Wortmarke auf den Anmeldeseiten. Leer = nur die
|
||||||
#: Seitenhintergrund. Der Lizenzserver setzt sie bewusst nicht: die
|
#: Wortmarke, ohne Zeile darunter; die Marke selbst zeigen beide
|
||||||
#: Funktionsliste von TESM waere dort schlicht falsch.
|
#: Anwendungen.
|
||||||
auth_tagline: str = "",
|
auth_tagline: str = "",
|
||||||
app_version: str = "2.0.2",
|
#: Beide Anwendungen geben ihre eigene Paketversion mit -- und die laeuft
|
||||||
|
#: absichtlich auseinander: was sich nicht geaendert hat, bekommt keine
|
||||||
|
#: neue Nummer. Eine feste Zahl an dieser Stelle wuerde irgendwann eine
|
||||||
|
#: falsche Version in die Fussnote schreiben.
|
||||||
|
app_version: str = "unbekannt",
|
||||||
built_in_trusted_keys: tuple[str, ...] = (),
|
built_in_trusted_keys: tuple[str, ...] = (),
|
||||||
enable_license: bool = True,
|
enable_license: bool = True,
|
||||||
template_folder: str | None = None,
|
template_folder: str | None = None,
|
||||||
@@ -146,6 +172,10 @@ def create_base_app(
|
|||||||
static_folder=static_folder or "static",
|
static_folder=static_folder or "static",
|
||||||
static_url_path="/assets",
|
static_url_path="/assets",
|
||||||
)
|
)
|
||||||
|
# Umlaute bleiben Umlaute -- auch in JSON. Flask wuerde sonst nach ASCII
|
||||||
|
# ausweichen ("ü"), und genau darin stehen die Flash-Meldungen in der
|
||||||
|
# Seite: eine Meldung mit "ue" war lesbar, eine mit "ü" wurde zu Zahlen.
|
||||||
|
app.json.ensure_ascii = False
|
||||||
|
|
||||||
# Templates und Design-System des Kerns liegen im Paket ``tesm_core`` und
|
# Templates und Design-System des Kerns liegen im Paket ``tesm_core`` und
|
||||||
# werden ueber einen eigenen Blueprint bereitgestellt. Dadurch kann jede
|
# werden ueber einen eigenen Blueprint bereitgestellt. Dadurch kann jede
|
||||||
@@ -163,6 +193,10 @@ def create_base_app(
|
|||||||
config.data_dir.mkdir(parents=True, exist_ok=True)
|
config.data_dir.mkdir(parents=True, exist_ok=True)
|
||||||
config.log_dir.mkdir(parents=True, exist_ok=True)
|
config.log_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Die Groessengrenze einer Anfrage kommt aus der Einstellung, nicht aus
|
||||||
|
# der Konfiguration beim Start.
|
||||||
|
app.request_class = uploads.UploadLimitRequest
|
||||||
|
|
||||||
app.config.update(
|
app.config.update(
|
||||||
SECRET_KEY=load_or_create_secret_key(config.secret_key_file),
|
SECRET_KEY=load_or_create_secret_key(config.secret_key_file),
|
||||||
# site_key statt app_key: Cookies unterscheiden keine Ports. Zwei
|
# site_key statt app_key: Cookies unterscheiden keine Ports. Zwei
|
||||||
@@ -172,6 +206,9 @@ def create_base_app(
|
|||||||
SESSION_COOKIE_HTTPONLY=True,
|
SESSION_COOKIE_HTTPONLY=True,
|
||||||
SESSION_COOKIE_SECURE=config.security.cookie_secure,
|
SESSION_COOKIE_SECURE=config.security.cookie_secure,
|
||||||
SESSION_COOKIE_SAMESITE=config.security.cookie_samesite,
|
SESSION_COOKIE_SAMESITE=config.security.cookie_samesite,
|
||||||
|
# Nur die aeussere Schranke aus der Umgebung. Die eigentliche Grenze
|
||||||
|
# steht in der Einstellung und wird pro Anfrage gelesen (uploads.py) --
|
||||||
|
# sonst braeuchte jede Aenderung einen Neustart aller Arbeiter.
|
||||||
MAX_CONTENT_LENGTH=config.security.max_content_length,
|
MAX_CONTENT_LENGTH=config.security.max_content_length,
|
||||||
JSON_SORT_KEYS=False,
|
JSON_SORT_KEYS=False,
|
||||||
TRAP_HTTP_EXCEPTIONS=False,
|
TRAP_HTTP_EXCEPTIONS=False,
|
||||||
@@ -222,12 +259,21 @@ def create_base_app(
|
|||||||
wert = 0
|
wert = 0
|
||||||
return wert if wert > 0 else config.security.session_idle_minutes
|
return wert if wert > 0 else config.security.session_idle_minutes
|
||||||
|
|
||||||
|
def _gesamtdauer(conn: Any) -> int:
|
||||||
|
"""Gesamtdauer aus den Einstellungen -- ueber dieselbe offene Verbindung."""
|
||||||
|
try:
|
||||||
|
wert = int(settings.get(conn, "session_absolute_hours") or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
wert = 0
|
||||||
|
return wert if wert > 0 else config.security.session_absolute_hours
|
||||||
|
|
||||||
app.session_interface = SqliteSessionInterface(
|
app.session_interface = SqliteSessionInterface(
|
||||||
database,
|
database,
|
||||||
cookie_name=app.config["SESSION_COOKIE_NAME"],
|
cookie_name=app.config["SESSION_COOKIE_NAME"],
|
||||||
idle_minutes=config.security.session_idle_minutes,
|
idle_minutes=config.security.session_idle_minutes,
|
||||||
absolute_hours=config.security.session_absolute_hours,
|
absolute_hours=config.security.session_absolute_hours,
|
||||||
idle_lookup=_leerlauf,
|
idle_lookup=_leerlauf,
|
||||||
|
absolute_lookup=_gesamtdauer,
|
||||||
)
|
)
|
||||||
|
|
||||||
# -- Schema und Startzustand ------------------------------------------ #
|
# -- Schema und Startzustand ------------------------------------------ #
|
||||||
@@ -259,6 +305,17 @@ def create_base_app(
|
|||||||
|
|
||||||
@app.after_request
|
@app.after_request
|
||||||
def _finish_request(response): # type: ignore[no-untyped-def]
|
def _finish_request(response): # type: ignore[no-untyped-def]
|
||||||
|
# Ein Aktualisierungsdurchlauf, der mit Sitzungscookie kommt und
|
||||||
|
# unangemeldet endet, hat gerade seine Sitzung verloren. Bei einer
|
||||||
|
# geschuetzten Seite sagt das schon der Status (401); bei einer, die
|
||||||
|
# auch oeffentlich antwortet -- die Statusuebersicht etwa -- kommt eine
|
||||||
|
# 200 mit den oeffentlichen Kacheln zurueck, und der offene Tab merkt
|
||||||
|
# nichts: er zeigt weiter eine Oberflaeche, als sei alles in Ordnung.
|
||||||
|
# Deshalb ein Kopf, den der Browser unabhaengig vom Status auswerten
|
||||||
|
# kann.
|
||||||
|
if session_store.is_automatic(request) and not current_user.is_authenticated:
|
||||||
|
if request.cookies.get(app.config["SESSION_COOKIE_NAME"], ""):
|
||||||
|
response.headers["X-Session-Expired"] = "1"
|
||||||
hsts = ""
|
hsts = ""
|
||||||
if config.security.hsts_enabled:
|
if config.security.hsts_enabled:
|
||||||
hsts = f"max-age={config.security.hsts_max_age}; includeSubDomains"
|
hsts = f"max-age={config.security.hsts_max_age}; includeSubDomains"
|
||||||
@@ -317,6 +374,26 @@ def create_base_app(
|
|||||||
# -- Fehlerseiten ------------------------------------------------------ #
|
# -- Fehlerseiten ------------------------------------------------------ #
|
||||||
@app.errorhandler(HTTPException)
|
@app.errorhandler(HTTPException)
|
||||||
def _http_error(error: HTTPException): # type: ignore[no-untyped-def]
|
def _http_error(error: HTTPException): # type: ignore[no-untyped-def]
|
||||||
|
if error.code == 404:
|
||||||
|
# Vor der Fehlerseite: war das eine Adresse aus der Zeit vor 2.5.0?
|
||||||
|
# Erst hier gefragt, damit die Tabelle nie eine bestehende Route
|
||||||
|
# verdeckt -- auf dem Lizenzserver ist /lizenz/<ticket> echt.
|
||||||
|
ziel = legacy_urls.neue_adresse(request.path)
|
||||||
|
if ziel is not None:
|
||||||
|
parameter = legacy_urls.neue_parameter(request.args.to_dict(flat=True))
|
||||||
|
if parameter:
|
||||||
|
ziel = ziel + "?" + urlencode(parameter)
|
||||||
|
return redirect(ziel)
|
||||||
|
if error.code == 413:
|
||||||
|
# "Request Entity Too Large" allein hilft niemandem weiter: die
|
||||||
|
# Zahl steht in einer Einstellung, und wer hochlaedt, soll sie
|
||||||
|
# sehen. Vorher stand hier nur der nackte Status.
|
||||||
|
grenze = uploads.limit_mb()
|
||||||
|
error.description = (
|
||||||
|
f"Diese Datei ist größer als erlaubt. Zurzeit sind {grenze} MB je Upload "
|
||||||
|
"eingestellt; ändern kann das ein Administrator unter "
|
||||||
|
'"Einstellungen -> Webserver und TLS".'
|
||||||
|
)
|
||||||
if _wants_json():
|
if _wants_json():
|
||||||
return {
|
return {
|
||||||
"error": error.name,
|
"error": error.name,
|
||||||
@@ -370,7 +447,7 @@ def create_base_app(
|
|||||||
with app.app_context():
|
with app.app_context():
|
||||||
trash_views.register_core_providers()
|
trash_views.register_core_providers()
|
||||||
|
|
||||||
app.add_url_rule("/gesundheit", "health", _health, methods=["GET"])
|
app.add_url_rule("/health", "health", _health, methods=["GET"])
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ import sqlite3
|
|||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Iterable
|
from typing import Any, Collection, Iterable, Mapping, Sequence
|
||||||
|
|
||||||
from .db import Database
|
from .db import Database
|
||||||
|
|
||||||
@@ -116,6 +116,49 @@ def log(
|
|||||||
return int(cursor.lastrowid or 0)
|
return int(cursor.lastrowid or 0)
|
||||||
|
|
||||||
|
|
||||||
|
def diff(
|
||||||
|
vorher: Mapping[str, Any] | None,
|
||||||
|
nachher: Mapping[str, Any] | None,
|
||||||
|
*,
|
||||||
|
felder: Sequence[tuple[str, str]] | None = None,
|
||||||
|
geheim: Collection[str] = (),
|
||||||
|
) -> str:
|
||||||
|
"""Was sich geaendert hat: ``Feld: alt -> neu``, mit Komma getrennt.
|
||||||
|
|
||||||
|
Damit steht im Protokoll nicht nur *dass* etwas geaendert wurde, sondern
|
||||||
|
auch *von was auf was* -- der Weg zurueck steht damit in der Zeile selbst.
|
||||||
|
|
||||||
|
``felder`` gibt Auswahl und Beschriftung vor (``[("ssh_port", "SSH-Port")]``);
|
||||||
|
ohne Angabe werden alle gemeinsamen Felder verglichen. Namen in ``geheim``
|
||||||
|
werden nie im Klartext geschrieben -- dort steht nur, dass sie sich
|
||||||
|
geaendert haben.
|
||||||
|
"""
|
||||||
|
if vorher is None or nachher is None:
|
||||||
|
return ""
|
||||||
|
paare = felder or [(name, name) for name in sorted(set(vorher) & set(nachher))]
|
||||||
|
teile: list[str] = []
|
||||||
|
for name, beschriftung in paare:
|
||||||
|
if name not in vorher or name not in nachher:
|
||||||
|
continue
|
||||||
|
alt, neu = vorher[name], nachher[name]
|
||||||
|
if alt == neu:
|
||||||
|
continue
|
||||||
|
if name in geheim:
|
||||||
|
teile.append(f"{beschriftung}: geaendert")
|
||||||
|
continue
|
||||||
|
teile.append(f"{beschriftung}: {_wert(alt)} -> {_wert(neu)}")
|
||||||
|
return ", ".join(teile)
|
||||||
|
|
||||||
|
|
||||||
|
def _wert(wert: Any) -> str:
|
||||||
|
if wert is None or wert == "":
|
||||||
|
return "(leer)"
|
||||||
|
if isinstance(wert, bool):
|
||||||
|
return "ein" if wert else "aus"
|
||||||
|
text = " ".join(str(wert).split())
|
||||||
|
return text if len(text) <= 80 else text[:79] + "…"
|
||||||
|
|
||||||
|
|
||||||
def verify_chain(conn: sqlite3.Connection, *, limit: int | None = None) -> dict[str, Any]:
|
def verify_chain(conn: sqlite3.Connection, *, limit: int | None = None) -> dict[str, Any]:
|
||||||
"""Prueft die Hash-Kette. Rueckgabe: ``{"ok", "checked", "broken_at", "reason"}``."""
|
"""Prueft die Hash-Kette. Rueckgabe: ``{"ok", "checked", "broken_at", "reason"}``."""
|
||||||
sql = "SELECT * FROM audit_log ORDER BY id"
|
sql = "SELECT * FROM audit_log ORDER BY id"
|
||||||
@@ -133,14 +176,14 @@ def verify_chain(conn: sqlite3.Connection, *, limit: int | None = None) -> dict[
|
|||||||
"ok": False,
|
"ok": False,
|
||||||
"checked": checked,
|
"checked": checked,
|
||||||
"broken_at": data["id"],
|
"broken_at": data["id"],
|
||||||
"reason": "Vorgaenger-Hash passt nicht -- eine Zeile wurde entfernt oder eingefuegt.",
|
"reason": "Vorgänger-Hash passt nicht – eine Zeile wurde entfernt oder eingefügt.",
|
||||||
}
|
}
|
||||||
if compute_hash(str(data["prev_hash"]), data) != data["hash"]:
|
if compute_hash(str(data["prev_hash"]), data) != data["hash"]:
|
||||||
return {
|
return {
|
||||||
"ok": False,
|
"ok": False,
|
||||||
"checked": checked,
|
"checked": checked,
|
||||||
"broken_at": data["id"],
|
"broken_at": data["id"],
|
||||||
"reason": "Zeileninhalt passt nicht zum gespeicherten Hash -- Eintrag geaendert.",
|
"reason": "Zeileninhalt passt nicht zum gespeicherten Hash | Eintrag geändert.",
|
||||||
}
|
}
|
||||||
expected_prev = str(data["hash"])
|
expected_prev = str(data["hash"])
|
||||||
checked += 1
|
checked += 1
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import re
|
|||||||
import socket
|
import socket
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import ssl
|
import ssl
|
||||||
|
import time
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
@@ -59,20 +60,20 @@ SETTINGS: tuple[SettingSpec, ...] = (
|
|||||||
section="ldap",
|
section="ldap",
|
||||||
choices=(("ldaps", "LDAPS (Port 636)"), ("starttls", "StartTLS (Port 389)"), ("none", "Unverschluesselt")),
|
choices=(("ldaps", "LDAPS (Port 636)"), ("starttls", "StartTLS (Port 389)"), ("none", "Unverschluesselt")),
|
||||||
),
|
),
|
||||||
SettingSpec("ldap_verify_cert", True, "bool", "Serverzertifikat pruefen", section="ldap"),
|
SettingSpec("ldap_verify_cert", True, "bool", "Serverzertifikat prüfen", section="ldap"),
|
||||||
SettingSpec("ldap_ca_file", "", "str", "CA-Zertifikat (Pfad)", section="ldap"),
|
SettingSpec("ldap_ca_file", "", "str", "CA-Zertifikat (Pfad)", section="ldap"),
|
||||||
SettingSpec(
|
SettingSpec(
|
||||||
"ldap_allow_insecure",
|
"ldap_allow_insecure",
|
||||||
False,
|
False,
|
||||||
"bool",
|
"bool",
|
||||||
"Unverschluesselte Verbindung ausdruecklich erlauben",
|
"Unverschlüsselte Verbindung ausdrücklich erlauben",
|
||||||
hint="Nur fuer Testumgebungen. Das Passwort geht dabei im Klartext ueber das Netz.",
|
hint="Nur für Testumgebungen. Das Passwort geht dabei im Klartext über das Netz.",
|
||||||
section="ldap",
|
section="ldap",
|
||||||
),
|
),
|
||||||
SettingSpec("ldap_base_dn", "", "str", "Basis-DN (leer = automatisch)", section="ldap"),
|
SettingSpec("ldap_base_dn", "", "str", "Basis-DN (leer = automatisch)", section="ldap"),
|
||||||
SettingSpec("ldap_user_attribute", "sAMAccountName", "str", "Anmeldeattribut", section="ldap"),
|
SettingSpec("ldap_user_attribute", "sAMAccountName", "str", "Anmeldeattribut", section="ldap"),
|
||||||
SettingSpec("ldap_email_attribute", "userPrincipalName", "str", "E-Mail-Attribut", section="ldap"),
|
SettingSpec("ldap_email_attribute", "userPrincipalName", "str", "E-Mail-Attribut", section="ldap"),
|
||||||
SettingSpec("ldap_group_sync", True, "bool", "Gruppen aus dem Verzeichnis uebernehmen", section="ldap"),
|
SettingSpec("ldap_group_sync", True, "bool", "Gruppen aus dem Verzeichnis übernehmen", section="ldap"),
|
||||||
SettingSpec("ldap_timeout", 8, "int", "Zeitlimit (Sekunden)", section="ldap", minimum=1, maximum=60),
|
SettingSpec("ldap_timeout", 8, "int", "Zeitlimit (Sekunden)", section="ldap", minimum=1, maximum=60),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -123,7 +124,7 @@ def bind_credentials(conn: sqlite3.Connection) -> tuple[str, str]:
|
|||||||
try:
|
try:
|
||||||
secret = core().keystore.decrypt(encrypted, aad=AAD_LDAP_BIND)
|
secret = core().keystore.decrypt(encrypted, aad=AAD_LDAP_BIND)
|
||||||
except Exception: # noqa: BLE001 - Schluessel gewechselt/Datei kaputt
|
except Exception: # noqa: BLE001 - Schluessel gewechselt/Datei kaputt
|
||||||
logger.error("Bind-Passwort des Verzeichnisdienstes konnte nicht entschluesselt werden")
|
logger.error("Bind-Passwort des Verzeichnisdienstes konnte nicht entschlüsselt werden")
|
||||||
return str(row.get("username") or ""), ""
|
return str(row.get("username") or ""), ""
|
||||||
return str(row.get("username") or ""), secret
|
return str(row.get("username") or ""), secret
|
||||||
|
|
||||||
@@ -162,19 +163,82 @@ def _tls_object(config: dict[str, Any]):
|
|||||||
|
|
||||||
|
|
||||||
def _server(config: dict[str, Any]):
|
def _server(config: dict[str, Any]):
|
||||||
|
"""Das Server-Objekt -- so sparsam wie moeglich.
|
||||||
|
|
||||||
|
``get_info`` entscheidet, was ldap3 beim Verbinden **zusaetzlich** holt.
|
||||||
|
Mit ``ALL`` ist das die Beschreibung des Verzeichnisses *und sein ganzes
|
||||||
|
Schema*: bei einem Active Directory sind das Tausende Attribut- und
|
||||||
|
Klassendefinitionen, mehrere Megabyte, bei jeder Verbindung neu. Fuer eine
|
||||||
|
Anmeldung braucht davon nichts -- gelesen wird hoechstens der
|
||||||
|
``defaultNamingContext``, wenn kein Basis-DN eingetragen ist, und dafuer
|
||||||
|
genuegt ``DSA`` (nur die Serverinformation, ein Datensatz).
|
||||||
|
|
||||||
|
Liegt der Domain-Controller in einem anderen Netz, ist genau das der
|
||||||
|
Unterschied zwischen "geht sofort" und "dauert manchmal".
|
||||||
|
"""
|
||||||
from ldap3 import Server
|
from ldap3 import Server
|
||||||
|
|
||||||
security = str(config["ldap_security"])
|
security = str(config["ldap_security"])
|
||||||
|
braucht_basis = not str(config.get("ldap_base_dn") or "").strip()
|
||||||
return Server(
|
return Server(
|
||||||
str(config["ldap_host"]),
|
str(config["ldap_host"]),
|
||||||
port=int(config["ldap_port"]),
|
port=int(config["ldap_port"]),
|
||||||
use_ssl=security == "ldaps",
|
use_ssl=security == "ldaps",
|
||||||
tls=_tls_object(config) if security in {"ldaps", "starttls"} else None,
|
tls=_tls_object(config) if security in {"ldaps", "starttls"} else None,
|
||||||
get_info="ALL",
|
get_info="DSA" if braucht_basis else "NO_INFO",
|
||||||
connect_timeout=int(config["ldap_timeout"]),
|
connect_timeout=int(config["ldap_timeout"]),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
#: Fehler, die nur "gerade jetzt nicht" bedeuten -- nicht "falsches Passwort".
|
||||||
|
#:
|
||||||
|
#: Die Liste stammt aus der Fassung 1.2.8, wo dieses Problem schon einmal
|
||||||
|
#: geloest war. Der Kommentar dort beschreibt es genauer, als ich es koennte:
|
||||||
|
#: ein Domain-Controller in einem anderen, langsameren Netzsegment -- die
|
||||||
|
#: allererste Verbindung dorthin ist oft spuerbar langsamer als jede folgende,
|
||||||
|
#: und der erste Login schlug mit "Benutzer/Passwort ungueltig" fehl, waehrend
|
||||||
|
#: ein sofortiger zweiter Versuch klappte.
|
||||||
|
TRANSIENTE_FEHLER: tuple[type[Exception], ...] = ()
|
||||||
|
|
||||||
|
|
||||||
|
def _transiente_fehler() -> tuple[type[Exception], ...]:
|
||||||
|
"""Die Ausnahmeklassen von ldap3, die einen Wiederholversuch verdienen."""
|
||||||
|
global TRANSIENTE_FEHLER
|
||||||
|
if TRANSIENTE_FEHLER:
|
||||||
|
return TRANSIENTE_FEHLER
|
||||||
|
from ldap3.core.exceptions import (
|
||||||
|
LDAPResponseTimeoutError,
|
||||||
|
LDAPSocketOpenError,
|
||||||
|
LDAPSocketReceiveError,
|
||||||
|
LDAPSocketSendError,
|
||||||
|
)
|
||||||
|
|
||||||
|
TRANSIENTE_FEHLER = (
|
||||||
|
LDAPSocketOpenError,
|
||||||
|
LDAPSocketReceiveError,
|
||||||
|
LDAPSocketSendError,
|
||||||
|
LDAPResponseTimeoutError,
|
||||||
|
)
|
||||||
|
return TRANSIENTE_FEHLER
|
||||||
|
|
||||||
|
|
||||||
|
def mit_wiederholung(aufbau: Any) -> Any:
|
||||||
|
"""Genau ein zweiter Versuch -- und nur bei einem Netzfehler.
|
||||||
|
|
||||||
|
Nicht bei einer abgelehnten Anmeldung: ein Tippfehler im Passwort wuerde
|
||||||
|
sonst den Sperrzaehler des Kontos zweimal statt einmal hochzaehlen. Das
|
||||||
|
ist die Regel aus 1.2.8, und sie ist richtig.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return aufbau()
|
||||||
|
except _transiente_fehler() as fehler:
|
||||||
|
logger.info(
|
||||||
|
"Verzeichnisdienst antwortete nicht (%s) -- ein zweiter Versuch",
|
||||||
|
type(fehler).__name__,
|
||||||
|
)
|
||||||
|
return aufbau()
|
||||||
|
|
||||||
|
|
||||||
def _connect(config: dict[str, Any], user: str, password: str):
|
def _connect(config: dict[str, Any], user: str, password: str):
|
||||||
"""Baut eine Verbindung auf und bindet. Wirft bei Netz-/TLS-Problemen."""
|
"""Baut eine Verbindung auf und bindet. Wirft bei Netz-/TLS-Problemen."""
|
||||||
from ldap3 import Connection
|
from ldap3 import Connection
|
||||||
@@ -182,7 +246,7 @@ def _connect(config: dict[str, Any], user: str, password: str):
|
|||||||
security = str(config["ldap_security"])
|
security = str(config["ldap_security"])
|
||||||
if security == "none" and not config["ldap_allow_insecure"]:
|
if security == "none" and not config["ldap_allow_insecure"]:
|
||||||
raise PermissionError(
|
raise PermissionError(
|
||||||
"Unverschluesselte Verzeichnisverbindungen sind deaktiviert. "
|
"Unverschlüsselte Verzeichnisverbindungen sind deaktiviert. "
|
||||||
"Nutzen Sie LDAPS oder StartTLS."
|
"Nutzen Sie LDAPS oder StartTLS."
|
||||||
)
|
)
|
||||||
connection = Connection(
|
connection = Connection(
|
||||||
@@ -192,6 +256,12 @@ def _connect(config: dict[str, Any], user: str, password: str):
|
|||||||
auto_bind=False,
|
auto_bind=False,
|
||||||
raise_exceptions=False,
|
raise_exceptions=False,
|
||||||
receive_timeout=int(config["ldap_timeout"]),
|
receive_timeout=int(config["ldap_timeout"]),
|
||||||
|
# Ein Active Directory antwortet auf eine Suche ab der Domaenenwurzel
|
||||||
|
# mit Verweisen auf andere Controller. ldap3 folgt ihnen von sich aus
|
||||||
|
# -- und baut dafuer neue Verbindungen auf, womoeglich in ein anderes
|
||||||
|
# Netz. Fuer eine Anmeldung ist das nicht nur unnoetig, sondern die
|
||||||
|
# Quelle genau der Wartezeiten, die niemand erklaeren kann.
|
||||||
|
auto_referrals=False,
|
||||||
)
|
)
|
||||||
if security == "starttls":
|
if security == "starttls":
|
||||||
if not connection.open():
|
if not connection.open():
|
||||||
@@ -249,12 +319,23 @@ def authenticate(conn: sqlite3.Connection, username: str, password: str) -> Dire
|
|||||||
bind_user, bind_password = bind_credentials(conn)
|
bind_user, bind_password = bind_credentials(conn)
|
||||||
warnings: list[str] = []
|
warnings: list[str] = []
|
||||||
if str(config["ldap_security"]) == "none":
|
if str(config["ldap_security"]) == "none":
|
||||||
warnings.append("Unverschluesselte Verzeichnisverbindung in Benutzung")
|
warnings.append("Unverschlüsselte Verzeichnisverbindung in Benutzung")
|
||||||
if not config["ldap_verify_cert"]:
|
if not config["ldap_verify_cert"]:
|
||||||
warnings.append("Serverzertifikat des Verzeichnisdienstes wird nicht geprueft")
|
warnings.append("Serverzertifikat des Verzeichnisdienstes wird nicht geprüft")
|
||||||
|
|
||||||
|
begonnen = time.monotonic()
|
||||||
|
zeiten: list[str] = []
|
||||||
|
|
||||||
|
def gemessen(name: str, seit: float) -> float:
|
||||||
|
"""Haelt eine Stufe mit ihrer Dauer fest und gibt die neue Marke."""
|
||||||
|
jetzt = time.monotonic()
|
||||||
|
zeiten.append(f"{name} {int((jetzt - seit) * 1000)}ms")
|
||||||
|
return jetzt
|
||||||
|
|
||||||
try:
|
try:
|
||||||
service_conn, bound = _connect(config, bind_user, bind_password)
|
service_conn, bound = mit_wiederholung(
|
||||||
|
lambda: _connect(config, bind_user, bind_password)
|
||||||
|
)
|
||||||
except (PermissionError, ConnectionError) as exc:
|
except (PermissionError, ConnectionError) as exc:
|
||||||
logger.error("Verzeichnisdienst: %s", exc)
|
logger.error("Verzeichnisdienst: %s", exc)
|
||||||
return DirectoryResult("unavailable", message=str(exc), warnings=tuple(warnings))
|
return DirectoryResult("unavailable", message=str(exc), warnings=tuple(warnings))
|
||||||
@@ -271,8 +352,10 @@ def authenticate(conn: sqlite3.Connection, username: str, password: str) -> Dire
|
|||||||
warnings=tuple(warnings),
|
warnings=tuple(warnings),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
marke = gemessen("Dienstbindung", begonnen)
|
||||||
try:
|
try:
|
||||||
base = _base_dn(service_conn, config)
|
base = _base_dn(service_conn, config)
|
||||||
|
marke = gemessen("Basis-DN", marke)
|
||||||
if not base:
|
if not base:
|
||||||
return DirectoryResult(
|
return DirectoryResult(
|
||||||
"unavailable", message="Basis-DN konnte nicht ermittelt werden.", warnings=tuple(warnings)
|
"unavailable", message="Basis-DN konnte nicht ermittelt werden.", warnings=tuple(warnings)
|
||||||
@@ -288,8 +371,10 @@ def authenticate(conn: sqlite3.Connection, username: str, password: str) -> Dire
|
|||||||
attributes=list(dict.fromkeys(wanted)),
|
attributes=list(dict.fromkeys(wanted)),
|
||||||
size_limit=2,
|
size_limit=2,
|
||||||
)
|
)
|
||||||
|
marke = gemessen("Benutzersuche", marke)
|
||||||
entries = list(service_conn.entries)
|
entries = list(service_conn.entries)
|
||||||
if len(entries) != 1:
|
if len(entries) != 1:
|
||||||
|
logger.info("Verzeichnisanmeldung %s: %s", username, ", ".join(zeiten))
|
||||||
return DirectoryResult("invalid", warnings=tuple(warnings))
|
return DirectoryResult("invalid", warnings=tuple(warnings))
|
||||||
|
|
||||||
entry = entries[0]
|
entry = entries[0]
|
||||||
@@ -300,9 +385,18 @@ def authenticate(conn: sqlite3.Connection, username: str, password: str) -> Dire
|
|||||||
|
|
||||||
# Zweite, unabhaengige Verbindung mit den Benutzerdaten -- die eigentliche
|
# Zweite, unabhaengige Verbindung mit den Benutzerdaten -- die eigentliche
|
||||||
# Passwortpruefung. Niemals ueber die Dienstkonto-Verbindung rebinden.
|
# Passwortpruefung. Niemals ueber die Dienstkonto-Verbindung rebinden.
|
||||||
user_conn, user_bound = _connect(config, dn, password)
|
#
|
||||||
|
# Mit Wiederholversuch, und genau hier zeigte sich das Symptom: bei
|
||||||
|
# einem Domain-Controller in einem anderen Netz schlug der erste
|
||||||
|
# Versuch mit "Benutzer/Passwort ungueltig" fehl, der zweite gleich
|
||||||
|
# darauf klappte. Wiederholt wird nur bei einem Netzfehler -- eine
|
||||||
|
# abgelehnte Anmeldung bleibt abgelehnt, sonst zaehlt ein Tippfehler
|
||||||
|
# den Sperrzaehler des Kontos zweimal hoch.
|
||||||
|
user_conn, user_bound = mit_wiederholung(lambda: _connect(config, dn, password))
|
||||||
|
marke = gemessen("Passwortpruefung", marke)
|
||||||
try:
|
try:
|
||||||
if not user_bound:
|
if not user_bound:
|
||||||
|
logger.info("Verzeichnisanmeldung %s: %s", username, ", ".join(zeiten))
|
||||||
return DirectoryResult("invalid", warnings=tuple(warnings))
|
return DirectoryResult("invalid", warnings=tuple(warnings))
|
||||||
finally:
|
finally:
|
||||||
try:
|
try:
|
||||||
@@ -313,6 +407,7 @@ def authenticate(conn: sqlite3.Connection, username: str, password: str) -> Dire
|
|||||||
group_dns: tuple[str, ...] = ()
|
group_dns: tuple[str, ...] = ()
|
||||||
if config["ldap_group_sync"]:
|
if config["ldap_group_sync"]:
|
||||||
group_dns = _resolve_groups(service_conn, base, dn)
|
group_dns = _resolve_groups(service_conn, base, dn)
|
||||||
|
marke = gemessen("Gruppen", marke)
|
||||||
|
|
||||||
profile = DirectoryProfile(
|
profile = DirectoryProfile(
|
||||||
username=_attr(entry, attribute) or username,
|
username=_attr(entry, attribute) or username,
|
||||||
@@ -322,6 +417,15 @@ def authenticate(conn: sqlite3.Connection, username: str, password: str) -> Dire
|
|||||||
email=_attr(entry, email_attribute),
|
email=_attr(entry, email_attribute),
|
||||||
group_dns=group_dns,
|
group_dns=group_dns,
|
||||||
)
|
)
|
||||||
|
# Eine Zeile je Anmeldung mit den Dauern der Stufen. Aus "dauert
|
||||||
|
# manchmal laenger" wird damit eine Zahl -- und man sieht, ob es die
|
||||||
|
# Verbindung ist, die Suche oder die Gruppen.
|
||||||
|
logger.info(
|
||||||
|
"Verzeichnisanmeldung %s: %s, gesamt %dms",
|
||||||
|
username,
|
||||||
|
", ".join(zeiten),
|
||||||
|
int((time.monotonic() - begonnen) * 1000),
|
||||||
|
)
|
||||||
return DirectoryResult("ok", profile=profile, warnings=tuple(warnings))
|
return DirectoryResult("ok", profile=profile, warnings=tuple(warnings))
|
||||||
finally:
|
finally:
|
||||||
try:
|
try:
|
||||||
@@ -359,7 +463,7 @@ def _resolve_groups(connection: Any, base: str, user_dn: str) -> tuple[str, ...]
|
|||||||
)
|
)
|
||||||
return tuple(str(entry.entry_dn) for entry in connection.entries)
|
return tuple(str(entry.entry_dn) for entry in connection.entries)
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
logger.warning("Gruppenaufloesung fehlgeschlagen: %s", exc)
|
logger.warning("Gruppenauflösung fehlgeschlagen: %s", exc)
|
||||||
return ()
|
return ()
|
||||||
|
|
||||||
|
|
||||||
@@ -423,18 +527,24 @@ def _apply_group_mappings(
|
|||||||
zugewiesene Gruppen bleiben erhalten, wenn sie kein Verzeichnis-Mapping
|
zugewiesene Gruppen bleiben erhalten, wenn sie kein Verzeichnis-Mapping
|
||||||
haben -- sonst waere jede lokale Zuweisung nach dem naechsten Login weg.
|
haben -- sonst waere jede lokale Zuweisung nach dem naechsten Login weg.
|
||||||
"""
|
"""
|
||||||
if not group_dns:
|
|
||||||
return
|
|
||||||
mappings = Database.all(
|
mappings = Database.all(
|
||||||
conn, "SELECT ad_group_dn, app_group_id FROM ldap_group_mappings"
|
conn, "SELECT ad_group_dn, app_group_id, grants_admin FROM ldap_group_mappings"
|
||||||
)
|
)
|
||||||
if not mappings:
|
if not mappings:
|
||||||
return
|
return
|
||||||
mapped_group_ids = {int(row["app_group_id"]) for row in mappings}
|
|
||||||
lowered = {dn.lower() for dn in group_dns}
|
lowered = {dn.lower() for dn in group_dns}
|
||||||
|
# Das Kennzeichen zuerst -- es haengt an keiner Gruppe und muss auch dann
|
||||||
|
# ausgewertet werden, wenn das Konto in keiner zugeordneten Gruppe steht.
|
||||||
|
_kennzeichen_setzen(conn, user_id, mappings, lowered)
|
||||||
|
if not group_dns:
|
||||||
|
return
|
||||||
|
gruppenzuordnungen = [row for row in mappings if row["app_group_id"] is not None]
|
||||||
|
if not gruppenzuordnungen:
|
||||||
|
return
|
||||||
|
mapped_group_ids = {int(row["app_group_id"]) for row in gruppenzuordnungen}
|
||||||
wanted = {
|
wanted = {
|
||||||
int(row["app_group_id"])
|
int(row["app_group_id"])
|
||||||
for row in mappings
|
for row in gruppenzuordnungen
|
||||||
if str(row["ad_group_dn"]).lower() in lowered
|
if str(row["ad_group_dn"]).lower() in lowered
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -452,6 +562,71 @@ def _apply_group_mappings(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _kennzeichen_setzen(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
user_id: int,
|
||||||
|
mappings: list[dict[str, Any]],
|
||||||
|
gruppen: set[str],
|
||||||
|
) -> None:
|
||||||
|
"""Setzt oder entzieht das Administratorkennzeichen nach dem Verzeichnis.
|
||||||
|
|
||||||
|
Wirkung wie bei Gruppen: **ersetzend**. Wer aus der zugeordneten
|
||||||
|
AD-Gruppe herausfaellt, verliert das Kennzeichen bei der naechsten
|
||||||
|
Anmeldung -- sonst waere ein Austritt im Verzeichnis eine Aenderung, die
|
||||||
|
hier nie ankommt.
|
||||||
|
|
||||||
|
Zwei Grenzen, beide absichtlich:
|
||||||
|
|
||||||
|
* Nur **Verzeichniskonten**. Ein lokales Konto (etwa das, das
|
||||||
|
``tesm-admin create-admin`` anlegt) bleibt unberuehrt -- es ist der
|
||||||
|
Notausgang, wenn das Verzeichnis nicht erreichbar ist.
|
||||||
|
* Nie den letzten entsperrten Administrator herunterstufen. Ein
|
||||||
|
Tippfehler im DN wuerde sonst beim naechsten Login die Anlage
|
||||||
|
verschliessen, und niemand koennte ihn korrigieren.
|
||||||
|
"""
|
||||||
|
zuordnungen = [row for row in mappings if int(row["grants_admin"] or 0) == 1]
|
||||||
|
if not zuordnungen:
|
||||||
|
return
|
||||||
|
soll = any(str(row["ad_group_dn"]).lower() in gruppen for row in zuordnungen)
|
||||||
|
zeile = Database.one(
|
||||||
|
conn, "SELECT is_admin, auth_source FROM users WHERE id=?", (user_id,)
|
||||||
|
)
|
||||||
|
if zeile is None or str(zeile["auth_source"]) != "ldap":
|
||||||
|
return
|
||||||
|
ist = bool(zeile["is_admin"])
|
||||||
|
if ist == soll:
|
||||||
|
return
|
||||||
|
if not soll:
|
||||||
|
uebrig = Database.value(
|
||||||
|
conn,
|
||||||
|
"SELECT COUNT(*) FROM users WHERE is_admin=1 AND is_locked=0 "
|
||||||
|
"AND deleted_at IS NULL AND id<>?",
|
||||||
|
(user_id,),
|
||||||
|
default=0,
|
||||||
|
)
|
||||||
|
if not uebrig:
|
||||||
|
logger.warning(
|
||||||
|
"Administratorkennzeichen bleibt bei Benutzer %s: es waere das letzte.",
|
||||||
|
user_id,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE users SET is_admin=?, updated_at=? WHERE id=?", (int(soll), _now(), user_id)
|
||||||
|
)
|
||||||
|
audit.log(
|
||||||
|
conn,
|
||||||
|
actor="system",
|
||||||
|
action="auth.admin_flag_changed",
|
||||||
|
target=str(user_id),
|
||||||
|
detail=(
|
||||||
|
"Administrator: "
|
||||||
|
+ ("nein -> ja" if soll else "ja -> nein")
|
||||||
|
+ " (aus der Verzeichniszuordnung)"
|
||||||
|
),
|
||||||
|
severity="warning",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _user_group_ids(conn: sqlite3.Connection, user_id: int) -> list[dict[str, Any]]:
|
def _user_group_ids(conn: sqlite3.Connection, user_id: int) -> list[dict[str, Any]]:
|
||||||
return Database.all(conn, "SELECT group_id AS id FROM user_groups WHERE user_id=?", (user_id,))
|
return Database.all(conn, "SELECT group_id AS id FROM user_groups WHERE user_id=?", (user_id,))
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from flask import abort, flash, g, redirect, request, session, url_for
|
|||||||
from werkzeug.local import LocalProxy
|
from werkzeug.local import LocalProxy
|
||||||
|
|
||||||
from ..rbac.model import Action, PermissionTree
|
from ..rbac.model import Action, PermissionTree
|
||||||
|
from ..security.sessions import is_automatic
|
||||||
|
|
||||||
AUTH_SOURCES = ("local", "ldap")
|
AUTH_SOURCES = ("local", "ldap")
|
||||||
|
|
||||||
@@ -76,25 +77,40 @@ class User:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def effective_permissions(self) -> frozenset[str]:
|
def effective_permissions(self) -> frozenset[str]:
|
||||||
"""Rechte nach Anwendung der Bereichs-Kill-Switches."""
|
"""Rechte nach Anwendung der Bereichs-Kill-Switches.
|
||||||
|
|
||||||
|
Ein echter Administrator hat den Verwaltungsbereich immer dabei -- auch
|
||||||
|
ohne Gruppe und ohne dass jemand ihn angehakt haette. Sonst koennte
|
||||||
|
sich das letzte Administratorkonto aus der eigenen Anlage aussperren,
|
||||||
|
indem es seine Gruppe verliert.
|
||||||
|
"""
|
||||||
if self._effective is None:
|
if self._effective is None:
|
||||||
tree = self._tree
|
tree = self._tree
|
||||||
self._effective = (
|
if tree is None:
|
||||||
frozenset(tree.effective(self.granted)) if tree is not None else frozenset()
|
self._effective = frozenset()
|
||||||
)
|
else:
|
||||||
|
eigene = set(tree.effective(self.granted))
|
||||||
|
if self.is_admin:
|
||||||
|
eigene |= set(tree.verwaltungsrechte())
|
||||||
|
self._effective = frozenset(eigene)
|
||||||
return self._effective
|
return self._effective
|
||||||
|
|
||||||
def has(self, *permissions: str) -> bool:
|
def has(self, *permissions: str) -> bool:
|
||||||
"""True, wenn **alle** angegebenen Rechte greifen. Admins duerfen immer alles."""
|
"""True, wenn **alle** angegebenen Rechte greifen.
|
||||||
if self.is_admin:
|
|
||||||
return True
|
Das Administratorkennzeichen gibt seit 2.8.0 **den Verwaltungsbereich**
|
||||||
|
frei und nicht mehr jede Pruefung: Benutzer, Gruppen, Einstellungen,
|
||||||
|
Sicherung, Papierkorb, Diagnose. Funktionsrechte -- Geraete, Neustarts,
|
||||||
|
DHCP, Freigaben -- kommen ueber Gruppen, und die darf sich ein
|
||||||
|
Administrator selbst zuweisen. Der Unterschied ist nicht, *ob* er sie
|
||||||
|
bekommen kann, sondern dass es dann eine Zeile im
|
||||||
|
Aenderungsprotokoll gibt und in der Matrix zu sehen ist.
|
||||||
|
"""
|
||||||
if not permissions:
|
if not permissions:
|
||||||
return True
|
return True
|
||||||
return all(permission in self.effective_permissions for permission in permissions)
|
return all(permission in self.effective_permissions for permission in permissions)
|
||||||
|
|
||||||
def has_any(self, permissions: Iterable[str]) -> bool:
|
def has_any(self, permissions: Iterable[str]) -> bool:
|
||||||
if self.is_admin:
|
|
||||||
return True
|
|
||||||
effective = self.effective_permissions
|
effective = self.effective_permissions
|
||||||
return any(permission in effective for permission in permissions)
|
return any(permission in effective for permission in permissions)
|
||||||
|
|
||||||
@@ -189,9 +205,22 @@ def _deny(message: str) -> Any:
|
|||||||
if not current_user.is_authenticated:
|
if not current_user.is_authenticated:
|
||||||
if request.method == "GET" and not request.headers.get("X-Requested-With"):
|
if request.method == "GET" and not request.headers.get("X-Requested-With"):
|
||||||
session["_after_login"] = request.full_path if request.query_string else request.path
|
session["_after_login"] = request.full_path if request.query_string else request.path
|
||||||
|
# Eine automatische Aktualisierung fragt einen Ausschnitt ab und
|
||||||
|
# erwartet HTML. Eine Umleitung auf die Anmeldeseite wuerde sie
|
||||||
|
# dankbar entgegennehmen und eine ganze Seite in eine Kachel setzen --
|
||||||
|
# der Tab bliebe stumm stehen, obwohl die Sitzung abgelaufen ist. 401
|
||||||
|
# sagt es deutlich; der Browser laedt daraufhin die Seite neu und der
|
||||||
|
# Mensch sieht die Anmeldemaske.
|
||||||
|
if is_automatic(request):
|
||||||
|
abort(401, description="Sitzung abgelaufen.")
|
||||||
if request.accept_mimetypes.accept_json and not request.accept_mimetypes.accept_html:
|
if request.accept_mimetypes.accept_json and not request.accept_mimetypes.accept_html:
|
||||||
abort(401, description="Anmeldung erforderlich.")
|
abort(401, description="Anmeldung erforderlich.")
|
||||||
flash("Bitte melden Sie sich an.", "info")
|
# "Bitte melden Sie sich an." stand auf einer Seite, die aus einem
|
||||||
|
# Anmeldefeld, einem Passwortfeld und einem Knopf "Anmelden" besteht --
|
||||||
|
# eine Meldung, die nichts hinzufuegt. Wer dagegen gerade noch
|
||||||
|
# angemeldet war, soll erfahren, warum er es nicht mehr ist.
|
||||||
|
if getattr(g, "session_expired", False):
|
||||||
|
flash("Ihre Sitzung ist abgelaufen. Bitte melden Sie sich erneut an.", "info")
|
||||||
return redirect(url_for("auth.login"))
|
return redirect(url_for("auth.login"))
|
||||||
abort(403, description=message)
|
abort(403, description=message)
|
||||||
|
|
||||||
@@ -206,6 +235,37 @@ def login_required(view: Callable[..., Any]) -> Callable[..., Any]:
|
|||||||
return wrapper
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
def _verlangt_wiederanmeldung(permission: str) -> bool:
|
||||||
|
"""Fragt das Rechtemodell -- ausserhalb einer Anwendung nie."""
|
||||||
|
try:
|
||||||
|
from ..extension import core
|
||||||
|
|
||||||
|
return bool(core().permissions.verlangt_wiederanmeldung(permission))
|
||||||
|
except Exception: # noqa: BLE001 - ohne Modell keine zusaetzliche Huerde
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _wiederanmeldung_frisch() -> bool:
|
||||||
|
from .service import reauth_is_fresh # zyklischen Import vermeiden
|
||||||
|
|
||||||
|
return reauth_is_fresh()
|
||||||
|
|
||||||
|
|
||||||
|
def _bitte_bestaetigen(permission: str) -> Any:
|
||||||
|
"""Zur Passwortbestaetigung schicken -- oder es einem Skript sagen.
|
||||||
|
|
||||||
|
Ein Aufruf per fetch bekaeme sonst die Anmeldeseite als Antwort und wuesste
|
||||||
|
nichts damit anzufangen.
|
||||||
|
"""
|
||||||
|
if is_automatic(request) or (
|
||||||
|
request.accept_mimetypes.accept_json and not request.accept_mimetypes.accept_html
|
||||||
|
):
|
||||||
|
abort(403, description="Bitte bestätigen Sie zur Sicherheit Ihr Passwort.")
|
||||||
|
session["_after_reauth"] = request.full_path if request.query_string else request.path
|
||||||
|
flash("Bitte bestätigen Sie zur Sicherheit Ihr Passwort.", "info")
|
||||||
|
return redirect(url_for("auth.reauth"))
|
||||||
|
|
||||||
|
|
||||||
def permission_required(*permissions: str, mode: str = "all") -> Callable[..., Any]:
|
def permission_required(*permissions: str, mode: str = "all") -> Callable[..., Any]:
|
||||||
"""Erzwingt Rechte serverseitig.
|
"""Erzwingt Rechte serverseitig.
|
||||||
|
|
||||||
@@ -223,7 +283,15 @@ def permission_required(*permissions: str, mode: str = "all") -> Callable[..., A
|
|||||||
else current_user.has(*permissions)
|
else current_user.has(*permissions)
|
||||||
)
|
)
|
||||||
if not ok:
|
if not ok:
|
||||||
return _deny("Fuer diese Seite fehlt Ihnen die Berechtigung.")
|
return _deny("Für diese Seite fehlt Ihnen die Berechtigung.")
|
||||||
|
# Geheimnisse und Exporte verlangen eine frische Bestaetigung --
|
||||||
|
# das steht im Rechtemodell und wird deshalb hier abgefragt, nicht
|
||||||
|
# in jeder Route einzeln. Vorher hing es daran, dass jemand an den
|
||||||
|
# Dekorator dachte: dieselbe Lizenz herunterzuladen fragte nach,
|
||||||
|
# sie per Mail zu verschicken nicht.
|
||||||
|
heikel = [p for p in permissions if _verlangt_wiederanmeldung(p)]
|
||||||
|
if heikel and not _wiederanmeldung_frisch():
|
||||||
|
return _bitte_bestaetigen(heikel[0])
|
||||||
return view(*args, **kwargs)
|
return view(*args, **kwargs)
|
||||||
|
|
||||||
wrapper._required_permissions = permissions # type: ignore[attr-defined]
|
wrapper._required_permissions = permissions # type: ignore[attr-defined]
|
||||||
@@ -265,7 +333,7 @@ def reauth_required(view: Callable[..., Any]) -> Callable[..., Any]:
|
|||||||
return _deny("Anmeldung erforderlich.")
|
return _deny("Anmeldung erforderlich.")
|
||||||
if not reauth_is_fresh():
|
if not reauth_is_fresh():
|
||||||
session["_after_reauth"] = request.full_path if request.query_string else request.path
|
session["_after_reauth"] = request.full_path if request.query_string else request.path
|
||||||
flash("Bitte bestaetigen Sie zur Sicherheit Ihr Passwort.", "info")
|
flash("Bitte bestätigen Sie zur Sicherheit Ihr Passwort.", "info")
|
||||||
return redirect(url_for("auth.reauth"))
|
return redirect(url_for("auth.reauth"))
|
||||||
return view(*args, **kwargs)
|
return view(*args, **kwargs)
|
||||||
|
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ def login():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/login/zweiter-faktor", methods=["GET", "POST"])
|
@bp.route("/login/second-factor", methods=["GET", "POST"])
|
||||||
def second_factor():
|
def second_factor():
|
||||||
conn = db()
|
conn = db()
|
||||||
user_row = service.pending_totp_user(conn)
|
user_row = service.pending_totp_user(conn)
|
||||||
@@ -116,7 +116,7 @@ def logout_get():
|
|||||||
return redirect(url_for("auth.login"))
|
return redirect(url_for("auth.login"))
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/bestaetigen", methods=["GET", "POST"])
|
@bp.route("/confirm", methods=["GET", "POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def reauth():
|
def reauth():
|
||||||
target = _safe_next(session.get("_after_reauth"))
|
target = _safe_next(session.get("_after_reauth"))
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ def authenticate(
|
|||||||
if ip_verdict.blocked:
|
if ip_verdict.blocked:
|
||||||
return AuthOutcome(
|
return AuthOutcome(
|
||||||
"rate_limited",
|
"rate_limited",
|
||||||
"Zu viele Anmeldeversuche von dieser Adresse. Bitte spaeter erneut versuchen.",
|
"Zu viele Anmeldeversuche von dieser Adresse. Bitte später erneut versuchen.",
|
||||||
retry_after=ip_verdict.retry_after_seconds,
|
retry_after=ip_verdict.retry_after_seconds,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -165,7 +165,7 @@ def authenticate(
|
|||||||
if user_verdict.blocked:
|
if user_verdict.blocked:
|
||||||
return AuthOutcome(
|
return AuthOutcome(
|
||||||
"rate_limited",
|
"rate_limited",
|
||||||
"Dieses Konto ist nach mehreren Fehlversuchen voruebergehend gesperrt.",
|
"Dieses Konto ist nach mehreren Fehlversuchen vorübergehend gesperrt.",
|
||||||
retry_after=user_verdict.retry_after_seconds,
|
retry_after=user_verdict.retry_after_seconds,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -248,7 +248,7 @@ def _verify_credentials(
|
|||||||
return AuthOutcome(
|
return AuthOutcome(
|
||||||
"directory_unavailable",
|
"directory_unavailable",
|
||||||
"Der Verzeichnisdienst ist derzeit nicht erreichbar. "
|
"Der Verzeichnisdienst ist derzeit nicht erreichbar. "
|
||||||
"Bitte versuchen Sie es spaeter erneut.",
|
"Bitte versuchen Sie es später erneut.",
|
||||||
)
|
)
|
||||||
if result.status != "ok" or result.profile is None:
|
if result.status != "ok" or result.profile is None:
|
||||||
return AuthOutcome("invalid", GENERIC_FAILURE)
|
return AuthOutcome("invalid", GENERIC_FAILURE)
|
||||||
@@ -257,6 +257,24 @@ def _verify_credentials(
|
|||||||
return AuthOutcome("ok", user_row=user_row, group_dns=result.profile.group_dns)
|
return AuthOutcome("ok", user_row=user_row, group_dns=result.profile.group_dns)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _ist_erstanmeldung(user_row: dict[str, Any]) -> bool:
|
||||||
|
"""Meldet sich dieses Konto zum ersten Mal ueberhaupt an?
|
||||||
|
|
||||||
|
Das Aenderungsprotokoll soll Aenderungen zeigen. Eine taegliche Anmeldung
|
||||||
|
ist keine -- sie erzeugte aber jedes Mal eine Zeile und schob damit echte
|
||||||
|
Eintraege aus dem Blickfeld. Interessant ist genau die erste: mit ihr
|
||||||
|
entsteht das Konto aus dem Verzeichnisdienst und betritt jemand die
|
||||||
|
Anwendung, der vorher nicht darin war.
|
||||||
|
|
||||||
|
Nicht betroffen: fehlgeschlagene Anmeldungen und Sperren. Die stehen
|
||||||
|
weiterhin **jedes Mal** im Protokoll -- dort liegt der sicherheitsrelevante
|
||||||
|
Teil, und Haeufung ist dort das Signal. Wer wann zuletzt angemeldet war,
|
||||||
|
steht ausserdem unveraendert am Konto und in der Sitzungsuebersicht.
|
||||||
|
"""
|
||||||
|
return not str(user_row.get("last_login_at") or "").strip()
|
||||||
|
|
||||||
|
|
||||||
def complete_login(
|
def complete_login(
|
||||||
conn: sqlite3.Connection,
|
conn: sqlite3.Connection,
|
||||||
user_row: dict[str, Any],
|
user_row: dict[str, Any],
|
||||||
@@ -275,20 +293,25 @@ def complete_login(
|
|||||||
"""
|
"""
|
||||||
from ..security import csrf
|
from ..security import csrf
|
||||||
|
|
||||||
|
# Vor dem UPDATE lesen -- danach steht ueberall ein Zeitstempel.
|
||||||
|
erstanmeldung = _ist_erstanmeldung(user_row)
|
||||||
|
|
||||||
with core().database.transaction(conn):
|
with core().database.transaction(conn):
|
||||||
session_store.rotate(conn, session, reason="login") # type: ignore[arg-type]
|
session_store.rotate(conn, session, reason="login") # type: ignore[arg-type]
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE users SET last_login_at=? WHERE id=?", (_iso(_now()), user_row["id"])
|
"UPDATE users SET last_login_at=? WHERE id=?", (_iso(_now()), user_row["id"])
|
||||||
)
|
)
|
||||||
|
if erstanmeldung:
|
||||||
audit.log(
|
audit.log(
|
||||||
conn,
|
conn,
|
||||||
actor=str(user_row["username"]),
|
actor=str(user_row["username"]),
|
||||||
actor_id=int(user_row["id"]),
|
actor_id=int(user_row["id"]),
|
||||||
action="auth.login",
|
action="auth.first_login",
|
||||||
target=str(user_row["username"]),
|
target=str(user_row["username"]),
|
||||||
detail=f"Quelle: {user_row.get('auth_source') or 'local'}",
|
detail=f"Erste Anmeldung | Quelle: {user_row.get('auth_source') or 'local'}",
|
||||||
ip=ip,
|
ip=ip,
|
||||||
request_id=getattr(g, "request_id", ""),
|
request_id=getattr(g, "request_id", ""),
|
||||||
|
severity="notice",
|
||||||
)
|
)
|
||||||
|
|
||||||
session.clear()
|
session.clear()
|
||||||
@@ -336,15 +359,11 @@ def logout(conn: sqlite3.Connection) -> None:
|
|||||||
current_app.logger.exception(
|
current_app.logger.exception(
|
||||||
"Abmeldehaken %r fehlgeschlagen", getattr(hook, "__name__", hook)
|
"Abmeldehaken %r fehlgeschlagen", getattr(hook, "__name__", hook)
|
||||||
)
|
)
|
||||||
with core().database.transaction(conn):
|
# Kein Protokolleintrag fuer das Abmelden: eine Abmeldung aendert
|
||||||
audit.log(
|
# nichts. Sie stand als "Info" in jeder zweiten Zeile und verdraengte
|
||||||
conn,
|
# die Eintraege, um die es geht. Wer wissen will, wer wann da war,
|
||||||
actor=user.username,
|
# liest die Sitzungsuebersicht; fehlgeschlagene Anmeldungen und
|
||||||
actor_id=user.id,
|
# Sperren stehen weiterhin jedes Mal im Protokoll.
|
||||||
action="auth.logout",
|
|
||||||
target=user.username,
|
|
||||||
ip=client_ip(),
|
|
||||||
)
|
|
||||||
session.destroy() if hasattr(session, "destroy") else session.clear() # type: ignore[attr-defined]
|
session.destroy() if hasattr(session, "destroy") else session.clear() # type: ignore[attr-defined]
|
||||||
g.user = ANONYMOUS
|
g.user = ANONYMOUS
|
||||||
|
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ def run_web_setup(app: Any, extension: Any, args: argparse.Namespace) -> int:
|
|||||||
port = getattr(args, flag, None)
|
port = getattr(args, flag, None)
|
||||||
if port:
|
if port:
|
||||||
if not 1 <= int(port) <= 65535:
|
if not 1 <= int(port) <= 65535:
|
||||||
print(f"Ungueltiger Port: {port}", file=sys.stderr)
|
print(f"Ungültiger Port: {port}", file=sys.stderr)
|
||||||
return 2
|
return 2
|
||||||
values[key] = int(port)
|
values[key] = int(port)
|
||||||
|
|
||||||
@@ -93,11 +93,11 @@ def run_web_setup(app: Any, extension: Any, args: argparse.Namespace) -> int:
|
|||||||
|
|
||||||
# -- Zertifikat --------------------------------------------------- #
|
# -- Zertifikat --------------------------------------------------- #
|
||||||
config = _config_from_settings(extension, conn)
|
config = _config_from_settings(extension, conn)
|
||||||
cert: dict[str, Any] = {"ok": True, "message": "Zertifikat unveraendert."}
|
cert: dict[str, Any] = {"ok": True, "message": "Zertifikat unverändert."}
|
||||||
|
|
||||||
if args.acme:
|
if args.acme:
|
||||||
if sysops is None or not sysops.available:
|
if sysops is None or not sysops.available:
|
||||||
print("Der Systemhelfer ist nicht verfuegbar.", file=sys.stderr)
|
print("Der Systemhelfer ist nicht verfügbar.", file=sys.stderr)
|
||||||
return 2
|
return 2
|
||||||
cert = webserver.request_certificate(
|
cert = webserver.request_certificate(
|
||||||
sysops,
|
sysops,
|
||||||
@@ -118,7 +118,7 @@ def run_web_setup(app: Any, extension: Any, args: argparse.Namespace) -> int:
|
|||||||
|
|
||||||
if args.self_signed and not args.acme:
|
if args.self_signed and not args.acme:
|
||||||
if sysops is None or not sysops.available:
|
if sysops is None or not sysops.available:
|
||||||
print("Der Systemhelfer ist nicht verfuegbar.", file=sys.stderr)
|
print("Der Systemhelfer ist nicht verfügbar.", file=sys.stderr)
|
||||||
return 2
|
return 2
|
||||||
suggested = webserver.local_names()
|
suggested = webserver.local_names()
|
||||||
cert = webserver.create_self_signed(
|
cert = webserver.create_self_signed(
|
||||||
@@ -144,7 +144,7 @@ def run_web_setup(app: Any, extension: Any, args: argparse.Namespace) -> int:
|
|||||||
if args.https:
|
if args.https:
|
||||||
if not (config.cert_path and config.key_path):
|
if not (config.cert_path and config.key_path):
|
||||||
print(
|
print(
|
||||||
"HTTPS verlangt Zertifikat und Schluessel -- bitte --self-signed "
|
"HTTPS verlangt Zertifikat und Schlüssel – bitte --self-signed "
|
||||||
"oder --acme angeben.",
|
"oder --acme angeben.",
|
||||||
file=sys.stderr,
|
file=sys.stderr,
|
||||||
)
|
)
|
||||||
@@ -172,7 +172,7 @@ def run_web_setup(app: Any, extension: Any, args: argparse.Namespace) -> int:
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
if sysops is None or not sysops.available:
|
if sysops is None or not sysops.available:
|
||||||
print("Der Systemhelfer ist nicht verfuegbar.", file=sys.stderr)
|
print("Der Systemhelfer ist nicht verfügbar.", file=sys.stderr)
|
||||||
return 2
|
return 2
|
||||||
result = webserver.apply_config(sysops, rendered)
|
result = webserver.apply_config(sysops, rendered)
|
||||||
print(result["message"])
|
print(result["message"])
|
||||||
@@ -194,7 +194,7 @@ def add_web_setup(sub: Any, runner: Any) -> None:
|
|||||||
help="nginx und TLS einrichten (Port 80/443)",
|
help="nginx und TLS einrichten (Port 80/443)",
|
||||||
description=(
|
description=(
|
||||||
"Setzt die Webserver-Einstellungen und erzeugt daraus die nginx-Site. "
|
"Setzt die Webserver-Einstellungen und erzeugt daraus die nginx-Site. "
|
||||||
"Die Datei wird immer vollstaendig neu geschrieben -- eine von Hand "
|
"Die Datei wird immer vollständig neu geschrieben – eine von Hand "
|
||||||
"bearbeitete Site-Datei gibt es in diesem Projekt bewusst nicht."
|
"bearbeitete Site-Datei gibt es in diesem Projekt bewusst nicht."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -207,20 +207,20 @@ def add_web_setup(sub: Any, runner: Any) -> None:
|
|||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--acme",
|
"--acme",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
help="Zertifikat per Let's Encrypt anfordern (Domain muss oeffentlich zeigen)",
|
help="Zertifikat per Let's Encrypt anfordern (Domain muss öffentlich zeigen)",
|
||||||
)
|
)
|
||||||
parser.add_argument("--domain", help="Oeffentlicher Name dieser Installation")
|
parser.add_argument("--domain", help="Öffentlicher Name dieser Installation")
|
||||||
parser.add_argument("--acme-email", help="Kontaktadresse fuer Let's Encrypt")
|
parser.add_argument("--acme-email", help="Kontaktadresse für Let's Encrypt")
|
||||||
parser.add_argument("--hsts", action="store_true", help="HSTS-Header setzen")
|
parser.add_argument("--hsts", action="store_true", help="HSTS-Header setzen")
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--no-redirect",
|
"--no-redirect",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
help="Port 80 nicht auf HTTPS umleiten (Sonderfall)",
|
help="Port 80 nicht auf HTTPS umleiten (Sonderfall)",
|
||||||
)
|
)
|
||||||
parser.add_argument("--max-body-mb", type=int, help="Obergrenze fuer Uploads")
|
parser.add_argument("--max-body-mb", type=int, help="Obergrenze für Uploads")
|
||||||
parser.add_argument("--http-port", type=int, help="Abweichender HTTP-Port (Standard 80)")
|
parser.add_argument("--http-port", type=int, help="Abweichender HTTP-Port (Standard 80)")
|
||||||
parser.add_argument("--https-port", type=int, help="Abweichender HTTPS-Port (Standard 443)")
|
parser.add_argument("--https-port", type=int, help="Abweichender HTTPS-Port (Standard 443)")
|
||||||
parser.add_argument("--apply", action="store_true", help="nginx schreiben, pruefen, neu laden")
|
parser.add_argument("--apply", action="store_true", help="nginx schreiben, prüfen, neu laden")
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--print-config", action="store_true", help="Nur die erzeugte Datei ausgeben"
|
"--print-config", action="store_true", help="Nur die erzeugte Datei ausgeben"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ class CoreConfig:
|
|||||||
"""Technischer Kurzname, z. B. ``tesm`` -- Namensraum fuer Pfade und Units."""
|
"""Technischer Kurzname, z. B. ``tesm`` -- Namensraum fuer Pfade und Units."""
|
||||||
|
|
||||||
site_key: str
|
site_key: str
|
||||||
"""Name *dieser Installation* auf dem Host, z. B. ``tesm-opus``.
|
"""Name *dieser Installation* auf dem Host, z. B. ``tesm-test``.
|
||||||
|
|
||||||
Weicht vom ``app_key`` ab, sobald neben der Hauptinstallation eine zweite
|
Weicht vom ``app_key`` ab, sobald neben der Hauptinstallation eine zweite
|
||||||
Instanz laeuft. Alles, was ausserhalb von ``/srv/<name>`` liegt und damit
|
Instanz laeuft. Alles, was ausserhalb von ``/srv/<name>`` liegt und damit
|
||||||
@@ -175,11 +175,11 @@ def load_core_config(
|
|||||||
|
|
||||||
bind = (_env(env_prefix, "BIND") or default_bind).strip()
|
bind = (_env(env_prefix, "BIND") or default_bind).strip()
|
||||||
if not re.fullmatch(r"[0-9A-Za-z_.\[\]:-]{1,80}:\d{1,5}", bind):
|
if not re.fullmatch(r"[0-9A-Za-z_.\[\]:-]{1,80}:\d{1,5}", bind):
|
||||||
raise ValueError(f"Ungueltige Bind-Adresse: {bind!r}")
|
raise ValueError(f"Ungültige Bind-Adresse: {bind!r}")
|
||||||
|
|
||||||
site_key = (_env(env_prefix, "SITE_KEY") or app_key).strip().lower()
|
site_key = (_env(env_prefix, "SITE_KEY") or app_key).strip().lower()
|
||||||
if not re.fullmatch(r"[a-z0-9][a-z0-9-]{0,39}", site_key):
|
if not re.fullmatch(r"[a-z0-9][a-z0-9-]{0,39}", site_key):
|
||||||
raise ValueError(f"Ungueltiger SITE_KEY: {site_key!r}")
|
raise ValueError(f"Ungültiger SITE_KEY: {site_key!r}")
|
||||||
|
|
||||||
return CoreConfig(
|
return CoreConfig(
|
||||||
app_key=app_key,
|
app_key=app_key,
|
||||||
|
|||||||
@@ -141,11 +141,16 @@ CREATE TABLE IF NOT EXISTS service_accounts (
|
|||||||
updated_at TEXT NOT NULL
|
updated_at TEXT NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- Eine Verzeichnisgruppe zeigt entweder auf eine lokale Gruppe **oder** auf
|
||||||
|
-- das Administratorkennzeichen (grants_admin). Darum ist app_group_id
|
||||||
|
-- nullbar: "Domänen-Admins" soll echte Administratoren erzeugen koennen und
|
||||||
|
-- nicht nur Mitglieder der Gruppe "Administration".
|
||||||
CREATE TABLE IF NOT EXISTS ldap_group_mappings (
|
CREATE TABLE IF NOT EXISTS ldap_group_mappings (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
ad_group_dn TEXT NOT NULL UNIQUE,
|
ad_group_dn TEXT NOT NULL UNIQUE,
|
||||||
ad_group_name TEXT NOT NULL,
|
ad_group_name TEXT NOT NULL,
|
||||||
app_group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
app_group_id INTEGER REFERENCES groups(id) ON DELETE CASCADE,
|
||||||
|
grants_admin INTEGER NOT NULL DEFAULT 0,
|
||||||
created_at TEXT NOT NULL
|
created_at TEXT NOT NULL
|
||||||
);
|
);
|
||||||
"""
|
"""
|
||||||
@@ -186,8 +191,145 @@ CREATE INDEX IF NOT EXISTS idx_jobs_kind_state ON jobs(kind, state);
|
|||||||
CREATE INDEX IF NOT EXISTS idx_jobs_created ON jobs(created_at);
|
CREATE INDEX IF NOT EXISTS idx_jobs_created ON jobs(created_at);
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
def benenne_rechte_um(conn) -> None: # type: ignore[no-untyped-def]
|
||||||
|
"""Schreibt bestehende Gruppenrechte auf das neue Vokabular um.
|
||||||
|
|
||||||
|
``devices.view`` wird ``devices.read``, ``devices.edit`` wird
|
||||||
|
``devices.write``. Ohne diesen Schritt stehen in den Gruppen nur noch
|
||||||
|
Schluessel, die der Rechtebaum nicht kennt -- und
|
||||||
|
:meth:`PermissionTree.sanitize` wirft Unbekanntes **still** weg. Nach dem
|
||||||
|
Update waeren alle Gruppen leer und niemand kaeme mehr an seine Seiten:
|
||||||
|
kein Fehler, den man sieht, sondern einer, den man erst merkt, wenn jemand
|
||||||
|
anruft.
|
||||||
|
|
||||||
|
Im Kern und nicht in den Anwendungen, weil die Tabelle beiden gehoert.
|
||||||
|
Idempotent: was schon neu heisst, wird nicht angefasst.
|
||||||
|
"""
|
||||||
|
from .rbac.legacy_permissions import UMBENANNTE_AKTIONEN
|
||||||
|
|
||||||
|
for alt, neu in UMBENANNTE_AKTIONEN.items():
|
||||||
|
# Erst die Dubletten weg, die beim Umschreiben entstehen wuerden: haette
|
||||||
|
# eine Gruppe (aus welchem Grund auch immer) beide Schluessel, brach der
|
||||||
|
# eindeutige Index. Lieber vorher aufraeumen als in der Migration
|
||||||
|
# scheitern.
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM group_permissions WHERE permission LIKE ? AND EXISTS ("
|
||||||
|
" SELECT 1 FROM group_permissions AS andere"
|
||||||
|
" WHERE andere.group_id = group_permissions.group_id"
|
||||||
|
" AND andere.permission = substr(group_permissions.permission, 1,"
|
||||||
|
" length(group_permissions.permission) - ?) || ?)",
|
||||||
|
(f"%.{alt}", len(alt), neu),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE group_permissions"
|
||||||
|
" SET permission = substr(permission, 1, length(permission) - ?) || ?"
|
||||||
|
" WHERE permission LIKE ?",
|
||||||
|
(len(alt), neu, f"%.{alt}"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def zuordnung_auf_kennzeichen(conn) -> None: # type: ignore[no-untyped-def]
|
||||||
|
"""Erlaubt einer Verzeichnisgruppe, das Administratorkennzeichen zu setzen.
|
||||||
|
|
||||||
|
Bisher konnte eine Zuordnung nur auf eine lokale Gruppe zeigen. "Domänen-
|
||||||
|
Admins" landete deshalb in der Gruppe *Administration* -- und wurde damit
|
||||||
|
gerade **kein** echter Administrator: die Gruppe pflegt Benutzer und
|
||||||
|
Rechte, hat aber kein Kennzeichen und kann sich keines geben.
|
||||||
|
|
||||||
|
Zwei Aenderungen an der Tabelle, und SQLite kann Spalten nicht
|
||||||
|
umdefinieren: also neu anlegen und umkopieren. ``app_group_id`` wird
|
||||||
|
nullbar (eine Zuordnung auf das Kennzeichen zeigt auf keine Gruppe), und
|
||||||
|
``grants_admin`` sagt, dass genau das gemeint ist.
|
||||||
|
"""
|
||||||
|
spalten = {
|
||||||
|
str(zeile[1]) for zeile in conn.execute("PRAGMA table_info(ldap_group_mappings)")
|
||||||
|
}
|
||||||
|
if "grants_admin" in spalten:
|
||||||
|
return
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE ldap_group_mappings_neu (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
ad_group_dn TEXT NOT NULL UNIQUE,
|
||||||
|
ad_group_name TEXT NOT NULL,
|
||||||
|
app_group_id INTEGER REFERENCES groups(id) ON DELETE CASCADE,
|
||||||
|
grants_admin INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO ldap_group_mappings_neu "
|
||||||
|
"(id, ad_group_dn, ad_group_name, app_group_id, grants_admin, created_at) "
|
||||||
|
"SELECT id, ad_group_dn, ad_group_name, app_group_id, 0, created_at "
|
||||||
|
" FROM ldap_group_mappings"
|
||||||
|
)
|
||||||
|
conn.execute("DROP TABLE ldap_group_mappings")
|
||||||
|
conn.execute("ALTER TABLE ldap_group_mappings_neu RENAME TO ldap_group_mappings")
|
||||||
|
|
||||||
|
|
||||||
|
def teile_rechte_auf(conn) -> None: # type: ignore[no-untyped-def]
|
||||||
|
"""Verteilt ein altes Sammelrecht auf die Zeilen, die daraus wurden.
|
||||||
|
|
||||||
|
Additiv: wer ``dhcp.edit`` hatte, bekommt alle fuenf DHCP-Zeilen, und das
|
||||||
|
alte Recht verschwindet. Ohne diesen Schritt stuende in der Gruppe ein
|
||||||
|
Schluessel, den der Baum nicht kennt -- und weg waere er still, denn genau
|
||||||
|
das tut :meth:`PermissionTree.sanitize` mit Unbekanntem.
|
||||||
|
|
||||||
|
Laeuft nach ``benenne_rechte_um``: die Tabelle nennt beide Schreibweisen,
|
||||||
|
wo es darauf ankommt, und ein zweiter Lauf findet nichts mehr zu tun.
|
||||||
|
"""
|
||||||
|
from .rbac.legacy_permissions import (
|
||||||
|
ERWEITERTE_RECHTE,
|
||||||
|
GETEILTE_RECHTE,
|
||||||
|
neue_schluessel,
|
||||||
|
)
|
||||||
|
|
||||||
|
def gruppen_mit(recht: str) -> list[int]:
|
||||||
|
return [
|
||||||
|
int(zeile[0])
|
||||||
|
for zeile in conn.execute(
|
||||||
|
"SELECT DISTINCT group_id FROM group_permissions WHERE permission=?",
|
||||||
|
(recht,),
|
||||||
|
).fetchall()
|
||||||
|
]
|
||||||
|
|
||||||
|
# Geteilt: das alte Recht gibt es nicht mehr, also ersetzen.
|
||||||
|
for altes, neue in GETEILTE_RECHTE.items():
|
||||||
|
gruppen = gruppen_mit(altes)
|
||||||
|
if not gruppen:
|
||||||
|
continue
|
||||||
|
conn.executemany(
|
||||||
|
"INSERT OR IGNORE INTO group_permissions (group_id, permission) VALUES (?,?)",
|
||||||
|
[(gid, recht) for gid in gruppen for recht in neue],
|
||||||
|
)
|
||||||
|
conn.execute("DELETE FROM group_permissions WHERE permission=?", (altes,))
|
||||||
|
|
||||||
|
# Erweitert: das Recht bleibt und bekommt dazu, was bisher an ihm hing.
|
||||||
|
# Loeschen waere hier falsch -- es ist keines von gestern.
|
||||||
|
for recht, dazu in ERWEITERTE_RECHTE.items():
|
||||||
|
gruppen = gruppen_mit(recht)
|
||||||
|
if not gruppen:
|
||||||
|
continue
|
||||||
|
conn.executemany(
|
||||||
|
"INSERT OR IGNORE INTO group_permissions (group_id, permission) VALUES (?,?)",
|
||||||
|
[(gid, weiteres) for gid in gruppen for weiteres in dazu],
|
||||||
|
)
|
||||||
|
assert neue_schluessel # in derselben Tabelle nachgeschlagen wie beim Import
|
||||||
|
|
||||||
|
|
||||||
CORE_MIGRATIONS: tuple[Migration, ...] = (
|
CORE_MIGRATIONS: tuple[Migration, ...] = (
|
||||||
Migration(version=1, name="baseline", sql=V1_BASELINE),
|
Migration(
|
||||||
|
version=1,
|
||||||
|
name="baseline",
|
||||||
|
sql=V1_BASELINE,
|
||||||
|
geaendert_weil=(
|
||||||
|
"Das Grundschema legt ldap_group_mappings jetzt mit grants_admin und nullbarer app_group_id an -- eine neue Installation soll nicht erst core/6 brauchen, um die Tabelle wieder umzubauen."
|
||||||
|
),
|
||||||
|
),
|
||||||
Migration(version=2, name="trusted-issuer-keys", sql=V2_TRUSTED_KEYS),
|
Migration(version=2, name="trusted-issuer-keys", sql=V2_TRUSTED_KEYS),
|
||||||
Migration(version=3, name="jobs", sql=V3_JOBS),
|
Migration(version=3, name="jobs", sql=V3_JOBS),
|
||||||
|
Migration(version=4, name="rechte-read-write", fn=benenne_rechte_um),
|
||||||
|
Migration(version=5, name="rechte-je-funktion", fn=teile_rechte_auf),
|
||||||
|
Migration(version=6, name="ldap-kennzeichen", fn=zuordnung_auf_kennzeichen),
|
||||||
)
|
)
|
||||||
@@ -80,8 +80,8 @@ class Database:
|
|||||||
"""Oeffnet eine neue Verbindung. Nicht aufrufen, solange eine Transaktion laeuft."""
|
"""Oeffnet eine neue Verbindung. Nicht aufrufen, solange eine Transaktion laeuft."""
|
||||||
if self._depth() > 0:
|
if self._depth() > 0:
|
||||||
raise NestedConnectionError(
|
raise NestedConnectionError(
|
||||||
"Es laeuft bereits eine Schreibtransaktion auf dieser Datenbank. "
|
"Es läuft bereits eine Schreibtransaktion auf dieser Datenbank. "
|
||||||
"Reiche die bestehende Verbindung durch, statt eine neue zu oeffnen "
|
"Reiche die bestehende Verbindung durch, statt eine neue zu öffnen "
|
||||||
"(sonst blockiert SQLite sich selbst)."
|
"(sonst blockiert SQLite sich selbst)."
|
||||||
)
|
)
|
||||||
return self._raw_connect()
|
return self._raw_connect()
|
||||||
@@ -119,6 +119,29 @@ class Database:
|
|||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def dauerhaft(self, conn: sqlite3.Connection) -> Iterator[sqlite3.Connection]:
|
||||||
|
"""Fuer Schreibvorgaenge, die einen Stromausfall ueberstehen muessen.
|
||||||
|
|
||||||
|
Im Alltag laeuft die Datenbank mit ``synchronous=NORMAL``: schnell, und
|
||||||
|
bei einem Stromausfall gehen hoechstens die letzten Sekunden verloren --
|
||||||
|
die Datei bleibt heil. Fuer einen Messwert ist das genau richtig.
|
||||||
|
|
||||||
|
Fuer eine Unterschrift nicht. Dort liegt die Datei anschliessend auf der
|
||||||
|
Freigabe, und fehlte der Eintrag dazu, waere sie "unbekannt" statt
|
||||||
|
nachweisbar. Ein fsync kostet ein paar Millisekunden -- bei ein paar
|
||||||
|
Unterschriften am Tag ist das keine Diskussion wert.
|
||||||
|
|
||||||
|
Umschliesst die Transaktion, nicht umgekehrt: ``PRAGMA synchronous``
|
||||||
|
wirkt auf den naechsten Commit, und gesetzt werden kann es nur
|
||||||
|
ausserhalb einer laufenden Transaktion.
|
||||||
|
"""
|
||||||
|
conn.execute("PRAGMA synchronous=FULL")
|
||||||
|
try:
|
||||||
|
yield conn
|
||||||
|
finally:
|
||||||
|
conn.execute("PRAGMA synchronous=NORMAL")
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# Transaktionen
|
# Transaktionen
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ def core(app: Flask | None = None) -> TesmCore:
|
|||||||
try:
|
try:
|
||||||
return target.extensions["tesm"] # type: ignore[return-value]
|
return target.extensions["tesm"] # type: ignore[return-value]
|
||||||
except KeyError as exc: # pragma: no cover - Programmierfehler
|
except KeyError as exc: # pragma: no cover - Programmierfehler
|
||||||
raise RuntimeError("tesm_core ist fuer diese Anwendung nicht initialisiert") from exc
|
raise RuntimeError("tesm_core ist für diese Anwendung nicht initialisiert") from exc
|
||||||
|
|
||||||
|
|
||||||
def db() -> sqlite3.Connection:
|
def db() -> sqlite3.Connection:
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ def check_private_file(path: Path) -> list[str]:
|
|||||||
except OSError:
|
except OSError:
|
||||||
return problems
|
return problems
|
||||||
if mode & (stat.S_IRGRP | stat.S_IROTH | stat.S_IWGRP | stat.S_IWOTH):
|
if mode & (stat.S_IRGRP | stat.S_IROTH | stat.S_IWGRP | stat.S_IWOTH):
|
||||||
problems.append(f"{path} ist fuer Gruppe/Andere lesbar oder schreibbar (erwartet 0600)")
|
problems.append(f"{path} ist für Gruppe/Andere lesbar oder schreibbar (erwartet 0600)")
|
||||||
return problems
|
return problems
|
||||||
|
|
||||||
|
|
||||||
@@ -110,11 +110,11 @@ class Keystore:
|
|||||||
created_at=str(entry.get("created_at", "")),
|
created_at=str(entry.get("created_at", "")),
|
||||||
)
|
)
|
||||||
if len(key.material) != _KEY_BYTES:
|
if len(key.material) != _KEY_BYTES:
|
||||||
raise ValueError(f"Schluessel {key.key_id} hat die falsche Laenge")
|
raise ValueError(f"Schlüssel {key.key_id} hat die falsche Länge")
|
||||||
self._keys[key.key_id] = key
|
self._keys[key.key_id] = key
|
||||||
self._active = str(raw.get("active") or "")
|
self._active = str(raw.get("active") or "")
|
||||||
if self._active not in self._keys:
|
if self._active not in self._keys:
|
||||||
raise ValueError("Aktiver Schluessel fehlt im Schluesselbund")
|
raise ValueError("Aktiver Schlüssel fehlt im Schlüsselbund")
|
||||||
return
|
return
|
||||||
self._active = "1"
|
self._active = "1"
|
||||||
self._keys = {
|
self._keys = {
|
||||||
@@ -160,7 +160,7 @@ class Keystore:
|
|||||||
def drop_key(self, key_id: str) -> None:
|
def drop_key(self, key_id: str) -> None:
|
||||||
"""Entfernt einen alten Schluessel -- erst nach vollstaendiger Neuverschluesselung."""
|
"""Entfernt einen alten Schluessel -- erst nach vollstaendiger Neuverschluesselung."""
|
||||||
if key_id == self._active:
|
if key_id == self._active:
|
||||||
raise ValueError("Der aktive Schluessel kann nicht entfernt werden")
|
raise ValueError("Der aktive Schlüssel kann nicht entfernt werden")
|
||||||
self._keys.pop(key_id, None)
|
self._keys.pop(key_id, None)
|
||||||
self._persist()
|
self._persist()
|
||||||
|
|
||||||
@@ -183,14 +183,14 @@ class Keystore:
|
|||||||
raise DecryptionError(f"Unbekannte Chiffrat-Version: {prefix!r}")
|
raise DecryptionError(f"Unbekannte Chiffrat-Version: {prefix!r}")
|
||||||
key = self._keys.get(key_id)
|
key = self._keys.get(key_id)
|
||||||
if key is None:
|
if key is None:
|
||||||
raise DecryptionError(f"Schluessel {key_id} ist nicht im Schluesselbund")
|
raise DecryptionError(f"Schlüssel {key_id} ist nicht im Schlüsselbund")
|
||||||
try:
|
try:
|
||||||
plain = AESGCM(key.material).decrypt(
|
plain = AESGCM(key.material).decrypt(
|
||||||
_b64d(nonce_b64), _b64d(ct_b64), aad.encode("utf-8")
|
_b64d(nonce_b64), _b64d(ct_b64), aad.encode("utf-8")
|
||||||
)
|
)
|
||||||
except (InvalidTag, ValueError) as exc:
|
except (InvalidTag, ValueError) as exc:
|
||||||
raise DecryptionError(
|
raise DecryptionError(
|
||||||
"Chiffrat konnte nicht entschluesselt werden (falscher Schluessel, "
|
"Chiffrat konnte nicht entschlüsselt werden (falscher Schlüssel, "
|
||||||
"manipulierte Daten oder falscher Verwendungszweck)"
|
"manipulierte Daten oder falscher Verwendungszweck)"
|
||||||
) from exc
|
) from exc
|
||||||
return plain.decode("utf-8")
|
return plain.decode("utf-8")
|
||||||
|
|||||||
@@ -0,0 +1,242 @@
|
|||||||
|
"""Alte, deutsche Adressen auf die neuen englischen umlenken.
|
||||||
|
|
||||||
|
Mit 2.5.0 folgen die Adressen der Navigation und sind englisch:
|
||||||
|
``/monitoring/host-keys`` statt ``/hostschluessel``. Das ist eine gute
|
||||||
|
Aenderung und eine unangenehme zugleich -- Lesezeichen, offene Browsertabs und
|
||||||
|
ein Link in einer Mail zeigen weiter auf die alte Adresse. Ein offener Tab ist
|
||||||
|
dabei der unangenehmste Fall: die Statusuebersicht fragt alle 20 Sekunden ihre
|
||||||
|
Kacheln ab und bekam nach dem Update jedes Mal einen 404.
|
||||||
|
|
||||||
|
Deshalb diese Tabelle. Sie greift **nur, wenn sonst ein 404 herauskaeme** --
|
||||||
|
also nie vor einer bestehenden Route. Das ist keine Feinheit: auf dem
|
||||||
|
Lizenzserver ist ``/lizenz/<ticket>`` eine echte, weiterhin gueltige Adresse
|
||||||
|
(sie steht in verschickten Mails), waehrend dieselbe Wurzel in TESM zur
|
||||||
|
Lizenzseite fuehrte. Wer zuerst das Routing fragt, muss sich nicht zwischen
|
||||||
|
beidem entscheiden.
|
||||||
|
|
||||||
|
Die Tabelle ist bewusst vollstaendig und nicht nur auf Wurzeln gekuerzt: eine
|
||||||
|
Umleitung von ``/dateifreigaben/x/vorschau`` auf eine Uebersichtsseite waere
|
||||||
|
zwar kein 404 mehr, aber auch nicht das, was jemand angeklickt hat.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Mapping
|
||||||
|
|
||||||
|
#: Alte Wurzel -> (neue Wurzel, Uebersetzung der Segmente darunter).
|
||||||
|
WURZELN: dict[str, tuple[str, Mapping[str, str]]] = {
|
||||||
|
"/dateifreigaben": (
|
||||||
|
"/fileshares",
|
||||||
|
{
|
||||||
|
"herunterladen": "download",
|
||||||
|
"vorschau": "preview",
|
||||||
|
"hochladen": "upload",
|
||||||
|
"formular": "form",
|
||||||
|
"ordner": "folder",
|
||||||
|
"umbenennen": "rename",
|
||||||
|
"loeschen": "delete",
|
||||||
|
"freigeben": "release",
|
||||||
|
"zuordnungen": "mappings",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"/hostschluessel": (
|
||||||
|
"/monitoring/host-keys",
|
||||||
|
{"pruefen": "probe", "freigeben": "trust", "verwerfen": "forget", "verwaist": "orphaned"},
|
||||||
|
),
|
||||||
|
"/zugangsdaten": (
|
||||||
|
"/monitoring/credentials",
|
||||||
|
{"neu": "new", "loeschen": "delete", "anzeigen": "reveal"},
|
||||||
|
),
|
||||||
|
"/switche": (
|
||||||
|
"/monitoring/switches",
|
||||||
|
{
|
||||||
|
"neu": "new",
|
||||||
|
"loeschen": "delete",
|
||||||
|
"hostkey": "host-key",
|
||||||
|
"pruefen": "probe",
|
||||||
|
"freigeben": "trust",
|
||||||
|
"verwerfen": "forget",
|
||||||
|
"kommando": "command",
|
||||||
|
"konsole": "console",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"/clients": (
|
||||||
|
"/monitoring/clients",
|
||||||
|
{
|
||||||
|
"neu": "new",
|
||||||
|
"aktiv": "active",
|
||||||
|
"loeschen": "delete",
|
||||||
|
"neustart": "restart",
|
||||||
|
"hostkey": "host-key",
|
||||||
|
"pruefen": "probe",
|
||||||
|
"freigeben": "trust",
|
||||||
|
"verwerfen": "forget",
|
||||||
|
"konsole": "console",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"/wartung": (
|
||||||
|
"/maintenance",
|
||||||
|
{
|
||||||
|
"paket-nachinstallieren": "install-package",
|
||||||
|
"auftrag": "job",
|
||||||
|
"ausgabe": "output",
|
||||||
|
"aktualisieren": "update",
|
||||||
|
"neustarten": "restart",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"/protokolle": (
|
||||||
|
"/logs",
|
||||||
|
{
|
||||||
|
"kea": "dhcp",
|
||||||
|
"neustarts": "restarts",
|
||||||
|
"laufend": "live",
|
||||||
|
"inhalt": "content",
|
||||||
|
"verlauf": "history",
|
||||||
|
"datei": "file",
|
||||||
|
"archiv": "archive",
|
||||||
|
"archiv-export": "archive-export",
|
||||||
|
"archiv-loeschen": "archive-delete",
|
||||||
|
"aenderungen": "audit",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"/verwaltung": (
|
||||||
|
"/administration",
|
||||||
|
{
|
||||||
|
"benutzer": "users",
|
||||||
|
"neu": "new",
|
||||||
|
"loeschen": "delete",
|
||||||
|
"sitzungen-beenden": "end-sessions",
|
||||||
|
"verzeichnis-suche": "directory-search",
|
||||||
|
"verzeichnis-uebernehmen": "directory-import",
|
||||||
|
"gruppen": "groups",
|
||||||
|
"vorlage-anwenden": "apply-preset",
|
||||||
|
"sitzungen": "sessions",
|
||||||
|
"beenden": "end",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"/einstellungen": (
|
||||||
|
"/administration",
|
||||||
|
{
|
||||||
|
"zeitzone": "timezone",
|
||||||
|
"pruefung-jetzt": "check-now",
|
||||||
|
"netzwerk": "network",
|
||||||
|
"bestaetigen": "confirm",
|
||||||
|
"webserver": "web",
|
||||||
|
"uebernehmen": "apply",
|
||||||
|
"zertifikat": "certificate",
|
||||||
|
"selbstsigniert": "self-signed",
|
||||||
|
"verzeichnisdienst": "directory",
|
||||||
|
"gruppensuche": "group-search",
|
||||||
|
"zuordnung": "mapping",
|
||||||
|
"loeschen": "delete",
|
||||||
|
"anbieter": "vendor",
|
||||||
|
"logo-entfernen": "remove-logo",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"/sicherung": (
|
||||||
|
"/administration/backup",
|
||||||
|
{"vorschau": "preview", "uebernehmen": "apply", "verwerfen": "discard"},
|
||||||
|
),
|
||||||
|
"/papierkorb": (
|
||||||
|
"/administration/trash",
|
||||||
|
{"wiederherstellen": "restore", "endgueltig-loeschen": "purge", "leeren": "empty"},
|
||||||
|
),
|
||||||
|
"/diagnose": (
|
||||||
|
"/administration/diagnostics",
|
||||||
|
{"aufraeumen": "cleanup", "schluessel-rotieren": "rotate-keys"},
|
||||||
|
),
|
||||||
|
"/eigene-lizenz": (
|
||||||
|
"/own-license",
|
||||||
|
{
|
||||||
|
"einspielen": "install",
|
||||||
|
"aktivieren": "activate",
|
||||||
|
"deaktivieren": "deactivate",
|
||||||
|
"offline-antwort": "offline-response",
|
||||||
|
"signaturschluessel": "signing-key",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"/lizenzen": (
|
||||||
|
"/licenses",
|
||||||
|
{
|
||||||
|
"aus-ticket": "from-ticket",
|
||||||
|
"datei": "file",
|
||||||
|
"neu-signieren": "resign",
|
||||||
|
"widerrufen": "revoke",
|
||||||
|
"loeschen": "delete",
|
||||||
|
"versenden": "send",
|
||||||
|
"sub-lizenzserver": "sub-servers",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"/lizenz": (
|
||||||
|
"/administration/license",
|
||||||
|
{
|
||||||
|
"einspielen": "install",
|
||||||
|
"aktivieren": "activate",
|
||||||
|
"deaktivieren": "deactivate",
|
||||||
|
"offline-antwort": "offline-response",
|
||||||
|
"serverzertifikat": "server-certificate",
|
||||||
|
"aussteller": "issuer",
|
||||||
|
"entfernen": "remove",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"/kunden": ("/customers", {"neu": "new", "loeschen": "delete"}),
|
||||||
|
"/konto": (
|
||||||
|
"/account",
|
||||||
|
{
|
||||||
|
"stammdaten": "profile",
|
||||||
|
"profilbild": "avatar",
|
||||||
|
"sicherheit": "security",
|
||||||
|
"passwort": "password",
|
||||||
|
"zwei-faktor": "two-factor",
|
||||||
|
"starten": "start",
|
||||||
|
"bestaetigen": "confirm",
|
||||||
|
"deaktivieren": "disable",
|
||||||
|
"codes-erneuern": "recovery-codes",
|
||||||
|
"sitzungen": "sessions",
|
||||||
|
"beenden": "end",
|
||||||
|
"alle-beenden": "end-all",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"/uebersicht": ("/overview", {"kacheln": "tiles"}),
|
||||||
|
"/gesundheit": ("/health", {}),
|
||||||
|
"/bestaetigen": ("/confirm", {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
#: Abfrageparameter, die mit umbenannt wurden.
|
||||||
|
PARAMETER = {
|
||||||
|
"freigabe": "share",
|
||||||
|
"pfad": "path",
|
||||||
|
"aktion": "action",
|
||||||
|
"benutzer": "actor",
|
||||||
|
"stufe": "severity",
|
||||||
|
"seite": "page",
|
||||||
|
"methode": "method",
|
||||||
|
"kunde": "customer",
|
||||||
|
"freischalten": "unlocked",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#: Wurzeln, unter denen eine unbekannte Unterseite **nicht** umgelenkt wird.
|
||||||
|
#: ``/lizenz/<ticket>`` ist auf dem Lizenzserver eine echte Adresse; ein
|
||||||
|
#: abgelaufenes oder falsches Ticket gehoert dort auf die Fehlerseite und nicht
|
||||||
|
#: in die Verwaltung.
|
||||||
|
STRENG = {"/lizenz"}
|
||||||
|
|
||||||
|
|
||||||
|
def neue_adresse(pfad: str) -> str | None:
|
||||||
|
"""Uebersetzt eine alte Adresse -- oder ``None``, wenn es keine ist."""
|
||||||
|
for alt, (neu, segmente) in WURZELN.items():
|
||||||
|
if pfad != alt and not pfad.startswith(alt + "/"):
|
||||||
|
continue
|
||||||
|
rest = pfad[len(alt) :]
|
||||||
|
teile = rest.split("/")
|
||||||
|
if alt in STRENG:
|
||||||
|
erstes = next((teil for teil in teile if teil), "")
|
||||||
|
if erstes and erstes not in segmente:
|
||||||
|
return None
|
||||||
|
return neu + "/".join(segmente.get(teil, teil) for teil in teile)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def neue_parameter(args: Mapping[str, str]) -> dict[str, str]:
|
||||||
|
return {PARAMETER.get(name, name): wert for name, wert in args.items()}
|
||||||
@@ -34,7 +34,7 @@ import urllib.request
|
|||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Callable
|
from typing import Any, Callable, Iterable
|
||||||
|
|
||||||
import tesm_licensing as lic
|
import tesm_licensing as lic
|
||||||
|
|
||||||
@@ -54,6 +54,21 @@ SETTING_HEARTBEAT_ERROR = "license_heartbeat_error"
|
|||||||
EXPIRY_HINT_DAYS = 90
|
EXPIRY_HINT_DAYS = 90
|
||||||
SETTING_EXPORT_GRACE = "license_export_grace"
|
SETTING_EXPORT_GRACE = "license_export_grace"
|
||||||
|
|
||||||
|
#: Module, die der Betreiber vor Ort abgeschaltet hat (Liste von Namen).
|
||||||
|
#:
|
||||||
|
#: Die Lizenz sagt, *was erlaubt ist*; sie sagt nicht, was ein Haus auch
|
||||||
|
#: benutzen will. Wer kein DHCP ueber TESM fahren will, weil ein anderer Server
|
||||||
|
#: das tut, soll den Punkt nicht wegdruecken muessen -- und vor allem soll
|
||||||
|
#: niemand versehentlich eine fremde Kea-Konfiguration ueberschreiben koennen.
|
||||||
|
#: Abschalten ist deshalb mehr als Aufraeumen der Navigation: die Sperre sitzt
|
||||||
|
#: an derselben Stelle wie die Lizenzpruefung, also vor jeder Route.
|
||||||
|
#: Bewusst **ohne** das Praefix ``license_``: Sicherungen lassen alles mit
|
||||||
|
#: diesem Praefix draussen, weil Lizenz- und Aktivierungszustand zur Maschine
|
||||||
|
#: gehoeren und nicht in ein Backup. Der Schalter ist aber keine Lizenzangabe,
|
||||||
|
#: sondern eine Entscheidung des Hauses -- die soll eine Wiederherstellung
|
||||||
|
#: mitbringen.
|
||||||
|
SETTING_MODULES_OFF = "modules_disabled"
|
||||||
|
|
||||||
STATUS_INACTIVE = "inactive"
|
STATUS_INACTIVE = "inactive"
|
||||||
STATUS_PENDING = "pending"
|
STATUS_PENDING = "pending"
|
||||||
STATUS_ACTIVE = "active"
|
STATUS_ACTIVE = "active"
|
||||||
@@ -68,10 +83,10 @@ API_PATHS = {
|
|||||||
#: die einzige Wahrheit -- Templates lesen sie, Routen erzwingen sie.
|
#: die einzige Wahrheit -- Templates lesen sie, Routen erzwingen sie.
|
||||||
LICENSED_FEATURES: dict[str, str] = {
|
LICENSED_FEATURES: dict[str, str] = {
|
||||||
"directory": "Verzeichnisanmeldung (LDAP/AD) konfigurieren",
|
"directory": "Verzeichnisanmeldung (LDAP/AD) konfigurieren",
|
||||||
"nginx": "Webserver-Einstellungen aendern",
|
"nginx": "Webserver-Einstellungen ändern",
|
||||||
"export": "Daten exportieren",
|
"export": "Daten exportieren",
|
||||||
"trash_restore": "Papierkorb wiederherstellen und endgueltig loeschen",
|
"trash_restore": "Papierkorb wiederherstellen und endgültig löschen",
|
||||||
"monitor_interval": "Pruefintervall der Ueberwachung aendern",
|
"monitor_interval": "Prüfintervall der Überwachung ändern",
|
||||||
"auto_poe_restart": "Automatischer PoE-Neustart bei Ausfall",
|
"auto_poe_restart": "Automatischer PoE-Neustart bei Ausfall",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,7 +100,7 @@ def describe_transport_error(exc: BaseException, endpoint: str) -> str:
|
|||||||
"""
|
"""
|
||||||
text = str(exc)
|
text = str(exc)
|
||||||
if "CERTIFICATE_VERIFY_FAILED" in text or "certificate verify failed" in text:
|
if "CERTIFICATE_VERIFY_FAILED" in text or "certificate verify failed" in text:
|
||||||
detail = "Das Zertifikat konnte nicht geprueft werden"
|
detail = "Das Zertifikat konnte nicht geprüft werden"
|
||||||
if "self-signed" in text or "self signed" in text:
|
if "self-signed" in text or "self signed" in text:
|
||||||
detail = "Der Lizenzserver benutzt ein selbstsigniertes Zertifikat"
|
detail = "Der Lizenzserver benutzt ein selbstsigniertes Zertifikat"
|
||||||
return (
|
return (
|
||||||
@@ -94,7 +109,7 @@ def describe_transport_error(exc: BaseException, endpoint: str) -> str:
|
|||||||
"einer anerkannten Stelle."
|
"einer anerkannten Stelle."
|
||||||
)
|
)
|
||||||
if "Name or service not known" in text or "nodename nor servname" in text:
|
if "Name or service not known" in text or "nodename nor servname" in text:
|
||||||
return f"Der Name im Endpunkt ({endpoint}) laesst sich nicht aufloesen."
|
return f"Der Name im Endpunkt ({endpoint}) lässt sich nicht auflösen."
|
||||||
if "Connection refused" in text:
|
if "Connection refused" in text:
|
||||||
return f"{endpoint} nimmt keine Verbindung an."
|
return f"{endpoint} nimmt keine Verbindung an."
|
||||||
if "timed out" in text or "timeout" in text.lower():
|
if "timed out" in text or "timeout" in text.lower():
|
||||||
@@ -121,6 +136,8 @@ class LicenseState:
|
|||||||
last_heartbeat_at: str = ""
|
last_heartbeat_at: str = ""
|
||||||
heartbeat_error: str = ""
|
heartbeat_error: str = ""
|
||||||
pending_action: str = ""
|
pending_action: str = ""
|
||||||
|
#: Vor Ort abgeschaltet -- unabhaengig davon, was die Lizenz erlaubt.
|
||||||
|
modules_off: frozenset[str] = frozenset()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def role(self) -> str:
|
def role(self) -> str:
|
||||||
@@ -128,8 +145,14 @@ class LicenseState:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def modules(self) -> frozenset[str]:
|
def modules(self) -> frozenset[str]:
|
||||||
|
"""Was die Lizenz freischaltet -- ohne Ruecksicht auf den Schalter."""
|
||||||
return self.status.modules if self.status else frozenset()
|
return self.status.modules if self.status else frozenset()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def modules_in_use(self) -> frozenset[str]:
|
||||||
|
"""Was tatsaechlich laeuft: lizenziert **und** nicht abgeschaltet."""
|
||||||
|
return self.modules - self.modules_off
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def customer_name(self) -> str:
|
def customer_name(self) -> str:
|
||||||
return str((self.status.customer if self.status else {}).get("name") or "")
|
return str((self.status.customer if self.status else {}).get("name") or "")
|
||||||
@@ -179,6 +202,7 @@ class LicenseManager:
|
|||||||
"""Liest Lizenzdatei und Status neu ein."""
|
"""Liest Lizenzdatei und Status neu ein."""
|
||||||
with self._lock:
|
with self._lock:
|
||||||
state = LicenseState()
|
state = LicenseState()
|
||||||
|
state.modules_off = self.abgeschaltete_module(conn)
|
||||||
state.last_heartbeat_at = str(self.settings.get(conn, SETTING_LAST_HEARTBEAT) or "")
|
state.last_heartbeat_at = str(self.settings.get(conn, SETTING_LAST_HEARTBEAT) or "")
|
||||||
state.heartbeat_error = str(self.settings.get(conn, SETTING_HEARTBEAT_ERROR) or "")
|
state.heartbeat_error = str(self.settings.get(conn, SETTING_HEARTBEAT_ERROR) or "")
|
||||||
pending = self._pending(conn)
|
pending = self._pending(conn)
|
||||||
@@ -239,9 +263,51 @@ class LicenseManager:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def module_active(self, name: str) -> bool:
|
def module_active(self, name: str) -> bool:
|
||||||
"""Ist ein Vollmodul (dhcp/fileshare/maintenance) freigeschaltet?"""
|
"""Ist ein Vollmodul (dhcp/fileshare/maintenance) in Betrieb?
|
||||||
|
|
||||||
|
Zwei Bedingungen, und beide muessen gelten: die Lizenz muss es
|
||||||
|
freischalten, und der Betreiber darf es nicht abgeschaltet haben. Diese
|
||||||
|
eine Funktion fragen die Navigation, jede Route und die Hintergrund-
|
||||||
|
aufgaben -- darum sitzt der Schalter hier und nicht in einer Vorlage.
|
||||||
|
"""
|
||||||
|
return (
|
||||||
|
self.active()
|
||||||
|
and name in self._state.modules
|
||||||
|
and name not in self._state.modules_off
|
||||||
|
)
|
||||||
|
|
||||||
|
def module_licensed(self, name: str) -> bool:
|
||||||
|
"""Schaltet die Lizenz das Modul frei? (Ohne den Schalter vor Ort.)"""
|
||||||
return self.active() and name in self._state.modules
|
return self.active() and name in self._state.modules
|
||||||
|
|
||||||
|
def abgeschaltete_module(self, conn: sqlite3.Connection) -> frozenset[str]:
|
||||||
|
"""Liest die Liste aus den Einstellungen."""
|
||||||
|
gespeichert = self.settings.get(conn, SETTING_MODULES_OFF) or []
|
||||||
|
if isinstance(gespeichert, str): # Altbestand: ein einzelner Name
|
||||||
|
gespeichert = [gespeichert]
|
||||||
|
return frozenset(str(name) for name in gespeichert)
|
||||||
|
|
||||||
|
def module_abschalten(
|
||||||
|
self, conn: sqlite3.Connection, namen: Iterable[str], *, actor: str = ""
|
||||||
|
) -> tuple[frozenset[str], frozenset[str]]:
|
||||||
|
"""Setzt die Liste der abgeschalteten Module neu.
|
||||||
|
|
||||||
|
Gibt ``(eingeschaltet, abgeschaltet)`` zurueck -- also nur das, was sich
|
||||||
|
wirklich geaendert hat; der Aufrufer schreibt daraus seinen
|
||||||
|
Protokolleintrag.
|
||||||
|
|
||||||
|
Der Zustand wird hier nicht neu eingelesen, sondern nur an dieser einen
|
||||||
|
Stelle nachgezogen: ein voller Neuaufbau wuerde die Lizenzdatei erneut
|
||||||
|
pruefen, obwohl sich an ihr nichts geaendert hat -- und der Schalter ist
|
||||||
|
das Einzige, was jetzt anders ist.
|
||||||
|
"""
|
||||||
|
vorher = self.abgeschaltete_module(conn)
|
||||||
|
nachher = frozenset(str(name) for name in namen)
|
||||||
|
self.settings.set(conn, SETTING_MODULES_OFF, sorted(nachher), actor=actor)
|
||||||
|
with self._lock:
|
||||||
|
self._state.modules_off = nachher
|
||||||
|
return vorher - nachher, nachher - vorher
|
||||||
|
|
||||||
def feature_allowed(self, feature: str) -> bool:
|
def feature_allowed(self, feature: str) -> bool:
|
||||||
"""Lizenzpflichtige Einzelfunktion.
|
"""Lizenzpflichtige Einzelfunktion.
|
||||||
|
|
||||||
@@ -257,8 +323,8 @@ class LicenseManager:
|
|||||||
def require(self, feature: str) -> None:
|
def require(self, feature: str) -> None:
|
||||||
if not self.feature_allowed(feature):
|
if not self.feature_allowed(feature):
|
||||||
raise LicenseError(
|
raise LicenseError(
|
||||||
f"{LICENSED_FEATURES.get(feature, feature)} ist ohne gueltige, "
|
f"{LICENSED_FEATURES.get(feature, feature)} ist ohne gültige, "
|
||||||
"aktivierte Lizenz nicht moeglich."
|
"aktivierte Lizenz nicht möglich."
|
||||||
)
|
)
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
@@ -284,7 +350,7 @@ class LicenseManager:
|
|||||||
try:
|
try:
|
||||||
payload = json.loads(text)
|
payload = json.loads(text)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise LicenseError("Die Datei ist kein gueltiges JSON.") from exc
|
raise LicenseError("Die Datei ist kein gültiges JSON.") from exc
|
||||||
|
|
||||||
try:
|
try:
|
||||||
document, client_key = lic.split_bundle(payload)
|
document, client_key = lic.split_bundle(payload)
|
||||||
@@ -294,20 +360,20 @@ class LicenseManager:
|
|||||||
try:
|
try:
|
||||||
lic.verify_license(document) # innere Konsistenz
|
lic.verify_license(document) # innere Konsistenz
|
||||||
except lic.LicensingError as exc:
|
except lic.LicensingError as exc:
|
||||||
raise LicenseError(f"Die Lizenzdatei ist ungueltig: {exc}") from exc
|
raise LicenseError(f"Die Lizenzdatei ist ungültig: {exc}") from exc
|
||||||
|
|
||||||
master_pubkey = str(document.get("master_pubkey") or "")
|
master_pubkey = str(document.get("master_pubkey") or "")
|
||||||
known = self.trusted_keys(conn)
|
known = self.trusted_keys(conn)
|
||||||
if known and master_pubkey not in set(known) and not allow_new_issuer:
|
if known and master_pubkey not in set(known) and not allow_new_issuer:
|
||||||
raise LicenseError(
|
raise LicenseError(
|
||||||
"Diese Lizenz wurde von einem anderen Lizenzserver ausgestellt "
|
"Diese Lizenz wurde von einem anderen Lizenzserver ausgestellt "
|
||||||
f"(Schluessel {lic.key_id(master_pubkey)}). Ein Administrator muss den "
|
f"(Schlüssel {lic.key_id(master_pubkey)}). Ein Administrator muss den "
|
||||||
"Wechsel des Ausstellers ausdruecklich bestaetigen."
|
"Wechsel des Ausstellers ausdrücklich bestätigen."
|
||||||
)
|
)
|
||||||
|
|
||||||
if not client_key and not self.key_path.is_file():
|
if not client_key and not self.key_path.is_file():
|
||||||
raise LicenseError(
|
raise LicenseError(
|
||||||
"Der Datei fehlt der Client-Schluessel. Bitte das vollstaendige "
|
"Der Datei fehlt der Client-Schlüssel. Bitte das vollständige "
|
||||||
"Lizenz-Bundle des Lizenzservers verwenden."
|
"Lizenz-Bundle des Lizenzservers verwenden."
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -332,7 +398,7 @@ class LicenseManager:
|
|||||||
actor=actor,
|
actor=actor,
|
||||||
action="license.issuer_pinned",
|
action="license.issuer_pinned",
|
||||||
target=lic.key_id(master_pubkey),
|
target=lic.key_id(master_pubkey),
|
||||||
detail="Trust on first use" if not known else "Aussteller-Wechsel bestaetigt",
|
detail="Trust on first use" if not known else "Aussteller-Wechsel bestätigt",
|
||||||
severity="notice",
|
severity="notice",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -397,11 +463,11 @@ class LicenseManager:
|
|||||||
self, action: str, *, hostname: str = "", report: dict[str, Any] | None = None
|
self, action: str, *, hostname: str = "", report: dict[str, Any] | None = None
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
if not self._state.valid:
|
if not self._state.valid:
|
||||||
raise LicenseError("Es ist keine gueltige Lizenz installiert.")
|
raise LicenseError("Es ist keine gültige Lizenz installiert.")
|
||||||
key = self.client_key()
|
key = self.client_key()
|
||||||
if not key:
|
if not key:
|
||||||
raise LicenseError(
|
raise LicenseError(
|
||||||
"Der Client-Schluessel dieser Lizenz fehlt. Bitte die Lizenzdatei erneut importieren."
|
"Der Client-Schlüssel dieser Lizenz fehlt. Bitte die Lizenzdatei erneut importieren."
|
||||||
)
|
)
|
||||||
return lic.build_client_request(
|
return lic.build_client_request(
|
||||||
action, self._state.document, key, hostname=hostname, report=report
|
action, self._state.document, key, hostname=hostname, report=report
|
||||||
@@ -442,8 +508,8 @@ class LicenseManager:
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
reason = (
|
reason = (
|
||||||
"In dieser Lizenz ist kein Lizenzserver hinterlegt -- sie kann sich nur "
|
"In dieser Lizenz ist kein Lizenzserver hinterlegt – sie kann sich nur "
|
||||||
"ueber den Offline-Weg aktivieren."
|
"über den Offline-Weg aktivieren."
|
||||||
)
|
)
|
||||||
|
|
||||||
# Offline-Weg: Anfrage merken -- **mit** dem Code. Zwischen Anfrage und
|
# Offline-Weg: Anfrage merken -- **mit** dem Code. Zwischen Anfrage und
|
||||||
@@ -475,7 +541,7 @@ class LicenseManager:
|
|||||||
"code": code,
|
"code": code,
|
||||||
"message": (
|
"message": (
|
||||||
(reason + " " if reason else "")
|
(reason + " " if reason else "")
|
||||||
+ "Der Offline-Weg steht offen: uebermitteln Sie den angezeigten Code an "
|
+ "Der Offline-Weg steht offen: übermitteln Sie den angezeigten Code an "
|
||||||
"Ihren Ansprechpartner und tragen Sie die Antwort hier ein."
|
"Ihren Ansprechpartner und tragen Sie die Antwort hier ein."
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
@@ -527,7 +593,7 @@ class LicenseManager:
|
|||||||
current_seq=current_seq,
|
current_seq=current_seq,
|
||||||
)
|
)
|
||||||
except lic.LicensingError as exc:
|
except lic.LicensingError as exc:
|
||||||
raise LicenseError(f"Die Antwort des Lizenzservers ist nicht gueltig: {exc}") from exc
|
raise LicenseError(f"Die Antwort des Lizenzservers ist nicht gültig: {exc}") from exc
|
||||||
|
|
||||||
status = str(response.get("status") or "")
|
status = str(response.get("status") or "")
|
||||||
detail = str(response.get("detail") or "")
|
detail = str(response.get("detail") or "")
|
||||||
@@ -597,7 +663,7 @@ class LicenseManager:
|
|||||||
actor=actor,
|
actor=actor,
|
||||||
)
|
)
|
||||||
self.settings.set(conn, SETTING_HEARTBEAT_ERROR, "", actor=actor)
|
self.settings.set(conn, SETTING_HEARTBEAT_ERROR, "", actor=actor)
|
||||||
message = "Verbindung zum Lizenzserver bestaetigt."
|
message = "Verbindung zum Lizenzserver bestätigt."
|
||||||
|
|
||||||
self.settings.set(conn, SETTING_PENDING, "", actor=actor)
|
self.settings.set(conn, SETTING_PENDING, "", actor=actor)
|
||||||
# Ein erfolgreicher Heartbeat laeuft alle paar Minuten und ist kein
|
# Ein erfolgreicher Heartbeat laeuft alle paar Minuten und ist kein
|
||||||
@@ -648,7 +714,7 @@ class LicenseManager:
|
|||||||
url: str, payload: dict[str, Any], *, timeout: int, ca_pem: str = ""
|
url: str, payload: dict[str, Any], *, timeout: int, ca_pem: str = ""
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
if not url.startswith(("https://", "http://")):
|
if not url.startswith(("https://", "http://")):
|
||||||
raise ValueError("Ungueltiger Endpunkt")
|
raise ValueError("Ungültiger Endpunkt")
|
||||||
data = json.dumps(payload).encode("utf-8")
|
data = json.dumps(payload).encode("utf-8")
|
||||||
request = urllib.request.Request( # noqa: S310 - Schema oben geprueft
|
request = urllib.request.Request( # noqa: S310 - Schema oben geprueft
|
||||||
url,
|
url,
|
||||||
@@ -748,7 +814,7 @@ class LicenseManager:
|
|||||||
}
|
}
|
||||||
if not state.valid:
|
if not state.valid:
|
||||||
return {
|
return {
|
||||||
"label": "Lizenz ungueltig",
|
"label": "Lizenz ungültig",
|
||||||
"css_class": "status-pill--danger",
|
"css_class": "status-pill--danger",
|
||||||
"dot": "1",
|
"dot": "1",
|
||||||
"dot_class": "status-pill__dot--pulse",
|
"dot_class": "status-pill__dot--pulse",
|
||||||
@@ -773,29 +839,29 @@ class LicenseManager:
|
|||||||
"dot": "1",
|
"dot": "1",
|
||||||
"dot_class": "status-pill__dot--pulse",
|
"dot_class": "status-pill__dot--pulse",
|
||||||
"title": (
|
"title": (
|
||||||
"Die Kulanzfrist laeuft noch, Funktionen bleiben vorerst verfuegbar."
|
"Die Kulanzfrist läuft noch, Funktionen bleiben vorerst verfügbar."
|
||||||
if status.grace_active
|
if status.grace_active
|
||||||
else "Lizenzpflichtige Funktionen sind abgeschaltet."
|
else "Lizenzpflichtige Funktionen sind abgeschaltet."
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
if status.expiring_soon:
|
if status.expiring_soon:
|
||||||
return {
|
return {
|
||||||
"label": f"Lizenz laeuft in {status.days_left} Tagen ab",
|
"label": f"Lizenz läuft in {status.days_left} Tagen ab",
|
||||||
"css_class": "status-pill--warning",
|
"css_class": "status-pill--warning",
|
||||||
"dot": "1",
|
"dot": "1",
|
||||||
"dot_class": "status-pill__dot--pulse",
|
"dot_class": "status-pill__dot--pulse",
|
||||||
"title": "Bitte rechtzeitig verlaengern.",
|
"title": "Bitte rechtzeitig verlängern.",
|
||||||
}
|
}
|
||||||
# Zwischen der Warnschwelle und 90 Tagen: gruen, rein als Hinweis. Wer
|
# Zwischen der Warnschwelle und 90 Tagen: gruen, rein als Hinweis. Wer
|
||||||
# eine Verlaengerung im Voraus plant, will das Datum sehen, ohne dass es
|
# eine Verlaengerung im Voraus plant, will das Datum sehen, ohne dass es
|
||||||
# nach einem Problem aussieht.
|
# nach einem Problem aussieht.
|
||||||
if status.days_left is not None and 0 < status.days_left <= EXPIRY_HINT_DAYS:
|
if status.days_left is not None and 0 < status.days_left <= EXPIRY_HINT_DAYS:
|
||||||
return {
|
return {
|
||||||
"label": f"Lizenz laeuft in {status.days_left} Tagen ab",
|
"label": f"Lizenz läuft in {status.days_left} Tagen ab",
|
||||||
"css_class": "",
|
"css_class": "",
|
||||||
"dot": "1",
|
"dot": "1",
|
||||||
"dot_class": "",
|
"dot_class": "",
|
||||||
"title": "Nur ein Hinweis -- es ist nichts eingeschraenkt.",
|
"title": "Nur ein Hinweis – es ist nichts eingeschränkt.",
|
||||||
}
|
}
|
||||||
if state.last_heartbeat_at:
|
if state.last_heartbeat_at:
|
||||||
try:
|
try:
|
||||||
@@ -809,6 +875,6 @@ class LicenseManager:
|
|||||||
"css_class": "status-pill--warning",
|
"css_class": "status-pill--warning",
|
||||||
"dot": "1",
|
"dot": "1",
|
||||||
"dot_class": "",
|
"dot_class": "",
|
||||||
"title": "Rein informativ -- es wird nichts abgeschaltet.",
|
"title": "Rein informativ – es wird nichts abgeschaltet.",
|
||||||
}
|
}
|
||||||
return None
|
return None
|
||||||
@@ -94,7 +94,7 @@ def init_app(app: Flask, *, log_dir: Path, debug: bool, json_logs: bool = False)
|
|||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
except OSError as exc: # pragma: no cover - z. B. fehlende Rechte
|
except OSError as exc: # pragma: no cover - z. B. fehlende Rechte
|
||||||
root.warning("Konnte app.log nicht oeffnen: %s", exc)
|
root.warning("Konnte app.log nicht öffnen: %s", exc)
|
||||||
|
|
||||||
# Zugriffsprotokoll separat: eine Zeile pro Anfrage, ohne Stacktraces.
|
# Zugriffsprotokoll separat: eine Zeile pro Anfrage, ohne Stacktraces.
|
||||||
access = logging.getLogger(ACCESS_LOGGER)
|
access = logging.getLogger(ACCESS_LOGGER)
|
||||||
|
|||||||
@@ -50,6 +50,12 @@ class Migration:
|
|||||||
sql: str = ""
|
sql: str = ""
|
||||||
fn: MigrationFn | None = None
|
fn: MigrationFn | None = None
|
||||||
allow_integrity_error: bool = False
|
allow_integrity_error: bool = False
|
||||||
|
#: Begruendung, falls der Text einer bereits angewendeten Migration
|
||||||
|
#: absichtlich geaendert wurde -- etwa weil eine frische Installation
|
||||||
|
#: mit der neuen Schreibweise starten muss. Ohne Begruendung warnt der
|
||||||
|
#: Lauf, und das soll er auch: ein stiller Umbau einer angewendeten
|
||||||
|
#: Migration ist fast immer ein Versehen.
|
||||||
|
geaendert_weil: str = ""
|
||||||
tags: tuple[str, ...] = field(default=())
|
tags: tuple[str, ...] = field(default=())
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -175,10 +181,26 @@ class MigrationRunner:
|
|||||||
record = known.get(migration.version)
|
record = known.get(migration.version)
|
||||||
if record is not None:
|
if record is not None:
|
||||||
if record["checksum"] != migration.checksum:
|
if record["checksum"] != migration.checksum:
|
||||||
|
if migration.geaendert_weil:
|
||||||
|
# Mit Begruendung ist es eine Notiz, keine Warnung:
|
||||||
|
# manchmal muss der Text einer laengst angewendeten
|
||||||
|
# Migration mit, damit eine **frische** Installation
|
||||||
|
# denselben Stand erreicht. Wer das Protokoll liest,
|
||||||
|
# soll den Unterschied zu einem Versehen sehen.
|
||||||
|
logger.info(
|
||||||
|
"Migration %s/%s (%s) wurde absichtlich geändert: %s "
|
||||||
|
"Auf dieser Installation ist sie längst angewendet und "
|
||||||
|
"wird nicht erneut ausgeführt.",
|
||||||
|
component,
|
||||||
|
migration.version,
|
||||||
|
migration.name,
|
||||||
|
migration.geaendert_weil,
|
||||||
|
)
|
||||||
|
else:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Migration %s/%s (%s) wurde nachtraeglich geaendert "
|
"Migration %s/%s (%s) wurde nachträglich geändert "
|
||||||
"(Pruefsumme %s != %s). Bereits angewendete Migrationen werden "
|
"(Prüfsumme %s != %s). Bereits angewendete Migrationen werden "
|
||||||
"nicht erneut ausgefuehrt -- neue Aenderungen brauchen eine neue "
|
"nicht erneut ausgeführt – neue Änderungen brauchen eine neue "
|
||||||
"Versionsnummer.",
|
"Versionsnummer.",
|
||||||
component,
|
component,
|
||||||
migration.version,
|
migration.version,
|
||||||
@@ -210,7 +232,7 @@ class MigrationRunner:
|
|||||||
raise
|
raise
|
||||||
logger.error(
|
logger.error(
|
||||||
"Migration %s/%s (%s) konnte wegen bestehender Altdaten nicht "
|
"Migration %s/%s (%s) konnte wegen bestehender Altdaten nicht "
|
||||||
"angewendet werden. Der Start laeuft weiter; die Daten muessen "
|
"angewendet werden. Der Start läuft weiter; die Daten müssen "
|
||||||
"manuell bereinigt werden.",
|
"manuell bereinigt werden.",
|
||||||
component,
|
component,
|
||||||
migration.version,
|
migration.version,
|
||||||
|
|||||||
@@ -16,10 +16,10 @@ A = Action
|
|||||||
USERS = Resource(
|
USERS = Resource(
|
||||||
key="users",
|
key="users",
|
||||||
label="Benutzer",
|
label="Benutzer",
|
||||||
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE),
|
actions=(A.READ, A.CREATE, A.WRITE, A.DELETE),
|
||||||
hint="Konten anlegen, sperren, Gruppen zuordnen",
|
hint="Konten anlegen, sperren, Gruppen zuordnen",
|
||||||
action_hints={
|
action_hints={
|
||||||
A.EDIT: "Stammdaten, Sperre und Gruppenzuordnung aendern.",
|
A.WRITE: "Stammdaten, Sperre und Gruppenzuordnung ändern.",
|
||||||
A.DELETE: "Konto in den Papierkorb legen (Verzeichniskonten sofort entfernen).",
|
A.DELETE: "Konto in den Papierkorb legen (Verzeichniskonten sofort entfernen).",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -27,35 +27,46 @@ USERS = Resource(
|
|||||||
GROUPS = Resource(
|
GROUPS = Resource(
|
||||||
key="groups",
|
key="groups",
|
||||||
label="Gruppen und Rechte",
|
label="Gruppen und Rechte",
|
||||||
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE),
|
actions=(A.READ, A.CREATE, A.WRITE, A.DELETE),
|
||||||
hint="Rechtematrix pflegen",
|
hint="Rechtematrix pflegen",
|
||||||
action_hints={
|
action_hints={
|
||||||
A.EDIT: "Rechte vergeben -- nur Rechte, die man selbst besitzt.",
|
A.WRITE: "Rechte vergeben – nur Rechte, die man selbst besitzt.",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
SETTINGS_SYSTEM = Resource(
|
SETTINGS_SYSTEM = Resource(
|
||||||
key="settings_system",
|
key="settings_system",
|
||||||
label="Systemeinstellungen",
|
label="Systemeinstellungen",
|
||||||
actions=(A.VIEW, A.EDIT, A.EXECUTE),
|
actions=(A.READ, A.WRITE, A.EXECUTE),
|
||||||
hint="Hostname, Zeitzone, Protokollrotation, Navigation",
|
hint="Hostname, Zeitzone, Protokollrotation, Sitzungsdauer",
|
||||||
action_hints={A.EXECUTE: "Systemaktionen ausloesen (Dienste, Netzwerk uebernehmen)."},
|
action_hints={A.EXECUTE: "Systemaktionen auslösen (Dienste, Netzwerk übernehmen)."},
|
||||||
|
)
|
||||||
|
|
||||||
|
#: Die Reihenfolge des Menues lief unter "Systemaktionen auslösen" mit -- also
|
||||||
|
#: unter demselben Recht wie das Aendern des Hostnamens. Es ist aber das
|
||||||
|
#: harmloseste, was man in der Verwaltung tun kann, und das haeufigste.
|
||||||
|
SETTINGS_NAV = Resource(
|
||||||
|
key="settings_nav",
|
||||||
|
parent="settings_system",
|
||||||
|
label="Navigation",
|
||||||
|
actions=(A.READ, A.WRITE),
|
||||||
|
hint="Reihenfolge der Menüpunkte und ihrer Unterpunkte",
|
||||||
)
|
)
|
||||||
|
|
||||||
SETTINGS_LICENSE = Resource(
|
SETTINGS_LICENSE = Resource(
|
||||||
key="settings_license",
|
key="settings_license",
|
||||||
label="Lizenz",
|
label="Lizenz",
|
||||||
actions=(A.VIEW, A.EDIT),
|
actions=(A.READ, A.WRITE),
|
||||||
hint="Lizenzdatei einspielen, aktivieren, deaktivieren",
|
hint="Lizenzdatei einspielen, aktivieren, deaktivieren",
|
||||||
)
|
)
|
||||||
|
|
||||||
SETTINGS_DIRECTORY = Resource(
|
SETTINGS_DIRECTORY = Resource(
|
||||||
key="settings_directory",
|
key="settings_directory",
|
||||||
label="Verzeichnisdienst (LDAP/AD)",
|
label="Verzeichnisdienst (LDAP/AD)",
|
||||||
actions=(A.VIEW, A.EDIT, A.EXECUTE, A.SECRETS),
|
actions=(A.READ, A.WRITE, A.EXECUTE, A.SECRETS),
|
||||||
hint="Anbindung und Gruppen-Zuordnung",
|
hint="Anbindung und Gruppen-Zuordnung",
|
||||||
action_hints={
|
action_hints={
|
||||||
A.EXECUTE: "Verbindungstest ausfuehren.",
|
A.EXECUTE: "Verbindungstest ausführen.",
|
||||||
A.SECRETS: "Bind-Passwort des Dienstkontos setzen.",
|
A.SECRETS: "Bind-Passwort des Dienstkontos setzen.",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -63,67 +74,78 @@ SETTINGS_DIRECTORY = Resource(
|
|||||||
SETTINGS_WEB = Resource(
|
SETTINGS_WEB = Resource(
|
||||||
key="settings_web",
|
key="settings_web",
|
||||||
label="Webserver und TLS",
|
label="Webserver und TLS",
|
||||||
actions=(A.VIEW, A.EDIT, A.EXECUTE),
|
actions=(A.READ, A.WRITE, A.EXECUTE),
|
||||||
hint="Domain, Zertifikat, HSTS",
|
hint="Domain, Zertifikat, HSTS",
|
||||||
action_hints={A.EXECUTE: "Konfiguration uebernehmen, Zertifikat anfordern."},
|
action_hints={A.EXECUTE: "Konfiguration übernehmen, Zertifikat anfordern."},
|
||||||
)
|
)
|
||||||
|
|
||||||
SETTINGS_NETWORK = Resource(
|
SETTINGS_NETWORK = Resource(
|
||||||
key="settings_network",
|
key="settings_network",
|
||||||
label="Netzwerkeinstellungen",
|
label="Netzwerkeinstellungen",
|
||||||
actions=(A.VIEW, A.EDIT),
|
actions=(A.READ, A.WRITE),
|
||||||
hint="IP-Adresse, Gateway, DNS des Hosts",
|
hint="IP-Adresse, Gateway, DNS des Hosts",
|
||||||
)
|
)
|
||||||
|
|
||||||
BACKUP = Resource(
|
#: Sichern und Einspielen sind zwei Zeilen, weil es zwei Vorgaenge mit sehr
|
||||||
key="backup",
|
#: verschiedenen Folgen sind: eine Sicherung zu erzeugen aendert nichts, eine
|
||||||
label="Sicherung und Uebertragung",
|
#: einzuspielen aendert alles. Sie liefen unter einem Recht, und wer eine
|
||||||
actions=(A.VIEW, A.EXECUTE, A.EXPORT, A.SECRETS),
|
#: Sicherung ziehen durfte, durfte damit auch eine fremde einspielen.
|
||||||
hint="Verschluesselter Export und Import",
|
BACKUP_EXPORT = Resource(
|
||||||
|
key="backup_export",
|
||||||
|
label="Sicherung erstellen",
|
||||||
|
actions=(A.READ, A.EXPORT, A.SECRETS),
|
||||||
|
hint="Verschlüsselter Export des Datenstands",
|
||||||
action_hints={
|
action_hints={
|
||||||
A.EXECUTE: "Eine Sicherung einspielen.",
|
|
||||||
A.EXPORT: "Sicherung erzeugen und herunterladen.",
|
A.EXPORT: "Sicherung erzeugen und herunterladen.",
|
||||||
A.SECRETS: "Zugangsdaten in die Sicherung einschliessen.",
|
A.SECRETS: "Zugangsdaten in die Sicherung einschließen.",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
BACKUP_IMPORT = Resource(
|
||||||
|
key="backup_import",
|
||||||
|
label="Sicherung einspielen",
|
||||||
|
actions=(A.READ, A.EXECUTE),
|
||||||
|
hint="Prüfen, übernehmen, verwerfen",
|
||||||
|
action_hints={A.EXECUTE: "Eine geprüfte Sicherung anwenden."},
|
||||||
|
)
|
||||||
|
|
||||||
TRASH = Resource(
|
TRASH = Resource(
|
||||||
key="trash",
|
key="trash",
|
||||||
label="Papierkorb",
|
label="Papierkorb",
|
||||||
actions=(A.VIEW, A.EDIT, A.DELETE),
|
actions=(A.READ, A.WRITE, A.DELETE),
|
||||||
hint="Geloeschtes wiederherstellen oder endgueltig entfernen",
|
hint="Gelöschtes wiederherstellen oder endgültig entfernen",
|
||||||
action_hints={
|
action_hints={
|
||||||
A.EDIT: "Eintraege wiederherstellen.",
|
A.WRITE: "Einträge wiederherstellen.",
|
||||||
A.DELETE: "Eintraege endgueltig entfernen.",
|
A.DELETE: "Einträge endgültig entfernen.",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
DIAGNOSTICS = Resource(
|
DIAGNOSTICS = Resource(
|
||||||
key="diagnostics",
|
key="diagnostics",
|
||||||
label="Diagnose",
|
label="Diagnose",
|
||||||
actions=(A.VIEW,),
|
actions=(A.READ,),
|
||||||
hint="Schemastand, Protokollintegritaet, Schluesselrechte",
|
hint="Schemastand, Protokollintegrität, Schlüsselrechte",
|
||||||
)
|
)
|
||||||
|
|
||||||
LOGS_LIVE = Resource(
|
LOGS_LIVE = Resource(
|
||||||
key="logs_live",
|
key="logs_live",
|
||||||
label="Laufendes Protokoll",
|
label="Laufendes Protokoll",
|
||||||
actions=(A.VIEW,),
|
actions=(A.READ,),
|
||||||
)
|
)
|
||||||
|
|
||||||
LOGS_HISTORY = Resource(
|
LOGS_HISTORY = Resource(
|
||||||
key="logs_history",
|
key="logs_history",
|
||||||
label="Protokollverlauf",
|
label="Protokollverlauf",
|
||||||
actions=(A.VIEW, A.DELETE, A.EXPORT),
|
actions=(A.READ, A.DELETE, A.EXPORT),
|
||||||
hint="Rotierte und archivierte Protokolle",
|
hint="Rotierte und archivierte Protokolle",
|
||||||
action_hints={A.DELETE: "Archivierte Tage vom Server entfernen."},
|
action_hints={A.DELETE: "Archivierte Tage vom Server entfernen."},
|
||||||
)
|
)
|
||||||
|
|
||||||
LOGS_AUDIT = Resource(
|
LOGS_AUDIT = Resource(
|
||||||
key="logs_audit",
|
key="logs_audit",
|
||||||
label="Aenderungsprotokoll",
|
label="Änderungsprotokoll",
|
||||||
actions=(A.VIEW, A.EXPORT),
|
actions=(A.READ, A.EXPORT),
|
||||||
hint="Wer hat wann was geaendert",
|
hint="Wer hat wann was geändert",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -139,11 +161,13 @@ def administration_area(*extra: Resource) -> Area:
|
|||||||
USERS,
|
USERS,
|
||||||
GROUPS,
|
GROUPS,
|
||||||
SETTINGS_SYSTEM,
|
SETTINGS_SYSTEM,
|
||||||
|
SETTINGS_NAV,
|
||||||
SETTINGS_LICENSE,
|
SETTINGS_LICENSE,
|
||||||
SETTINGS_DIRECTORY,
|
SETTINGS_DIRECTORY,
|
||||||
SETTINGS_WEB,
|
SETTINGS_WEB,
|
||||||
SETTINGS_NETWORK,
|
SETTINGS_NETWORK,
|
||||||
BACKUP,
|
BACKUP_EXPORT,
|
||||||
|
BACKUP_IMPORT,
|
||||||
TRASH,
|
TRASH,
|
||||||
DIAGNOSTICS,
|
DIAGNOSTICS,
|
||||||
*extra,
|
*extra,
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
"""Alte Rechteschluessel auf neue -- an einer Stelle, nicht an dreien.
|
||||||
|
|
||||||
|
Mit 2.8.0 heisst das Aktionsvokabular durchgehend wie das, was es tut:
|
||||||
|
``view`` wurde ``read``, ``edit`` wurde ``write``. Danach steht in der Matrix
|
||||||
|
"Write" und im Aenderungsprotokoll ``write``, nicht zweierlei.
|
||||||
|
|
||||||
|
Drei Stellen brauchen die Zuordnung, und darum steht sie hier und nicht dort:
|
||||||
|
|
||||||
|
* die **Migration**, die bestehende Gruppenzeilen umschreibt,
|
||||||
|
* der **Import**, dem eine Sicherung aus 2.7.x untergeschoben wird -- ohne die
|
||||||
|
Zuordnung landen dort lauter unbekannte Schluessel, und
|
||||||
|
:meth:`PermissionTree.sanitize` wirft Unbekanntes absichtlich **still** weg.
|
||||||
|
Aus einer Sicherung wuerden Gruppen ohne Rechte.
|
||||||
|
* der **Test**, der prueft, dass kein alter Schluessel mehr im Code steht.
|
||||||
|
|
||||||
|
Warum nicht dreimal dieselbe Ersetzung tippen: weil die dritte Kopie beim
|
||||||
|
naechsten Umbau vergessen wird. Genau so entstand das Muster bei den Adressen
|
||||||
|
(:mod:`tesm_core.legacy_urls`).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
#: Aktion alt -> neu. Die Ressource bleibt, wie sie heisst.
|
||||||
|
UMBENANNTE_AKTIONEN: dict[str, str] = {
|
||||||
|
"view": "read",
|
||||||
|
"edit": "write",
|
||||||
|
}
|
||||||
|
|
||||||
|
#: Ein altes Recht, das in mehrere neue zerfaellt -- ein Recht je Funktion.
|
||||||
|
#:
|
||||||
|
#: Die Aufteilung ist **additiv**: wer das alte Recht hatte, bekommt alle
|
||||||
|
#: neuen. Niemand verliert etwas; ab jetzt laesst sich jedes Stueck einzeln
|
||||||
|
#: vergeben. Der umgekehrte Weg waere auch denkbar gewesen -- nur das
|
||||||
|
#: Harmloseste uebernehmen --, waere aber ein Update, das Leuten Rechte
|
||||||
|
#: wegnimmt, ohne dass jemand es entschieden hat.
|
||||||
|
#:
|
||||||
|
#: Die Schluessel stehen fuer beide Anwendungen in einer Tabelle. Auf einer
|
||||||
|
#: TESM-Datenbank tut die Zeile fuer ``licenses.write`` nichts, weil sie dort
|
||||||
|
#: in keiner Gruppe steht -- und umgekehrt. Zwei getrennte Tabellen waeren
|
||||||
|
#: zwei Stellen, die man pflegen muss.
|
||||||
|
GETEILTE_RECHTE: dict[str, tuple[str, ...]] = {
|
||||||
|
# Drei Neustartwege mit drei verschiedenen Folgen.
|
||||||
|
"devices.execute": (
|
||||||
|
"device_restart_poe.read",
|
||||||
|
"device_restart_poe.execute",
|
||||||
|
"device_restart_ssh.read",
|
||||||
|
"device_restart_ssh.execute",
|
||||||
|
"device_restart_rpc.read",
|
||||||
|
"device_restart_rpc.execute",
|
||||||
|
),
|
||||||
|
# Die Switch-Konsole war "Switch ausfuehren".
|
||||||
|
"switches.execute": ("switch_console.read", "switch_console.execute"),
|
||||||
|
# Vier Funktionen: aktualisieren, neu starten, Werkzeug installieren --
|
||||||
|
# und die Client-Konsole, die jetzt bei den Clients steht.
|
||||||
|
"maintenance.read": (
|
||||||
|
"servicing_update.read",
|
||||||
|
"servicing_reboot.read",
|
||||||
|
"servicing_tool.read",
|
||||||
|
"device_console.read",
|
||||||
|
),
|
||||||
|
"maintenance.execute": (
|
||||||
|
"servicing_update.read",
|
||||||
|
"servicing_update.execute",
|
||||||
|
"servicing_reboot.read",
|
||||||
|
"servicing_reboot.execute",
|
||||||
|
"servicing_tool.read",
|
||||||
|
"servicing_tool.execute",
|
||||||
|
"device_console.read",
|
||||||
|
"device_console.execute",
|
||||||
|
),
|
||||||
|
# Elf Funktionen an einem Recht.
|
||||||
|
"dhcp.read": (
|
||||||
|
"dhcp_subnets.read",
|
||||||
|
"dhcp_options.read",
|
||||||
|
"dhcp_reservations.read",
|
||||||
|
"dhcp_config.read",
|
||||||
|
"dhcp_service.read",
|
||||||
|
),
|
||||||
|
"dhcp.write": (
|
||||||
|
"dhcp_subnets.read",
|
||||||
|
"dhcp_subnets.create",
|
||||||
|
"dhcp_subnets.write",
|
||||||
|
"dhcp_subnets.delete",
|
||||||
|
"dhcp_options.read",
|
||||||
|
"dhcp_options.write",
|
||||||
|
"dhcp_reservations.read",
|
||||||
|
"dhcp_reservations.create",
|
||||||
|
"dhcp_reservations.write",
|
||||||
|
"dhcp_reservations.delete",
|
||||||
|
"dhcp_config.read",
|
||||||
|
"dhcp_config.execute",
|
||||||
|
),
|
||||||
|
"dhcp.execute": ("dhcp_service.read", "dhcp_service.execute"),
|
||||||
|
# Sichern und Einspielen.
|
||||||
|
"backup.read": ("backup_export.read", "backup_import.read"),
|
||||||
|
"backup.export": ("backup_export.read", "backup_export.export"),
|
||||||
|
"backup.secrets": ("backup_export.read", "backup_export.secrets"),
|
||||||
|
"backup.execute": ("backup_import.read", "backup_import.execute"),
|
||||||
|
# Lizenzserver: "aendern" hiess widerrufen **oder** erneuern -- zwei
|
||||||
|
# Eingriffe mit entgegengesetzter Wirkung unter einem Haken.
|
||||||
|
"licenses.create": ("license_issue.read", "license_issue.execute"),
|
||||||
|
"licenses.write": (
|
||||||
|
"license_renew.read",
|
||||||
|
"license_renew.execute",
|
||||||
|
"license_revoke.read",
|
||||||
|
"license_revoke.execute",
|
||||||
|
),
|
||||||
|
"licenses.execute": ("license_activation.read", "license_activation.execute"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#: Ein Recht, das **bleibt** und bisher noch etwas anderes mitgab.
|
||||||
|
#:
|
||||||
|
#: Der Unterschied zu :data:`GETEILTE_RECHTE` ist keine Formsache: dort ist der
|
||||||
|
#: alte Schluessel verschwunden und muss ersetzt werden, hier gilt er weiter
|
||||||
|
#: und bekommt nur Gesellschaft. Beides in eine Tabelle zu werfen hiesse, in
|
||||||
|
#: der Migration ein Recht zu loeschen, das es noch gibt -- und im Test nicht
|
||||||
|
#: mehr pruefen zu koennen, welcher Fall gemeint war.
|
||||||
|
ERWEITERTE_RECHTE: dict[str, tuple[str, ...]] = {
|
||||||
|
# Die Navigationsreihenfolge lief unter "Systemaktionen ausloesen" mit.
|
||||||
|
"settings_system.execute": ("settings_nav.read", "settings_nav.write"),
|
||||||
|
# Einen Host-Schluessel freizugeben hing an den Stammdaten.
|
||||||
|
"devices.write": ("hostkeys.read", "hostkeys.execute"),
|
||||||
|
"switches.write": ("hostkeys.read", "hostkeys.execute"),
|
||||||
|
# Umbenennen war auch "Freigaben zuordnen".
|
||||||
|
"fileshare.write": (
|
||||||
|
"fileshare_mappings.read",
|
||||||
|
"fileshare_mappings.create",
|
||||||
|
"fileshare_mappings.write",
|
||||||
|
"fileshare_mappings.delete",
|
||||||
|
),
|
||||||
|
# Ausgefuellt ablegen war auch "unterschreiben".
|
||||||
|
"fileshare_pdf.create": ("fileshare_signature.read", "fileshare_signature.execute"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def neue_schluessel(alt: str) -> tuple[str, ...]:
|
||||||
|
"""Was aus einem alten Rechteschluessel wird -- eins oder mehrere.
|
||||||
|
|
||||||
|
Unbekanntes kommt unveraendert zurueck: ob ein Schluessel gilt, entscheidet
|
||||||
|
der Rechtebaum, nicht diese Tabelle. Sie benennt nur um.
|
||||||
|
"""
|
||||||
|
if alt in GETEILTE_RECHTE:
|
||||||
|
return GETEILTE_RECHTE[alt]
|
||||||
|
ressource, punkt, aktion = alt.rpartition(".")
|
||||||
|
if not punkt:
|
||||||
|
return (alt,)
|
||||||
|
neu = UMBENANNTE_AKTIONEN.get(aktion)
|
||||||
|
selbst = f"{ressource}.{neu}" if neu else alt
|
||||||
|
# Ein Recht, das bleibt, nimmt mit, was es bisher mitgab -- sonst verliert
|
||||||
|
# jemand beim Update genau die Funktion, die vorher an diesem Haken hing.
|
||||||
|
return (selbst, *ERWEITERTE_RECHTE.get(selbst, ()))
|
||||||
|
|
||||||
|
|
||||||
|
def umgeschrieben(rechte: object) -> list[str]:
|
||||||
|
"""Eine ganze Liste umschreiben, ohne Dubletten und in fester Reihenfolge."""
|
||||||
|
if not isinstance(rechte, (list, tuple, set, frozenset)):
|
||||||
|
return []
|
||||||
|
ergebnis: set[str] = set()
|
||||||
|
for recht in rechte:
|
||||||
|
ergebnis.update(neue_schluessel(str(recht)))
|
||||||
|
return sorted(ergebnis)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"UMBENANNTE_AKTIONEN",
|
||||||
|
"GETEILTE_RECHTE",
|
||||||
|
"ERWEITERTE_RECHTE",
|
||||||
|
"neue_schluessel",
|
||||||
|
"umgeschrieben",
|
||||||
|
]
|
||||||
@@ -1,6 +1,14 @@
|
|||||||
"""Rechtemodell: Bereiche, Ressourcen, Aktionen.
|
"""Rechtemodell: Bereiche, Ressourcen, Aktionen.
|
||||||
|
|
||||||
Das Vorgaengermodell kannte fuenf Spalten (R/W/E/D/X), wobei ``E`` ("Aendern")
|
Das Vokabular heisst seit 2.8.0 durchgehend wie das, was es tut: **Read,
|
||||||
|
Create, Write, Delete, Execute, Export, Secrets**. Bis dahin hiessen zwei
|
||||||
|
Aktionen anders (``view``, ``edit``), waehrend die Matrix "Bearbeiten" zeigte
|
||||||
|
und die Kurzfassung "U" -- dieselbe Aktion in drei Schreibweisen. Der
|
||||||
|
Kill-Switch eines Bereichs heisst entsprechend ``<bereich>.read``.
|
||||||
|
:mod:`tesm_core.rbac.legacy_permissions` schreibt alte Schluessel um; die
|
||||||
|
Migration ``core/4`` tut das fuer bestehende Gruppen.
|
||||||
|
|
||||||
|
Das Modell vor 2.0 kannte fuenf Spalten (R/W/E/D/X), wobei ``E`` ("Aendern")
|
||||||
das Loeschen **mit abdeckte** und ``D``/``X`` je nach Zeile etwas voellig anderes
|
das Loeschen **mit abdeckte** und ``D``/``X`` je nach Zeile etwas voellig anderes
|
||||||
bedeuteten (Dashboard-Neustart bzw. Export). Das war kompakt, aber:
|
bedeuteten (Dashboard-Neustart bzw. Export). Das war kompakt, aber:
|
||||||
|
|
||||||
@@ -15,7 +23,7 @@ Aktionen eine Ressource ueberhaupt kennt, entscheidet die Ressource; die Matrix
|
|||||||
rendert nur die Spalten, die im jeweiligen Bereich wirklich vorkommen.
|
rendert nur die Spalten, die im jeweiligen Bereich wirklich vorkommen.
|
||||||
|
|
||||||
Beibehalten wird der bewaehrte **Kill-Switch**: jeder Bereich hat ein
|
Beibehalten wird der bewaehrte **Kill-Switch**: jeder Bereich hat ein
|
||||||
``<bereich>.view``-Recht. Ohne dieses greift kein einziges Recht innerhalb des
|
``<bereich>.read``-Recht. Ohne dieses greift kein einziges Recht innerhalb des
|
||||||
Bereichs -- unabhaengig davon, was einzeln angehakt ist. Das erlaubt es, einer
|
Bereichs -- unabhaengig davon, was einzeln angehakt ist. Das erlaubt es, einer
|
||||||
Gruppe einen ganzen Bereich mit einem Klick zu entziehen, ohne die fein
|
Gruppe einen ganzen Bereich mit einem Klick zu entziehen, ohne die fein
|
||||||
eingestellten Einzelrechte zu verlieren.
|
eingestellten Einzelrechte zu verlieren.
|
||||||
@@ -29,38 +37,68 @@ from typing import Iterable, Mapping, Sequence
|
|||||||
|
|
||||||
|
|
||||||
class Action(str, Enum):
|
class Action(str, Enum):
|
||||||
"""Aktionsvokabular. Bedeutung ist ueberall dieselbe -- keine Ausnahmen."""
|
"""Aktionsvokabular. Bedeutung ist ueberall dieselbe -- keine Ausnahmen.
|
||||||
|
|
||||||
VIEW = "view"
|
Die Namen sind die englischen, weil sie in jedem Rechtesystem dieselben
|
||||||
|
sind und keiner Uebersetzung bedarf: wer ``write`` liest, weiss es. Zwei
|
||||||
|
hiessen bis 2.7.x anders (``view``, ``edit``) -- in der Matrix stand
|
||||||
|
"Bearbeiten" und im Protokoll ``edit``, also zweierlei fuer dasselbe.
|
||||||
|
:mod:`tesm_core.rbac.legacy_permissions` schreibt die alten Schluessel um.
|
||||||
|
"""
|
||||||
|
|
||||||
|
READ = "read"
|
||||||
CREATE = "create"
|
CREATE = "create"
|
||||||
EDIT = "edit"
|
WRITE = "write"
|
||||||
DELETE = "delete"
|
DELETE = "delete"
|
||||||
EXECUTE = "execute"
|
EXECUTE = "execute"
|
||||||
EXPORT = "export"
|
EXPORT = "export"
|
||||||
SECRETS = "secrets"
|
SECRETS = "secrets"
|
||||||
|
|
||||||
|
|
||||||
#: Kuerzel und Erklaerung je Aktion -- Kopfzeile der Rechtematrix.
|
#: Kuerzel, englischer Name, deutsche Beschriftung, Erklaerung.
|
||||||
ACTION_META: dict[Action, tuple[str, str, str]] = {
|
#:
|
||||||
Action.VIEW: ("R", "Ansehen", "Datensaetze und Seiten dieses Bereichs lesen."),
|
#: Beides hat seinen Ort: die Matrix fuehrt die Spalte unter ihrem englischen
|
||||||
Action.CREATE: ("C", "Anlegen", "Neue Datensaetze erstellen."),
|
#: Namen (das ist das Vokabular, und es steht so auch im Schluessel), die
|
||||||
Action.EDIT: ("U", "Bearbeiten", "Bestehende Datensaetze aendern -- ohne Loeschen."),
|
#: Kleinschrift darunter und jeder Satz in der Oberflaeche bleiben deutsch.
|
||||||
Action.DELETE: ("D", "Loeschen", "Datensaetze loeschen bzw. endgueltig entfernen."),
|
ACTION_META: dict[Action, tuple[str, str, str, str]] = {
|
||||||
Action.EXECUTE: ("X", "Ausfuehren", "Aktionen ausloesen (Neustart, Dienste, Jobs, Import)."),
|
Action.READ: ("R", "Read", "ansehen", "Datensätze und Seiten dieses Bereichs ansehen."),
|
||||||
Action.EXPORT: ("E", "Exportieren", "Daten aus dem System heraustragen (Datei-Download)."),
|
Action.CREATE: ("C", "Create", "anlegen", "Neue Datensätze anlegen."),
|
||||||
|
Action.WRITE: (
|
||||||
|
"W",
|
||||||
|
"Write",
|
||||||
|
"ändern",
|
||||||
|
"Bestehende Datensätze ändern – ohne Anlegen, ohne Löschen.",
|
||||||
|
),
|
||||||
|
Action.DELETE: ("D", "Delete", "löschen", "Datensätze löschen bzw. endgültig entfernen."),
|
||||||
|
Action.EXECUTE: (
|
||||||
|
"X",
|
||||||
|
"Execute",
|
||||||
|
"auslösen",
|
||||||
|
"Aktionen auslösen (Neustart, Dienste, Aufträge, Import).",
|
||||||
|
),
|
||||||
|
Action.EXPORT: (
|
||||||
|
"E",
|
||||||
|
"Export",
|
||||||
|
"mitnehmen",
|
||||||
|
"Daten aus dem System heraustragen (Datei-Download).",
|
||||||
|
),
|
||||||
Action.SECRETS: (
|
Action.SECRETS: (
|
||||||
"S",
|
"S",
|
||||||
"Geheimnisse",
|
"Secrets",
|
||||||
"Passwoerter und Schluessel im Klartext sehen oder mitnehmen.",
|
"Klartext",
|
||||||
|
"Passwörter und Schlüssel im Klartext sehen oder mitnehmen.",
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
#: Aktionen, die zusaetzlich eine frische Passworteingabe verlangen (sudo-Modus).
|
#: Aktionen, die zusaetzlich eine frische Passworteingabe verlangen (sudo-Modus).
|
||||||
SENSITIVE_ACTIONS: frozenset[Action] = frozenset({Action.SECRETS, Action.EXPORT})
|
SENSITIVE_ACTIONS: frozenset[Action] = frozenset({Action.SECRETS, Action.EXPORT})
|
||||||
|
|
||||||
#: ``view`` ist Voraussetzung fuer alles andere in derselben Zeile.
|
#: ``read`` ist Voraussetzung fuer alles andere in derselben Zeile. Umgekehrt
|
||||||
|
#: gilt nichts: wer anlegen darf, darf damit nicht aendern, und wer aendern
|
||||||
|
#: darf, nicht anlegen. Das ist die Trennung, die man in einer
|
||||||
|
#: Verwaltungsanwendung am haeufigsten braucht.
|
||||||
IMPLIED_BY: dict[Action, tuple[Action, ...]] = {
|
IMPLIED_BY: dict[Action, tuple[Action, ...]] = {
|
||||||
action: (Action.VIEW,) for action in Action if action is not Action.VIEW
|
action: (Action.READ,) for action in Action if action is not Action.READ
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -74,6 +112,32 @@ class Resource:
|
|||||||
hint: str = ""
|
hint: str = ""
|
||||||
#: Abweichende Erklaerung einzelner Aktionen (z. B. was "Ausfuehren" hier meint).
|
#: Abweichende Erklaerung einzelner Aktionen (z. B. was "Ausfuehren" hier meint).
|
||||||
action_hints: Mapping[Action, str] = field(default_factory=dict)
|
action_hints: Mapping[Action, str] = field(default_factory=dict)
|
||||||
|
#: Zu welcher Zeile diese gehoert -- rein fuer die Darstellung.
|
||||||
|
#:
|
||||||
|
#: Aus einer Zeile "Clients" sind sechs geworden (Stammdaten, drei
|
||||||
|
#: Neustartwege, Konsole, Host-Schluessel). Untereinander und alle gleich
|
||||||
|
#: eingerueckt liest man sie als sechs unabhaengige Dinge; eingerueckt
|
||||||
|
#: unter ihrer Hauptzeile sieht man, dass sie zusammengehoeren. Auf die
|
||||||
|
#: Rechte hat das keine Wirkung: jede Zeile bleibt einzeln vergebbar, und
|
||||||
|
#: die Hauptzeile ist **nicht** Voraussetzung fuer ihre Unterzeilen.
|
||||||
|
parent: str = ""
|
||||||
|
#: Aktionen, die hier **keine** frische Passwortbestaetigung verlangen,
|
||||||
|
#: obwohl sie in SENSITIVE_ACTIONS stehen. Bewusst hier und nicht als
|
||||||
|
#: stilles Weglassen in der Route: eine Ausnahme von einer
|
||||||
|
#: Sicherheitsregel gehoert dorthin, wo man sie sieht und begruenden muss.
|
||||||
|
ohne_wiederanmeldung: tuple[Action, ...] = ()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def short_label(self) -> str:
|
||||||
|
"""Beschriftung ohne den Namen der Hauptzeile.
|
||||||
|
|
||||||
|
Unter "Clients" steht "Neustart über PoE" und nicht "Clients: Neustart
|
||||||
|
über PoE" -- das Wort stand sonst siebenmal untereinander. Ausserhalb
|
||||||
|
der Matrix (Protokoll, Meldungen) bleibt der vollstaendige Name.
|
||||||
|
"""
|
||||||
|
if self.parent and ": " in self.label:
|
||||||
|
return self.label.split(": ", 1)[1]
|
||||||
|
return self.label
|
||||||
|
|
||||||
def permission(self, action: Action) -> str:
|
def permission(self, action: Action) -> str:
|
||||||
return f"{self.key}.{action.value}"
|
return f"{self.key}.{action.value}"
|
||||||
@@ -96,8 +160,9 @@ class Area:
|
|||||||
hint: str = ""
|
hint: str = ""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def view_key(self) -> str:
|
def read_key(self) -> str:
|
||||||
return f"{self.key}.view"
|
"""Der Kill-Switch des Bereichs. Ohne ihn greift keine Zeile darunter."""
|
||||||
|
return f"{self.key}.read"
|
||||||
|
|
||||||
def columns(self) -> tuple[Action, ...]:
|
def columns(self) -> tuple[Action, ...]:
|
||||||
"""Nur die Aktionen, die in diesem Bereich tatsaechlich vorkommen."""
|
"""Nur die Aktionen, die in diesem Bereich tatsaechlich vorkommen."""
|
||||||
@@ -123,45 +188,92 @@ class PermissionTree:
|
|||||||
self._parent: dict[str, str] = {}
|
self._parent: dict[str, str] = {}
|
||||||
self._all: list[str] = []
|
self._all: list[str] = []
|
||||||
self._resources: dict[str, tuple[Area, Resource]] = {}
|
self._resources: dict[str, tuple[Area, Resource]] = {}
|
||||||
|
self._verwaltung: frozenset[str] | None = None
|
||||||
|
|
||||||
seen_keys: set[str] = set()
|
seen_keys: set[str] = set()
|
||||||
for area in self.areas:
|
for area in self.areas:
|
||||||
if area.key in seen_keys:
|
if area.key in seen_keys:
|
||||||
raise ValueError(f"Doppelter Bereichsschluessel: {area.key}")
|
raise ValueError(f"Doppelter Bereichsschlüssel: {area.key}")
|
||||||
seen_keys.add(area.key)
|
seen_keys.add(area.key)
|
||||||
self._by_key[area.key] = area
|
self._by_key[area.key] = area
|
||||||
self._all.append(area.view_key)
|
self._all.append(area.read_key)
|
||||||
for resource in area.resources:
|
for resource in area.resources:
|
||||||
if resource.key in seen_keys:
|
if resource.key in seen_keys:
|
||||||
raise ValueError(f"Doppelter Ressourcenschluessel: {resource.key}")
|
raise ValueError(f"Doppelter Ressourcenschlüssel: {resource.key}")
|
||||||
seen_keys.add(resource.key)
|
seen_keys.add(resource.key)
|
||||||
if Action.VIEW not in resource.actions:
|
if Action.READ not in resource.actions:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Ressource {resource.key} muss die Aktion 'view' anbieten -- "
|
f"Ressource {resource.key} muss die Aktion 'read' anbieten – "
|
||||||
"sie ist Voraussetzung fuer jede andere Aktion."
|
"sie ist Voraussetzung für jede andere Aktion."
|
||||||
)
|
)
|
||||||
self._resources[resource.key] = (area, resource)
|
self._resources[resource.key] = (area, resource)
|
||||||
for permission in resource.permissions():
|
for permission in resource.permissions():
|
||||||
self._all.append(permission)
|
self._all.append(permission)
|
||||||
self._parent[permission] = area.view_key
|
self._parent[permission] = area.read_key
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
def all_keys(self) -> tuple[str, ...]:
|
def all_keys(self) -> tuple[str, ...]:
|
||||||
return tuple(self._all)
|
return tuple(self._all)
|
||||||
|
|
||||||
|
#: Der Bereich, den ein echter Administrator immer ganz hat.
|
||||||
|
#:
|
||||||
|
#: Nicht "alles": das Kennzeichen ``is_admin`` gab bisher jede Pruefung
|
||||||
|
#: frei, auch Geraete, DHCP und Dateifreigaben. Ein Administrator ist aber
|
||||||
|
#: jemand, der die **Anlage** verwaltet -- Benutzer, Rechte,
|
||||||
|
#: Einstellungen --, und nicht automatisch jemand, der Maschinen neu
|
||||||
|
#: startet oder in Freigaben liest. Diese Rechte holt er sich ueber eine
|
||||||
|
#: Gruppe, und dann steht es im Aenderungsprotokoll.
|
||||||
|
VERWALTUNGSBEREICH = "administration"
|
||||||
|
|
||||||
|
def verwaltungsrechte(self) -> frozenset[str]:
|
||||||
|
"""Alle Rechte des Verwaltungsbereichs, samt seinem Kill-Switch.
|
||||||
|
|
||||||
|
Einmal gerechnet und gemerkt: die Antwort haengt nur am Baum, und der
|
||||||
|
entsteht beim Import.
|
||||||
|
"""
|
||||||
|
if self._verwaltung is None:
|
||||||
|
bereich = self._by_key.get(self.VERWALTUNGSBEREICH)
|
||||||
|
if bereich is None:
|
||||||
|
self._verwaltung = frozenset()
|
||||||
|
else:
|
||||||
|
rechte = {bereich.read_key}
|
||||||
|
for resource in bereich.resources:
|
||||||
|
rechte.update(resource.permissions())
|
||||||
|
self._verwaltung = frozenset(rechte)
|
||||||
|
return self._verwaltung
|
||||||
|
|
||||||
def area(self, key: str) -> Area | None:
|
def area(self, key: str) -> Area | None:
|
||||||
return self._by_key.get(key)
|
return self._by_key.get(key)
|
||||||
|
|
||||||
def parent_view_key(self, permission: str) -> str | None:
|
def parent_read_key(self, permission: str) -> str | None:
|
||||||
return self._parent.get(permission)
|
return self._parent.get(permission)
|
||||||
|
|
||||||
def resource(self, key: str) -> tuple[Area, Resource] | None:
|
def resource(self, key: str) -> tuple[Area, Resource] | None:
|
||||||
return self._resources.get(key)
|
return self._resources.get(key)
|
||||||
|
|
||||||
|
def verlangt_wiederanmeldung(self, permission: str) -> bool:
|
||||||
|
"""Braucht dieses Recht eine frische Passwortbestaetigung?
|
||||||
|
|
||||||
|
Die Antwort steht im Modell (``SENSITIVE_ACTIONS``) und nicht in der
|
||||||
|
Route -- sonst vergisst sie die naechste, die dazukommt.
|
||||||
|
"""
|
||||||
|
resource_key, _, action_value = permission.rpartition(".")
|
||||||
|
entry = self._resources.get(resource_key)
|
||||||
|
if entry is None:
|
||||||
|
return False
|
||||||
|
_, resource = entry
|
||||||
|
try:
|
||||||
|
action = Action(action_value)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
if action in resource.ohne_wiederanmeldung:
|
||||||
|
return False
|
||||||
|
return action in SENSITIVE_ACTIONS
|
||||||
|
|
||||||
def label_for(self, permission: str) -> str:
|
def label_for(self, permission: str) -> str:
|
||||||
"""Menschenlesbare Beschreibung eines Rechteschluessels -- fuer Audit-Logs."""
|
"""Menschenlesbare Beschreibung eines Rechteschluessels -- fuer Audit-Logs."""
|
||||||
if permission.endswith(".view") and permission[:-5] in self._by_key:
|
if permission.endswith(".read") and permission[:-5] in self._by_key:
|
||||||
return f"Bereich {self._by_key[permission[:-5]].label}: Zugang"
|
return f"Bereich {self._by_key[permission[:-5]].label}: Zugang"
|
||||||
resource_key, _, action_value = permission.rpartition(".")
|
resource_key, _, action_value = permission.rpartition(".")
|
||||||
entry = self._resources.get(resource_key)
|
entry = self._resources.get(resource_key)
|
||||||
@@ -172,7 +284,10 @@ class PermissionTree:
|
|||||||
action = Action(action_value)
|
action = Action(action_value)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return permission
|
return permission
|
||||||
return f"{resource.label}: {ACTION_META[action][1]}"
|
# Die deutsche Beschriftung, nicht der englische Name: dieser Text
|
||||||
|
# landet in Saetzen ("Sie koennen kein Recht vergeben ...") und im
|
||||||
|
# Aenderungsprotokoll, und die sind deutsch.
|
||||||
|
return f"{resource.label}: {ACTION_META[action][2]}"
|
||||||
|
|
||||||
def sanitize(self, permissions: Iterable[str]) -> list[str]:
|
def sanitize(self, permissions: Iterable[str]) -> list[str]:
|
||||||
"""Wirft unbekannte Rechte weg und ergaenzt implizierte.
|
"""Wirft unbekannte Rechte weg und ergaenzt implizierte.
|
||||||
@@ -180,7 +295,7 @@ class PermissionTree:
|
|||||||
Regeln:
|
Regeln:
|
||||||
* Ein Recht, das nicht im Baum steht, wird verworfen (kein stilles
|
* Ein Recht, das nicht im Baum steht, wird verworfen (kein stilles
|
||||||
Weiterschleppen von Altrechten nach einem Refactoring).
|
Weiterschleppen von Altrechten nach einem Refactoring).
|
||||||
* Jede Aktion impliziert ``view`` derselben Zeile.
|
* Jede Aktion impliziert ``read`` derselben Zeile -- aber nur diese.
|
||||||
* Existiert irgendein Recht eines Bereichs, wird der Kill-Switch des
|
* Existiert irgendein Recht eines Bereichs, wird der Kill-Switch des
|
||||||
Bereichs mitgesetzt -- sonst waere die Auswahl wirkungslos und der
|
Bereichs mitgesetzt -- sonst waere die Auswahl wirkungslos und der
|
||||||
Bearbeiter wuerde sich wundern.
|
Bearbeiter wuerde sich wundern.
|
||||||
@@ -200,13 +315,13 @@ class PermissionTree:
|
|||||||
result.add(candidate)
|
result.add(candidate)
|
||||||
for area in self.areas:
|
for area in self.areas:
|
||||||
if any(p in result for resource in area.resources for p in resource.permissions()):
|
if any(p in result for resource in area.resources for p in resource.permissions()):
|
||||||
result.add(area.view_key)
|
result.add(area.read_key)
|
||||||
return sorted(result)
|
return sorted(result)
|
||||||
|
|
||||||
def effective(self, permissions: Iterable[str]) -> set[str]:
|
def effective(self, permissions: Iterable[str]) -> set[str]:
|
||||||
"""Rechte, die nach Anwendung der Kill-Switches tatsaechlich greifen."""
|
"""Rechte, die nach Anwendung der Kill-Switches tatsaechlich greifen."""
|
||||||
granted = set(permissions)
|
granted = set(permissions)
|
||||||
active = {key for key in granted if key.endswith(".view") and key[:-5] in self._by_key}
|
active = {key for key in granted if key.endswith(".read") and key[:-5] in self._by_key}
|
||||||
return {
|
return {
|
||||||
key
|
key
|
||||||
for key in granted
|
for key in granted
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ from typing import Any, Iterable, Sequence
|
|||||||
|
|
||||||
from ..db import Database
|
from ..db import Database
|
||||||
from ..security import passwords
|
from ..security import passwords
|
||||||
from .model import Action, PermissionTree, RolePreset
|
from .model import ACTION_META, Action, PermissionTree, RolePreset
|
||||||
|
|
||||||
|
|
||||||
class PermissionDenied(Exception):
|
class PermissionDenied(Exception):
|
||||||
@@ -124,7 +124,20 @@ def group_permissions(conn: sqlite3.Connection, group_id: int) -> list[str]:
|
|||||||
def guard_no_amplification(
|
def guard_no_amplification(
|
||||||
actor: Any, tree: PermissionTree, added: Iterable[str], *, what: str = "Recht"
|
actor: Any, tree: PermissionTree, added: Iterable[str], *, what: str = "Recht"
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Verhindert, dass jemand Rechte vergibt, die er selbst nicht besitzt."""
|
"""Verhindert, dass jemand Rechte vergibt, die er selbst nicht besitzt.
|
||||||
|
|
||||||
|
Ein echter Administrator darf es -- und das ist seit 2.8.0 die bewusst
|
||||||
|
offene Tuer: sein Kennzeichen gibt ihm den Verwaltungsbereich, die
|
||||||
|
Funktionen holt er sich ueber eine Gruppe, auch fuer sich selbst. Der
|
||||||
|
Unterschied zu vorher ist nicht, *ob* er an die Funktionen kommt, sondern
|
||||||
|
dass es dann eine Zeile im Aenderungsprotokoll gibt und in der Matrix zu
|
||||||
|
sehen ist, statt unsichtbar im Kennzeichen zu stecken.
|
||||||
|
|
||||||
|
Fuer alle anderen bleibt die Regel dicht -- auch fuer die Gruppe
|
||||||
|
"Administration", die Benutzer und Gruppen pflegt: sie kann keiner Gruppe
|
||||||
|
ein Recht geben, das sie nicht selbst hat, und sich damit nicht ueber eine
|
||||||
|
neue Gruppe hochstufen.
|
||||||
|
"""
|
||||||
if getattr(actor, "is_admin", False):
|
if getattr(actor, "is_admin", False):
|
||||||
return
|
return
|
||||||
missing = sorted(p for p in added if not actor.has(p))
|
missing = sorted(p for p in added if not actor.has(p))
|
||||||
@@ -132,7 +145,7 @@ def guard_no_amplification(
|
|||||||
labels = ", ".join(tree.label_for(p) for p in missing[:6])
|
labels = ", ".join(tree.label_for(p) for p in missing[:6])
|
||||||
more = "" if len(missing) <= 6 else f" (und {len(missing) - 6} weitere)"
|
more = "" if len(missing) <= 6 else f" (und {len(missing) - 6} weitere)"
|
||||||
raise PermissionDenied(
|
raise PermissionDenied(
|
||||||
f"Sie koennen kein {what} vergeben, das Sie selbst nicht besitzen: {labels}{more}."
|
f"Sie können kein {what} vergeben, das Sie selbst nicht besitzen: {labels}{more}."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -160,7 +173,7 @@ def create_group(
|
|||||||
if existing["deleted_at"]:
|
if existing["deleted_at"]:
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
f"Eine Gruppe namens {clean_name!r} liegt im Papierkorb. "
|
f"Eine Gruppe namens {clean_name!r} liegt im Papierkorb. "
|
||||||
"Stellen Sie sie wieder her oder loeschen Sie sie endgueltig."
|
"Stellen Sie sie wieder her oder löschen Sie sie endgültig."
|
||||||
)
|
)
|
||||||
raise ValidationError(f"Eine Gruppe namens {clean_name!r} existiert bereits.")
|
raise ValidationError(f"Eine Gruppe namens {clean_name!r} existiert bereits.")
|
||||||
|
|
||||||
@@ -205,7 +218,7 @@ def update_group(
|
|||||||
|
|
||||||
if name is not None and name.strip() != group.name:
|
if name is not None and name.strip() != group.name:
|
||||||
if group.is_system:
|
if group.is_system:
|
||||||
raise PermissionDenied("Systemgruppen koennen nicht umbenannt werden.")
|
raise PermissionDenied("Systemgruppen können nicht umbenannt werden.")
|
||||||
clean = name.strip()
|
clean = name.strip()
|
||||||
if not clean:
|
if not clean:
|
||||||
raise ValidationError("Der Gruppenname darf nicht leer sein.")
|
raise ValidationError("Der Gruppenname darf nicht leer sein.")
|
||||||
@@ -224,13 +237,13 @@ def update_group(
|
|||||||
"UPDATE groups SET description=?, updated_at=? WHERE id=?",
|
"UPDATE groups SET description=?, updated_at=? WHERE id=?",
|
||||||
(description.strip(), _now(), group_id),
|
(description.strip(), _now(), group_id),
|
||||||
)
|
)
|
||||||
changes.append("Beschreibung geaendert")
|
changes.append("Beschreibung geändert")
|
||||||
|
|
||||||
if permissions is not None:
|
if permissions is not None:
|
||||||
if group.is_system and not unlock_system:
|
if group.is_system and not unlock_system:
|
||||||
raise PermissionDenied(
|
raise PermissionDenied(
|
||||||
"Die Rechte dieser Systemgruppe sind gesperrt. "
|
"Die Rechte dieser Systemgruppe sind gesperrt. "
|
||||||
"Sie muessen sie zuerst ausdruecklich freischalten."
|
"Sie müssen sie zuerst ausdrücklich freischalten."
|
||||||
)
|
)
|
||||||
wanted = tree.sanitize(permissions)
|
wanted = tree.sanitize(permissions)
|
||||||
added = sorted(set(wanted) - set(group.permissions))
|
added = sorted(set(wanted) - set(group.permissions))
|
||||||
@@ -240,7 +253,7 @@ def update_group(
|
|||||||
_write_permissions(conn, group_id, wanted)
|
_write_permissions(conn, group_id, wanted)
|
||||||
conn.execute("UPDATE groups SET updated_at=? WHERE id=?", (_now(), group_id))
|
conn.execute("UPDATE groups SET updated_at=? WHERE id=?", (_now(), group_id))
|
||||||
if added:
|
if added:
|
||||||
changes.append("Rechte hinzugefuegt: " + ", ".join(tree.label_for(p) for p in added))
|
changes.append("Rechte hinzugefügt: " + ", ".join(tree.label_for(p) for p in added))
|
||||||
if removed:
|
if removed:
|
||||||
changes.append("Rechte entfernt: " + ", ".join(tree.label_for(p) for p in removed))
|
changes.append("Rechte entfernt: " + ", ".join(tree.label_for(p) for p in removed))
|
||||||
|
|
||||||
@@ -252,7 +265,7 @@ def soft_delete_group(conn: sqlite3.Connection, group_id: int) -> None:
|
|||||||
if group is None:
|
if group is None:
|
||||||
raise ValidationError("Diese Gruppe existiert nicht.")
|
raise ValidationError("Diese Gruppe existiert nicht.")
|
||||||
if group.is_system:
|
if group.is_system:
|
||||||
raise PermissionDenied("Systemgruppen koennen nicht geloescht werden.")
|
raise PermissionDenied("Systemgruppen können nicht gelöscht werden.")
|
||||||
conn.execute("UPDATE groups SET deleted_at=? WHERE id=?", (_now(), group_id))
|
conn.execute("UPDATE groups SET deleted_at=? WHERE id=?", (_now(), group_id))
|
||||||
|
|
||||||
|
|
||||||
@@ -315,6 +328,113 @@ def ensure_presets(
|
|||||||
return created
|
return created
|
||||||
|
|
||||||
|
|
||||||
|
def unberuehrte_vorlagen_entfernen(
|
||||||
|
conn: sqlite3.Connection, tree: PermissionTree, presets: Sequence[RolePreset]
|
||||||
|
) -> list[str]:
|
||||||
|
"""Legt Vorlagengruppen in den Papierkorb -- aber nur unberuehrte.
|
||||||
|
|
||||||
|
Wird gebraucht, wenn eine Vorlage aus der Auslieferung verschwindet. Auf
|
||||||
|
einer frischen Installation entsteht sie dann gar nicht erst; auf einer
|
||||||
|
bestehenden steht sie in der Datenbank, und eine Migration, die sie einfach
|
||||||
|
loescht, nimmt einem Kunden womoeglich eine Gruppe weg, mit der er
|
||||||
|
arbeitet.
|
||||||
|
|
||||||
|
Darum diese fuenf Bedingungen, und alle fuenf muessen zutreffen:
|
||||||
|
|
||||||
|
* Die Gruppe stammt **aus der Auslieferung**: ihr ``preset_key`` ist der der
|
||||||
|
Vorlage. Diese Kennung setzt nur :func:`ensure_presets`; eine selbst
|
||||||
|
angelegte Gruppe hat sie nicht, auch wenn sie zufaellig denselben Namen
|
||||||
|
traegt. Ueber den Namen wird bewusst *nicht* gesucht -- sonst raeumte
|
||||||
|
dieser Schritt jemandem seine eigene Gruppe "Betrieb" weg.
|
||||||
|
* Sie heisst noch so, wie sie ausgeliefert wurde. Ein anderer Name heisst:
|
||||||
|
jemand hat sie uebernommen.
|
||||||
|
* Die Gruppe hat **keine Mitglieder**. Wer Mitglieder hat, wird benutzt.
|
||||||
|
* Ihre Rechte sind **unveraendert** die der Vorlage. Jede Abweichung heisst:
|
||||||
|
jemand hat sie angepasst, also ist sie seine, nicht unsere.
|
||||||
|
* Sie ist keine Systemgruppe.
|
||||||
|
|
||||||
|
Geloescht wird weich: die Gruppe liegt im Papierkorb und kommt mit einem
|
||||||
|
Klick zurueck. Gibt die Namen der einsortierten Gruppen zurueck.
|
||||||
|
"""
|
||||||
|
entfernt: list[str] = []
|
||||||
|
gruppen = list_groups(conn)
|
||||||
|
for preset in presets:
|
||||||
|
gruppe = next(
|
||||||
|
(
|
||||||
|
g
|
||||||
|
for g in gruppen
|
||||||
|
if g.preset_key
|
||||||
|
and g.preset_key == preset.key
|
||||||
|
and g.name.lower() == preset.name.lower()
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if gruppe is None or gruppe.is_system or gruppe.member_count:
|
||||||
|
continue
|
||||||
|
if set(gruppe.permissions) != set(tree.expand_preset(preset)):
|
||||||
|
continue
|
||||||
|
conn.execute("UPDATE groups SET deleted_at=? WHERE id=?", (_now(), gruppe.id))
|
||||||
|
entfernt.append(gruppe.name)
|
||||||
|
return entfernt
|
||||||
|
|
||||||
|
|
||||||
|
def funktionsgruppe_anlegen(
|
||||||
|
conn: sqlite3.Connection, tree: PermissionTree, preset: RolePreset
|
||||||
|
) -> int:
|
||||||
|
"""Legt die Vollzugriffsgruppe an, falls sie fehlt, und gibt ihre Nummer.
|
||||||
|
|
||||||
|
Getrennt von :func:`funktionsgruppe_fuer_administratoren`, weil das zwei
|
||||||
|
verschiedene Dinge sind: hier entsteht nur die Gruppe, dort bekommt sie
|
||||||
|
**jeder** Administrator. Wer ein einzelnes Konto aufnehmen will -- etwa
|
||||||
|
das, das an der Kommandozeile entsteht --, braucht das erste ohne das
|
||||||
|
zweite; sonst faengt sich ein Administrator, der die Gruppe bewusst
|
||||||
|
abgelegt hat, sie beim naechsten Aufruf wieder ein.
|
||||||
|
"""
|
||||||
|
zeile = Database.one(
|
||||||
|
conn, "SELECT id FROM groups WHERE name=? COLLATE NOCASE", (preset.name,)
|
||||||
|
)
|
||||||
|
if zeile is not None:
|
||||||
|
return int(zeile["id"])
|
||||||
|
cursor = conn.execute(
|
||||||
|
"INSERT INTO groups (name, description, is_system, is_default, preset_key, "
|
||||||
|
"created_at, updated_at) VALUES (?,?,0,0,?,?,?)",
|
||||||
|
(preset.name, preset.description, preset.key, _now(), _now()),
|
||||||
|
)
|
||||||
|
gruppe = int(cursor.lastrowid or 0)
|
||||||
|
_write_permissions(conn, gruppe, tree.expand_preset(preset))
|
||||||
|
return gruppe
|
||||||
|
|
||||||
|
|
||||||
|
def funktionsgruppe_fuer_administratoren(
|
||||||
|
conn: sqlite3.Connection, tree: PermissionTree, preset: RolePreset
|
||||||
|
) -> int:
|
||||||
|
"""Legt die Vollzugriffsgruppe an und gibt sie allen Administratoren.
|
||||||
|
|
||||||
|
Der Uebergang zu 2.8.0: bis dahin gab das Kennzeichen ``is_admin`` jede
|
||||||
|
Pruefung frei. Danach gibt es den Verwaltungsbereich, und alles andere
|
||||||
|
kommt ueber Gruppen. Ohne diesen Schritt fehlt einem bestehenden
|
||||||
|
Administrator nach dem Update die halbe Anwendung -- und er haelt das fuer
|
||||||
|
einen Fehler, nicht fuer eine Absicht.
|
||||||
|
|
||||||
|
Auf einer frischen Installation gibt es beim Migrieren noch kein Konto;
|
||||||
|
dann entsteht nur die Gruppe, und wer sie will, weist sie sich zu. Genau so
|
||||||
|
ist die neue Trennung gedacht.
|
||||||
|
|
||||||
|
Gibt die Nummer der Gruppe zurueck.
|
||||||
|
"""
|
||||||
|
gruppe = funktionsgruppe_anlegen(conn, tree, preset)
|
||||||
|
conn.executemany(
|
||||||
|
"INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?,?)",
|
||||||
|
[
|
||||||
|
(int(row["id"]), gruppe)
|
||||||
|
for row in Database.all(
|
||||||
|
conn, "SELECT id FROM users WHERE is_admin=1 AND deleted_at IS NULL"
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
return gruppe
|
||||||
|
|
||||||
|
|
||||||
def default_group_id(conn: sqlite3.Connection) -> int | None:
|
def default_group_id(conn: sqlite3.Connection) -> int | None:
|
||||||
value = Database.value(
|
value = Database.value(
|
||||||
conn, "SELECT id FROM groups WHERE is_default=1 AND deleted_at IS NULL ORDER BY id LIMIT 1"
|
conn, "SELECT id FROM groups WHERE is_default=1 AND deleted_at IS NULL ORDER BY id LIMIT 1"
|
||||||
@@ -410,7 +530,7 @@ def guard_target_account(actor: Any, target: dict[str, Any]) -> None:
|
|||||||
"""Ein Administratorkonto darf nur ein echter Administrator anfassen."""
|
"""Ein Administratorkonto darf nur ein echter Administrator anfassen."""
|
||||||
if bool(target.get("is_admin")) and not getattr(actor, "is_admin", False):
|
if bool(target.get("is_admin")) and not getattr(actor, "is_admin", False):
|
||||||
raise PermissionDenied(
|
raise PermissionDenied(
|
||||||
"Administratorkonten koennen nur von Administratoren bearbeitet werden."
|
"Administratorkonten können nur von Administratoren bearbeitet werden."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -420,8 +540,8 @@ def guard_last_admin(conn: sqlite3.Connection, user_id: int, *, action: str) ->
|
|||||||
return
|
return
|
||||||
if count_active_admins(conn, exclude_id=user_id) == 0:
|
if count_active_admins(conn, exclude_id=user_id) == 0:
|
||||||
raise PermissionDenied(
|
raise PermissionDenied(
|
||||||
f"{action} nicht moeglich: es muss mindestens ein entsperrtes "
|
f"{action} nicht möglich: es muss mindestens ein entsperrtes "
|
||||||
"Administratorkonto uebrig bleiben."
|
"Administratorkonto übrig bleiben."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -482,12 +602,12 @@ def create_user(
|
|||||||
if existing["deleted_at"]:
|
if existing["deleted_at"]:
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
f"Der Benutzername {clean!r} liegt im Papierkorb. Stellen Sie das Konto "
|
f"Der Benutzername {clean!r} liegt im Papierkorb. Stellen Sie das Konto "
|
||||||
"wieder her oder loeschen Sie es endgueltig."
|
"wieder her oder löschen Sie es endgültig."
|
||||||
)
|
)
|
||||||
raise ValidationError(f"Der Benutzername {clean!r} ist bereits vergeben.")
|
raise ValidationError(f"Der Benutzername {clean!r} ist bereits vergeben.")
|
||||||
|
|
||||||
if is_admin and not getattr(actor, "is_admin", False):
|
if is_admin and not getattr(actor, "is_admin", False):
|
||||||
raise PermissionDenied("Nur Administratoren koennen weitere Administratoren anlegen.")
|
raise PermissionDenied("Nur Administratoren können weitere Administratoren anlegen.")
|
||||||
|
|
||||||
if auth_source == "local":
|
if auth_source == "local":
|
||||||
problems = policy.check(password, username=clean)
|
problems = policy.check(password, username=clean)
|
||||||
@@ -552,24 +672,30 @@ def update_user(
|
|||||||
# ueberschrieben -- die Bearbeitung waere eine Illusion.
|
# ueberschrieben -- die Bearbeitung waere eine Illusion.
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
"Name und E-Mail eines Verzeichniskontos werden bei jeder Anmeldung "
|
"Name und E-Mail eines Verzeichniskontos werden bei jeder Anmeldung "
|
||||||
"aus dem Verzeichnis uebernommen und koennen hier nicht geaendert werden."
|
"aus dem Verzeichnis übernommen und können hier nicht geändert werden."
|
||||||
)
|
)
|
||||||
|
|
||||||
fields: dict[str, Any] = {}
|
fields: dict[str, Any] = {}
|
||||||
|
# Mit Werten, nicht nur "geaendert": aus dem Protokoll soll man den
|
||||||
|
# vorherigen Stand ablesen koennen, ohne eine Sicherung auszupacken.
|
||||||
|
beschriftungen = {"first_name": "Vorname", "last_name": "Nachname", "email": "E-Mail"}
|
||||||
for column, value in (
|
for column, value in (
|
||||||
("first_name", first_name),
|
("first_name", first_name),
|
||||||
("last_name", last_name),
|
("last_name", last_name),
|
||||||
("email", email),
|
("email", email),
|
||||||
):
|
):
|
||||||
if value is not None and value.strip() != str(target.get(column) or ""):
|
alt = str(target.get(column) or "")
|
||||||
|
if value is not None and value.strip() != alt:
|
||||||
fields[column] = value.strip()
|
fields[column] = value.strip()
|
||||||
changes.append(f"{column} geaendert")
|
changes.append(
|
||||||
|
f"{beschriftungen[column]}: {alt or '(leer)'} -> {value.strip() or '(leer)'}"
|
||||||
|
)
|
||||||
|
|
||||||
if is_admin is not None and bool(is_admin) != bool(target["is_admin"]):
|
if is_admin is not None and bool(is_admin) != bool(target["is_admin"]):
|
||||||
if not getattr(actor, "is_admin", False):
|
if not getattr(actor, "is_admin", False):
|
||||||
raise PermissionDenied("Das Admin-Kennzeichen kann nur ein Administrator aendern.")
|
raise PermissionDenied("Das Admin-Kennzeichen kann nur ein Administrator ändern.")
|
||||||
if int(target["id"]) == int(getattr(actor, "id", 0)):
|
if int(target["id"]) == int(getattr(actor, "id", 0)):
|
||||||
raise PermissionDenied("Sie koennen Ihr eigenes Admin-Kennzeichen nicht aendern.")
|
raise PermissionDenied("Sie können Ihr eigenes Admin-Kennzeichen nicht ändern.")
|
||||||
if not is_admin:
|
if not is_admin:
|
||||||
guard_last_admin(conn, user_id, action="Herabstufen")
|
guard_last_admin(conn, user_id, action="Herabstufen")
|
||||||
fields["is_admin"] = int(bool(is_admin))
|
fields["is_admin"] = int(bool(is_admin))
|
||||||
@@ -577,7 +703,7 @@ def update_user(
|
|||||||
|
|
||||||
if is_locked is not None and bool(is_locked) != bool(target["is_locked"]):
|
if is_locked is not None and bool(is_locked) != bool(target["is_locked"]):
|
||||||
if int(target["id"]) == int(getattr(actor, "id", 0)):
|
if int(target["id"]) == int(getattr(actor, "id", 0)):
|
||||||
raise PermissionDenied("Sie koennen Ihr eigenes Konto nicht sperren.")
|
raise PermissionDenied("Sie können Ihr eigenes Konto nicht sperren.")
|
||||||
if is_locked:
|
if is_locked:
|
||||||
guard_last_admin(conn, user_id, action="Sperren")
|
guard_last_admin(conn, user_id, action="Sperren")
|
||||||
fields["is_locked"] = int(bool(is_locked))
|
fields["is_locked"] = int(bool(is_locked))
|
||||||
@@ -595,7 +721,7 @@ def update_user(
|
|||||||
after = {int(gid) for gid in group_ids}
|
after = {int(gid) for gid in group_ids}
|
||||||
if before != after:
|
if before != after:
|
||||||
set_user_groups(conn, user_id, sorted(after), actor=actor, tree=tree)
|
set_user_groups(conn, user_id, sorted(after), actor=actor, tree=tree)
|
||||||
changes.append("Gruppenzuordnung geaendert")
|
changes.append("Gruppenzuordnung geändert")
|
||||||
|
|
||||||
return changes
|
return changes
|
||||||
|
|
||||||
@@ -613,7 +739,7 @@ def set_password(
|
|||||||
raise ValidationError("Dieses Konto existiert nicht.")
|
raise ValidationError("Dieses Konto existiert nicht.")
|
||||||
if str(row.get("auth_source")) == "ldap":
|
if str(row.get("auth_source")) == "ldap":
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
"Verzeichniskonten haben hier kein Passwort -- es wird im Verzeichnisdienst gepflegt."
|
"Verzeichniskonten haben hier kein Passwort – es wird im Verzeichnisdienst gepflegt."
|
||||||
)
|
)
|
||||||
problems = policy.check(new_password, username=str(row["username"]))
|
problems = policy.check(new_password, username=str(row["username"]))
|
||||||
if problems:
|
if problems:
|
||||||
@@ -631,7 +757,7 @@ def soft_delete_user(conn: sqlite3.Connection, user_id: int, *, actor: Any) -> s
|
|||||||
raise ValidationError("Dieses Konto existiert nicht (mehr).")
|
raise ValidationError("Dieses Konto existiert nicht (mehr).")
|
||||||
guard_target_account(actor, target)
|
guard_target_account(actor, target)
|
||||||
if int(target["id"]) == int(getattr(actor, "id", 0)):
|
if int(target["id"]) == int(getattr(actor, "id", 0)):
|
||||||
raise PermissionDenied("Sie koennen Ihr eigenes Konto nicht loeschen.")
|
raise PermissionDenied("Sie können Ihr eigenes Konto nicht löschen.")
|
||||||
guard_last_admin(conn, user_id, action="Loeschen")
|
guard_last_admin(conn, user_id, action="Loeschen")
|
||||||
|
|
||||||
if str(target.get("auth_source")) == "ldap":
|
if str(target.get("auth_source")) == "ldap":
|
||||||
@@ -677,7 +803,7 @@ def matrix_state(
|
|||||||
granted = set(permissions)
|
granted = set(permissions)
|
||||||
state: dict[str, dict[str, bool]] = {}
|
state: dict[str, dict[str, bool]] = {}
|
||||||
for area in tree.areas:
|
for area in tree.areas:
|
||||||
state[area.key] = {"view": area.view_key in granted}
|
state[area.key] = {"read": area.read_key in granted}
|
||||||
for resource in area.resources:
|
for resource in area.resources:
|
||||||
state[resource.key] = {
|
state[resource.key] = {
|
||||||
action.value: resource.permission(action) in granted for action in resource.actions
|
action.value: resource.permission(action) in granted for action in resource.actions
|
||||||
@@ -690,7 +816,7 @@ def summarize_permissions(tree: PermissionTree, permissions: Iterable[str]) -> l
|
|||||||
granted = set(permissions)
|
granted = set(permissions)
|
||||||
lines: list[str] = []
|
lines: list[str] = []
|
||||||
for area in tree.areas:
|
for area in tree.areas:
|
||||||
if area.view_key not in granted:
|
if area.read_key not in granted:
|
||||||
continue
|
continue
|
||||||
parts: list[str] = []
|
parts: list[str] = []
|
||||||
for resource in area.resources:
|
for resource in area.resources:
|
||||||
@@ -706,12 +832,9 @@ def summarize_permissions(tree: PermissionTree, permissions: Iterable[str]) -> l
|
|||||||
return lines
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
#: Abgeleitet und nicht abgeschrieben: die Buchstaben stehen in ACTION_META.
|
||||||
|
#: Die zweite Liste hier hatte "U" fuer Bearbeiten, waehrend die Matrix "W"
|
||||||
|
#: zeigte -- dieselbe Aktion, zwei Kuerzel, je nachdem wo man hinsah.
|
||||||
ACTION_LETTERS: dict[Action, str] = {
|
ACTION_LETTERS: dict[Action, str] = {
|
||||||
Action.VIEW: "R",
|
action: meta[0] for action, meta in ACTION_META.items()
|
||||||
Action.CREATE: "C",
|
|
||||||
Action.EDIT: "U",
|
|
||||||
Action.DELETE: "D",
|
|
||||||
Action.EXECUTE: "X",
|
|
||||||
Action.EXPORT: "E",
|
|
||||||
Action.SECRETS: "S",
|
|
||||||
}
|
}
|
||||||
@@ -112,7 +112,7 @@ def protect() -> None:
|
|||||||
if not expected or not provided:
|
if not expected or not provided:
|
||||||
abort(403, description="Das Sicherheitstoken des Formulars fehlt. Bitte Seite neu laden.")
|
abort(403, description="Das Sicherheitstoken des Formulars fehlt. Bitte Seite neu laden.")
|
||||||
if not hmac.compare_digest(str(expected), str(provided)):
|
if not hmac.compare_digest(str(expected), str(provided)):
|
||||||
abort(403, description="Das Sicherheitstoken des Formulars ist ungueltig. Bitte Seite neu laden.")
|
abort(403, description="Das Sicherheitstoken des Formulars ist ungültig. Bitte Seite neu laden.")
|
||||||
|
|
||||||
|
|
||||||
def exempt(view):
|
def exempt(view):
|
||||||
|
|||||||
@@ -65,10 +65,10 @@ class PasswordPolicy:
|
|||||||
if classes < self.require_classes:
|
if classes < self.require_classes:
|
||||||
problems.append(
|
problems.append(
|
||||||
f"Mindestens {self.require_classes} verschiedene Zeichenarten "
|
f"Mindestens {self.require_classes} verschiedene Zeichenarten "
|
||||||
"(Klein-/Grossbuchstaben, Ziffern, Sonderzeichen)."
|
"(Klein-/Großbuchstaben, Ziffern, Sonderzeichen)."
|
||||||
)
|
)
|
||||||
if normalized.lower() in _FORBIDDEN:
|
if normalized.lower() in _FORBIDDEN:
|
||||||
problems.append("Dieses Passwort ist zu haeufig und damit unsicher.")
|
problems.append("Dieses Passwort ist zu häufig und damit unsicher.")
|
||||||
if username and len(username) >= 3 and username.lower() in normalized.lower():
|
if username and len(username) >= 3 and username.lower() in normalized.lower():
|
||||||
problems.append("Das Passwort darf den Benutzernamen nicht enthalten.")
|
problems.append("Das Passwort darf den Benutzernamen nicht enthalten.")
|
||||||
if len(set(normalized)) <= 3 and normalized:
|
if len(set(normalized)) <= 3 and normalized:
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ import sqlite3
|
|||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from flask import Flask, Request, Response
|
from flask import Flask, Request, Response, g
|
||||||
|
from flask import request as aktuelle_anfrage
|
||||||
from flask.sessions import SessionInterface, SessionMixin
|
from flask.sessions import SessionInterface, SessionMixin
|
||||||
from werkzeug.datastructures import CallbackDict
|
from werkzeug.datastructures import CallbackDict
|
||||||
|
|
||||||
@@ -34,6 +35,79 @@ from ..db import Database
|
|||||||
|
|
||||||
SESSION_ID_BYTES = 32
|
SESSION_ID_BYTES = 32
|
||||||
|
|
||||||
|
#: Kopfzeile, mit der der Browser eine Anfrage als **automatisch** kennzeichnet:
|
||||||
|
#: ein Aktualisierungsdurchlauf einer Kachel, kein Klick eines Menschen.
|
||||||
|
#:
|
||||||
|
#: Ohne diese Unterscheidung ist die automatische Abmeldung wirkungslos, und das
|
||||||
|
#: war sie auch: ein offener Tab fragt seine Bereiche alle 15 Sekunden neu ab,
|
||||||
|
#: jede Abfrage verlaengerte das Leerlauffenster, und niemand wurde je
|
||||||
|
#: abgemeldet -- nachweisbar an einer Sitzung, deren ``last_seen_at`` eine
|
||||||
|
#: Minute alt war, obwohl seit zehn Stunden niemand am Rechner sass.
|
||||||
|
#:
|
||||||
|
#: Die Entscheidung gehoert zum Client: nur er weiss, ob ein Zeitgeber oder ein
|
||||||
|
#: Mensch die Anfrage ausgeloest hat. Ein Angreifer koennte den Kopf setzen --
|
||||||
|
#: er wuerde damit seine *eigene* Sitzung schneller ablaufen lassen.
|
||||||
|
AUTO_REFRESH_HEADER = "X-Auto-Refresh"
|
||||||
|
|
||||||
|
|
||||||
|
#: Marke auf der Ansichtsfunktion. Steht an der Route, damit man sie dort sieht,
|
||||||
|
#: wo sie gilt.
|
||||||
|
FRAGMENT_ATTRIBUT = "_tesm_fragment"
|
||||||
|
|
||||||
|
|
||||||
|
def fragment(view: Any) -> Any:
|
||||||
|
"""Kennzeichnet eine Route als Aktualisierungsausschnitt.
|
||||||
|
|
||||||
|
Solche Routen ruft ein Zeitgeber auf, kein Mensch. Sie duerfen die Sitzung
|
||||||
|
benutzen, halten sie aber nicht am Leben -- sonst bleibt jeder offene Tab
|
||||||
|
unbegrenzt angemeldet.
|
||||||
|
|
||||||
|
Der Kopf ``X-Auto-Refresh`` allein genuegt dafuer nicht: er setzt voraus,
|
||||||
|
dass der Browser das aktuelle Skript faehrt. Ein Tab, der seit Stunden
|
||||||
|
offen ist, faehrt das alte -- und das ist genau der Tab, um den es geht.
|
||||||
|
"""
|
||||||
|
setattr(view, FRAGMENT_ATTRIBUT, True)
|
||||||
|
return view
|
||||||
|
|
||||||
|
|
||||||
|
def _ist_fragment_route() -> bool:
|
||||||
|
"""Gehoert die laufende Anfrage zu einer als Ausschnitt markierten Route?
|
||||||
|
|
||||||
|
``request.endpoint`` steht hier nicht immer schon: Flask oeffnet die Sitzung
|
||||||
|
**vor** dem Routing (``RequestContext.push`` ruft ``open_session`` und erst
|
||||||
|
danach ``match_request``). In ``save_session`` ist der Endpunkt gesetzt, in
|
||||||
|
``open_session`` nicht -- und ohne diesen Nachtrag griff die Erkennung
|
||||||
|
genau dort nicht, wo sie gebraucht wird.
|
||||||
|
"""
|
||||||
|
from flask import current_app
|
||||||
|
|
||||||
|
try:
|
||||||
|
endpunkt = getattr(aktuelle_anfrage, "endpoint", None)
|
||||||
|
if not endpunkt:
|
||||||
|
# Selbst nachsehen. Ein zweiter Routenabgleich je Anfrage ist
|
||||||
|
# billiger als eine Abmeldung, die nicht stattfindet.
|
||||||
|
adapter = current_app.url_map.bind_to_environ(aktuelle_anfrage.environ)
|
||||||
|
endpunkt, _ = adapter.match(
|
||||||
|
aktuelle_anfrage.path, method=aktuelle_anfrage.method
|
||||||
|
)
|
||||||
|
ansicht = current_app.view_functions.get(str(endpunkt))
|
||||||
|
except Exception: # noqa: BLE001 - keine Route, Umleitung, kein App-Kontext
|
||||||
|
return False
|
||||||
|
return bool(getattr(ansicht, FRAGMENT_ATTRIBUT, False))
|
||||||
|
|
||||||
|
|
||||||
|
def is_automatic(request: Request) -> bool:
|
||||||
|
"""Kam die Anfrage von einem Zeitgeber statt von einem Menschen?
|
||||||
|
|
||||||
|
Zwei Kennzeichen, und eines genuegt: der Kopf, den das Skript setzt,
|
||||||
|
und die Route selbst. Das zweite traegt auch dann, wenn ein lange
|
||||||
|
offener Tab noch ein aelteres Skript faehrt -- und das ist der Tab,
|
||||||
|
um den es geht.
|
||||||
|
"""
|
||||||
|
if request.headers.get(AUTO_REFRESH_HEADER, "") == "1":
|
||||||
|
return True
|
||||||
|
return _ist_fragment_route()
|
||||||
|
|
||||||
|
|
||||||
def _now() -> datetime:
|
def _now() -> datetime:
|
||||||
return datetime.now(timezone.utc)
|
return datetime.now(timezone.utc)
|
||||||
@@ -89,6 +163,7 @@ class SqliteSessionInterface(SessionInterface):
|
|||||||
idle_minutes: int,
|
idle_minutes: int,
|
||||||
absolute_hours: int,
|
absolute_hours: int,
|
||||||
idle_lookup: Any = None,
|
idle_lookup: Any = None,
|
||||||
|
absolute_lookup: Any = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.database = database
|
self.database = database
|
||||||
self.cookie_name = cookie_name
|
self.cookie_name = cookie_name
|
||||||
@@ -98,6 +173,16 @@ class SqliteSessionInterface(SessionInterface):
|
|||||||
#: Zeit zur Laufzeit einstellbar, ohne den Dienst neu zu starten -- und
|
#: Zeit zur Laufzeit einstellbar, ohne den Dienst neu zu starten -- und
|
||||||
#: ohne eine zweite Datenbankverbindung, denn sie bekommt die offene.
|
#: ohne eine zweite Datenbankverbindung, denn sie bekommt die offene.
|
||||||
self._idle_lookup = idle_lookup
|
self._idle_lookup = idle_lookup
|
||||||
|
self._absolute_lookup = absolute_lookup
|
||||||
|
|
||||||
|
def _absolute(self, conn: Any) -> int:
|
||||||
|
"""Gesamtdauer in Stunden -- einstellbar wie die Leerlaufgrenze."""
|
||||||
|
if self._absolute_lookup is None:
|
||||||
|
return self.absolute_hours
|
||||||
|
try:
|
||||||
|
return int(self._absolute_lookup(conn)) or self.absolute_hours
|
||||||
|
except Exception: # pragma: no cover - Einstellung unlesbar
|
||||||
|
return self.absolute_hours
|
||||||
|
|
||||||
def _idle(self, conn: Any) -> int:
|
def _idle(self, conn: Any) -> int:
|
||||||
if self._idle_lookup is None:
|
if self._idle_lookup is None:
|
||||||
@@ -135,6 +220,11 @@ class SqliteSessionInterface(SessionInterface):
|
|||||||
"WHERE id=?",
|
"WHERE id=?",
|
||||||
(_iso(now), sid),
|
(_iso(now), sid),
|
||||||
)
|
)
|
||||||
|
# Fuer die Anmeldeseite vermerken, **warum** sie erscheint. Ohne
|
||||||
|
# das steht dort entweder nichts -- dann wundert sich, wer
|
||||||
|
# gerade noch angemeldet war -- oder eine Meldung, die jeder
|
||||||
|
# Besucher zu sehen bekommt und die deshalb nichts sagt.
|
||||||
|
g.session_expired = True
|
||||||
return self.session_class(new=True)
|
return self.session_class(new=True)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -154,8 +244,17 @@ class SqliteSessionInterface(SessionInterface):
|
|||||||
}
|
}
|
||||||
# Leerlauffenster verlaengern, aber hoechstens einmal pro Minute
|
# Leerlauffenster verlaengern, aber hoechstens einmal pro Minute
|
||||||
# schreiben -- sonst kostet jeder AJAX-Poll einen Schreibvorgang.
|
# schreiben -- sonst kostet jeder AJAX-Poll einen Schreibvorgang.
|
||||||
|
#
|
||||||
|
# Eine automatische Abfrage verlaengert gar nichts. Sie darf die
|
||||||
|
# Sitzung benutzen, solange diese lebt, aber sie haelt sie nicht am
|
||||||
|
# Leben: sonst bleibt jeder offene Tab bis in alle Ewigkeit
|
||||||
|
# angemeldet, und "Abmeldung nach 30 Minuten Leerlauf" ist eine
|
||||||
|
# Zusage, die die Anwendung nicht einhaelt. Damit bleibt
|
||||||
|
# ``last_seen_at`` ausserdem, was der Name sagt: der letzte
|
||||||
|
# Zeitpunkt, an dem ein Mensch da war.
|
||||||
last_seen = _parse(row.get("last_seen_at"))
|
last_seen = _parse(row.get("last_seen_at"))
|
||||||
if last_seen is None or (now - last_seen) > timedelta(seconds=60):
|
veraltet = last_seen is None or (now - last_seen) > timedelta(seconds=60)
|
||||||
|
if veraltet and not is_automatic(request):
|
||||||
with self.database.transaction(conn):
|
with self.database.transaction(conn):
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE user_sessions SET last_seen_at=?, idle_expires_at=? WHERE id=?",
|
"UPDATE user_sessions SET last_seen_at=?, idle_expires_at=? WHERE id=?",
|
||||||
@@ -192,6 +291,9 @@ class SqliteSessionInterface(SessionInterface):
|
|||||||
payload = json.dumps(dict(session), separators=(",", ":"), sort_keys=True)
|
payload = json.dumps(dict(session), separators=(",", ":"), sort_keys=True)
|
||||||
with self.database.session() as conn, self.database.transaction(conn):
|
with self.database.session() as conn, self.database.transaction(conn):
|
||||||
if session.new or not session.sid:
|
if session.new or not session.sid:
|
||||||
|
# Eine vorhandene ID nicht wegwerfen: nach einem Wechsel beim
|
||||||
|
# Anmelden steht sie schon, und andere haben sie sich gemerkt.
|
||||||
|
if not session.sid:
|
||||||
session.sid = secrets.token_urlsafe(SESSION_ID_BYTES)
|
session.sid = secrets.token_urlsafe(SESSION_ID_BYTES)
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO user_sessions "
|
"INSERT INTO user_sessions "
|
||||||
@@ -204,13 +306,38 @@ class SqliteSessionInterface(SessionInterface):
|
|||||||
_iso(now),
|
_iso(now),
|
||||||
_iso(now),
|
_iso(now),
|
||||||
_iso(now + timedelta(minutes=self._idle(conn))),
|
_iso(now + timedelta(minutes=self._idle(conn))),
|
||||||
_iso(now + timedelta(hours=self.absolute_hours)),
|
_iso(now + timedelta(hours=self._absolute(conn))),
|
||||||
session.get("_ip", ""),
|
session.get("_ip", ""),
|
||||||
session.get("_ua", ""),
|
session.get("_ua", ""),
|
||||||
payload,
|
payload,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
session.new = False
|
session.new = False
|
||||||
|
# ``save_session`` bekommt die Anfrage nicht als Argument -- anders als
|
||||||
|
# ``open_session``, wo ein Parameter denselben Namen traegt. Hier ist es
|
||||||
|
# der Anfragekontext von Flask; die Antwort wird darin erzeugt.
|
||||||
|
elif is_automatic(aktuelle_anfrage):
|
||||||
|
# Die zweite Schreibstelle, und die eigentliche Ursache dafuer,
|
||||||
|
# dass die Abmeldung nie ausgeloest hat: hier wurde das
|
||||||
|
# Leerlauffenster bei **jeder** Anfrage verlaengert, die die
|
||||||
|
# Sitzung anfasst. Die Pruefung in open_session lief damit ins
|
||||||
|
# Leere -- ein Aktualisierungsdurchlauf einer Kachel fasst die
|
||||||
|
# Sitzung an (Flash-Speicher, CSRF), und schon stand das Fenster
|
||||||
|
# wieder 30 Minuten offen. Auf dem Lizenzserver fiel es nicht
|
||||||
|
# auf: dort pollt keine Kachel.
|
||||||
|
#
|
||||||
|
# Geaenderte Sitzungsdaten muessen trotzdem erhalten bleiben --
|
||||||
|
# sie zu verwerfen waere eine zweite Fehlerquelle. Nur die
|
||||||
|
# Fristen bleiben stehen.
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE user_sessions SET user_id=?, data=?, reauth_at=? WHERE id=?",
|
||||||
|
(
|
||||||
|
session.get("user_id"),
|
||||||
|
payload,
|
||||||
|
session.get("_reauth_at"),
|
||||||
|
session.sid,
|
||||||
|
),
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE user_sessions SET user_id=?, data=?, last_seen_at=?, "
|
"UPDATE user_sessions SET user_id=?, data=?, last_seen_at=?, "
|
||||||
@@ -219,7 +346,11 @@ class SqliteSessionInterface(SessionInterface):
|
|||||||
session.get("user_id"),
|
session.get("user_id"),
|
||||||
payload,
|
payload,
|
||||||
_iso(now),
|
_iso(now),
|
||||||
_iso(now + timedelta(minutes=self.idle_minutes)),
|
# ``_idle(conn)`` und nicht ``idle_minutes``: die Zeit ist
|
||||||
|
# zur Laufzeit einstellbar, und diese Stelle hat die
|
||||||
|
# Einstellung ignoriert. Bei der Vorgabe von 30 Minuten
|
||||||
|
# fiel das nicht auf -- bei einer geaenderten schon.
|
||||||
|
_iso(now + timedelta(minutes=self._idle(conn))),
|
||||||
session.get("_reauth_at"),
|
session.get("_reauth_at"),
|
||||||
session.sid,
|
session.sid,
|
||||||
),
|
),
|
||||||
@@ -254,7 +385,12 @@ def rotate(conn: sqlite3.Connection, session: ServerSession, *, reason: str = "p
|
|||||||
"UPDATE user_sessions SET revoked_at=?, revoked_reason=? WHERE id=?",
|
"UPDATE user_sessions SET revoked_at=?, revoked_reason=? WHERE id=?",
|
||||||
(_iso(_now()), reason, old_sid),
|
(_iso(_now()), reason, old_sid),
|
||||||
)
|
)
|
||||||
session.sid = ""
|
# Die neue ID entsteht **jetzt** und nicht erst beim Speichern der Antwort.
|
||||||
|
# Zwischen beidem laufen die Anmeldehaken, und einer davon bindet die
|
||||||
|
# Dateifreigaben ein und schreibt sich die Sitzung dazu. Mit einer leeren
|
||||||
|
# Sitzung hielt der Aufraeumer diese Einbindungen fuer verwaist und loeste
|
||||||
|
# sie binnen Minuten wieder.
|
||||||
|
session.sid = secrets.token_urlsafe(SESSION_ID_BYTES)
|
||||||
session.new = True
|
session.new = True
|
||||||
session.modified = True
|
session.modified = True
|
||||||
return old_sid
|
return old_sid
|
||||||
|
|||||||
@@ -58,6 +58,30 @@ class SettingSpec:
|
|||||||
return str(raw)
|
return str(raw)
|
||||||
|
|
||||||
def serialize(self, value: Any) -> str:
|
def serialize(self, value: Any) -> str:
|
||||||
|
"""Ein Wert als Text fuer die Datenbank.
|
||||||
|
|
||||||
|
Der Wert kann als Python-Wert kommen (aus einem Formular, aus dem
|
||||||
|
Code) -- oder als die Zeichenkette, die schon in der Datenbank stand.
|
||||||
|
Genau so liest eine **Sicherung** ihn wieder ein, und genau dort lag
|
||||||
|
ein stiller Fehler: ``serialize("0")`` ergab ``"1"``, weil jede nicht
|
||||||
|
leere Zeichenkette wahr ist. Aus jedem abgeschalteten Schalter wurde
|
||||||
|
beim Einspielen ein eingeschalteter -- auch aus
|
||||||
|
``ldap_allow_insecure``. Ein JSON-Feld wurde ein zweites Mal kodiert,
|
||||||
|
und aus der Navigationsreihenfolge damit eine Zeichenkette.
|
||||||
|
|
||||||
|
Deshalb: eine Zeichenkette erst lesen. Was hier herauskommt, muss
|
||||||
|
durch ``serialize`` unveraendert hindurchgehen -- sonst ueberlebt kein
|
||||||
|
Wert eine Sicherung.
|
||||||
|
"""
|
||||||
|
if isinstance(value, str):
|
||||||
|
if self.kind == "bool":
|
||||||
|
value = self.parse(value)
|
||||||
|
elif self.kind == "json":
|
||||||
|
try:
|
||||||
|
value = json.loads(value)
|
||||||
|
except ValueError:
|
||||||
|
# Kein JSON-Text, also ein Wert: dann wird er kodiert.
|
||||||
|
pass
|
||||||
if self.kind == "bool":
|
if self.kind == "bool":
|
||||||
return "1" if value else "0"
|
return "1" if value else "0"
|
||||||
if self.kind == "json":
|
if self.kind == "json":
|
||||||
@@ -67,6 +91,45 @@ class SettingSpec:
|
|||||||
return "" if value is None else str(value)
|
return "" if value is None else str(value)
|
||||||
|
|
||||||
|
|
||||||
|
#: So lang darf ein Wert im Protokoll werden -- danach wird gekuerzt. Ein
|
||||||
|
#: JSON-Feld mit der halben Navigationsreihenfolge macht die Zeile unlesbar.
|
||||||
|
WERT_GRENZE = 80
|
||||||
|
|
||||||
|
|
||||||
|
class Aenderung(str):
|
||||||
|
"""Eine geaenderte Einstellung, lesbar als ``schluessel: alt -> neu``.
|
||||||
|
|
||||||
|
Eine Zeichenkette und kein eigener Typ, weil die Aufrufer die Liste
|
||||||
|
weiterhin mit ``", ".join(...)`` in einen Protokolleintrag schreiben --
|
||||||
|
so gibt es keine Stelle, die man beim Umbau vergessen kann.
|
||||||
|
"""
|
||||||
|
|
||||||
|
key: str
|
||||||
|
alt: str
|
||||||
|
neu: str
|
||||||
|
|
||||||
|
def __new__(cls, key: str, alt: Any, neu: Any, *, geheim: bool = False) -> "Aenderung":
|
||||||
|
alt_text = "(gesetzt)" if geheim and alt else _kurz(alt)
|
||||||
|
neu_text = "(geaendert)" if geheim else _kurz(neu)
|
||||||
|
selbst = super().__new__(cls, f"{key}: {alt_text} -> {neu_text}")
|
||||||
|
selbst.key = key
|
||||||
|
selbst.alt = alt_text
|
||||||
|
selbst.neu = neu_text
|
||||||
|
return selbst
|
||||||
|
|
||||||
|
|
||||||
|
def _kurz(wert: Any) -> str:
|
||||||
|
"""Ein Wert als kurzer Text -- leer wird sichtbar, lang wird gekuerzt."""
|
||||||
|
if wert is None or wert == "":
|
||||||
|
return "(leer)"
|
||||||
|
if isinstance(wert, bool):
|
||||||
|
return "ein" if wert else "aus"
|
||||||
|
text = " ".join(str(wert).split())
|
||||||
|
if len(text) > WERT_GRENZE:
|
||||||
|
return text[: WERT_GRENZE - 1] + "…"
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
class SettingsStore:
|
class SettingsStore:
|
||||||
"""Zugriff auf ``settings``. Alle Methoden nehmen die bestehende Verbindung."""
|
"""Zugriff auf ``settings``. Alle Methoden nehmen die bestehende Verbindung."""
|
||||||
|
|
||||||
@@ -140,16 +203,24 @@ class SettingsStore:
|
|||||||
|
|
||||||
def set_many(
|
def set_many(
|
||||||
self, conn: sqlite3.Connection, values: dict[str, Any], *, actor: str = ""
|
self, conn: sqlite3.Connection, values: dict[str, Any], *, actor: str = ""
|
||||||
) -> list[str]:
|
) -> list["Aenderung"]:
|
||||||
"""Setzt mehrere Werte, gibt die tatsaechlich geaenderten Schluessel zurueck."""
|
"""Setzt mehrere Werte und gibt die Aenderungen zurueck.
|
||||||
changed: list[str] = []
|
|
||||||
|
Jede Aenderung ist eine Zeichenkette der Form ``schluessel: alt -> neu``
|
||||||
|
-- und traegt die Einzelteile als Attribute. Damit steht im
|
||||||
|
Aenderungsprotokoll, was sich geaendert hat, ohne dass jede
|
||||||
|
Einstellungsseite es einzeln zusammenbauen muss. Vorher stand dort nur
|
||||||
|
der Schluessel, und die Frage "was war vorher eingestellt?" liess sich
|
||||||
|
aus dem Protokoll nicht beantworten.
|
||||||
|
"""
|
||||||
|
changed: list[Aenderung] = []
|
||||||
current = self.get_many(conn, list(values))
|
current = self.get_many(conn, list(values))
|
||||||
for key, value in values.items():
|
for key, value in values.items():
|
||||||
spec = self.spec(key)
|
spec = self.spec(key)
|
||||||
if spec.serialize(current.get(key)) == spec.serialize(value):
|
if spec.serialize(current.get(key)) == spec.serialize(value):
|
||||||
continue
|
continue
|
||||||
self.set(conn, key, value, actor=actor)
|
self.set(conn, key, value, actor=actor)
|
||||||
changed.append(key)
|
changed.append(Aenderung(key, current.get(key), value, geheim=spec.kind == "secret"))
|
||||||
return changed
|
return changed
|
||||||
|
|
||||||
def delete(self, conn: sqlite3.Connection, key: str) -> None:
|
def delete(self, conn: sqlite3.Connection, key: str) -> None:
|
||||||
|
|||||||
@@ -412,7 +412,10 @@
|
|||||||
und beide sind rot. */
|
und beide sind rot. */
|
||||||
.pill--critical {
|
.pill--critical {
|
||||||
background: var(--danger);
|
background: var(--danger);
|
||||||
color: var(--surface-0);
|
/* Weiss wie bei .btn--danger. Vorher stand hier ein Token, das es nicht
|
||||||
|
gibt -- die Deklaration war damit unwirksam, und die Schrift blieb in
|
||||||
|
der Textfarbe: dunkelgrau auf Rot. */
|
||||||
|
color: #fff;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
.pill--critical::before { background: currentColor; }
|
.pill--critical::before { background: currentColor; }
|
||||||
@@ -682,7 +685,10 @@
|
|||||||
.modal__sub { font-size: var(--fs-xs); color: var(--text-faint); margin-top: 2px; }
|
.modal__sub { font-size: var(--fs-xs); color: var(--text-faint); margin-top: 2px; }
|
||||||
/* Eine Datei-Vorschau soll die ganze Flaeche des Dialogs nutzen; der
|
/* Eine Datei-Vorschau soll die ganze Flaeche des Dialogs nutzen; der
|
||||||
uebliche Innenabstand des Dialogkoerpers wuerde sie einrahmen. */
|
uebliche Innenabstand des Dialogkoerpers wuerde sie einrahmen. */
|
||||||
.modal__body--flush { padding: 0; max-height: none; }
|
/* Zwei Klassen am selben Element: diese hier muss gewinnen, obwohl
|
||||||
|
.modal__body weiter unten steht. Mit der doppelten Klasse haengt das
|
||||||
|
nicht mehr an der Reihenfolge im Stylesheet. */
|
||||||
|
.modal__body.modal__body--flush { padding: 0; max-height: none; overflow: visible; }
|
||||||
.modal__caption {
|
.modal__caption {
|
||||||
padding: 0 var(--sp-5) var(--sp-2);
|
padding: 0 var(--sp-5) var(--sp-2);
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
@@ -695,7 +701,177 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: min(70dvh, 760px);
|
height: min(70dvh, 760px);
|
||||||
border: 0;
|
border: 0;
|
||||||
background: var(--surface-0);
|
/* Die Vorschau zeigt fremde Dokumente; ein neutraler Grund gehoert
|
||||||
|
darunter. --surface-0 gibt es nicht, der Rahmen blieb durchsichtig. */
|
||||||
|
background: var(--surface-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -- Betrachter: Bild, PDF, Rahmen ------------------------------------- */
|
||||||
|
/* Eine feste Hoehe fuer alle drei Anzeigen: sonst springt der Dialog beim
|
||||||
|
Wechsel zwischen Foto und PDF. */
|
||||||
|
.viewer {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
/* Was der Dialog uebrig laesst: Kopf, Dateiname und Fusszeile brauchen
|
||||||
|
zusammen rund 190 Pixel. Ein fester Anteil der Fensterhoehe schob die
|
||||||
|
Zoomleiste auf kleinen Fenstern unter den Rand. */
|
||||||
|
height: min(calc(100dvh - 240px), 760px);
|
||||||
|
min-height: 220px;
|
||||||
|
background: var(--surface-1);
|
||||||
|
}
|
||||||
|
/* Hochkant -- Tablet oder Monitor im Hochformat -- gibt es unter dem Dialog
|
||||||
|
viel Platz, den die Obergrenze verschenkt. Beim Ausfuellen eines
|
||||||
|
Formulars zaehlt jede sichtbare Zeile, also nehmen wir dort die Hoehe,
|
||||||
|
die da ist. Der Rest (Kopf, Dateiname, Fusszeile) braucht rund 200 Pixel;
|
||||||
|
die 240 lassen etwas Luft. */
|
||||||
|
@media (orientation: portrait) {
|
||||||
|
.viewer {
|
||||||
|
height: calc(100dvh - 240px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.viewer > .file-preview {
|
||||||
|
flex: 1;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
.viewer__stage {
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
/* Ohne min-height waechst die Buehne mit dem Bild, und dessen
|
||||||
|
max-height:100% haengt dann an sich selbst: ein 4032x3024-Foto stand
|
||||||
|
36 Pixel ueber den Rand hinaus. */
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
/* Die Seite darf nicht mitwandern, wenn im Bild geschoben wird. */
|
||||||
|
touch-action: none;
|
||||||
|
cursor: zoom-in;
|
||||||
|
}
|
||||||
|
.viewer__stage--gezogen {
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
.viewer__img {
|
||||||
|
/* Der Ausgangszustand ist "passt in die Flaeche" -- genau das fehlte:
|
||||||
|
ein Foto vom iPad ist 4032 Pixel breit und wurde im Rahmen 1:1
|
||||||
|
gezeigt, also als Ausschnitt aus der Mitte. */
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
/* Zoomen soll nicht unscharf wirken, solange nicht vergroessert wird. */
|
||||||
|
transform-origin: center center;
|
||||||
|
will-change: transform;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-drag: none;
|
||||||
|
}
|
||||||
|
/* pdf.js verlangt einen absolut positionierten, scrollenden Behaelter --
|
||||||
|
der liegt deshalb *innen*. Aussen ein gewoehnlicher Flex-Kasten, damit
|
||||||
|
Zoomleiste und Speicherzeile ihren Platz behalten. Vorher rechnete der
|
||||||
|
Bereich mit "bottom: var(--sp-10)", also mit genau einer Leiste; die
|
||||||
|
zweite Zeile lag dann unter dem Dialogrand. */
|
||||||
|
.viewer__pdf {
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
/* Ein PDF ist weisses Papier. pdf.js malt Formularfelder mit Systemfarben
|
||||||
|
und ueberlaesst Ankreuzfelder dem Browser -- im dunklen Design heisst
|
||||||
|
CanvasText weiss, und die Haken waren auf dem Papier unsichtbar. Fuer
|
||||||
|
diesen Ausschnitt gilt deshalb immer das helle Schema. */
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
/* pdf.js rechnet fuer die Seite mit dem Standard "content-box": ihr
|
||||||
|
9-Pixel-Rahmen liegt *ausserhalb* der angegebenen Breite. Unser globales
|
||||||
|
"border-box" rechnete ihn hinein -- die Leinwand wurde 18 Pixel schmaler
|
||||||
|
als die Anmerkungsebene, und die Formularfelder wanderten nach rechts
|
||||||
|
unten, immer weiter je weiter unten sie lagen. */
|
||||||
|
.viewer__pdf .pdfViewer .page {
|
||||||
|
box-sizing: content-box;
|
||||||
|
}
|
||||||
|
.viewer__pdfscroll {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
overflow: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
.viewer__bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
padding: var(--sp-2) var(--sp-4);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
background: var(--surface-2, var(--surface-1));
|
||||||
|
}
|
||||||
|
.viewer__hint {
|
||||||
|
margin-inline-start: auto;
|
||||||
|
}
|
||||||
|
/* Bildfeld eines Formulars: der Kasten zeigt das gewaehlte Foto, die
|
||||||
|
Schaltflaeche liegt darin. Acrobats eigener Knopf tut im Browser nichts --
|
||||||
|
das hier ist der Ersatz. */
|
||||||
|
.viewer__bildfeld [data-viewer-bildvorschau] {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.viewer__bildwahl {
|
||||||
|
position: absolute;
|
||||||
|
inset: auto 0 4px 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.viewer__bildwahl > span {
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: var(--r-sm, 6px);
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
font-size: var(--fs-xs);
|
||||||
|
font-weight: 600;
|
||||||
|
box-shadow: var(--shadow-sm, 0 1px 3px rgb(0 0 0 / 0.3));
|
||||||
|
}
|
||||||
|
.viewer__bildfeld--belegt .viewer__bildwahl > span {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
.viewer__save {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: var(--sp-3);
|
||||||
|
padding: var(--sp-3) var(--sp-4);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
background: var(--surface-2, var(--surface-1));
|
||||||
|
}
|
||||||
|
.viewer__save .viewer__feld {
|
||||||
|
flex: 1 1 12rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
/* Die Entscheidung steht rechts und der Haken darueber: er gehoert zum
|
||||||
|
Speichern, nicht zu den Feldern davor. */
|
||||||
|
.viewer__saveaktionen {
|
||||||
|
margin-left: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
}
|
||||||
|
.viewer__saveknoepfe {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
}
|
||||||
|
/* Nach "gibt es schon": der Haken ist die Antwort -- also faellt er auf. */
|
||||||
|
.viewer__saveaktionen .check.is-gefragt {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 3px;
|
||||||
|
border-radius: var(--r-xs);
|
||||||
|
}
|
||||||
|
/* Eigener Name: ".field--inline" gibt es schon und steht dort fuer
|
||||||
|
"waagerecht" -- diese Regel haette sie app-weit umgestellt. */
|
||||||
|
.viewer__feld {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
}
|
}
|
||||||
/* Der Dateiname ist ein Knopf (er oeffnet die Vorschau), soll aber wie ein
|
/* Der Dateiname ist ein Knopf (er oeffnet die Vorschau), soll aber wie ein
|
||||||
Link aussehen -- ein Knopf mit Rahmen mitten in einer Tabellenzeile waere
|
Link aussehen -- ein Knopf mit Rahmen mitten in einer Tabellenzeile waere
|
||||||
@@ -834,7 +1010,25 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
.matrix thead th:first-child { text-align: left; }
|
.matrix thead th:first-child { text-align: left; }
|
||||||
.matrix td:not(:first-child), .matrix th:not(:first-child) { width: 62px; text-align: center; }
|
/* Kuerzel, englischer Name, deutsche Beschriftung -- untereinander. Nur das
|
||||||
|
Kuerzel allein war eine Spalte, die man nachschlagen musste; die Legende
|
||||||
|
steht unter einer Tabelle, die man dafuer erst zu Ende scrollt. */
|
||||||
|
.matrix__spalte { display: flex; flex-direction: column; align-items: center; line-height: 1.15; }
|
||||||
|
.matrix__spalte b {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--fs-sm);
|
||||||
|
color: var(--text);
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
.matrix__spalte span { font-size: var(--fs-xs); color: var(--text-muted); text-transform: none; }
|
||||||
|
.matrix__spalte small {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: 0;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
.matrix td:not(:first-child), .matrix th:not(:first-child) { width: 72px; text-align: center; }
|
||||||
.matrix tr.matrix__area > td {
|
.matrix tr.matrix__area > td {
|
||||||
background: var(--surface-2);
|
background: var(--surface-2);
|
||||||
font-weight: 640;
|
font-weight: 640;
|
||||||
@@ -849,7 +1043,10 @@
|
|||||||
width: 17px;
|
width: 17px;
|
||||||
height: 17px;
|
height: 17px;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
background: var(--surface-0);
|
/* Dunkler als die Karte -- genau das war die Absicht: "dunkler heisst,
|
||||||
|
es gibt das Recht nicht". Mit dem nicht existierenden --surface-0 war
|
||||||
|
das Feld durchsichtig und damit so hell wie die Karte. */
|
||||||
|
background: var(--canvas);
|
||||||
border: 1px solid var(--border-subtle);
|
border: 1px solid var(--border-subtle);
|
||||||
box-shadow: inset 0 1px 2px rgb(0 0 0 / 28%);
|
box-shadow: inset 0 1px 2px rgb(0 0 0 / 28%);
|
||||||
vertical-align: -3px;
|
vertical-align: -3px;
|
||||||
@@ -857,6 +1054,46 @@
|
|||||||
}
|
}
|
||||||
.matrix__resource-label { display: flex; flex-direction: column; gap: 1px; }
|
.matrix__resource-label { display: flex; flex-direction: column; gap: 1px; }
|
||||||
.matrix__resource-label small { color: var(--text-faint); font-size: var(--fs-xs); }
|
.matrix__resource-label small { color: var(--text-faint); font-size: var(--fs-xs); }
|
||||||
|
.matrix__zeile { display: flex; align-items: baseline; gap: var(--sp-2); }
|
||||||
|
.matrix__zeile b { font-weight: 600; }
|
||||||
|
|
||||||
|
/* Unterzeile: gehoert zur Zeile darueber. Ohne Einrueckung liest man sechs
|
||||||
|
unabhaengige Dinge, wo sechs Teile eines Ganzen stehen. */
|
||||||
|
.matrix tr.matrix__unterzeile > td:first-child { padding-left: var(--sp-6); }
|
||||||
|
.matrix tr.matrix__unterzeile > td:first-child .matrix__zeile b { font-weight: 500; }
|
||||||
|
|
||||||
|
/* Was eine Aktion in dieser Zeile bedeutet -- in der Zeile, nicht im
|
||||||
|
Titelattribut: mit dem Finger gibt es kein "hover". */
|
||||||
|
.matrix__bedeutung { color: var(--text-faint); font-size: var(--fs-xs); }
|
||||||
|
.matrix__bedeutung b {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matrix__alle {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-muted);
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 2px 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.matrix__alle:hover { color: var(--accent); border-color: var(--accent); }
|
||||||
|
|
||||||
|
.matrix__filter {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: var(--sp-3);
|
||||||
|
margin-bottom: var(--sp-3);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.matrix__filter-hint { color: var(--text-faint); font-size: var(--fs-xs); }
|
||||||
|
/* Eine ausgefilterte Zeile ist versteckt, nicht entfernt: ihre Kaestchen
|
||||||
|
bleiben im Formular, sonst loescht ein Filter beim Speichern Rechte. */
|
||||||
|
.matrix tr[hidden] { display: none; }
|
||||||
.matrix__legend {
|
.matrix__legend {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -943,9 +1180,15 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
padding: var(--sp-6);
|
padding: var(--sp-6);
|
||||||
|
/* Zwei warme Kreise, Geometrie und Staerke aus der Fassung 1.x
|
||||||
|
uebernommen: oben links deutlich, unten rechts angedeutet. Die vorige
|
||||||
|
Fassung setzte den zweiten Kreis auf --info und 10 % -- ein kaltes
|
||||||
|
Blau, das man auf dunklem Grund nicht sah und das nicht zur Marke
|
||||||
|
gehoert. Ueber color-mix statt festem rgba, damit es im hellen Thema
|
||||||
|
nicht schmutzig wird. */
|
||||||
background:
|
background:
|
||||||
radial-gradient(1100px 520px at 12% -10%, color-mix(in srgb, var(--accent) 12%, transparent), transparent 70%),
|
radial-gradient(circle at 15% 15%, color-mix(in srgb, var(--accent) 14%, transparent), transparent 45%),
|
||||||
radial-gradient(900px 480px at 100% 110%, color-mix(in srgb, var(--info) 10%, transparent), transparent 70%),
|
radial-gradient(circle at 85% 85%, color-mix(in srgb, var(--accent) 8%, transparent), transparent 40%),
|
||||||
var(--canvas);
|
var(--canvas);
|
||||||
}
|
}
|
||||||
/* Die Wortmarke liegt hinter allem (z-index 0), die Anmeldebox davor.
|
/* Die Wortmarke liegt hinter allem (z-index 0), die Anmeldebox davor.
|
||||||
@@ -965,6 +1208,12 @@
|
|||||||
--tile-accent: var(--text-faint);
|
--tile-accent: var(--text-faint);
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
/* Spalte, damit die Fusszeile unten haengen kann (siehe .tile__actions).
|
||||||
|
Die Kacheln eines Rasters sind gleich hoch, ihr Inhalt aber nicht: mal
|
||||||
|
fehlt der Switch, mal der Standort. Ohne diesen Halt stuenden die Knoepfe
|
||||||
|
in jeder Kachel auf einer anderen Hoehe -- und das Auge sucht sie. */
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
padding: var(--sp-4);
|
padding: var(--sp-4);
|
||||||
padding-left: calc(var(--sp-4) + 3px);
|
padding-left: calc(var(--sp-4) + 3px);
|
||||||
border: 1px solid var(--border-subtle);
|
border: 1px solid var(--border-subtle);
|
||||||
@@ -1026,24 +1275,81 @@
|
|||||||
color: var(--text-faint);
|
color: var(--text-faint);
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-page { position: relative; overflow: hidden; align-content: center; }
|
/* ---------------------------------------------------------------------- #
|
||||||
|
Bildschirmsitzung (VNC)
|
||||||
|
|
||||||
|
Die Leinwand bekommt einen dunklen Grund -- unabhaengig vom Thema der
|
||||||
|
Oberflaeche: was darauf erscheint, ist der Bildschirm eines fremden
|
||||||
|
Geraets, und ein heller Rahmen darum laesst ihn wie einen Teil dieser
|
||||||
|
Seite aussehen. Er ist keiner.
|
||||||
|
---------------------------------------------------------------------- */
|
||||||
|
.screen {
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
background: #101216;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.screen__canvas {
|
||||||
|
width: 100%;
|
||||||
|
/* Genug Hoehe, dass ein Bildschirm erkennbar ist, ohne die Seite zu
|
||||||
|
sprengen; noVNC skaliert das Bild hinein. */
|
||||||
|
height: min(72vh, 760px);
|
||||||
|
}
|
||||||
|
.screen__status {
|
||||||
|
font-size: var(--fs-sm);
|
||||||
|
color: var(--text-faint);
|
||||||
|
}
|
||||||
|
.screen__status--ok { color: var(--success); }
|
||||||
|
.screen__status--warnung { color: var(--warning); }
|
||||||
|
.screen__status--fehler { color: var(--danger); }
|
||||||
|
|
||||||
|
/* Fusszeile der Kachel: links der Bildschirm, rechts der Neustart.
|
||||||
|
``margin-top: auto`` schiebt sie an den unteren Rand -- unabhaengig
|
||||||
|
davon, wie viel darueber steht. Die beiden Eingriffe stehen damit in
|
||||||
|
jeder Kachel an derselben Stelle, und der eine liegt nicht dort, wo eben
|
||||||
|
noch der andere war. */
|
||||||
|
.tile__actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
margin-top: auto;
|
||||||
|
padding-top: var(--sp-3);
|
||||||
|
}
|
||||||
|
.tile__actions > :last-child { margin-left: auto; }
|
||||||
|
/* Steht nur ein Knopf da, bleibt er links -- sonst spraenge der Neustart bei
|
||||||
|
einem Geraet ohne Bildschirmzugang an die Stelle, an der nebenan VNC
|
||||||
|
steht. Das Fuellstueck haelt den Platz. */
|
||||||
|
.tile__spacer { flex: 1 1 auto; }
|
||||||
|
|
||||||
|
/* Marke und Karte stehen uebereinander im normalen Fluss. Vorher lag die
|
||||||
|
Marke absolut positioniert hinter der Karte -- auf einem flachen Fenster
|
||||||
|
schnitt die Karte mitten durch den Schriftzug und die Funktionszeile. Ein
|
||||||
|
Stapel kann nicht kollidieren, und die Seite darf notfalls scrollen. */
|
||||||
|
.auth-stack {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-8);
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
.auth-backdrop {
|
.auth-backdrop {
|
||||||
position: absolute;
|
|
||||||
top: 4vh;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
width: min(72vw, 880px);
|
width: min(72vw, 880px);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 0;
|
/* Volle Deckung: solange die Marke hinter der Karte lag, war sie ein
|
||||||
opacity: 0.5;
|
Wasserzeichen und musste zurueckhaltend sein. Jetzt steht sie ueber der
|
||||||
|
Karte und ist das Erste, was man sieht -- halbtransparent sah sie dort
|
||||||
|
nur blass aus. Die Farben kommen ohnehin aus zurueckhaltenden Token. */
|
||||||
|
opacity: 1;
|
||||||
}
|
}
|
||||||
.auth-backdrop svg { width: 100%; height: auto; display: block; }
|
.auth-backdrop svg { width: 100%; height: auto; display: block; }
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.auth-backdrop { width: min(90vw, 460px); opacity: 0.32; }
|
.auth-backdrop { width: min(90vw, 460px); }
|
||||||
}
|
}
|
||||||
/* Wer wenig Bewegung/Reize moechte, bekommt das Motiv dezenter. */
|
/* Wer wenig Bewegung/Reize moechte, bekommt das Motiv dezenter. */
|
||||||
|
/* Wer weniger Reize moechte, bekommt die Marke ruhiger -- aber nicht
|
||||||
|
unsichtbar: sie ist die Ueberschrift der Seite. */
|
||||||
@media (prefers-contrast: more) {
|
@media (prefers-contrast: more) {
|
||||||
.auth-backdrop { opacity: 0.18; }
|
.auth-backdrop { opacity: 0.75; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-card {
|
.auth-card {
|
||||||
@@ -1138,3 +1444,340 @@
|
|||||||
.hide-sm { display: none !important; }
|
.hide-sm { display: none !important; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* -- Sortierbare Liste (Navigationsreihenfolge) --------------------------- */
|
||||||
|
/* Symbol und Beschriftung stehen in einer Zeile -- so wie im Menue selbst.
|
||||||
|
Vorher brach die Beschriftung unter das Symbol, und die Liste sah aus wie
|
||||||
|
etwas anderes als das, was sie sortiert. */
|
||||||
|
|
||||||
|
.reorder {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reorder__row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
background: var(--surface-2);
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reorder__row > .flex-1 {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reorder__row.is-dragging {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reorder__grip {
|
||||||
|
color: var(--text-muted);
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reorder__pos {
|
||||||
|
min-width: 1.6em;
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: var(--fs-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reorder__moves {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reorder__group {
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reorder--child {
|
||||||
|
margin: 4px 0 4px 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reorder--child .reorder__row {
|
||||||
|
background: transparent;
|
||||||
|
border-style: dashed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -- Unterschrift --------------------------------------------------------- */
|
||||||
|
/* Die Zeichenflaeche braucht "touch-action: none": sonst scrollt ein iPad die
|
||||||
|
Seite, statt einen Strich zu zeichnen -- und es sieht aus, als reagierte das
|
||||||
|
Feld nicht. */
|
||||||
|
|
||||||
|
.viewer__sign {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
background: var(--surface-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer__signfelder {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer__signpad {
|
||||||
|
width: 100%;
|
||||||
|
height: 160px;
|
||||||
|
border: 1px dashed var(--border-strong, var(--border));
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
background: #fff;
|
||||||
|
touch-action: none;
|
||||||
|
cursor: crosshair;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer__signaktionen {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer__pdfscroll--setzen {
|
||||||
|
cursor: crosshair;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer__pdfscroll--setzen .page {
|
||||||
|
outline: 2px dashed var(--accent, #c8791f);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Die gesetzte Unterschrift liegt auf der Seite -- in Prozent, damit sie beim
|
||||||
|
Zoomen an ihrer Stelle bleibt. Sie nimmt Zeiger an: verschieben mit dem
|
||||||
|
Block, Groesse mit dem Griff unten rechts, entfernen mit dem Kreuz.
|
||||||
|
"touch-action: none" ist auf dem iPad die Bedingung dafuer, dass ein Finger
|
||||||
|
zieht statt die Seite zu scrollen. */
|
||||||
|
/* Die Marke ist genau die Zeichnung: ihre Unterkante liegt auf dem
|
||||||
|
Ankerpunkt -- dieselbe Kante, an der pdf-lib das Bild setzt. Der Text
|
||||||
|
haengt darunter und darf die Hoehe nicht mitbestimmen. */
|
||||||
|
.viewer__unterschrift {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 3;
|
||||||
|
cursor: grab;
|
||||||
|
touch-action: none;
|
||||||
|
/* "outline" statt "border": ein Rahmen haette die Marke um zwei Pixel
|
||||||
|
groesser gemacht und die Bildkante vom Ankerpunkt weggeschoben. */
|
||||||
|
outline: 1px dashed transparent;
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer__unterschrift:hover,
|
||||||
|
.viewer__unterschrift.is-fassend {
|
||||||
|
outline-color: var(--accent);
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer__unterschrift img {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dieselbe Rechnung wie im PDF (Breite durch 18): auf dem Schirm steht, was
|
||||||
|
spaeter in der Datei steht. */
|
||||||
|
/* "width: max-content": der Strich ist so lang wie der Text darunter -- so
|
||||||
|
wie im PDF. Waere er so breit wie der Block, zeigte die Vorschau etwas
|
||||||
|
anderes als die Datei. */
|
||||||
|
.viewer__unterschrifttext {
|
||||||
|
/* Unter der Zeichnung, nicht in ihr: "top: 100%" setzt die Oberkante des
|
||||||
|
Strichs auf die Bildkante -- genau dort zieht ihn auch pdf-lib. Abstand,
|
||||||
|
Strichstaerke und Innenabstand rechnet das Skript in Punkten. */
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
/* Helvetica: dieselbe Schrift, die das PDF benutzt (StandardFonts.Helvetica).
|
||||||
|
Mit der Hausschrift sah die Vorschau anders aus als das Ergebnis. Die
|
||||||
|
Groesse setzt das Skript je Unterschrift -- hier steht nur ein Rueckfall. */
|
||||||
|
font-family: Helvetica, Arial, sans-serif;
|
||||||
|
/* Die tatsaechliche Groesse setzt das Skript in Pixeln (Punkte mal
|
||||||
|
Pixel-je-Punkt der Seite). Dieser Wert gilt nur, solange sie noch nicht
|
||||||
|
gerechnet wurde -- geerbt wuerde sonst die Schriftgroesse der Seite. */
|
||||||
|
font-size: 8px;
|
||||||
|
line-height: 1.15;
|
||||||
|
color: #111;
|
||||||
|
border-top: 1px solid rgba(0, 0, 0, 0.85);
|
||||||
|
pointer-events: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
width: max-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer__unterschriftgriff,
|
||||||
|
.viewer__unterschriftweg {
|
||||||
|
position: absolute;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--surface-1);
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1;
|
||||||
|
opacity: 0;
|
||||||
|
cursor: nwse-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer__unterschriftweg {
|
||||||
|
cursor: pointer;
|
||||||
|
top: -8px;
|
||||||
|
right: -8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer__unterschriftgriff {
|
||||||
|
right: -8px;
|
||||||
|
bottom: -8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer__unterschrift:hover .viewer__unterschriftgriff,
|
||||||
|
.viewer__unterschrift:hover .viewer__unterschriftweg,
|
||||||
|
.viewer__unterschrift.is-fassend .viewer__unterschriftgriff {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Auf einem Tastbildschirm gibt es kein "hover" -- dort stehen die Knoepfe
|
||||||
|
immer, sonst kaeme man nie an sie heran. */
|
||||||
|
@media (hover: none) {
|
||||||
|
.viewer__unterschriftgriff,
|
||||||
|
.viewer__unterschriftweg {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -- Ergebnis der Signaturpruefung ---------------------------------------- */
|
||||||
|
/* Der Schleier liegt ueber der Seite und nimmt keine Klicks an: ein
|
||||||
|
unterschriebenes Dokument ist flach, aber bei einem Irrtum soll die
|
||||||
|
Kennzeichnung nicht auch noch die Bedienung blockieren. */
|
||||||
|
|
||||||
|
.viewer__pdf--unveraendert {
|
||||||
|
box-shadow: inset 0 0 0 3px var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer__pdf--unveraendert::after,
|
||||||
|
.viewer__pdf--veraendert::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer__pdf--unveraendert::after {
|
||||||
|
background: rgba(47, 158, 68, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer__pdf--veraendert {
|
||||||
|
box-shadow: inset 0 0 0 3px var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer__pdf--veraendert::after {
|
||||||
|
background: rgba(201, 42, 42, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer__pruefung {
|
||||||
|
margin: 0;
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: var(--fs-xs);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer__pruefung--gut {
|
||||||
|
color: var(--success);
|
||||||
|
background: rgba(47, 158, 68, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer__pruefung--schlecht {
|
||||||
|
color: var(--danger);
|
||||||
|
background: rgba(201, 42, 42, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -- Tabelle, die schmal zu Bloecken wird -------------------------------- */
|
||||||
|
/* Eine Protokollzeile besteht aus sieben Angaben, von denen eine lang ist:
|
||||||
|
das Detail. Auf einem breiten Schirm passt das nebeneinander. Wird es eng,
|
||||||
|
ist eine Spalte von zwei Zentimetern fuer eine Pruefsumme keine Loesung --
|
||||||
|
dann steht jede Angabe auf ihrer eigenen Zeile, beschriftet. */
|
||||||
|
|
||||||
|
.table--stapelbar .td--detail {
|
||||||
|
/* Zeilenumbrueche aus dem Eintrag bleiben stehen: die Pruefsumme steht
|
||||||
|
unter dem lesbaren Teil, nicht mitten darin. Und ein Hash ohne Trennstelle
|
||||||
|
darf notfalls hart umbrechen. */
|
||||||
|
white-space: pre-line;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
max-width: 52ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.table--stapelbar thead {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table--stapelbar,
|
||||||
|
.table--stapelbar tbody,
|
||||||
|
.table--stapelbar tr,
|
||||||
|
.table--stapelbar td {
|
||||||
|
display: block;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table--stapelbar tr {
|
||||||
|
padding: var(--sp-3) 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table--stapelbar td {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 8.5rem 1fr;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
align-items: baseline;
|
||||||
|
border: 0;
|
||||||
|
padding: 2px var(--sp-3);
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table--stapelbar td::before {
|
||||||
|
content: attr(data-spalte);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: var(--fs-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table--stapelbar .td--detail {
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Leere Zellen brauchen keine Zeile. */
|
||||||
|
.table--stapelbar td:empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -- Dateiliste: Symbol und Name in einer Zeile -------------------------- */
|
||||||
|
/* Das Symbol ist ein Block-SVG; ohne diese Regel stand der Name darunter und
|
||||||
|
eine Ordnerzeile sah doppelt so hoch aus wie eine Dateizeile. */
|
||||||
|
|
||||||
|
.file-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-cell > svg {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
@@ -45,22 +45,45 @@ function csrfToken() {
|
|||||||
return document.body?.dataset.csrf || "";
|
return document.body?.dataset.csrf || "";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** fetch mit CSRF-Header und JSON-Erwartung. */
|
/**
|
||||||
|
* fetch mit CSRF-Header und JSON-Erwartung.
|
||||||
|
*
|
||||||
|
* `auto: true` kennzeichnet eine Anfrage, die ein Zeitgeber ausgeloest hat und
|
||||||
|
* kein Mensch. Der Server verlaengert das Leerlauffenster der Sitzung dann
|
||||||
|
* nicht -- ohne diese Unterscheidung haelt jeder offene Tab die Anmeldung
|
||||||
|
* unbegrenzt am Leben, und die automatische Abmeldung findet nie statt.
|
||||||
|
*
|
||||||
|
* Der HTTP-Status haengt am geworfenen Fehler, damit der Aufrufer 401
|
||||||
|
* ("Sitzung abgelaufen") von einem Netzfehler unterscheiden kann.
|
||||||
|
*/
|
||||||
export async function api(url, options = {}) {
|
export async function api(url, options = {}) {
|
||||||
const opts = { credentials: "same-origin", ...options };
|
const { auto = false, ...rest } = options;
|
||||||
|
const opts = { credentials: "same-origin", ...rest };
|
||||||
opts.headers = {
|
opts.headers = {
|
||||||
"X-Requested-With": "fetch",
|
"X-Requested-With": "fetch",
|
||||||
"X-CSRF-Token": csrfToken(),
|
"X-CSRF-Token": csrfToken(),
|
||||||
|
...(auto ? { "X-Auto-Refresh": "1" } : {}),
|
||||||
...(options.headers || {}),
|
...(options.headers || {}),
|
||||||
};
|
};
|
||||||
const response = await fetch(url, opts);
|
const response = await fetch(url, opts);
|
||||||
|
// Eine Seite, die auch oeffentlich antwortet -- die Statusuebersicht etwa --
|
||||||
|
// liefert nach dem Ablauf der Sitzung eine 200 mit den oeffentlichen
|
||||||
|
// Kacheln. Am Status ist der Verlust also nicht zu erkennen; an diesem Kopf
|
||||||
|
// schon. Behandelt wird er wie eine 401.
|
||||||
|
if (response.headers.get("X-Session-Expired") === "1") {
|
||||||
|
const abgelaufen = new Error("Die Sitzung ist abgelaufen.");
|
||||||
|
abgelaufen.status = 401;
|
||||||
|
throw abgelaufen;
|
||||||
|
}
|
||||||
const type = response.headers.get("content-type") || "";
|
const type = response.headers.get("content-type") || "";
|
||||||
const payload = type.includes("application/json") ? await response.json() : await response.text();
|
const payload = type.includes("application/json") ? await response.json() : await response.text();
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const message =
|
const message =
|
||||||
(payload && typeof payload === "object" && (payload.error || payload.message)) ||
|
(payload && typeof payload === "object" && (payload.error || payload.message)) ||
|
||||||
`Anfrage fehlgeschlagen (HTTP ${response.status})`;
|
`Anfrage fehlgeschlagen (HTTP ${response.status})`;
|
||||||
throw new Error(message);
|
const error = new Error(message);
|
||||||
|
error.status = response.status;
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
@@ -86,7 +109,7 @@ export function showToast(message, type = "info", { timeout = 6000 } = {}) {
|
|||||||
const close = document.createElement("button");
|
const close = document.createElement("button");
|
||||||
close.type = "button";
|
close.type = "button";
|
||||||
close.className = "toast__close";
|
close.className = "toast__close";
|
||||||
close.setAttribute("aria-label", "Meldung schliessen");
|
close.setAttribute("aria-label", "Meldung schließen");
|
||||||
close.textContent = "×";
|
close.textContent = "×";
|
||||||
toast.append(body, close);
|
toast.append(body, close);
|
||||||
toastStack.appendChild(toast);
|
toastStack.appendChild(toast);
|
||||||
@@ -270,7 +293,7 @@ function initConfirmations() {
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
pending = trigger;
|
pending = trigger;
|
||||||
titleNode.textContent = trigger.dataset.confirmTitle || "Bitte bestaetigen";
|
titleNode.textContent = trigger.dataset.confirmTitle || "Bitte bestätigen";
|
||||||
textNode.textContent = trigger.dataset.confirm;
|
textNode.textContent = trigger.dataset.confirm;
|
||||||
okButton.textContent = trigger.dataset.confirmOk || "Fortfahren";
|
okButton.textContent = trigger.dataset.confirmOk || "Fortfahren";
|
||||||
okButton.className = `btn btn--${trigger.dataset.confirmStyle || "danger"}`;
|
okButton.className = `btn btn--${trigger.dataset.confirmStyle || "danger"}`;
|
||||||
@@ -417,6 +440,56 @@ function initPermissionMatrix() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* -- Ganze Zeile, ganze Spalte, Filter -----------------------------
|
||||||
|
Bei 37 Zeilen und bis zu sieben Spalten je Bereich sind das ueber
|
||||||
|
zweihundert Kaestchen. Ohne diese drei Hilfen setzt man oben den
|
||||||
|
Bereichsschalter und laesst es dabei -- und die feine Aufteilung
|
||||||
|
haette nichts gebracht. */
|
||||||
|
// Nur was man sieht: eine vom Filter ausgeblendete Zeile darf ein
|
||||||
|
// Sammelknopf nicht mitnehmen. Sonst setzt man "alle Write" waehrend nach
|
||||||
|
// "Neustart" gefiltert ist und aendert dabei Zeilen, die gar nicht auf dem
|
||||||
|
// Schirm stehen -- und merkt es erst nach dem Speichern.
|
||||||
|
const sichtbar = (box) => !box.disabled && !box.closest("tr").hidden;
|
||||||
|
const felder = (bereich) =>
|
||||||
|
$$('input[type="checkbox"][name][data-action]', bereich).filter(sichtbar);
|
||||||
|
|
||||||
|
table.addEventListener("click", (event) => {
|
||||||
|
const ziel = event.target;
|
||||||
|
if (!(ziel instanceof HTMLElement)) return;
|
||||||
|
|
||||||
|
const zeilenknopf = ziel.closest("[data-matrix-row]");
|
||||||
|
if (zeilenknopf) {
|
||||||
|
event.preventDefault();
|
||||||
|
const row = zeilenknopf.closest("tr");
|
||||||
|
if (!row) return;
|
||||||
|
const kaestchen = felder(row);
|
||||||
|
// Erst wenn alles gesetzt ist, nimmt der Knopf weg -- sonst waere
|
||||||
|
// ein zweiter Klick auf eine halb gesetzte Zeile ein Ruecksprung.
|
||||||
|
const alleGesetzt = kaestchen.length > 0 && kaestchen.every((box) => box.checked);
|
||||||
|
kaestchen.forEach((box) => {
|
||||||
|
box.checked = !alleGesetzt;
|
||||||
|
box.dispatchEvent(new Event("change", { bubbles: true }));
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const spaltenknopf = ziel.closest("[data-matrix-column]");
|
||||||
|
if (spaltenknopf) {
|
||||||
|
event.preventDefault();
|
||||||
|
const aktion = spaltenknopf.dataset.matrixColumn;
|
||||||
|
const bereich = spaltenknopf.dataset.area;
|
||||||
|
const kaestchen = $$(
|
||||||
|
`tr[data-area="${bereich}"] input[data-action="${aktion}"][name]`,
|
||||||
|
table
|
||||||
|
).filter(sichtbar);
|
||||||
|
const alleGesetzt = kaestchen.length > 0 && kaestchen.every((box) => box.checked);
|
||||||
|
kaestchen.forEach((box) => {
|
||||||
|
box.checked = !alleGesetzt;
|
||||||
|
box.dispatchEvent(new Event("change", { bubbles: true }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
$$("[data-area-switch]", table).forEach((master) => {
|
$$("[data-area-switch]", table).forEach((master) => {
|
||||||
const areaKey = master.dataset.areaSwitch;
|
const areaKey = master.dataset.areaSwitch;
|
||||||
syncArea(areaKey);
|
syncArea(areaKey);
|
||||||
@@ -428,8 +501,12 @@ function initPermissionMatrix() {
|
|||||||
if (!(box instanceof HTMLInputElement) || !box.dataset.action) return;
|
if (!(box instanceof HTMLInputElement) || !box.dataset.action) return;
|
||||||
const row = box.closest("tr");
|
const row = box.closest("tr");
|
||||||
if (!row) return;
|
if (!row) return;
|
||||||
const viewBox = $('input[data-action="view"]', row);
|
// "read", nicht "view": die Aktion heisst seit 2.8.0 wie das, was sie
|
||||||
if (box.dataset.action === "view") {
|
// tut. Mit dem alten Namen fand diese Zeile das Kaestchen nicht mehr --
|
||||||
|
// die Matrix haette sich weiter bedienen lassen und dabei die Regel
|
||||||
|
// "jede Aktion setzt Ansehen voraus" stillschweigend fallen gelassen.
|
||||||
|
const viewBox = $('input[data-action="read"]', row);
|
||||||
|
if (box.dataset.action === "read") {
|
||||||
// "Ansehen" abwaehlen entfernt alles andere dieser Zeile.
|
// "Ansehen" abwaehlen entfernt alles andere dieser Zeile.
|
||||||
if (!box.checked) {
|
if (!box.checked) {
|
||||||
$$('input[type="checkbox"][name]', row).forEach((other) => {
|
$$('input[type="checkbox"][name]', row).forEach((other) => {
|
||||||
@@ -446,6 +523,30 @@ function initPermissionMatrix() {
|
|||||||
master.dispatchEvent(new Event("change"));
|
master.dispatchEvent(new Event("change"));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const suche = $("[data-matrix-filter]");
|
||||||
|
const zaehler = $("[data-matrix-count]");
|
||||||
|
if (suche) {
|
||||||
|
const zeilen = $$("tr[data-suchtext]", table);
|
||||||
|
const filtern = () => {
|
||||||
|
const begriff = suche.value.trim().toLowerCase();
|
||||||
|
let sichtbar = 0;
|
||||||
|
zeilen.forEach((row) => {
|
||||||
|
const passt = !begriff || (row.dataset.suchtext || "").includes(begriff);
|
||||||
|
// "hidden" und nicht aus dem Formular nehmen: ein Filter, der
|
||||||
|
// Kaestchen entfernt, loescht beim Speichern Rechte.
|
||||||
|
row.hidden = !passt;
|
||||||
|
if (passt) sichtbar += 1;
|
||||||
|
});
|
||||||
|
if (zaehler) {
|
||||||
|
zaehler.textContent = begriff
|
||||||
|
? `${sichtbar} von ${zeilen.length} Zeilen`
|
||||||
|
: `${zeilen.length} Zeilen`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
suche.addEventListener("input", filtern);
|
||||||
|
filtern();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -474,14 +575,14 @@ function initAutoRefresh() {
|
|||||||
nameField.value = entry.name || "";
|
nameField.value = entry.name || "";
|
||||||
dnField.value = entry.dn || "";
|
dnField.value = entry.dn || "";
|
||||||
results.hidden = true;
|
results.hidden = true;
|
||||||
show(`Uebernommen: ${entry.name || entry.dn}`);
|
show(`Übernommen: ${entry.name || entry.dn}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const search = async () => {
|
const search = async () => {
|
||||||
const term = query.value.trim();
|
const term = query.value.trim();
|
||||||
if (term.length < 2) { show("Bitte mindestens zwei Zeichen eingeben."); return; }
|
if (term.length < 2) { show("Bitte mindestens zwei Zeichen eingeben."); return; }
|
||||||
button.disabled = true;
|
button.disabled = true;
|
||||||
show("Suche laeuft ...");
|
show("Suche läuft ...");
|
||||||
results.innerHTML = "";
|
results.innerHTML = "";
|
||||||
try {
|
try {
|
||||||
const url = `${picker.dataset.searchUrl}?q=${encodeURIComponent(term)}`;
|
const url = `${picker.dataset.searchUrl}?q=${encodeURIComponent(term)}`;
|
||||||
@@ -509,10 +610,10 @@ function initAutoRefresh() {
|
|||||||
results.appendChild(row);
|
results.appendChild(row);
|
||||||
});
|
});
|
||||||
results.hidden = false;
|
results.hidden = false;
|
||||||
show(`${entries.length} Treffer -- zum Uebernehmen anklicken.`);
|
show(`${entries.length} Treffer – zum Übernehmen anklicken.`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
results.hidden = true;
|
results.hidden = true;
|
||||||
show("Die Suche ist fehlgeschlagen. Laeuft die Verbindung zum Verzeichnis?");
|
show("Die Suche ist fehlgeschlagen. Läuft die Verbindung zum Verzeichnis?");
|
||||||
} finally {
|
} finally {
|
||||||
button.disabled = false;
|
button.disabled = false;
|
||||||
}
|
}
|
||||||
@@ -556,7 +657,10 @@ function initAutoRefresh() {
|
|||||||
const tick = async () => {
|
const tick = async () => {
|
||||||
if (stopped || document.hidden) return schedule();
|
if (stopped || document.hidden) return schedule();
|
||||||
try {
|
try {
|
||||||
const html = await api(region.dataset.refreshUrl, { headers: { Accept: "text/html" } });
|
const html = await api(region.dataset.refreshUrl, {
|
||||||
|
auto: true,
|
||||||
|
headers: { Accept: "text/html" },
|
||||||
|
});
|
||||||
// Eine vollstaendige Seite gehoert nie in einen Ausschnitt. Zeigt die
|
// Eine vollstaendige Seite gehoert nie in einen Ausschnitt. Zeigt die
|
||||||
// URL versehentlich auf eine ganze Seite, waere das Ergebnis eine
|
// URL versehentlich auf eine ganze Seite, waere das Ergebnis eine
|
||||||
// Seite in der Seite -- bei jedem Durchlauf eine Ebene tiefer.
|
// Seite in der Seite -- bei jedem Durchlauf eine Ebene tiefer.
|
||||||
@@ -588,7 +692,16 @@ function initAutoRefresh() {
|
|||||||
initRelativeTimes(region);
|
initRelativeTimes(region);
|
||||||
initCountdowns(region);
|
initCountdowns(region);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
// 401 heisst: die Sitzung ist abgelaufen. Weiterpollen waere sinnlos,
|
||||||
|
// und stehenbleiben waere irrefuehrend -- die Kacheln zeigten dann
|
||||||
|
// beliebig alte Zahlen, als sei alles in Ordnung. Neu laden bringt die
|
||||||
|
// Anmeldemaske mit ihrem Hinweis.
|
||||||
|
if (error && error.status === 401) {
|
||||||
|
stopped = true;
|
||||||
|
window.location.reload();
|
||||||
|
return;
|
||||||
|
}
|
||||||
/* Netzfehler: beim naechsten Intervall erneut versuchen. */
|
/* Netzfehler: beim naechsten Intervall erneut versuchen. */
|
||||||
}
|
}
|
||||||
schedule();
|
schedule();
|
||||||
@@ -685,7 +798,7 @@ function initClipboard() {
|
|||||||
timeout: 2500,
|
timeout: 2500,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
showToast("Kopieren nicht moeglich -- bitte manuell markieren.", "warning");
|
showToast("Kopieren nicht möglich – bitte manuell markieren.", "warning");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -821,6 +934,81 @@ function initTileFilters() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* -- Reihenfolge aendern -------------------------------------------------- */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eine Liste, deren Reihenfolge man aendern kann -- ohne Bibliothek.
|
||||||
|
*
|
||||||
|
* Die Reihenfolge steckt in der Reihenfolge der versteckten Felder: das
|
||||||
|
* Formular schickt sie als wiederholtes Feld, und der Server liest sie als
|
||||||
|
* Liste. Verschiebt man einen Eintrag, verschiebt sich das Feld mit ihm.
|
||||||
|
*
|
||||||
|
* Zwei Wege, weil es zwei Arten von Geraeten gibt: Ziehen mit der Maus und
|
||||||
|
* zwei Knoepfe je Zeile. Die Knoepfe sind nicht die Notloesung -- auf einem
|
||||||
|
* Tablet sind sie der einzige Weg, der zuverlaessig funktioniert, und mit der
|
||||||
|
* Tastatur ebenso.
|
||||||
|
*/
|
||||||
|
function initReorder() {
|
||||||
|
$$('[data-behavior="reorder"]').forEach((liste) => {
|
||||||
|
// Nur die eigenen Zeilen: Gruppen enthalten wiederum eine sortierbare
|
||||||
|
// Liste, und deren Zeilen gehoeren der inneren Liste, nicht dieser.
|
||||||
|
const zeilen = () => Array.from(liste.querySelectorAll(":scope > [data-reorder-item]"));
|
||||||
|
|
||||||
|
const nummerieren = () => {
|
||||||
|
zeilen().forEach((zeile, index) => {
|
||||||
|
const nummer = zeile.querySelector("[data-reorder-position-text]");
|
||||||
|
if (nummer) nummer.textContent = String(index + 1);
|
||||||
|
const hoch = zeile.querySelector('[data-reorder-move="up"]');
|
||||||
|
const runter = zeile.querySelector('[data-reorder-move="down"]');
|
||||||
|
if (hoch) hoch.disabled = index === 0;
|
||||||
|
if (runter) runter.disabled = index === zeilen().length - 1;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
liste.addEventListener("click", (event) => {
|
||||||
|
const knopf = event.target.closest("[data-reorder-move]");
|
||||||
|
if (!knopf) return;
|
||||||
|
const zeile = knopf.closest("[data-reorder-item]");
|
||||||
|
if (!zeile || zeile.parentElement !== liste) return;
|
||||||
|
event.preventDefault();
|
||||||
|
if (knopf.dataset.reorderMove === "up") {
|
||||||
|
const davor = zeile.previousElementSibling;
|
||||||
|
if (davor) liste.insertBefore(zeile, davor);
|
||||||
|
} else {
|
||||||
|
const danach = zeile.nextElementSibling;
|
||||||
|
if (danach) liste.insertBefore(danach, zeile);
|
||||||
|
}
|
||||||
|
nummerieren();
|
||||||
|
zeile.querySelector(`[data-reorder-move="${knopf.dataset.reorderMove}"]`)?.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
let gezogen = null;
|
||||||
|
liste.addEventListener("dragstart", (event) => {
|
||||||
|
const zeile = event.target.closest("[data-reorder-item]");
|
||||||
|
if (!zeile || zeile.parentElement !== liste) return;
|
||||||
|
gezogen = zeile;
|
||||||
|
gezogen.classList.add("is-dragging");
|
||||||
|
if (event.dataTransfer) event.dataTransfer.effectAllowed = "move";
|
||||||
|
});
|
||||||
|
liste.addEventListener("dragend", () => {
|
||||||
|
if (gezogen) gezogen.classList.remove("is-dragging");
|
||||||
|
gezogen = null;
|
||||||
|
nummerieren();
|
||||||
|
});
|
||||||
|
liste.addEventListener("dragover", (event) => {
|
||||||
|
if (!gezogen) return;
|
||||||
|
event.preventDefault();
|
||||||
|
const ziel = event.target.closest("[data-reorder-item]");
|
||||||
|
if (!ziel || ziel === gezogen || ziel.parentElement !== liste) return;
|
||||||
|
const kasten = ziel.getBoundingClientRect();
|
||||||
|
const obereHaelfte = event.clientY < kasten.top + kasten.height / 2;
|
||||||
|
liste.insertBefore(gezogen, obereHaelfte ? ziel : ziel.nextElementSibling);
|
||||||
|
});
|
||||||
|
|
||||||
|
nummerieren();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/* -- Start ---------------------------------------------------------------- */
|
/* -- Start ---------------------------------------------------------------- */
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -858,8 +1046,23 @@ function boot() {
|
|||||||
initPalette();
|
initPalette();
|
||||||
initSubmitGuards();
|
initSubmitGuards();
|
||||||
initTileFilters();
|
initTileFilters();
|
||||||
|
initReorder();
|
||||||
initLogTails();
|
initLogTails();
|
||||||
unsavedGuard.init();
|
unsavedGuard.init();
|
||||||
|
initViewer();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Der Dateibetrachter kommt nur auf Seiten mit Vorschau -- und er zieht bei
|
||||||
|
einem PDF noch pdf.js nach. Beides erst dann, wenn es gebraucht wird. */
|
||||||
|
function initViewer() {
|
||||||
|
if (!document.querySelector("[data-viewer]")) return;
|
||||||
|
// Dieselbe Version wie diese Datei: app.js wird als ".../app.js?v=<Zeit>"
|
||||||
|
// eingebunden, und nginx liefert /core-assets/ mit "expires 7d". Ohne das
|
||||||
|
// Anhaengsel bliebe der alte Betrachter nach einem Update im Cache liegen.
|
||||||
|
const version = new URL(import.meta.url).search;
|
||||||
|
import(`./viewer.js${version}`)
|
||||||
|
.then((modul) => modul.initFilePreview())
|
||||||
|
.catch((fehler) => console.error("Dateibetrachter", fehler));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (document.readyState === "loading") {
|
if (document.readyState === "loading") {
|
||||||
|
|||||||
@@ -0,0 +1,288 @@
|
|||||||
|
// Bildschirmsitzung im Browser: noVNC an die WebSocket-Strecke von TESM.
|
||||||
|
//
|
||||||
|
// Der Client handelt das RFB-Protokoll unmittelbar mit dem Geraet aus; TESM
|
||||||
|
// reicht nur Bytes durch. Alles, was diese Datei tut, ist: verbinden, den
|
||||||
|
// Zustand in Worte fassen und nach Zugangsdaten fragen, wenn keine hinterlegt
|
||||||
|
// sind.
|
||||||
|
//
|
||||||
|
// Die Meldungen sind der eigentliche Wert. Eine Sitzung, die schwarz bleibt,
|
||||||
|
// laesst offen, ob es an der Berechtigung, am Netz, am Server oder am
|
||||||
|
// Passwort liegt -- und wer das nicht sieht, sucht den Fehler in TESM.
|
||||||
|
|
||||||
|
import RFB from "../vendor/novnc/core/rfb.js";
|
||||||
|
|
||||||
|
// Drei Eingriffe in private Methoden der mitgelieferten Bibliothek. Bewusst
|
||||||
|
// hier und nicht in der Bibliothek selbst: eine neue Fassung von noVNC laesst
|
||||||
|
// sich dann austauschen, ohne eine Aenderung nachzupflegen -- faellt eine
|
||||||
|
// Methode weg, entfaellt nur die Auskunft, nicht die Sitzung.
|
||||||
|
//
|
||||||
|
// Zwei davon holen nur Auskunft (_fail, _socketClose). Der dritte greift in
|
||||||
|
// die Wahl des Sicherheitsverfahrens ein -- aber erst, **nachdem** eines
|
||||||
|
// nachweislich nicht getragen hat.
|
||||||
|
//
|
||||||
|
// Die Vorgeschichte gehoert dazu, damit niemand den Schritt zurueckgeht: hier
|
||||||
|
// stand einmal eine Vorauswahl, die ohne hinterlegte Zugangsdaten ein
|
||||||
|
// Verfahren ohne Anmeldung erzwang, weil RealVNC RA2ne zuerst nennt. Das war
|
||||||
|
// geraten und falsch. Was jetzt hier steht, ist das Gegenteil einer
|
||||||
|
// Vermutung: der Server nennt seine Reihenfolge, die gilt -- und wenn seine
|
||||||
|
// erste Wahl die Verbindung abreissen laesst, ist das ein Befund, auf den man
|
||||||
|
// antworten darf. Gemessen am Geraet: RealVNC bietet 6 (RA2ne) an, waehlt man
|
||||||
|
// es, legt es auf; daneben liegt 1 (ohne Authentifizierung), und darueber
|
||||||
|
// laeuft auch der Viewer.
|
||||||
|
const istUnterstuetzt = RFB.prototype._isSupportedSecurityType;
|
||||||
|
|
||||||
|
//: Verfahren, die in dieser Sitzung schon abgerissen sind.
|
||||||
|
const gescheitert = new Set();
|
||||||
|
|
||||||
|
//: Hoechstens ein zweiter Anlauf. Jeder Versuch ist eine Verbindung, und ein
|
||||||
|
//: RealVNC Server zaehlt abgebrochene Versuche mit -- wer dreimal anklopft,
|
||||||
|
//: sperrt sich aus.
|
||||||
|
const HOECHSTENS = 2;
|
||||||
|
// Den *Grund* eines Fehlschlags kennt nur ``_fail`` -- das Ereignis
|
||||||
|
// "disconnect" traegt ihn nicht, und der Weg ueber die Protokollausgabe
|
||||||
|
// funktioniert nicht: die Bibliothek bindet ``console.error`` beim Laden, also
|
||||||
|
// bevor eine Seite etwas daran aendern koennte. (Genau daran ist der erste
|
||||||
|
// Versuch gescheitert -- die Meldung blieb leer.)
|
||||||
|
let letzterGrund = "";
|
||||||
|
const originalFail = RFB.prototype._fail;
|
||||||
|
RFB.prototype._fail = function (details) {
|
||||||
|
letzterGrund = String(details);
|
||||||
|
return originalFail.call(this, details);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Und der Abschluss der Leitung selbst: der Schliesscode sagt, ob TESM
|
||||||
|
// abgewiesen hat (1008), ob es ein Serverfehler war (1011) -- oder ob die
|
||||||
|
// Verbindung nie zustande kam (1006).
|
||||||
|
let letzterCode = 0;
|
||||||
|
const originalSocketClose = RFB.prototype._socketClose;
|
||||||
|
RFB.prototype._socketClose = function (ereignis) {
|
||||||
|
letzterCode = (ereignis && ereignis.code) || 0;
|
||||||
|
if (ereignis && ereignis.reason) {
|
||||||
|
letzterGrund = letzterGrund || String(ereignis.reason);
|
||||||
|
}
|
||||||
|
return originalSocketClose.call(this, ereignis);
|
||||||
|
};
|
||||||
|
|
||||||
|
const wurzel = document.getElementById("screen");
|
||||||
|
if (wurzel) {
|
||||||
|
starten(wurzel);
|
||||||
|
}
|
||||||
|
|
||||||
|
function starten(wurzel) {
|
||||||
|
const leinwand = document.getElementById("screen-canvas");
|
||||||
|
const zeile = document.getElementById("screen-status");
|
||||||
|
const abfrage = document.getElementById("screen-credentials");
|
||||||
|
const feldBenutzer = document.getElementById("screen-username");
|
||||||
|
const feldPasswort = document.getElementById("screen-password");
|
||||||
|
const knopfSenden = document.getElementById("screen-send");
|
||||||
|
const knopfTrennen = document.getElementById("screen-disconnect");
|
||||||
|
const knopfStrg = document.getElementById("screen-cad");
|
||||||
|
|
||||||
|
const pfad = wurzel.dataset.socket;
|
||||||
|
const schema = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||||
|
const adresse = `${schema}//${window.location.host}${pfad}`;
|
||||||
|
|
||||||
|
const vorgabe = JSON.parse(
|
||||||
|
document.getElementById("screen-credentials-data")?.textContent || "{}",
|
||||||
|
);
|
||||||
|
|
||||||
|
let rfb = null;
|
||||||
|
let versuche = 0;
|
||||||
|
let stand = false;
|
||||||
|
let letzterZugang = {};
|
||||||
|
|
||||||
|
function melden(text, art = "") {
|
||||||
|
zeile.textContent = text;
|
||||||
|
zeile.className = `screen__status${art ? " screen__status--" + art : ""}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function grund() {
|
||||||
|
const teile = [];
|
||||||
|
if (letzterGrund) {
|
||||||
|
teile.push(letzterGrund.replace(/^Failed (when|while) \w+: /, ""));
|
||||||
|
}
|
||||||
|
if (rfb && rfb._rfbAuthScheme > 0) {
|
||||||
|
teile.push(`gewähltes Verfahren: ${rfb._rfbAuthScheme}`);
|
||||||
|
}
|
||||||
|
if (gescheitert.size) {
|
||||||
|
teile.push(`erfolglos: ${[...gescheitert].join(", ")}`);
|
||||||
|
}
|
||||||
|
return teile.join(" · ");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hat das gewählte Verfahren die Verbindung abreissen lassen? Dann ist das
|
||||||
|
// ein Befund und keine Vermutung -- der naechste Anlauf laesst es aus.
|
||||||
|
//
|
||||||
|
// Nur solange nie ein Bild stand: bricht eine **laufende** Sitzung ab, liegt
|
||||||
|
// es nicht am Anmeldeverfahren, und ein stiller Neuaufbau wuerde die Ursache
|
||||||
|
// verdecken.
|
||||||
|
function naechsterVersuch() {
|
||||||
|
const verfahren = rfb && rfb._rfbAuthScheme > 0 ? rfb._rfbAuthScheme : 0;
|
||||||
|
if (!verfahren || versuche >= HOECHSTENS || gescheitert.has(verfahren)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
gescheitert.add(verfahren);
|
||||||
|
melden(
|
||||||
|
`Verfahren ${verfahren} hat die Verbindung nicht getragen – nächster Anlauf ` +
|
||||||
|
"mit dem nächsten, das der Server anbietet …",
|
||||||
|
"warnung",
|
||||||
|
);
|
||||||
|
const zugang = letzterZugang;
|
||||||
|
rfb = null;
|
||||||
|
window.setTimeout(() => verbinden(zugang), 400);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Steht die WebSocket-Strecke zu TESM überhaupt? Zwei sehr verschiedene
|
||||||
|
// Ursachen sehen von aussen gleich aus: der VNC-Server mag nicht, oder die
|
||||||
|
// Verbindung kommt nie zustande, weil ein Proxy zwischen Browser und TESM
|
||||||
|
// "Upgrade" nicht durchreicht. Diese Probe trennt sie.
|
||||||
|
function streckeTesten() {
|
||||||
|
return new Promise((fertig) => {
|
||||||
|
let beantwortet = false;
|
||||||
|
const pruefling = new WebSocket(`${schema}//${window.location.host}/ws/probe`);
|
||||||
|
const schliessen = (ergebnis) => {
|
||||||
|
if (beantwortet) return;
|
||||||
|
beantwortet = true;
|
||||||
|
try {
|
||||||
|
pruefling.close();
|
||||||
|
} catch (fehler) {
|
||||||
|
/* schon zu */
|
||||||
|
}
|
||||||
|
fertig(ergebnis);
|
||||||
|
};
|
||||||
|
pruefling.onmessage = () => schliessen(true);
|
||||||
|
pruefling.onerror = () => schliessen(false);
|
||||||
|
pruefling.onclose = () => schliessen(false);
|
||||||
|
window.setTimeout(() => schliessen(false), 4000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function verbinden(zugang) {
|
||||||
|
melden(versuche ? "Nächster Versuch …" : "Verbindung wird aufgebaut …");
|
||||||
|
letzterGrund = "";
|
||||||
|
letzterCode = 0;
|
||||||
|
letzterZugang = zugang;
|
||||||
|
versuche += 1;
|
||||||
|
|
||||||
|
// Was abgerissen ist, wird nicht noch einmal gewaehlt. Sonst faende noVNC
|
||||||
|
// dasselbe Verfahren wieder zuerst -- und der zweite Anlauf waere eine
|
||||||
|
// Wiederholung des ersten.
|
||||||
|
RFB.prototype._isSupportedSecurityType = function (typ) {
|
||||||
|
if (gescheitert.has(typ)) return false;
|
||||||
|
return istUnterstuetzt.call(this, typ);
|
||||||
|
};
|
||||||
|
|
||||||
|
rfb = new RFB(leinwand, adresse, {
|
||||||
|
credentials: zugang,
|
||||||
|
// Eine Bildschirmsitzung soll nicht nebenbei die Zwischenablage des
|
||||||
|
// Arbeitsplatzes mit dem Geraet teilen -- das ist eine eigene
|
||||||
|
// Entscheidung, und niemand hat sie hier getroffen.
|
||||||
|
clipboardTarget: null,
|
||||||
|
});
|
||||||
|
rfb.scaleViewport = true;
|
||||||
|
rfb.resizeSession = false;
|
||||||
|
rfb.focusOnClick = true;
|
||||||
|
|
||||||
|
rfb.addEventListener("connect", () => {
|
||||||
|
stand = true;
|
||||||
|
melden(`Verbunden mit ${wurzel.dataset.ziel}`, "ok");
|
||||||
|
abfrage.hidden = true;
|
||||||
|
leinwand.hidden = false;
|
||||||
|
knopfTrennen.hidden = false;
|
||||||
|
knopfStrg.hidden = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
rfb.addEventListener("disconnect", (ereignis) => {
|
||||||
|
leinwand.hidden = true;
|
||||||
|
knopfTrennen.hidden = true;
|
||||||
|
knopfStrg.hidden = true;
|
||||||
|
if (ereignis.detail && ereignis.detail.clean) {
|
||||||
|
melden("Sitzung beendet.");
|
||||||
|
} else if (!stand && naechsterVersuch()) {
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
const erklaerung = grund();
|
||||||
|
melden(
|
||||||
|
erklaerung
|
||||||
|
? `Verbindung abgebrochen – ${erklaerung}`
|
||||||
|
: "Verbindung abgebrochen. Wird geprüft, woran es liegt …",
|
||||||
|
"fehler",
|
||||||
|
);
|
||||||
|
// 1006 heisst: die Leitung ging zu, ohne dass die Gegenseite einen
|
||||||
|
// Grund geschickt hat -- typisch, wenn die WebSocket-Verbindung gar
|
||||||
|
// nicht erst zustande kam. Dann sagt die Probe, wer schweigt.
|
||||||
|
if (letzterCode === 1006 || !erklaerung) {
|
||||||
|
streckeTesten().then((steht) => {
|
||||||
|
melden(
|
||||||
|
steht
|
||||||
|
? `${erklaerung || "Verbindung abgebrochen"} – die WebSocket-Strecke zu ` +
|
||||||
|
"TESM selbst steht (Probe erfolgreich), der Abbruch kommt also von " +
|
||||||
|
"der VNC-Seite."
|
||||||
|
: "Die WebSocket-Verbindung zu TESM kommt nicht zustande – auch eine " +
|
||||||
|
"einfache Probe scheitert. Ein Proxy zwischen Browser und TESM muss " +
|
||||||
|
"die Kopfzeilen Upgrade und Connection durchreichen (nginx: " +
|
||||||
|
"location /ws/ mit proxy_set_header Upgrade $http_upgrade). Dasselbe " +
|
||||||
|
"gilt für die Browser-Konsole.",
|
||||||
|
"fehler",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rfb = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
rfb.addEventListener("credentialsrequired", (ereignis) => {
|
||||||
|
const gefragt = ereignis.detail.types || ["password"];
|
||||||
|
abfrage.hidden = false;
|
||||||
|
feldBenutzer.closest("label").hidden = !gefragt.includes("username");
|
||||||
|
melden("Der VNC-Server verlangt Zugangsdaten.", "warnung");
|
||||||
|
(gefragt.includes("username") ? feldBenutzer : feldPasswort).focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
rfb.addEventListener("securityfailure", (ereignis) => {
|
||||||
|
melden(
|
||||||
|
`Anmeldung abgelehnt: ${ereignis.detail.reason || "kein Grund genannt"}`,
|
||||||
|
"fehler",
|
||||||
|
);
|
||||||
|
abfrage.hidden = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
rfb.addEventListener("desktopname", (ereignis) => {
|
||||||
|
wurzel.dataset.desktop = ereignis.detail.name;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
knopfSenden?.addEventListener("click", () => {
|
||||||
|
const zugang = {};
|
||||||
|
if (feldBenutzer.value) zugang.username = feldBenutzer.value;
|
||||||
|
if (feldPasswort.value) zugang.password = feldPasswort.value;
|
||||||
|
if (rfb) {
|
||||||
|
rfb.sendCredentials(zugang);
|
||||||
|
} else {
|
||||||
|
verbinden(zugang);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
[feldBenutzer, feldPasswort].forEach((feld) =>
|
||||||
|
feld?.addEventListener("keydown", (ereignis) => {
|
||||||
|
if (ereignis.key === "Enter") {
|
||||||
|
ereignis.preventDefault();
|
||||||
|
knopfSenden.click();
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
knopfTrennen?.addEventListener("click", () => rfb && rfb.disconnect());
|
||||||
|
knopfStrg?.addEventListener("click", () => rfb && rfb.sendCtrlAltDel());
|
||||||
|
|
||||||
|
if (wurzel.dataset.blockiert === "1") {
|
||||||
|
melden(wurzel.dataset.grund || "Diese Sitzung kann nicht aufgebaut werden.", "fehler");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const zugang = {};
|
||||||
|
if (vorgabe.username) zugang.username = vorgabe.username;
|
||||||
|
if (vorgabe.password) zugang.password = vorgabe.password;
|
||||||
|
verbinden(zugang);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,13 @@
|
|||||||
|
maintainers:
|
||||||
|
- Samuel Mannehed for Cendio AB (@samhed)
|
||||||
|
- Pierre Ossman for Cendio AB (@CendioOssman)
|
||||||
|
maintainersEmeritus:
|
||||||
|
- Joel Martin (@kanaka)
|
||||||
|
- Solly Ross (@directxman12)
|
||||||
|
- @astrand
|
||||||
|
contributors:
|
||||||
|
# There are a bunch of people that should be here.
|
||||||
|
# If you want to be on this list, feel free send a PR
|
||||||
|
# to add yourself.
|
||||||
|
- jalf <git@jalf.dk>
|
||||||
|
- NTT corp.
|
||||||
Loaded 100 of 241 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user