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 | ||
|
|
a8bd5763fb | ||
|
|
d6ba98782e | ||
|
|
47ab97a0ac | ||
|
|
fbbf8eb87b | ||
|
|
9a760274f8 | ||
|
|
cf16809ee2 | ||
|
|
d8368fc200 | ||
|
|
e343aee026 | ||
|
|
4065b3586b | ||
|
|
5bb0809009 | ||
|
|
64e25bb9ae | ||
|
|
7354ff352b | ||
|
|
a193b9d6e9 | ||
|
|
56974a4308 | ||
|
|
b69d0745dd | ||
|
|
7ab6358b9f | ||
|
|
f02db4c6b5 | ||
|
|
c7ad0d3d49 | ||
|
|
f09aaae8a9 | ||
|
|
36816c57ef | ||
|
|
4a77a3ba32 | ||
|
|
7a1f2b164e | ||
|
|
682e9b4420 | ||
|
|
90e07aa310 | ||
|
|
c9ac0bf69b | ||
|
|
b70ebefb17 | ||
|
|
d90c27b8f8 | ||
|
|
8577cd24cd | ||
|
|
877903bd77 | ||
|
|
2b05d012ee | ||
|
|
0f0fa99e53 | ||
|
|
9b4cf8c2e1 | ||
|
|
1c0082d3d4 | ||
|
|
fc4b165d23 | ||
|
|
e81735d5ec | ||
|
|
2eaefc9e1f | ||
|
|
cb8b929c3c | ||
|
|
af5dfe30b4 | ||
|
|
efb0d2aa01 | ||
|
|
d461f8ca42 | ||
|
|
1b7fe81507 | ||
|
|
2c9ec8a100 | ||
|
|
09b222615a | ||
|
|
0074e48e09 | ||
|
|
a3f5f028f2 | ||
|
|
5bca29f3da | ||
|
|
4de5828573 | ||
|
|
f27dde466b | ||
|
|
8253ab3f0e | ||
|
|
befc76819e | ||
|
|
99d4d3cd01 | ||
|
|
c30ad41101 | ||
|
|
e17d91743a | ||
|
|
e934c3ff0f | ||
|
|
7b17c952ba | ||
|
|
d90095885f | ||
|
|
6ec9e5fd56 | ||
|
|
33753e4f41 |
No files matched your search
@@ -0,0 +1,9 @@
|
||||
# Auf dem Zielsystem laufen Shell- und Python-Dateien -- CRLF wuerde sie
|
||||
# unbrauchbar machen ("bad interpreter"). Darum hart auf LF festgelegt.
|
||||
* text=auto eol=lf
|
||||
*.sh text eol=lf
|
||||
*.py text eol=lf
|
||||
*.html text eol=lf
|
||||
*.css text eol=lf
|
||||
*.js text eol=lf
|
||||
deploy/tesm-helper text eol=lf
|
||||
+27
-13
@@ -1,16 +1,30 @@
|
||||
# Virtuelle Umgebung (Linux — das Projekt ist ausschließlich für Linux ausgelegt)
|
||||
/srv/tesm/venv/
|
||||
# Instanzdaten und Geheimnisse gehoeren nie ins Repo.
|
||||
instance/
|
||||
*.db
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
data.keys
|
||||
secret.key
|
||||
license.json
|
||||
license_key.json
|
||||
master_signing_key.json
|
||||
known_hosts
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.py[cod]
|
||||
.venv/
|
||||
*.egg-info/
|
||||
build/
|
||||
dist/
|
||||
|
||||
# Logfiles
|
||||
*.log
|
||||
# Werkzeuge
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
.mypy_cache/
|
||||
|
||||
# Secrets — niemals committen
|
||||
/srv/tesm/sqlite.db
|
||||
/srv/tesm/fernet.key
|
||||
/srv/tesm/secret.key
|
||||
/srv/tesm/known_hosts
|
||||
|
||||
# Laufzeit-Uploads (Profilbilder) — werden bei Bedarf automatisch angelegt
|
||||
/srv/tesm/static/uploads/
|
||||
# Release-Pakete entstehen aus einem Tag und haengen am Release -- sie gehoeren
|
||||
# nicht ins Repository. Einmal ist genau das passiert: der Tarball lag im
|
||||
# Arbeitsbaum, wanderte in den Commit, und das naechste Paket enthielt das
|
||||
# vorige. Doppelte Groesse, verschachtelter Inhalt.
|
||||
*.tar.gz
|
||||
@@ -0,0 +1,30 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "tesm"
|
||||
version = "2.8.18"
|
||||
description = "TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten und Aruba-Switchen"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"tesm-core>=2.0.0",
|
||||
"tesm-licensing>=2.0.0",
|
||||
"paramiko>=3.5",
|
||||
"PyYAML>=6.0",
|
||||
"ldap3>=2.9",
|
||||
"Flask-Sock>=0.7",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
server = ["gunicorn>=23.0"]
|
||||
|
||||
[project.scripts]
|
||||
tesm-admin = "tesm.cli:main"
|
||||
tesm-monitor = "tesm.worker:main"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
tesm = ["templates/**/*.html", "static/**/*"]
|
||||
@@ -0,0 +1,398 @@
|
||||
"""TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten und Aruba-Switchen.
|
||||
|
||||
Diese Datei ist die Anwendungsfabrik. Sie tut drei Dinge und nicht mehr:
|
||||
Konfiguration laden, das gemeinsame Geruest aus ``tesm_core`` aufbauen, die
|
||||
eigenen Blueprints anmelden. Der Vorgaenger hatte an dieser Stelle eine
|
||||
``app.py`` mit mehreren tausend Zeilen -- Routen, SQL, SSH, DHCP-Erzeugung und
|
||||
Templates in einer Datei.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from flask import Flask, current_app
|
||||
from tesm_core.app_factory import create_base_app
|
||||
from tesm_core.config import load_core_config
|
||||
from tesm_core.extension import core
|
||||
from tesm_core.views import trash as trash_views
|
||||
|
||||
from .navigation import NAVIGATION
|
||||
from .permissions import DHCP_PAGE, PERMISSIONS, ROLE_PRESETS
|
||||
from .schema import TESM_MIGRATIONS
|
||||
from .settings_spec import TESM_SETTINGS
|
||||
|
||||
#: Version aus den Metadaten des installierten Pakets -- die einzige Quelle
|
||||
#: ist ``pyproject.toml``. Vorher stand hier eine feste Zahl, und die
|
||||
#: Fusszeile zeigte vier Releases lang 2.0.2.
|
||||
#:
|
||||
#: Im Quellbaum ohne ``pip install`` gibt es keine Metadaten; dann steht dort
|
||||
#: "unbekannt" statt einer erfundenen Nummer. In einem editierbaren Install
|
||||
#: zeigt sie den Stand des letzten ``pip install -e`` -- also die Version, die
|
||||
#: tatsaechlich installiert ist.
|
||||
try: # pragma: no cover - haengt an der Installationsart
|
||||
from importlib.metadata import PackageNotFoundError, version as _paketversion
|
||||
|
||||
__version__ = _paketversion("tesm")
|
||||
except (ImportError, PackageNotFoundError): # pragma: no cover
|
||||
__version__ = "unbekannt"
|
||||
|
||||
APP_KEY = "tesm"
|
||||
APP_NAME = "TESM"
|
||||
APP_SHORT = "TE"
|
||||
APP_EDITION = "Geraeteverwaltung"
|
||||
#: Funktionszeile unter der Wortmarke auf der Anmeldeseite. Uebernommen vom
|
||||
#: Vorgaenger, wo sie Teil des Hintergrundmotivs war -- sie sagt in einer Zeile,
|
||||
#: was die Anwendung tut, und das gehoert auf die erste Seite.
|
||||
#: Zeile unter der Wortmarke auf den Anmeldeseiten. Kurz gehalten: sie ist
|
||||
#: eine Marke, keine Funktionsliste. "(PXE)" stand ausserdem fuer etwas, das
|
||||
#: es noch nicht gibt -- das Anmeldebanner hat es aus demselben Grund nie
|
||||
#: gezeigt. Wortgleich in ``deploy/tesm-motd``; ein Hygienetest haelt beides
|
||||
#: zusammen.
|
||||
AUTH_TAGLINE = "ÜBERWACHEN · BOOTEN · ANBINDEN"
|
||||
ENV_PREFIX = "TESM_"
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def create_app(**overrides: Any) -> Flask:
|
||||
config = load_core_config(
|
||||
app_key=APP_KEY, app_name=APP_NAME, env_prefix=ENV_PREFIX
|
||||
)
|
||||
|
||||
app = create_base_app(
|
||||
config=config,
|
||||
permissions=PERMISSIONS,
|
||||
nav=NAVIGATION,
|
||||
role_presets=ROLE_PRESETS,
|
||||
app_migrations={"tesm": TESM_MIGRATIONS},
|
||||
settings_specs=TESM_SETTINGS,
|
||||
import_name="tesm",
|
||||
app_short=APP_SHORT,
|
||||
app_edition=APP_EDITION,
|
||||
auth_tagline=AUTH_TAGLINE,
|
||||
app_version=__version__,
|
||||
enable_license=True,
|
||||
)
|
||||
|
||||
_register_blueprints(app)
|
||||
_register_registry(app)
|
||||
_register_trash_providers(app)
|
||||
_startup_tasks(app)
|
||||
|
||||
if overrides:
|
||||
app.config.update(overrides)
|
||||
return app
|
||||
|
||||
|
||||
def _register_blueprints(app: Flask) -> None:
|
||||
from .blueprints import (
|
||||
backup,
|
||||
credentials,
|
||||
dashboard,
|
||||
devices,
|
||||
dhcp,
|
||||
fileshare,
|
||||
license as license_bp,
|
||||
logs,
|
||||
maintenance,
|
||||
settings,
|
||||
switches,
|
||||
hostkeys,
|
||||
screen,
|
||||
terminal,
|
||||
)
|
||||
|
||||
app.register_blueprint(dashboard.bp)
|
||||
app.register_blueprint(devices.bp)
|
||||
app.register_blueprint(switches.bp)
|
||||
app.register_blueprint(credentials.bp)
|
||||
app.register_blueprint(hostkeys.bp)
|
||||
app.register_blueprint(maintenance.bp)
|
||||
app.register_blueprint(dhcp.bp)
|
||||
app.register_blueprint(fileshare.bp)
|
||||
app.register_blueprint(logs.bp) # tesmlogs: Kea + Neustarts
|
||||
app.register_blueprint(settings.bp)
|
||||
app.register_blueprint(license_bp.bp)
|
||||
app.register_blueprint(backup.bp)
|
||||
terminal.init_app(app)
|
||||
screen.init_app(app)
|
||||
|
||||
# Die Statusuebersicht ist die Startseite; ``index`` bleibt als Endpunkt
|
||||
# bestehen, weil der Kern (Weiterleitungen, Fehlerseiten) darauf verweist.
|
||||
app.add_url_rule("/", "index", dashboard.index_view)
|
||||
|
||||
|
||||
def _register_registry(app: Flask) -> None:
|
||||
"""Haengt app-spezifische Bausteine in den Kern ein."""
|
||||
from .services import inventory, monitor
|
||||
|
||||
extension = core(app)
|
||||
extension.registry["reencrypt_secrets"] = inventory.reencrypt_secrets
|
||||
# Angaben fuer die nginx-Vorlage im Kern.
|
||||
extension.registry["upstream"] = extension.config.bind
|
||||
extension.registry["static_root"] = str(extension.config.base_dir / "static")
|
||||
extension.registry["websocket_path"] = "/ws/"
|
||||
extension.registry["public_dashboard_url"] = "/"
|
||||
extension.registry["live_log"] = monitor.LiveLog(extension.config.log_dir / "live.log")
|
||||
extension.registry["status_pill_builder"] = _status_pills
|
||||
extension.login_hooks.append(_mount_shares_after_login)
|
||||
extension.logout_hooks.append(_release_shares_on_logout)
|
||||
|
||||
|
||||
def _mount_shares_after_login(conn: Any, user: Any, secret: str, group_dns: tuple) -> None:
|
||||
"""Bindet nach der Anmeldung die Freigaben dieses Benutzers ein.
|
||||
|
||||
Ohne diesen Haken blieb der ganze Freigabe-Teil Theorie: Zuordnungen liessen
|
||||
sich anlegen, es entstand aber nie eine Einbindung, und weil der Menuepunkt
|
||||
an vorhandenen Einbindungen haengt, war die Seite nie zu sehen.
|
||||
"""
|
||||
from flask import session
|
||||
|
||||
from .services import fileshare
|
||||
|
||||
extension = core()
|
||||
if extension.license is not None and not extension.license.module_active("fileshare"):
|
||||
return
|
||||
|
||||
with extension.database.transaction(conn):
|
||||
messages = fileshare.mount_for_login(
|
||||
conn,
|
||||
user_id=user.id,
|
||||
session_id=getattr(session, "sid", ""),
|
||||
username=user.username,
|
||||
password=secret,
|
||||
group_dns=group_dns,
|
||||
)
|
||||
for message in messages:
|
||||
current_app.logger.info("Dateifreigabe für %s | %s", user.username, message)
|
||||
|
||||
|
||||
def _release_shares_on_logout(conn: Any, user: Any) -> None:
|
||||
"""Haengt die Freigaben dieses Benutzers beim Abmelden wieder aus.
|
||||
|
||||
Eingebunden wird mit dem Konto des Benutzers. Bliebe die Einbindung nach
|
||||
dem Abmelden bestehen, waere sein Zugriff weiter offen -- bis irgendwann
|
||||
die Zeitgrenze greift. Das ist zu lange.
|
||||
"""
|
||||
from flask import session
|
||||
|
||||
from .services import fileshare
|
||||
|
||||
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):
|
||||
released = fileshare.release_all_for_user(conn, user.id, sitzung)
|
||||
if released:
|
||||
current_app.logger.info(
|
||||
"Dateifreigaben für %s gelöst: %d", user.username, released
|
||||
)
|
||||
|
||||
|
||||
def _status_pills(user: Any) -> list[dict[str, str]]:
|
||||
"""Zusaetzliche Anzeigen in der Topbar."""
|
||||
if not user.is_authenticated:
|
||||
return []
|
||||
from tesm_core.extension import db
|
||||
|
||||
from .services import kea, monitor
|
||||
|
||||
extension = core()
|
||||
pills: list[dict[str, str]] = []
|
||||
|
||||
verbindung = db()
|
||||
|
||||
# Timer bis zum naechsten Durchlauf, mit Knopf fuer "sofort pruefen". Der
|
||||
# Knopf gehoert in die Pille, weil er genau die Angabe daneben zuruecksetzt.
|
||||
if user.has_any(("devices.read", "monitoring.read")):
|
||||
intervall = int(extension.settings.get(verbindung, "monitor_interval_seconds") or 300)
|
||||
faellig = monitor.next_check_at(verbindung, intervall)
|
||||
if faellig is not None:
|
||||
eintrag = {
|
||||
"label": "Nächste Prüfung in",
|
||||
"countdown_to": faellig.isoformat(),
|
||||
"countdown_done": "jetzt",
|
||||
"css_class": "",
|
||||
"dot": "1",
|
||||
"dot_class": "",
|
||||
"title": f"Der Überwachungsdienst prüft alle {intervall} Sekunden.",
|
||||
}
|
||||
if user.has("settings_system.execute"):
|
||||
from flask import url_for
|
||||
|
||||
eintrag["action"] = url_for("settings.run_check_now")
|
||||
eintrag["action_title"] = "Alle Clients sofort prüfen"
|
||||
pills.append(eintrag)
|
||||
|
||||
counts = monitor.dashboard_counts(verbindung)
|
||||
if counts["offline"]:
|
||||
pills.append(
|
||||
{
|
||||
"label": f"{counts['offline']} offline",
|
||||
"css_class": "status-pill--danger",
|
||||
"dot": "1",
|
||||
"dot_class": "status-pill__dot--pulse",
|
||||
"title": "Geräte, die beim letzten Durchlauf nicht geantwortet haben.",
|
||||
}
|
||||
)
|
||||
|
||||
if user.has_any(DHCP_PAGE) and extension.license is not None:
|
||||
if extension.license.module_active("dhcp") and extension.sysops is not None:
|
||||
state = extension.sysops.service_state(kea.SERVICE_UNIT)
|
||||
if state["installed"] == "ja":
|
||||
active = state["active"] == "active"
|
||||
pills.append(
|
||||
{
|
||||
"label": "DHCP aktiv" if active else "DHCP gestoppt",
|
||||
"css_class": "" if active else "status-pill--warning",
|
||||
"dot": "1",
|
||||
"dot_class": "",
|
||||
"title": f"kea-dhcp4-server: {state['active']}, {state['enabled']}",
|
||||
}
|
||||
)
|
||||
return pills
|
||||
|
||||
|
||||
def _register_trash_providers(app: Flask) -> None:
|
||||
from .services import inventory
|
||||
|
||||
with app.app_context():
|
||||
trash_views.register(
|
||||
trash_views.TrashProvider(
|
||||
key="devices",
|
||||
label="Clients",
|
||||
icon="cpu",
|
||||
list_fn=lambda conn: inventory.trash_entries(
|
||||
conn, "devices", label_column="name", detail_columns=("mac", "ip")
|
||||
),
|
||||
restore_fn=lambda conn, ident: inventory.restore_row(
|
||||
conn, "devices", int(ident), unique_column="mac"
|
||||
),
|
||||
purge_fn=lambda conn, ident: inventory.purge_row(conn, "devices", int(ident)),
|
||||
hint="Die MAC-Adresse bleibt reserviert, solange das Gerät hier liegt.",
|
||||
)
|
||||
)
|
||||
trash_views.register(
|
||||
trash_views.TrashProvider(
|
||||
key="switches",
|
||||
label="Switche",
|
||||
icon="switch",
|
||||
list_fn=lambda conn: inventory.trash_entries(
|
||||
conn, "switches", label_column="hostname", detail_columns=("ip", "model")
|
||||
),
|
||||
restore_fn=lambda conn, ident: inventory.restore_row(
|
||||
conn, "switches", int(ident), unique_column="hostname"
|
||||
),
|
||||
purge_fn=lambda conn, ident: inventory.purge_row(conn, "switches", int(ident)),
|
||||
)
|
||||
)
|
||||
trash_views.register(
|
||||
trash_views.TrashProvider(
|
||||
key="credentials",
|
||||
label="Zugangsdaten",
|
||||
icon="key",
|
||||
list_fn=lambda conn: inventory.trash_entries(
|
||||
conn, "credentials", label_column="name", detail_columns=("username", "category")
|
||||
),
|
||||
restore_fn=lambda conn, ident: inventory.restore_row(
|
||||
conn, "credentials", int(ident), unique_column="name"
|
||||
),
|
||||
purge_fn=lambda conn, ident: inventory.purge_row(conn, "credentials", int(ident)),
|
||||
hint="Endgültiges Löschen entfernt auch das gespeicherte Passwort.",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _startup_tasks(app: Flask) -> None:
|
||||
"""Einmalige Aufgaben beim Start des echten Webprozesses.
|
||||
|
||||
Der Vorgaenger fuehrte solche Aufgaben bei **jedem** Modul-Import aus -- ein
|
||||
Hilfsskript, das ``app.py`` importierte, raeumte dadurch die
|
||||
Datei-Einbindungen fremder, aktiver Sitzungen ab. Darum laufen sie hier nur,
|
||||
wenn die systemd-Unit den Prozess ausdruecklich als Webprozess markiert hat.
|
||||
"""
|
||||
extension = core(app)
|
||||
if not extension.config.web_process:
|
||||
logger.info(
|
||||
"Startaufgaben übersprungen – dieser Prozess ist nicht als Webprozess markiert."
|
||||
)
|
||||
return
|
||||
|
||||
from .services import fileshare, maintenance
|
||||
|
||||
with extension.database.session() as conn, extension.database.transaction(conn):
|
||||
orphaned = maintenance.mark_orphaned_jobs(conn)
|
||||
# Einbindungen leben im Mount-Namensraum dieses Prozesses und sind mit
|
||||
# dem vorherigen verschwunden. Die Zeilen dazu muessen weg, sonst zeigt
|
||||
# die Oberflaeche Freigaben, die es nicht gibt.
|
||||
vergessen = fileshare.forget_all_mounts(conn)
|
||||
if orphaned:
|
||||
logger.warning("%s unterbrochene Wartungsaufträge als unklar markiert", orphaned)
|
||||
if vergessen:
|
||||
logger.info(
|
||||
"%s Freigabe-Einbindungen eines vorherigen Prozesses vergessen "
|
||||
"-- sie bestehen mit dessen Namensraum nicht mehr",
|
||||
vergessen,
|
||||
)
|
||||
|
||||
if extension.license is not None:
|
||||
import socket
|
||||
|
||||
extension.license.start_heartbeat(hostname=socket.gethostname())
|
||||
|
||||
_start_fileshare_sweep(app)
|
||||
|
||||
|
||||
#: Wie oft nach abgelaufenen Einbindungen gesehen wird.
|
||||
FILESHARE_SWEEP_SECONDS = 300
|
||||
|
||||
|
||||
def _start_fileshare_sweep(app: Flask) -> None:
|
||||
"""Loest abgelaufene Freigabe-Einbindungen -- im Webprozess.
|
||||
|
||||
Muss hier laufen und nicht im Ueberwachungsdienst: eingebunden wird im
|
||||
Mount-Namensraum dieses Dienstes, und ein anderer Dienst sieht diese
|
||||
Einbindungen nicht einmal. Ein ``umount`` von dort waere ein Aufruf ins
|
||||
Leere -- die Datenbankzeile waere als geloest markiert, die Freigabe aber
|
||||
weiter eingebunden.
|
||||
"""
|
||||
import threading
|
||||
import time
|
||||
|
||||
from .services import fileshare
|
||||
|
||||
def einmal() -> None:
|
||||
with app.app_context():
|
||||
geloest = fileshare.sweep_expired(core(app).database)
|
||||
if geloest:
|
||||
logger.info("%s abgelaufene Freigabe-Einbindungen 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
|
||||
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()
|
||||
|
||||
|
||||
# Fuer ``gunicorn tesm:app``
|
||||
def _lazy_app() -> Flask: # pragma: no cover - nur vom WSGI-Server benutzt
|
||||
return create_app()
|
||||
|
||||
|
||||
app = None # wird von wsgi.py gesetzt
|
||||
@@ -0,0 +1 @@
|
||||
"""Blueprints von TESM."""
|
||||
@@ -0,0 +1,236 @@
|
||||
"""Sicherung und Uebertragung: verschluesselter Export, zweistufiger Import."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from flask import Blueprint, Response, flash, redirect, render_template, request, session, url_for
|
||||
from tesm_core import audit
|
||||
from tesm_core.auth.models import current_user, permission_required, reauth_required
|
||||
from tesm_core.auth.service import client_ip
|
||||
from tesm_core.extension import core, db, transaction
|
||||
from tesm_core.licensing_client import LicenseError
|
||||
|
||||
from ..services import transfer
|
||||
|
||||
bp = Blueprint("backup", __name__, url_prefix="/administration/backup")
|
||||
|
||||
MAX_IMPORT_BYTES = 64 * 1024 * 1024
|
||||
PREVIEW_SESSION_KEY = "_import_token"
|
||||
|
||||
|
||||
def _audit(action: str, detail: str = "", severity: str = "info") -> None:
|
||||
with transaction() as conn:
|
||||
audit.log(
|
||||
conn,
|
||||
actor=current_user.username,
|
||||
actor_id=current_user.id,
|
||||
action=action,
|
||||
detail=detail,
|
||||
ip=client_ip(),
|
||||
severity=severity,
|
||||
)
|
||||
|
||||
|
||||
def _allowed_categories() -> list[transfer.Category]:
|
||||
allowed: list[transfer.Category] = []
|
||||
for category in transfer.CATEGORIES:
|
||||
if category.admin_only and not current_user.is_admin:
|
||||
continue
|
||||
if category.sensitive and not current_user.has("backup_export.secrets"):
|
||||
continue
|
||||
allowed.append(category)
|
||||
return allowed
|
||||
|
||||
|
||||
@bp.get("/")
|
||||
@permission_required("backup_export.read", "backup_import.read", mode="any")
|
||||
def overview():
|
||||
extension = core()
|
||||
conn = db()
|
||||
preview = None
|
||||
token = session.get(PREVIEW_SESSION_KEY, "")
|
||||
if token:
|
||||
try:
|
||||
payload = transfer.load_preview(conn, token, actor=current_user.username)
|
||||
preview = {"token": token, "summary": transfer.summarize(payload)}
|
||||
except transfer.TransferError:
|
||||
session.pop(PREVIEW_SESSION_KEY, None)
|
||||
|
||||
license_hint = ""
|
||||
if extension.license is not None:
|
||||
try:
|
||||
extension.license.require("export")
|
||||
except LicenseError as exc:
|
||||
license_hint = str(exc)
|
||||
|
||||
return render_template(
|
||||
"tesm/backup.html",
|
||||
categories=_allowed_categories(),
|
||||
all_categories=transfer.CATEGORIES,
|
||||
preview=preview,
|
||||
min_passphrase=transfer.MIN_PASSPHRASE_LENGTH,
|
||||
preview_ttl=transfer.PREVIEW_TTL_MINUTES,
|
||||
license_hint=license_hint,
|
||||
can_secrets=current_user.has("backup_export.secrets"),
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/export")
|
||||
@permission_required("backup_export.export")
|
||||
# Die frische Bestaetigung verlangt das Recht selbst (SENSITIVE_ACTIONS).
|
||||
def export():
|
||||
extension = core()
|
||||
conn = db()
|
||||
if extension.license is not None:
|
||||
try:
|
||||
extension.license.require("export")
|
||||
except LicenseError as exc:
|
||||
flash(str(exc), "danger")
|
||||
return redirect(url_for("backup.overview"))
|
||||
|
||||
allowed = {category.key for category in _allowed_categories()}
|
||||
chosen = [key for key in request.form.getlist("categories") if key in allowed]
|
||||
if not chosen:
|
||||
flash("Es wurde keine Kategorie ausgewählt.", "warning")
|
||||
return redirect(url_for("backup.overview"))
|
||||
|
||||
include_secrets = (
|
||||
request.form.get("include_secrets") == "1" and current_user.has("backup_export.secrets")
|
||||
)
|
||||
passphrase = request.form.get("passphrase") or ""
|
||||
repeat = request.form.get("passphrase_repeat") or ""
|
||||
if passphrase != repeat:
|
||||
flash("Die beiden Passphrasen stimmen nicht überein.", "danger")
|
||||
return redirect(url_for("backup.overview"))
|
||||
|
||||
try:
|
||||
payload = transfer.build_export(
|
||||
conn, extension.keystore, categories=chosen, include_secrets=include_secrets
|
||||
)
|
||||
envelope = transfer.seal(payload, passphrase)
|
||||
except transfer.TransferError as exc:
|
||||
flash(str(exc), "danger")
|
||||
return redirect(url_for("backup.overview"))
|
||||
|
||||
_audit(
|
||||
"backup.exported",
|
||||
f"Kategorien: {', '.join(chosen)}" + (" (mit Geheimnissen)" if include_secrets else ""),
|
||||
severity="warning" if include_secrets else "notice",
|
||||
)
|
||||
stamp = datetime.now().strftime("%Y%m%d-%H%M")
|
||||
return Response(
|
||||
envelope.encode("utf-8"),
|
||||
mimetype="application/json",
|
||||
headers={
|
||||
"Content-Disposition": f'attachment; filename="tesm-sicherung-{stamp}.json"',
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/import/preview")
|
||||
@permission_required("backup_import.execute")
|
||||
@reauth_required
|
||||
def import_preview():
|
||||
conn = db()
|
||||
upload = request.files.get("backup_file")
|
||||
if upload is None or not upload.filename:
|
||||
flash("Keine Datei ausgewählt.", "danger")
|
||||
return redirect(url_for("backup.overview"))
|
||||
raw = upload.stream.read(MAX_IMPORT_BYTES + 1)
|
||||
if len(raw) > MAX_IMPORT_BYTES:
|
||||
flash("Die Datei ist zu groß.", "danger")
|
||||
return redirect(url_for("backup.overview"))
|
||||
|
||||
try:
|
||||
payload, header = transfer.unseal(raw, request.form.get("passphrase") or "")
|
||||
except transfer.TransferError as exc:
|
||||
flash(str(exc), "danger")
|
||||
return redirect(url_for("backup.overview"))
|
||||
|
||||
with transaction():
|
||||
token = transfer.store_preview(conn, payload, actor=current_user.username)
|
||||
session[PREVIEW_SESSION_KEY] = token
|
||||
_audit("backup.import_previewed", f"Erstellt {header.get('created_at')}", severity="notice")
|
||||
flash(
|
||||
f"Datei geöffnet. Sie haben {transfer.PREVIEW_TTL_MINUTES} Minuten Zeit, die "
|
||||
"Übernahme zu bestätigen.",
|
||||
"success",
|
||||
)
|
||||
return redirect(url_for("backup.overview"))
|
||||
|
||||
|
||||
@bp.post("/import/apply")
|
||||
@permission_required("backup_import.execute")
|
||||
@reauth_required
|
||||
def import_apply():
|
||||
extension = core()
|
||||
conn = db()
|
||||
token = session.get(PREVIEW_SESSION_KEY, "")
|
||||
if not token:
|
||||
flash("Es liegt keine geöffnete Sicherung vor.", "danger")
|
||||
return redirect(url_for("backup.overview"))
|
||||
|
||||
allowed = {category.key for category in _allowed_categories()}
|
||||
chosen = [key for key in request.form.getlist("categories") if key in allowed]
|
||||
if not chosen:
|
||||
flash("Es wurde keine Kategorie ausgewählt.", "warning")
|
||||
return redirect(url_for("backup.overview"))
|
||||
|
||||
try:
|
||||
payload = transfer.load_preview(conn, token, actor=current_user.username)
|
||||
except transfer.TransferError as exc:
|
||||
session.pop(PREVIEW_SESSION_KEY, None)
|
||||
flash(str(exc), "danger")
|
||||
return redirect(url_for("backup.overview"))
|
||||
|
||||
# Wichtig: alles in **einer** Transaktion auf **einer** Verbindung. Der
|
||||
# Vorgaenger oeffnete hier ueber set_setting() eine zweite Verbindung und lief
|
||||
# dadurch in "database is locked".
|
||||
with transaction():
|
||||
report = transfer.apply_import(
|
||||
conn,
|
||||
extension.keystore,
|
||||
payload,
|
||||
categories=chosen,
|
||||
set_setting=lambda key, value: extension.settings.set(
|
||||
conn, key, value, actor=current_user.username
|
||||
),
|
||||
)
|
||||
transfer.drop_preview(conn, token)
|
||||
audit.log(
|
||||
conn,
|
||||
actor=current_user.username,
|
||||
actor_id=current_user.id,
|
||||
action="backup.imported",
|
||||
detail="; ".join(
|
||||
f"{key}: {counts['created']} neu, {counts['updated']} aktualisiert, "
|
||||
f"{counts['skipped']} übersprungen"
|
||||
for key, counts in report.items()
|
||||
),
|
||||
ip=client_ip(),
|
||||
severity="warning",
|
||||
)
|
||||
session.pop(PREVIEW_SESSION_KEY, None)
|
||||
|
||||
lines = [
|
||||
f"{transfer.CATEGORY_BY_KEY[key].label}: {counts['created']} neu, "
|
||||
f"{counts['updated']} aktualisiert"
|
||||
+ (f", {counts['skipped']} übersprungen" if counts["skipped"] else "")
|
||||
for key, counts in report.items()
|
||||
if key in transfer.CATEGORY_BY_KEY
|
||||
]
|
||||
flash("Übernahme abgeschlossen. " + " – ".join(lines), "success")
|
||||
return redirect(url_for("backup.overview"))
|
||||
|
||||
|
||||
@bp.post("/import/discard")
|
||||
@permission_required("backup_import.execute")
|
||||
def import_discard():
|
||||
token = session.pop(PREVIEW_SESSION_KEY, "")
|
||||
if token:
|
||||
with transaction() as conn:
|
||||
transfer.drop_preview(conn, token)
|
||||
flash("Zwischenstand verworfen.", "info")
|
||||
return redirect(url_for("backup.overview"))
|
||||
@@ -0,0 +1,143 @@
|
||||
"""Zugangsdaten: pflegen und -- mit eigenem Recht -- im Klartext ansehen."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from flask import Blueprint, abort, flash, redirect, render_template, request, url_for
|
||||
from tesm_core import audit
|
||||
from tesm_core.auth.models import current_user, permission_required
|
||||
from tesm_core.auth.service import client_ip
|
||||
from tesm_core.extension import core, db, transaction
|
||||
|
||||
from ..services import inventory
|
||||
|
||||
bp = Blueprint("credentials", __name__, url_prefix="/monitoring/credentials")
|
||||
|
||||
|
||||
def _audit(conn: Any, action: str, target: str, detail: str = "", severity: str = "info") -> None:
|
||||
audit.log(
|
||||
conn,
|
||||
actor=current_user.username,
|
||||
actor_id=current_user.id,
|
||||
action=action,
|
||||
target=target,
|
||||
detail=detail,
|
||||
ip=client_ip(),
|
||||
severity=severity,
|
||||
)
|
||||
|
||||
|
||||
@bp.get("/")
|
||||
@permission_required("credentials.read")
|
||||
def overview():
|
||||
conn = db()
|
||||
return render_template(
|
||||
"tesm/credentials.html",
|
||||
credentials=inventory.list_credentials(conn),
|
||||
categories=inventory.CREDENTIAL_CATEGORIES,
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/new")
|
||||
@permission_required("credentials.create")
|
||||
def create():
|
||||
try:
|
||||
with transaction() as conn:
|
||||
inventory.create_credential(
|
||||
conn,
|
||||
core().keystore,
|
||||
name=request.form.get("name", ""),
|
||||
username=request.form.get("username", ""),
|
||||
secret=request.form.get("secret", ""),
|
||||
category=request.form.get("category", "switch"),
|
||||
description=request.form.get("description", ""),
|
||||
actor=current_user.username,
|
||||
)
|
||||
_audit(conn, "credential.created", request.form.get("name", ""), severity="notice")
|
||||
flash("Zugangsdaten angelegt.", "success")
|
||||
except inventory.ValidationError as exc:
|
||||
flash(str(exc), "danger")
|
||||
return redirect(url_for("credentials.overview"))
|
||||
|
||||
|
||||
@bp.post("/<int:credential_id>")
|
||||
@permission_required("credentials.write")
|
||||
def update(credential_id: int):
|
||||
try:
|
||||
with transaction() as conn:
|
||||
changes = inventory.update_credential(
|
||||
conn,
|
||||
core().keystore,
|
||||
credential_id,
|
||||
name=request.form.get("name", ""),
|
||||
username=request.form.get("username", ""),
|
||||
secret=request.form.get("secret") or None,
|
||||
category=request.form.get("category", "switch"),
|
||||
description=request.form.get("description", ""),
|
||||
actor=current_user.username,
|
||||
)
|
||||
if changes:
|
||||
_audit(
|
||||
conn,
|
||||
"credential.updated",
|
||||
request.form.get("name", str(credential_id)),
|
||||
"; ".join(changes),
|
||||
severity="notice",
|
||||
)
|
||||
flash("Änderungen gespeichert." if changes else "Keine Änderungen.", "success" if changes else "info")
|
||||
except inventory.ValidationError as exc:
|
||||
flash(str(exc), "danger")
|
||||
return redirect(url_for("credentials.overview"))
|
||||
|
||||
|
||||
@bp.post("/<int:credential_id>/delete")
|
||||
@permission_required("credentials.delete")
|
||||
def delete(credential_id: int):
|
||||
try:
|
||||
with transaction() as conn:
|
||||
entry = next(
|
||||
(c for c in inventory.list_credentials(conn) if c.id == credential_id), None
|
||||
)
|
||||
if entry is None:
|
||||
abort(404)
|
||||
inventory.delete_credential(conn, credential_id)
|
||||
_audit(conn, "credential.deleted", entry.name, severity="warning")
|
||||
flash("Zugangsdaten in den Papierkorb verschoben.", "success")
|
||||
except inventory.ValidationError as exc:
|
||||
flash(str(exc), "danger")
|
||||
return redirect(url_for("credentials.overview"))
|
||||
|
||||
|
||||
@bp.post("/<int:credential_id>/reveal")
|
||||
@permission_required("credentials.secrets")
|
||||
# Die frische Bestaetigung verlangt das Recht selbst (SENSITIVE_ACTIONS).
|
||||
def reveal(credential_id: int):
|
||||
"""Zeigt ein gespeichertes Passwort im Klartext.
|
||||
|
||||
Drei Huerden, bewusst: eigenes Recht (``credentials.secrets``), frische
|
||||
Passwortbestaetigung und ein Eintrag im Aenderungsprotokoll mit Zeitpunkt,
|
||||
Benutzer und IP. Der Vorgaenger kannte keine dieser Huerden -- wer
|
||||
Zugangsdaten bearbeiten durfte, konnte sie exportieren und damit lesen.
|
||||
"""
|
||||
conn = db()
|
||||
entry = next((c for c in inventory.list_credentials(conn) if c.id == credential_id), None)
|
||||
if entry is None:
|
||||
abort(404)
|
||||
try:
|
||||
username, secret = inventory.credential_secret(conn, core().keystore, credential_id)
|
||||
except inventory.ValidationError as exc:
|
||||
flash(str(exc), "danger")
|
||||
return redirect(url_for("credentials.overview"))
|
||||
|
||||
with transaction():
|
||||
_audit(
|
||||
conn,
|
||||
"credential.secret_revealed",
|
||||
entry.name,
|
||||
"Klartextanzeige",
|
||||
severity="warning",
|
||||
)
|
||||
return render_template(
|
||||
"tesm/credential_reveal.html", entry=entry, username=username, secret=secret
|
||||
)
|
||||
@@ -0,0 +1,175 @@
|
||||
"""Statusuebersicht -- die Startseite.
|
||||
|
||||
Bewusst auch ohne Anmeldung erreichbar (abschaltbar ueber die Einstellung
|
||||
``dashboard_public``): sie ist ein Informationsboard fuer jeden im Netz, damit
|
||||
niemand sich anmelden muss, um zu sehen, ob ein Geraet laeuft.
|
||||
|
||||
Ohne Anmeldung zeigt sie **nur** Name und Erreichbarkeit aktiver Geraete. Keine
|
||||
IP-Adressen, keine MAC, keine Switch-/Portzuordnung, keine Zaehler zu
|
||||
deaktivierten Geraeten -- der Vorgaenger gab hier deutlich mehr heraus, als ein
|
||||
oeffentliches Board hergeben sollte.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from flask import Blueprint, abort, render_template, request
|
||||
from tesm_core.security.sessions import fragment
|
||||
from tesm_core.extension import core, db
|
||||
|
||||
from ..services import monitor
|
||||
|
||||
bp = Blueprint("dashboard", __name__)
|
||||
|
||||
|
||||
def _maintenance_active() -> bool:
|
||||
from tesm_core.extension import core
|
||||
|
||||
manager = core().license
|
||||
return manager is None or manager.module_active("maintenance")
|
||||
|
||||
|
||||
def _public_allowed() -> bool:
|
||||
return bool(core().settings.get(db(), "dashboard_public"))
|
||||
|
||||
|
||||
def _tiles(*, authenticated: bool) -> dict[str, Any]:
|
||||
from tesm_core.auth.models import current_user
|
||||
|
||||
from ..services import inventory, restart
|
||||
|
||||
from ..services import vnc as vnc_dienst
|
||||
|
||||
conn = db()
|
||||
counts = monitor.dashboard_counts(conn)
|
||||
rows = inventory.list_devices(conn, only_active=not authenticated)
|
||||
|
||||
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]] = []
|
||||
for row in rows:
|
||||
entry: dict[str, Any] = {
|
||||
"id": int(row["id"]),
|
||||
"name": str(row["name"]),
|
||||
"state": "disabled" if not row["is_active"] else str(row["state"]),
|
||||
"last_change_at": row["last_change_at"],
|
||||
"category": str(row["category"] or ""),
|
||||
"location": str(row["location"] or ""),
|
||||
}
|
||||
if detailed:
|
||||
entry.update(
|
||||
{
|
||||
"ip": str(row["ip"]),
|
||||
"mac": str(row["mac"]),
|
||||
"switch": str(row["switch_hostname"] or ""),
|
||||
"port": str(row["port"] or ""),
|
||||
"latency_ms": row["latency_ms"],
|
||||
"last_check_at": row["last_check_at"],
|
||||
"restart_count": row["restart_count"] or 0,
|
||||
"credential_category": str(row["credential_category"] or ""),
|
||||
# Nicht nur PoE: ein Geraet kann auch ueber SSH oder RPC
|
||||
# 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(
|
||||
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)
|
||||
|
||||
sections = [
|
||||
{
|
||||
# Vor "nicht erreichbar": ein Geraet, das gerade neu startet, ist
|
||||
# erwartbar weg. Wer die Uebersicht ansieht, will genau das zuerst
|
||||
# wissen -- sonst haelt er einen laufenden Neustart fuer einen Ausfall.
|
||||
"key": "restarting",
|
||||
"label": "Neustart läuft",
|
||||
"kind": "warning",
|
||||
"tiles": [tile for tile in tiles if tile["state"] == "restarting"],
|
||||
},
|
||||
{
|
||||
"key": "offline",
|
||||
"label": "Nicht erreichbar",
|
||||
"kind": "danger",
|
||||
"tiles": [tile for tile in tiles if tile["state"] == "offline"],
|
||||
},
|
||||
{
|
||||
"key": "online",
|
||||
"label": "Erreichbar",
|
||||
"kind": "ok",
|
||||
"tiles": [tile for tile in tiles if tile["state"] == "online"],
|
||||
},
|
||||
{
|
||||
"key": "unknown",
|
||||
"label": "Noch nicht geprüft",
|
||||
"kind": "warning",
|
||||
"tiles": [tile for tile in tiles if tile["state"] == "unknown"],
|
||||
},
|
||||
]
|
||||
if authenticated:
|
||||
sections.append(
|
||||
{
|
||||
"key": "disabled",
|
||||
"label": "Deaktiviert",
|
||||
"kind": "muted",
|
||||
"tiles": [tile for tile in tiles if tile["state"] == "disabled"],
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"counts": counts,
|
||||
"sections": sections,
|
||||
"detailed": detailed,
|
||||
"interval": int(core().settings.get(conn, "monitor_interval_seconds") or 300),
|
||||
}
|
||||
|
||||
|
||||
@bp.get("/overview")
|
||||
def index():
|
||||
from tesm_core.auth.models import current_user
|
||||
|
||||
if not current_user.is_authenticated and not _public_allowed():
|
||||
from flask import redirect, url_for
|
||||
|
||||
return redirect(url_for("auth.login"))
|
||||
data = _tiles(authenticated=bool(current_user.is_authenticated))
|
||||
return render_template("tesm/dashboard.html", **data)
|
||||
|
||||
|
||||
def index_view():
|
||||
"""Wird als Endpunkt ``index`` registriert (Startseite)."""
|
||||
return index()
|
||||
|
||||
|
||||
@bp.get("/overview/tiles")
|
||||
@fragment
|
||||
def tiles():
|
||||
"""Teilbereich fuer die automatische Aktualisierung -- liefert nur HTML-Fragment."""
|
||||
from tesm_core.auth.models import current_user
|
||||
|
||||
if not current_user.is_authenticated and not _public_allowed():
|
||||
abort(404)
|
||||
data = _tiles(authenticated=bool(current_user.is_authenticated))
|
||||
return render_template("tesm/_dashboard_tiles.html", **data)
|
||||
@@ -0,0 +1,517 @@
|
||||
"""Clients: Bestand pflegen, Neustart ausloesen, SSH-Host-Schluessel freigeben.
|
||||
|
||||
Die Host-Schluessel-Freigabe gab es zuerst nur fuer Switche. Fuer Clients
|
||||
fehlte sie -- mit der Folge, dass die Wartung sich weigerte ("Schluessel
|
||||
unbekannt") und es keine Stelle gab, an der man ihn haette ansehen und
|
||||
freigeben koennen. Eine Sackgasse, aus der man nur mit einem Shell-Zugang
|
||||
auf den Server herauskam.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from flask import (
|
||||
Blueprint,
|
||||
Response,
|
||||
abort,
|
||||
flash,
|
||||
redirect,
|
||||
render_template,
|
||||
request,
|
||||
url_for,
|
||||
)
|
||||
from tesm_core import audit
|
||||
from tesm_core.auth.models import current_user, permission_required, reauth_required
|
||||
from tesm_core.auth.service import client_ip
|
||||
from tesm_core.db import Database
|
||||
from tesm_core.extension import core, db, transaction
|
||||
|
||||
from ..services import inventory, monitor, restart, ssh, vnc
|
||||
|
||||
bp = Blueprint("devices", __name__, url_prefix="/monitoring/clients")
|
||||
|
||||
|
||||
def _audit(conn: Any, action: str, target: str, detail: str = "", severity: str = "info") -> None:
|
||||
audit.log(
|
||||
conn,
|
||||
actor=current_user.username,
|
||||
actor_id=current_user.id,
|
||||
action=action,
|
||||
target=target,
|
||||
detail=detail,
|
||||
ip=client_ip(),
|
||||
severity=severity,
|
||||
)
|
||||
|
||||
|
||||
def _maintenance_active() -> bool:
|
||||
manager = core().license
|
||||
return manager is None or manager.module_active("maintenance")
|
||||
|
||||
|
||||
def _form_values() -> dict[str, Any]:
|
||||
return {
|
||||
"name": request.form.get("name", ""),
|
||||
"mac": request.form.get("mac", ""),
|
||||
"ip": request.form.get("ip", ""),
|
||||
"switch_id": request.form.get("switch_id") or None,
|
||||
"port": request.form.get("port", ""),
|
||||
"credential_id": request.form.get("credential_id") or None,
|
||||
"ssh_port": request.form.get("ssh_port") or 22,
|
||||
"category": request.form.get("category", ""),
|
||||
"location": request.form.get("location", ""),
|
||||
"notes": request.form.get("notes", ""),
|
||||
"is_active": request.form.get("is_active") == "1",
|
||||
"auto_restart": request.form.get("auto_restart") == "1",
|
||||
"actor": current_user.username,
|
||||
}
|
||||
|
||||
|
||||
@bp.get("/")
|
||||
@permission_required("devices.read")
|
||||
def overview():
|
||||
conn = db()
|
||||
devices = inventory.list_devices(conn)
|
||||
maintenance_active = _maintenance_active()
|
||||
return render_template(
|
||||
"tesm/devices.html",
|
||||
devices=devices,
|
||||
restart_methods={
|
||||
int(device["id"]): restart.available(
|
||||
device, maintenance_active=maintenance_active
|
||||
)
|
||||
for device in devices
|
||||
},
|
||||
switches=inventory.list_switches(conn),
|
||||
category_labels=dict(inventory.CREDENTIAL_CATEGORIES),
|
||||
credential_categories=inventory.CREDENTIAL_CATEGORIES,
|
||||
category_effects=inventory.CATEGORY_EFFECTS,
|
||||
credentials=inventory.list_credentials(conn),
|
||||
categories=inventory.DEVICE_CATEGORIES,
|
||||
counts=monitor.dashboard_counts(conn),
|
||||
)
|
||||
|
||||
|
||||
def _credential_from_form(conn: Any) -> int | None:
|
||||
"""Legt bei Bedarf neue Zugangsdaten an und gibt deren Kennung zurueck.
|
||||
|
||||
``None`` heisst: nichts anzulegen, das Formular hat eine vorhandene Auswahl
|
||||
(oder gar keine). Fehler werden als ``ValidationError`` durchgereicht,
|
||||
damit das Geraet dann ebenfalls nicht entsteht -- ein Geraet ohne die
|
||||
beabsichtigten Zugangsdaten waere eine stille Halbausfuehrung.
|
||||
"""
|
||||
if request.form.get("credential_id") != "__neu__":
|
||||
return None
|
||||
if not current_user.has("credentials.create"):
|
||||
raise inventory.ValidationError(
|
||||
"Zum Anlegen neuer Zugangsdaten fehlt die Berechtigung."
|
||||
)
|
||||
name = (request.form.get("new_credential_name") or "").strip()
|
||||
if not name:
|
||||
raise inventory.ValidationError("Für neue Zugangsdaten wird eine Bezeichnung gebraucht.")
|
||||
return inventory.create_credential(
|
||||
conn,
|
||||
core().keystore,
|
||||
name=name,
|
||||
username=(request.form.get("new_credential_username") or "").strip(),
|
||||
secret=request.form.get("new_credential_secret") or "",
|
||||
category=request.form.get("new_credential_category", "linux"),
|
||||
description="Beim Anlegen eines Geräts erfasst.",
|
||||
actor=current_user.username,
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/new")
|
||||
@permission_required("devices.create")
|
||||
def create():
|
||||
try:
|
||||
with transaction() as conn:
|
||||
values = _form_values()
|
||||
new_credential = _credential_from_form(conn)
|
||||
if new_credential is not None:
|
||||
values["credential_id"] = new_credential
|
||||
device_id = inventory.create_device(conn, **values)
|
||||
_audit(
|
||||
conn,
|
||||
"device.created",
|
||||
request.form.get("name", ""),
|
||||
f"MAC {request.form.get('mac')}, IP {request.form.get('ip')}",
|
||||
severity="notice",
|
||||
)
|
||||
flash("Gerät angelegt.", "success")
|
||||
return redirect(url_for("devices.overview", highlight=device_id))
|
||||
except inventory.ValidationError as exc:
|
||||
flash(str(exc), "danger")
|
||||
return redirect(url_for("devices.overview"))
|
||||
|
||||
|
||||
@bp.post("/<int:device_id>")
|
||||
@permission_required("devices.write")
|
||||
def update(device_id: int):
|
||||
try:
|
||||
with transaction() as conn:
|
||||
values = _form_values()
|
||||
new_credential = _credential_from_form(conn)
|
||||
if new_credential is not None:
|
||||
values["credential_id"] = new_credential
|
||||
changes = inventory.update_device(conn, device_id, **values)
|
||||
if changes:
|
||||
_audit(
|
||||
conn,
|
||||
"device.updated",
|
||||
request.form.get("name", str(device_id)),
|
||||
"; ".join(changes),
|
||||
severity="notice",
|
||||
)
|
||||
flash("Änderungen gespeichert." if changes else "Keine Änderungen.", "success" if changes else "info")
|
||||
except inventory.ValidationError as exc:
|
||||
flash(str(exc), "danger")
|
||||
return redirect(url_for("devices.overview"))
|
||||
|
||||
|
||||
@bp.post("/<int:device_id>/active")
|
||||
@permission_required("devices.write")
|
||||
def toggle_active(device_id: int):
|
||||
active = request.form.get("active") == "1"
|
||||
with transaction() as conn:
|
||||
device = inventory.get_device(conn, device_id)
|
||||
if device is None:
|
||||
abort(404)
|
||||
inventory.set_device_active(conn, device_id, active)
|
||||
_audit(
|
||||
conn,
|
||||
"device.activated" if active else "device.deactivated",
|
||||
str(device["name"]),
|
||||
severity="notice",
|
||||
)
|
||||
flash("Gerät aktiviert." if active else "Gerät deaktiviert.", "success")
|
||||
return redirect(request.referrer or url_for("devices.overview"))
|
||||
|
||||
|
||||
@bp.post("/<int:device_id>/delete")
|
||||
@permission_required("devices.delete")
|
||||
def delete(device_id: int):
|
||||
with transaction() as conn:
|
||||
device = inventory.get_device(conn, device_id)
|
||||
if device is None:
|
||||
abort(404)
|
||||
inventory.delete_device(conn, device_id)
|
||||
_audit(conn, "device.deleted", str(device["name"]), severity="warning")
|
||||
flash("Gerät in den Papierkorb verschoben.", "success")
|
||||
return redirect(url_for("devices.overview"))
|
||||
|
||||
|
||||
@bp.post("/<int:device_id>/restart")
|
||||
# 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):
|
||||
"""Startet ein Geraet neu -- ueber den Weg, der dafuer gewaehlt wurde.
|
||||
|
||||
Der PoE-Neustart ist bewusst **nicht** lizenzpflichtig: ein Kunde ohne
|
||||
gueltige Lizenz soll ein haengendes Geraet weiter von Hand neu starten
|
||||
koennen. Nur der *automatische* Neustart bei Ausfall und die Wege ueber
|
||||
SSH bzw. RPC gehoeren zum Modul Wartung.
|
||||
"""
|
||||
extension = core()
|
||||
conn = db()
|
||||
device = inventory.get_device(conn, device_id)
|
||||
if device is None or device["deleted_at"]:
|
||||
abort(404)
|
||||
|
||||
maintenance_active = _maintenance_active()
|
||||
method = restart.resolve(
|
||||
dict(device),
|
||||
(request.form.get("method") or "").strip(),
|
||||
maintenance_active=maintenance_active,
|
||||
darf=current_user.has,
|
||||
)
|
||||
if method is None:
|
||||
options = restart.available(
|
||||
dict(device), maintenance_active=maintenance_active, darf=current_user.has
|
||||
)
|
||||
if options:
|
||||
flash(
|
||||
"Der gewählte Weg steht für dieses Gerät nicht zur Verfügung. "
|
||||
"Möglich wäre: " + ", ".join(entry.label for entry in options),
|
||||
"warning",
|
||||
)
|
||||
else:
|
||||
flash(
|
||||
f"Für {device['name']} ist kein Neustart möglich: es fehlen Switch und Port "
|
||||
"für den PoE-Weg und passende Zugangsdaten für SSH oder RPC.",
|
||||
"warning",
|
||||
)
|
||||
return redirect(request.referrer or url_for("devices.overview"))
|
||||
|
||||
if method.is_poe:
|
||||
return _restart_over_poe(extension, conn, device)
|
||||
return _restart_as_job(extension, conn, device, method)
|
||||
|
||||
|
||||
def _restart_over_poe(extension: Any, conn: Any, device: Any): # type: ignore[no-untyped-def]
|
||||
"""Sofort und synchron -- der Portzyklus dauert nur Sekunden."""
|
||||
live = extension.registry.get("live_log")
|
||||
try:
|
||||
outcome = monitor.restart_device_poe(
|
||||
conn,
|
||||
dict(device),
|
||||
keystore=extension.keystore,
|
||||
trigger="manuell",
|
||||
actor=current_user.username,
|
||||
live=live,
|
||||
database=extension.database,
|
||||
)
|
||||
with transaction():
|
||||
_audit(
|
||||
conn,
|
||||
"device.poe_restart",
|
||||
str(device["name"]),
|
||||
outcome["message"],
|
||||
severity="notice" if outcome["ok"] else "warning",
|
||||
)
|
||||
except inventory.ValidationError as exc:
|
||||
flash(str(exc), "danger")
|
||||
return redirect(request.referrer or url_for("devices.overview"))
|
||||
|
||||
flash(
|
||||
f"PoE-Neustart für {device['name']} durchgeführt."
|
||||
if outcome["ok"]
|
||||
else f"PoE-Neustart fehlgeschlagen: {outcome['message']}",
|
||||
"success" if outcome["ok"] else "danger",
|
||||
)
|
||||
return redirect(request.referrer or url_for("devices.overview"))
|
||||
|
||||
|
||||
def _restart_as_job(extension: Any, conn: Any, device: Any, method: Any): # type: ignore[no-untyped-def]
|
||||
"""Ueber SSH bzw. RPC -- als Auftrag, denn das Warten dauert Minuten."""
|
||||
from ..services import maintenance as maintenance_service
|
||||
|
||||
try:
|
||||
username, password = inventory.credential_secret(
|
||||
conn, extension.keystore, int(device["credential_id"])
|
||||
)
|
||||
except inventory.ValidationError as exc:
|
||||
flash(f"{device['name']}: {exc}", "danger")
|
||||
return redirect(request.referrer or url_for("devices.overview"))
|
||||
|
||||
with transaction():
|
||||
handle = maintenance_service.create_job(
|
||||
conn,
|
||||
kind=maintenance_service.JOB_KIND_REBOOT,
|
||||
subject=str(device["name"]),
|
||||
actor=current_user.username,
|
||||
message="Wartet auf Start",
|
||||
)
|
||||
_audit(
|
||||
conn,
|
||||
"device.restart",
|
||||
str(device["name"]),
|
||||
f"{method.label}, Auftrag {handle.id}",
|
||||
severity="notice",
|
||||
)
|
||||
|
||||
# Der Auftrag laeuft im Hintergrund und meldet sein Ergebnis erst, wenn das
|
||||
# Geraet zurueck ist. Der Zustand muss aber sofort umschlagen -- sonst sieht
|
||||
# die Uebersicht aus wie eine, in der nichts passiert ist.
|
||||
with transaction():
|
||||
monitor.mark_restarting(conn, int(device["id"]))
|
||||
|
||||
maintenance_service.start_background(
|
||||
maintenance_service.run_reboot,
|
||||
database=extension.database,
|
||||
job_id=handle.id,
|
||||
device=dict(device),
|
||||
username=username,
|
||||
password=password,
|
||||
wait_seconds=int(extension.settings.get(conn, "maintenance_reboot_wait") or 300),
|
||||
actor=current_user.username,
|
||||
# Der Weg ist oben schon entschieden (restart.resolve). Ihn hier
|
||||
# mitzugeben ist der Unterschied zwischen "Windows-Geraet ueber RPC" und
|
||||
# "Windows-Geraet ueber SSH auf Port 22, bis das Zeitlimit greift".
|
||||
method=method.key,
|
||||
)
|
||||
flash(
|
||||
f"{method.label} für {device['name']} gestartet. Der Verlauf steht unter Wartung.",
|
||||
"success",
|
||||
)
|
||||
return redirect(request.referrer or url_for("devices.overview"))
|
||||
|
||||
|
||||
@bp.get("/<int:device_id>")
|
||||
@permission_required("devices.read")
|
||||
def detail(device_id: int):
|
||||
conn = db()
|
||||
device = inventory.get_device(conn, device_id)
|
||||
if device is None:
|
||||
abort(404)
|
||||
events = Database.all(
|
||||
conn,
|
||||
"SELECT * FROM restart_events WHERE device_id=? ORDER BY ts DESC LIMIT 50",
|
||||
(device_id,),
|
||||
)
|
||||
host_keys = ssh.all_known_keys(conn, str(device["ip"]), int(device["ssh_port"] or 22))
|
||||
extension = core()
|
||||
return render_template(
|
||||
"tesm/device_detail.html",
|
||||
device=device,
|
||||
restart_methods=restart.available(
|
||||
device, maintenance_active=_maintenance_active()
|
||||
),
|
||||
events=events,
|
||||
switches=inventory.list_switches(conn),
|
||||
credentials=inventory.list_credentials(conn),
|
||||
categories=inventory.DEVICE_CATEGORIES,
|
||||
host_keys=host_keys,
|
||||
method_labels=monitor.METHOD_LABELS,
|
||||
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",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# SSH-Host-Schluessel
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@bp.post("/<int:device_id>/host-key/probe")
|
||||
@permission_required("devices.write")
|
||||
def probe_host_key(device_id: int):
|
||||
"""Liest den Host-Schluessel des Clients, ohne sich anzumelden.
|
||||
|
||||
Ausdruecklich getrennt vom Freigeben: erst ansehen und vergleichen, dann
|
||||
-- in einem zweiten, bestaetigten Schritt -- freigeben.
|
||||
"""
|
||||
conn = db()
|
||||
device = inventory.get_device(conn, device_id)
|
||||
if device is None:
|
||||
abort(404)
|
||||
try:
|
||||
record = ssh.probe_host_key(str(device["ip"]), int(device["ssh_port"] or 22))
|
||||
except ssh.SshError as exc:
|
||||
flash(str(exc), "danger")
|
||||
return redirect(url_for("devices.detail", device_id=device_id))
|
||||
|
||||
level, message = ssh.describe_probe(conn, record)
|
||||
flash(message, level)
|
||||
return redirect(
|
||||
url_for(
|
||||
"devices.detail",
|
||||
device_id=device_id,
|
||||
probed_type=record.key_type,
|
||||
probed_fingerprint=record.fingerprint,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/<int:device_id>/host-key/trust")
|
||||
@permission_required("devices.write")
|
||||
@reauth_required
|
||||
def trust_host_key(device_id: int):
|
||||
conn = db()
|
||||
device = inventory.get_device(conn, device_id)
|
||||
if device is None:
|
||||
abort(404)
|
||||
key_type = (request.form.get("key_type") or "").strip()
|
||||
fingerprint = (request.form.get("fingerprint") or "").strip()
|
||||
if not key_type or not ssh.valid_fingerprint(fingerprint):
|
||||
flash("Es wurde kein gültiger Fingerprint übergeben.", "danger")
|
||||
return redirect(url_for("devices.detail", device_id=device_id))
|
||||
|
||||
with transaction():
|
||||
ssh.trust_key(
|
||||
conn,
|
||||
host=str(device["ip"]),
|
||||
port=int(device["ssh_port"] or 22),
|
||||
key_type=key_type,
|
||||
fingerprint=fingerprint,
|
||||
actor=current_user.username,
|
||||
)
|
||||
_audit(
|
||||
conn,
|
||||
"device.host_key_trusted",
|
||||
str(device["name"]),
|
||||
f"{key_type} {fingerprint}",
|
||||
severity="notice",
|
||||
)
|
||||
flash("Host-Schlüssel freigegeben.", "success")
|
||||
return redirect(url_for("devices.detail", device_id=device_id))
|
||||
|
||||
|
||||
@bp.post("/<int:device_id>/host-key/forget")
|
||||
@permission_required("devices.write")
|
||||
def forget_host_key(device_id: int):
|
||||
conn = db()
|
||||
device = inventory.get_device(conn, device_id)
|
||||
if device is None:
|
||||
abort(404)
|
||||
with transaction():
|
||||
ssh.forget_key(conn, str(device["ip"]), int(device["ssh_port"] or 22))
|
||||
_audit(conn, "device.host_key_forgotten", str(device["name"]), severity="warning")
|
||||
flash("Gespeicherte Host-Schlüssel entfernt.", "info")
|
||||
return redirect(url_for("devices.detail", device_id=device_id))
|
||||
@@ -0,0 +1,554 @@
|
||||
"""DHCP-Server (Kea): Subnetze, Optionen, Reservierungen, Dienstzustand.
|
||||
|
||||
Lizenzpflichtiges Vollmodul -- ohne Lizenz verschwindet der Punkt aus der
|
||||
Navigation **und** jede Route hier antwortet mit 404. Ein direkter URL-Aufruf
|
||||
kommt damit genauso wenig durch wie ein fehlendes Menue.
|
||||
|
||||
Dienstaktionen (installieren, starten, stoppen) sind bewusst **nicht** an
|
||||
"Speichern" gekoppelt: wer eine Konfiguration ablegt, will nicht zwangsweise
|
||||
einen Dienst starten.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from flask import Blueprint, abort, flash, redirect, render_template, request, url_for
|
||||
from tesm_core import audit
|
||||
from tesm_core.auth.models import current_user, permission_required
|
||||
from tesm_core.auth.service import client_ip
|
||||
from tesm_core.db import Database
|
||||
from tesm_core.extension import core, db, transaction
|
||||
|
||||
from ..services import kea
|
||||
from ..permissions import DHCP_PAGE
|
||||
|
||||
bp = Blueprint("dhcp", __name__, url_prefix="/dhcp")
|
||||
|
||||
|
||||
@bp.before_request
|
||||
def _require_module() -> None:
|
||||
manager = core().license
|
||||
if manager is not None and not manager.module_active("dhcp"):
|
||||
abort(404)
|
||||
|
||||
|
||||
def _audit(action: str, target: str = "", detail: str = "", severity: str = "info") -> None:
|
||||
with transaction() as conn:
|
||||
audit.log(
|
||||
conn,
|
||||
actor=current_user.username,
|
||||
actor_id=current_user.id,
|
||||
action=action,
|
||||
target=target,
|
||||
detail=detail,
|
||||
ip=client_ip(),
|
||||
severity=severity,
|
||||
)
|
||||
|
||||
|
||||
def _settings_values() -> dict[str, Any]:
|
||||
return core().settings.get_many(
|
||||
db(), ["dhcp_domain", "dhcp_lease_default", "dhcp_lease_max", "dhcp_output_path"]
|
||||
)
|
||||
|
||||
|
||||
@bp.get("/")
|
||||
@permission_required(*DHCP_PAGE, mode="any")
|
||||
def overview():
|
||||
extension = core()
|
||||
conn = db()
|
||||
sysops = extension.sysops
|
||||
networks = kea.detect_networks(sysops) if sysops else []
|
||||
values = _settings_values()
|
||||
config, warnings = kea.render_config(conn, networks=networks, settings=values)
|
||||
|
||||
# Nur Adressen in eingerichteten Bereichen: alles andere waere eine
|
||||
# Reservierung, die Kea nie ausliefert.
|
||||
skipped: list[dict[str, str]] = []
|
||||
reservations = kea.collect_reservations(
|
||||
conn, only_networks=kea.configured_networks(conn, networks), skipped=skipped
|
||||
)
|
||||
leases = kea.read_leases(sysops) if sysops else []
|
||||
# Zustand aus den laufenden Leases -- wie im Windows-DHCP.
|
||||
reservations = kea.annotate_reservations(reservations, leases)
|
||||
reserved_ips = {str(entry["ip"]) for entry in reservations}
|
||||
|
||||
return render_template(
|
||||
"tesm/dhcp.html",
|
||||
subnets=Database.all(conn, "SELECT * FROM dhcp_subnets ORDER BY sort_order, id"),
|
||||
options=Database.all(
|
||||
conn,
|
||||
"SELECT o.*, v.value, v.id AS value_id FROM dhcp_options o "
|
||||
"LEFT JOIN dhcp_option_values v ON v.option_id = o.id AND v.device_id IS NULL "
|
||||
"ORDER BY o.code",
|
||||
),
|
||||
option_types=kea.OPTION_TYPES,
|
||||
device_option_values=_device_option_values(conn),
|
||||
reservations=reservations,
|
||||
skipped_reservations=skipped,
|
||||
manual_reservations=Database.all(conn, "SELECT * FROM dhcp_reservations ORDER BY ip"),
|
||||
leases=leases,
|
||||
reserved_ips=reserved_ips,
|
||||
networks=networks,
|
||||
default_gateway=kea.default_gateway(sysops) if sysops else "",
|
||||
service=kea.service_state(sysops) if sysops else {},
|
||||
values=values,
|
||||
preview=kea.config_to_text(config),
|
||||
warnings=warnings,
|
||||
lease_file=kea.LEASE_FILE,
|
||||
sysops_available=bool(sysops and sysops.available),
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Einstellungen und Subnetze
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@bp.post("/settings")
|
||||
@permission_required("dhcp_subnets.write")
|
||||
def save_settings():
|
||||
extension = core()
|
||||
conn = db()
|
||||
values = {
|
||||
key: request.form.get(key)
|
||||
for key in ("dhcp_domain", "dhcp_lease_default", "dhcp_lease_max", "dhcp_output_path")
|
||||
if key in request.form
|
||||
}
|
||||
with transaction():
|
||||
changed = extension.settings.set_many(conn, values, actor=current_user.username)
|
||||
_audit("dhcp.settings_saved", detail=", ".join(changed), severity="notice")
|
||||
flash("Einstellungen gespeichert." if changed else "Keine Änderungen.", "success" if changed else "info")
|
||||
return _write_and_redirect()
|
||||
|
||||
|
||||
@bp.post("/subnet")
|
||||
@permission_required("dhcp_subnets.create")
|
||||
def add_subnet():
|
||||
extension = core()
|
||||
conn = db()
|
||||
sysops = extension.sysops
|
||||
networks = kea.detect_networks(sysops) if sysops else []
|
||||
interface = (request.form.get("interface") or "").strip()
|
||||
start = (request.form.get("range_start") or "").strip()
|
||||
end = (request.form.get("range_end") or "").strip()
|
||||
|
||||
problem = kea.validate_subnet(networks, interface, start, end)
|
||||
if problem:
|
||||
flash(problem, "danger")
|
||||
return redirect(url_for("dhcp.overview"))
|
||||
|
||||
with transaction():
|
||||
conn.execute(
|
||||
"INSERT INTO dhcp_subnets (interface, range_start, range_end, gateway, dns, comment, "
|
||||
"enabled, sort_order, created_at, updated_at) "
|
||||
"VALUES (?,?,?,?,?,?,1,?,datetime('now'),datetime('now'))",
|
||||
(
|
||||
interface,
|
||||
start,
|
||||
end,
|
||||
(request.form.get("gateway") or "").strip(),
|
||||
(request.form.get("dns") or "").strip(),
|
||||
(request.form.get("comment") or "").strip(),
|
||||
int(request.form.get("sort_order") or 0),
|
||||
),
|
||||
)
|
||||
_audit("dhcp.subnet_added", f"{interface} {start}-{end}", severity="notice")
|
||||
flash("Subnetz angelegt.", "success")
|
||||
return _write_and_redirect()
|
||||
|
||||
|
||||
@bp.post("/subnet/<int:subnet_id>")
|
||||
@permission_required("dhcp_subnets.write")
|
||||
def update_subnet(subnet_id: int):
|
||||
extension = core()
|
||||
conn = db()
|
||||
sysops = extension.sysops
|
||||
networks = kea.detect_networks(sysops) if sysops else []
|
||||
interface = (request.form.get("interface") or "").strip()
|
||||
start = (request.form.get("range_start") or "").strip()
|
||||
end = (request.form.get("range_end") or "").strip()
|
||||
problem = kea.validate_subnet(networks, interface, start, end)
|
||||
if problem:
|
||||
flash(problem, "danger")
|
||||
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():
|
||||
conn.execute(
|
||||
"UPDATE dhcp_subnets SET interface=?, range_start=?, range_end=?, gateway=?, dns=?, "
|
||||
"comment=?, enabled=?, sort_order=?, updated_at=datetime('now') WHERE id=?",
|
||||
(
|
||||
interface,
|
||||
start,
|
||||
end,
|
||||
(request.form.get("gateway") or "").strip(),
|
||||
(request.form.get("dns") or "").strip(),
|
||||
(request.form.get("comment") or "").strip(),
|
||||
1 if request.form.get("enabled") == "1" else 0,
|
||||
int(request.form.get("sort_order") or 0),
|
||||
subnet_id,
|
||||
),
|
||||
)
|
||||
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")
|
||||
return _write_and_redirect()
|
||||
|
||||
|
||||
@bp.post("/subnet/<int:subnet_id>/delete")
|
||||
@permission_required("dhcp_subnets.delete")
|
||||
def delete_subnet(subnet_id: int):
|
||||
with transaction() as conn:
|
||||
conn.execute("DELETE FROM dhcp_subnets WHERE id=?", (subnet_id,))
|
||||
_audit("dhcp.subnet_deleted", str(subnet_id), severity="warning")
|
||||
flash("Subnetz entfernt.", "success")
|
||||
return _write_and_redirect()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Optionen
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@bp.post("/option/value")
|
||||
@permission_required("dhcp_options.write")
|
||||
def set_option_value():
|
||||
conn = db()
|
||||
option_id = int(request.form.get("option_id") or 0)
|
||||
value = (request.form.get("value") or "").strip()
|
||||
option = Database.one(conn, "SELECT * FROM dhcp_options WHERE id=?", (option_id,))
|
||||
if option is None:
|
||||
abort(404)
|
||||
with transaction():
|
||||
if value:
|
||||
conn.execute(
|
||||
"INSERT INTO dhcp_option_values (option_id, device_id, value) VALUES (?,NULL,?) "
|
||||
"ON CONFLICT(option_id) WHERE device_id IS NULL DO UPDATE SET value=excluded.value",
|
||||
(option_id, value),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"DELETE FROM dhcp_option_values WHERE option_id=? AND device_id IS NULL",
|
||||
(option_id,),
|
||||
)
|
||||
_audit("dhcp.option_set", str(option["name"]), value or "(geleert)")
|
||||
flash(f"Option {option['name']} gespeichert.", "success")
|
||||
return _write_and_redirect()
|
||||
|
||||
|
||||
def _device_option_values(conn) -> dict[int, list[dict[str, Any]]]: # type: ignore[no-untyped-def]
|
||||
"""Je-Geraet gesetzte Optionen, nach Geraet gruppiert -- fuer die Anzeige."""
|
||||
gruppen: dict[int, list[dict[str, Any]]] = {}
|
||||
for row in Database.all(
|
||||
conn,
|
||||
"SELECT v.id, v.device_id, v.value, o.code, o.name, o.id AS option_id "
|
||||
"FROM dhcp_option_values v JOIN dhcp_options o ON o.id = v.option_id "
|
||||
"WHERE v.device_id IS NOT NULL ORDER BY o.code",
|
||||
):
|
||||
gruppen.setdefault(int(row["device_id"]), []).append(dict(row))
|
||||
return gruppen
|
||||
|
||||
|
||||
@bp.post("/option/device")
|
||||
@permission_required("dhcp_options.write")
|
||||
def set_device_option_value():
|
||||
"""Setzt eine Option fuer ein einzelnes Geraet.
|
||||
|
||||
Der Wert landet in der Reservierung dieses Geraets, nicht global. Genau
|
||||
dafuer war ``dhcp_option_values.device_id`` von Anfang an gedacht -- der
|
||||
Erzeuger hat es immer schon ausgewertet, nur liess sich der Wert nirgends
|
||||
eingeben.
|
||||
"""
|
||||
conn = db()
|
||||
option_id = int(request.form.get("option_id") or 0)
|
||||
device_id = int(request.form.get("device_id") or 0)
|
||||
value = (request.form.get("value") or "").strip()
|
||||
|
||||
option = Database.one(conn, "SELECT * FROM dhcp_options WHERE id=?", (option_id,))
|
||||
device = Database.one(
|
||||
conn, "SELECT id, name FROM devices WHERE id=? AND deleted_at IS NULL", (device_id,)
|
||||
)
|
||||
if option is None or device is None:
|
||||
abort(404)
|
||||
|
||||
with transaction():
|
||||
if value:
|
||||
conn.execute(
|
||||
"INSERT INTO dhcp_option_values (option_id, device_id, value) VALUES (?,?,?) "
|
||||
"ON CONFLICT(option_id, device_id) WHERE device_id IS NOT NULL "
|
||||
"DO UPDATE SET value=excluded.value",
|
||||
(option_id, device_id, value),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"DELETE FROM dhcp_option_values WHERE option_id=? AND device_id=?",
|
||||
(option_id, device_id),
|
||||
)
|
||||
_audit(
|
||||
"dhcp.device_option_set",
|
||||
f"{device['name']} / {option['name']}",
|
||||
value or "(geleert)",
|
||||
)
|
||||
flash(
|
||||
f"Option {option['name']} für {device['name']} "
|
||||
f"{'gespeichert' if value else 'entfernt'}.",
|
||||
"success",
|
||||
)
|
||||
return _write_and_redirect()
|
||||
|
||||
|
||||
@bp.post("/option/new")
|
||||
@permission_required("dhcp_options.write")
|
||||
def add_custom_option():
|
||||
conn = db()
|
||||
try:
|
||||
code = int(request.form.get("code") or 0)
|
||||
except ValueError:
|
||||
flash("Der Optionscode muss eine Zahl sein.", "danger")
|
||||
return redirect(url_for("dhcp.overview"))
|
||||
name = (request.form.get("name") or "").strip()
|
||||
option_type = (request.form.get("type") or "").strip()
|
||||
if not 1 <= code <= 254:
|
||||
flash("Der Optionscode muss zwischen 1 und 254 liegen.", "danger")
|
||||
return redirect(url_for("dhcp.overview"))
|
||||
if not name or option_type not in kea.OPTION_TYPES:
|
||||
flash("Name und Typ sind erforderlich.", "danger")
|
||||
return redirect(url_for("dhcp.overview"))
|
||||
if Database.one(conn, "SELECT 1 FROM dhcp_options WHERE code=? OR name=?", (code, name)):
|
||||
flash("Code oder Name existiert bereits.", "danger")
|
||||
return redirect(url_for("dhcp.overview"))
|
||||
with transaction():
|
||||
conn.execute(
|
||||
"INSERT INTO dhcp_options (code, name, type, description, is_standard) "
|
||||
"VALUES (?,?,?,?,0)",
|
||||
(code, name, option_type, (request.form.get("description") or "").strip()),
|
||||
)
|
||||
_audit("dhcp.option_created", name, severity="notice")
|
||||
flash("Eigene Option angelegt.", "success")
|
||||
return redirect(url_for("dhcp.overview"))
|
||||
|
||||
|
||||
@bp.post("/option/<int:option_id>/delete")
|
||||
@permission_required("dhcp_options.write")
|
||||
def delete_custom_option(option_id: int):
|
||||
conn = db()
|
||||
option = Database.one(conn, "SELECT * FROM dhcp_options WHERE id=?", (option_id,))
|
||||
if option is None:
|
||||
abort(404)
|
||||
if int(option["is_standard"] or 0):
|
||||
flash(
|
||||
"Standardoptionen können nicht gelöscht werden – nur ihr Wert lässt sich leeren.",
|
||||
"danger",
|
||||
)
|
||||
return redirect(url_for("dhcp.overview"))
|
||||
with transaction():
|
||||
conn.execute("DELETE FROM dhcp_options WHERE id=?", (option_id,))
|
||||
_audit("dhcp.option_deleted", str(option["name"]), severity="warning")
|
||||
flash("Option entfernt.", "success")
|
||||
return _write_and_redirect()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Reservierungen
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@bp.post("/reservation")
|
||||
@permission_required("dhcp_reservations.create")
|
||||
def add_reservation():
|
||||
conn = db()
|
||||
mac = kea.normalize_mac(request.form.get("mac", ""))
|
||||
ip = (request.form.get("ip") or "").strip()
|
||||
if not mac:
|
||||
flash("Die MAC-Adresse ist ungültig.", "danger")
|
||||
return redirect(url_for("dhcp.overview"))
|
||||
|
||||
# Eine Reservierung ausserhalb aller eingerichteten Bereiche liefert Kea nie
|
||||
# aus. Sie anzunehmen und stillschweigend zu verwerfen waere die
|
||||
# schlechteste der drei Moeglichkeiten -- deshalb hier ablehnen.
|
||||
if not Database.value(conn, "SELECT COUNT(*) FROM dhcp_subnets WHERE enabled=1"):
|
||||
flash(
|
||||
"Es ist noch kein Bereich eingerichtet. Legen Sie zuerst ein Subnetz an – "
|
||||
"ohne eines wird keine Reservierung ausgeliefert.",
|
||||
"danger",
|
||||
)
|
||||
return redirect(url_for("dhcp.overview"))
|
||||
|
||||
sysops = core().sysops
|
||||
if sysops is None or not sysops.available:
|
||||
# Ohne Systemhelfer ist das Netz des Hosts nicht feststellbar. Dann hier
|
||||
# nicht blockieren: sonst waere in einer eingeschraenkten Umgebung gar
|
||||
# keine Reservierung mehr moeglich. Stattdessen benennen, was ungeprueft
|
||||
# bleibt.
|
||||
flash(
|
||||
"Der Bereich konnte nicht geprüft werden – ohne Systemhelfer ist das Netz "
|
||||
"dieses Servers nicht feststellbar.",
|
||||
"warning",
|
||||
)
|
||||
else:
|
||||
netze = kea.configured_networks(conn, kea.detect_networks(sysops))
|
||||
if netze and not kea.in_configured_network(ip, netze):
|
||||
bereiche = ", ".join(str(netz) for netz in netze)
|
||||
flash(
|
||||
f"{ip or 'Diese Adresse'} liegt in keinem eingerichteten Bereich "
|
||||
f"({bereiche}). Die Reservierung wäre ohne Wirkung.",
|
||||
"danger",
|
||||
)
|
||||
return redirect(url_for("dhcp.overview"))
|
||||
|
||||
vorher = Database.one(conn, "SELECT * FROM dhcp_reservations WHERE mac=?", (mac,))
|
||||
with transaction():
|
||||
conn.execute(
|
||||
"INSERT INTO dhcp_reservations (mac, ip, hostname, comment, created_at) "
|
||||
"VALUES (?,?,?,?,datetime('now')) ON CONFLICT(mac) DO UPDATE SET ip=excluded.ip, "
|
||||
"hostname=excluded.hostname, comment=excluded.comment",
|
||||
(
|
||||
mac,
|
||||
ip,
|
||||
(request.form.get("hostname") or "").strip(),
|
||||
(request.form.get("comment") or "").strip(),
|
||||
),
|
||||
)
|
||||
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")
|
||||
return _write_and_redirect()
|
||||
|
||||
|
||||
@bp.post("/reservation/<int:reservation_id>/delete")
|
||||
@permission_required("dhcp_reservations.delete")
|
||||
def delete_reservation(reservation_id: int):
|
||||
with transaction() as conn:
|
||||
conn.execute("DELETE FROM dhcp_reservations WHERE id=?", (reservation_id,))
|
||||
_audit("dhcp.reservation_deleted", str(reservation_id), severity="notice")
|
||||
flash("Reservierung entfernt.", "success")
|
||||
return _write_and_redirect()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Datei schreiben und Dienst steuern
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _write_config() -> dict[str, Any]:
|
||||
"""Schreibt die Konfiguration und startet den Dienst neu, falls er laeuft.
|
||||
|
||||
Ein gestoppter Dienst wird **nicht** automatisch gestartet -- das bleibt eine
|
||||
ausdrueckliche Entscheidung.
|
||||
"""
|
||||
extension = core()
|
||||
sysops = extension.sysops
|
||||
conn = db()
|
||||
if sysops is None or not sysops.available:
|
||||
return {"ok": False, "message": "Systemhelfer nicht verfügbar."}
|
||||
|
||||
networks = kea.detect_networks(sysops)
|
||||
config, warnings = kea.render_config(conn, networks=networks, settings=_settings_values())
|
||||
if not config["Dhcp4"]["subnet4"]:
|
||||
return {
|
||||
"ok": False,
|
||||
"message": "Es ist kein gültiges Subnetz konfiguriert – die Datei wurde nicht geschrieben.",
|
||||
"warnings": warnings,
|
||||
}
|
||||
result = sysops.write_kea_config(kea.config_to_text(config))
|
||||
if not result.ok:
|
||||
return {"ok": False, "message": result.message, "warnings": warnings}
|
||||
|
||||
state = sysops.service_state(kea.SERVICE_UNIT)
|
||||
restarted = False
|
||||
if state["active"] == "active":
|
||||
restart = sysops.service("restart", kea.SERVICE_UNIT)
|
||||
restarted = restart.ok
|
||||
if not restart.ok:
|
||||
return {
|
||||
"ok": False,
|
||||
"message": f"Datei geschrieben, Neustart fehlgeschlagen: {restart.message}",
|
||||
"warnings": warnings,
|
||||
}
|
||||
return {
|
||||
"ok": True,
|
||||
"message": "Konfiguration geschrieben." + (" Dienst neu gestartet." if restarted else ""),
|
||||
"warnings": warnings,
|
||||
}
|
||||
|
||||
|
||||
def _write_and_redirect():
|
||||
result = _write_config()
|
||||
for warning in result.get("warnings", []):
|
||||
flash(warning, "warning")
|
||||
if not result["ok"] and result.get("message"):
|
||||
flash(result["message"], "warning")
|
||||
elif result["ok"]:
|
||||
flash(result["message"], "info")
|
||||
return redirect(url_for("dhcp.overview"))
|
||||
|
||||
|
||||
@bp.post("/write")
|
||||
@permission_required("dhcp_config.execute")
|
||||
def write_config():
|
||||
return _write_and_redirect()
|
||||
|
||||
|
||||
@bp.post("/service/<action>")
|
||||
@permission_required("dhcp_service.execute")
|
||||
def service_action(action: str):
|
||||
allowed = {"install", "start", "stop"}
|
||||
if action not in allowed:
|
||||
abort(404)
|
||||
extension = core()
|
||||
sysops = extension.sysops
|
||||
if sysops is None or not sysops.available:
|
||||
flash("Systemaktionen sind in dieser Umgebung nicht verfügbar.", "warning")
|
||||
return redirect(url_for("dhcp.overview"))
|
||||
|
||||
if action == "install":
|
||||
result = sysops.apt_install(kea.PACKAGE_NAME)
|
||||
message = "Kea installiert." if result.ok else f"Installation fehlgeschlagen: {result.message}"
|
||||
elif action == "start":
|
||||
enable = sysops.service("enable", kea.SERVICE_UNIT)
|
||||
result = sysops.service("start", kea.SERVICE_UNIT)
|
||||
message = (
|
||||
"Dienst aktiviert und gestartet."
|
||||
if result.ok and enable.ok
|
||||
else f"Start fehlgeschlagen: {result.message or enable.message}"
|
||||
)
|
||||
else:
|
||||
sysops.service("disable", kea.SERVICE_UNIT)
|
||||
result = sysops.service("stop", kea.SERVICE_UNIT)
|
||||
message = "Dienst gestoppt und deaktiviert." if result.ok else f"Stoppen fehlgeschlagen: {result.message}"
|
||||
|
||||
_audit(f"dhcp.service_{action}", detail=message, severity="notice" if result.ok else "warning")
|
||||
flash(message, "success" if result.ok else "danger")
|
||||
return redirect(url_for("dhcp.overview"))
|
||||
@@ -0,0 +1,558 @@
|
||||
"""Dateifreigaben: Blaettern, Herunterladen, Hochladen, Verwalten."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from flask import (
|
||||
Blueprint,
|
||||
Response,
|
||||
abort,
|
||||
flash,
|
||||
redirect,
|
||||
render_template,
|
||||
request,
|
||||
send_file,
|
||||
url_for,
|
||||
)
|
||||
from tesm_core import audit
|
||||
from tesm_core.auth.models import current_user, permission_required
|
||||
from tesm_core.auth.service import client_ip
|
||||
from tesm_core import uploads
|
||||
from tesm_core.extension import core, db, transaction
|
||||
|
||||
from ..services import fileshare as service
|
||||
|
||||
bp = Blueprint("fileshare", __name__, url_prefix="/fileshares")
|
||||
|
||||
|
||||
@bp.before_request
|
||||
def _require_module() -> None:
|
||||
manager = core().license
|
||||
if manager is not None and not manager.module_active("fileshare"):
|
||||
abort(404)
|
||||
|
||||
|
||||
def _audit(action: str, target: str, detail: str = "", severity: str = "info") -> None:
|
||||
with transaction() as conn:
|
||||
audit.log(
|
||||
conn,
|
||||
actor=current_user.username,
|
||||
actor_id=current_user.id,
|
||||
action=action,
|
||||
target=target,
|
||||
detail=detail,
|
||||
ip=client_ip(),
|
||||
severity=severity,
|
||||
)
|
||||
|
||||
|
||||
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:
|
||||
conn = db()
|
||||
mounts = service.doppelte_zusammenfassen(
|
||||
conn, service.active_mounts(conn, current_user.id), service.aktuelle_sitzung()
|
||||
)
|
||||
for mount in mounts:
|
||||
if mount.token == token:
|
||||
return mount
|
||||
abort(404)
|
||||
|
||||
|
||||
@bp.get("/")
|
||||
@permission_required("fileshare.read")
|
||||
def browse():
|
||||
conn = db()
|
||||
mounts = service.doppelte_zusammenfassen(
|
||||
conn, service.active_mounts(conn, current_user.id), service.aktuelle_sitzung()
|
||||
)
|
||||
token = request.args.get("share") or (mounts[0].token if mounts else "")
|
||||
relative = request.args.get("path") or ""
|
||||
listing = None
|
||||
error = ""
|
||||
if token:
|
||||
try:
|
||||
mount = _mount_or_404(token)
|
||||
listing = service.list_directory(mount, relative)
|
||||
except service.FileshareError as exc:
|
||||
error = str(exc)
|
||||
return render_template(
|
||||
"tesm/fileshare.html",
|
||||
mounts=mounts,
|
||||
token=token,
|
||||
listing=listing,
|
||||
error=error,
|
||||
# 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>/download")
|
||||
# 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):
|
||||
mount = _mount_or_404(token)
|
||||
relative = request.args.get("path") or ""
|
||||
try:
|
||||
path = service.resolve_path(mount, relative)
|
||||
except service.FileshareError as exc:
|
||||
abort(400, description=str(exc))
|
||||
if not path.is_file():
|
||||
abort(404)
|
||||
_audit("fileshare.downloaded", f"{mount.label}:{relative}")
|
||||
return send_file(
|
||||
path,
|
||||
as_attachment=True,
|
||||
download_name=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>/preview")
|
||||
@permission_required("fileshare.read")
|
||||
def preview(token: str):
|
||||
mount = _mount_or_404(token)
|
||||
relative = request.args.get("path") or ""
|
||||
try:
|
||||
path = service.resolve_path(mount, relative)
|
||||
mimetype = service.preview_mime(path.name)
|
||||
except service.FileshareError as exc:
|
||||
abort(415, description=str(exc))
|
||||
if not path.is_file():
|
||||
abort(404)
|
||||
# ``Content-Disposition: inline`` mit engem MIME-Typ; die CSP der Anwendung
|
||||
# verhindert zusaetzlich, dass eingebettetes Skript ausgefuehrt wuerde.
|
||||
return Response(
|
||||
path.read_bytes(),
|
||||
mimetype=mimetype,
|
||||
headers={
|
||||
"Content-Disposition": f'inline; filename="{path.name}"',
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
# Die Vorschau laeuft in einem Rahmen auf der Freigabeseite. Die
|
||||
# anwendungsweite CSP verbietet mit "frame-ancestors 'none'" jedes
|
||||
# Einrahmen -- auch das durch die eigene Seite. Beides wird hier
|
||||
# deshalb ausdruecklich auf "gleicher Ursprung" gesetzt; die Header
|
||||
# der Anwendung werden per setdefault gesetzt und treten damit
|
||||
# zurueck.
|
||||
#
|
||||
# Bewusst nur die Rahmen-Direktiven und nicht die ganze strenge CSP:
|
||||
# was hier ausgeliefert wird, ist eine Datei mit engem, erlaubtem
|
||||
# MIME-Typ (kein HTML, kein SVG) und "nosniff" -- Skript kann darin
|
||||
# nicht ausgefuehrt werden. Eine vollstaendige CSP auf einer
|
||||
# PDF-Antwort riskiert dagegen, die eingebaute Anzeige des Browsers
|
||||
# zu blockieren.
|
||||
"Content-Security-Policy": "frame-ancestors 'self'",
|
||||
"X-Frame-Options": "SAMEORIGIN",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/<token>/upload")
|
||||
@permission_required("fileshare.create")
|
||||
def upload(token: str):
|
||||
mount = _mount_or_404(token)
|
||||
relative = request.form.get("path") or ""
|
||||
files = request.files.getlist("files")
|
||||
if not files:
|
||||
flash("Keine Datei ausgewählt.", "danger")
|
||||
return redirect(url_for("fileshare.browse", share=token, path=relative))
|
||||
|
||||
limit = uploads.limit_bytes(db())
|
||||
stored = 0
|
||||
try:
|
||||
target_dir = service.resolve_path(mount, relative)
|
||||
for upload_file in files:
|
||||
if not upload_file.filename:
|
||||
continue
|
||||
name = service.safe_filename(upload_file.filename)
|
||||
data = upload_file.stream.read(limit + 1)
|
||||
if len(data) > limit:
|
||||
flash(f"{name}: zu groß (Grenze {limit // 1024 // 1024} MB).", "danger")
|
||||
continue
|
||||
(target_dir / name).write_bytes(data)
|
||||
stored += 1
|
||||
except service.FileshareError as exc:
|
||||
flash(str(exc), "danger")
|
||||
return redirect(url_for("fileshare.browse", share=token, path=relative))
|
||||
|
||||
if stored:
|
||||
_audit("fileshare.uploaded", f"{mount.label}:{relative}", f"{stored} Datei(en)")
|
||||
flash(f"{stored} Datei(en) hochgeladen.", "success")
|
||||
return redirect(url_for("fileshare.browse", share=token, path=relative))
|
||||
|
||||
|
||||
@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")
|
||||
def make_directory(token: str):
|
||||
mount = _mount_or_404(token)
|
||||
relative = request.form.get("path") or ""
|
||||
try:
|
||||
service.make_directory(mount, relative, request.form.get("name", ""))
|
||||
except (service.FileshareError, OSError) as exc:
|
||||
flash(str(exc), "danger")
|
||||
return redirect(url_for("fileshare.browse", share=token, path=relative))
|
||||
_audit("fileshare.directory_created", f"{mount.label}:{relative}/{request.form.get('name')}")
|
||||
flash("Ordner angelegt.", "success")
|
||||
return redirect(url_for("fileshare.browse", share=token, path=relative))
|
||||
|
||||
|
||||
@bp.post("/<token>/rename")
|
||||
@permission_required("fileshare.write")
|
||||
def rename(token: str):
|
||||
mount = _mount_or_404(token)
|
||||
relative = request.form.get("path") or ""
|
||||
parent = request.form.get("parent") or ""
|
||||
try:
|
||||
service.rename_entry(mount, relative, request.form.get("name", ""))
|
||||
except (service.FileshareError, OSError) as exc:
|
||||
flash(str(exc), "danger")
|
||||
return redirect(url_for("fileshare.browse", share=token, path=parent))
|
||||
_audit("fileshare.renamed", f"{mount.label}:{relative}")
|
||||
flash("Umbenannt.", "success")
|
||||
return redirect(url_for("fileshare.browse", share=token, path=parent))
|
||||
|
||||
|
||||
@bp.post("/<token>/delete")
|
||||
@permission_required("fileshare.delete")
|
||||
def delete(token: str):
|
||||
mount = _mount_or_404(token)
|
||||
parent = request.form.get("parent") or ""
|
||||
removed = 0
|
||||
for relative in request.form.getlist("paths"):
|
||||
try:
|
||||
kind = service.delete_entry(mount, relative)
|
||||
_audit("fileshare.deleted", f"{mount.label}:{relative}", kind, severity="warning")
|
||||
removed += 1
|
||||
except (service.FileshareError, OSError) as exc:
|
||||
flash(str(exc), "danger")
|
||||
if removed:
|
||||
flash(f"{removed} Eintrag/Einträge gelöscht.", "success")
|
||||
return redirect(url_for("fileshare.browse", share=token, path=parent))
|
||||
|
||||
|
||||
@bp.post("/<token>/release")
|
||||
@permission_required("fileshare.read")
|
||||
def release(token: str):
|
||||
mount = _mount_or_404(token)
|
||||
with transaction() as conn:
|
||||
service.release_mount(conn, mount.id)
|
||||
_audit("fileshare.released", mount.label)
|
||||
flash("Einbindung gelöst.", "success")
|
||||
return redirect(url_for("fileshare.browse"))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Verwaltung der Zuordnungen (Einstellungen)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@bp.get("/mappings")
|
||||
@permission_required("fileshare_mappings.read")
|
||||
def mappings():
|
||||
from tesm_core.auth import directory
|
||||
|
||||
conn = db()
|
||||
return render_template(
|
||||
"tesm/fileshare_mappings.html",
|
||||
mappings=service.list_mappings(conn),
|
||||
directory_enabled=directory.is_enabled(conn),
|
||||
mount_root=str(core().settings.get(conn, "fileshare_mount_root")),
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/mappings")
|
||||
@permission_required("fileshare_mappings.create")
|
||||
def mapping_add():
|
||||
try:
|
||||
with transaction() as conn:
|
||||
service.add_mapping(
|
||||
conn,
|
||||
ad_group_dn=request.form.get("ad_group_dn", ""),
|
||||
ad_group_name=request.form.get("ad_group_name", ""),
|
||||
label=request.form.get("label", ""),
|
||||
unc=request.form.get("unc", ""),
|
||||
)
|
||||
_audit(
|
||||
"fileshare.mapping_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")
|
||||
except service.FileshareError as exc:
|
||||
flash(str(exc), "danger")
|
||||
return redirect(url_for("fileshare.mappings"))
|
||||
|
||||
|
||||
@bp.post("/mappings/<int:mapping_id>")
|
||||
@permission_required("fileshare_mappings.write")
|
||||
def mapping_update(mapping_id: int):
|
||||
try:
|
||||
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(
|
||||
conn,
|
||||
mapping_id,
|
||||
ad_group_dn=request.form.get("ad_group_dn", ""),
|
||||
ad_group_name=request.form.get("ad_group_name", ""),
|
||||
label=request.form.get("label", ""),
|
||||
unc=request.form.get("unc", ""),
|
||||
)
|
||||
nachher = service.get_mapping(conn, mapping_id)
|
||||
_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:
|
||||
flash(str(exc), "danger")
|
||||
return redirect(url_for("fileshare.mappings"))
|
||||
|
||||
|
||||
@bp.post("/mappings/<int:mapping_id>/delete")
|
||||
@permission_required("fileshare_mappings.delete")
|
||||
def mapping_delete(mapping_id: int):
|
||||
with transaction() as conn:
|
||||
entfernt = service.get_mapping(conn, mapping_id)
|
||||
service.delete_mapping(conn, mapping_id)
|
||||
# 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")
|
||||
return redirect(url_for("fileshare.mappings"))
|
||||
@@ -0,0 +1,253 @@
|
||||
"""SSH-Host-Schluessel -- alle Ziele auf einer Seite.
|
||||
|
||||
Die Freigabe einzelner Schluessel gibt es auch auf den Detailseiten von
|
||||
Clients und Switchen. Diese Seite beantwortet die andere Frage, die im Betrieb
|
||||
haeufiger ist: *wo fehlt noch eine Freigabe?* Sie vorher zu beantworten hiess,
|
||||
jede Detailseite einzeln aufzurufen.
|
||||
|
||||
Gezeigt werden beide Richtungen:
|
||||
|
||||
* Jedes Ziel (Client oder Switch) mit einer SSH-Adresse -- mit oder ohne
|
||||
Freigabe.
|
||||
* Freigegebene Schluessel, zu denen es kein Ziel mehr gibt. Die sind kein
|
||||
Fehler, aber sie sollten auffallen: ein Geraet wurde geloescht oder hat eine
|
||||
neue IP, und die alte Freigabe steht noch.
|
||||
|
||||
Die Rechte bleiben an den Objekten: wer Clients bearbeiten darf, gibt
|
||||
Client-Schluessel frei; fuer Switche gilt ``switches.edit``. Diese Seite
|
||||
buendelt die Ansicht, nicht die Berechtigung.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from flask import Blueprint, abort, flash, redirect, render_template, request, url_for
|
||||
from tesm_core import audit
|
||||
from tesm_core.auth.models import current_user, permission_required, reauth_required
|
||||
from tesm_core.auth.service import client_ip
|
||||
from tesm_core.db import Database
|
||||
from tesm_core.extension import core, db, transaction
|
||||
|
||||
from ..services import inventory, ssh
|
||||
|
||||
bp = Blueprint("hostkeys", __name__, url_prefix="/monitoring/host-keys")
|
||||
|
||||
KIND_DEVICE = "client"
|
||||
KIND_SWITCH = "switch"
|
||||
|
||||
#: Freigeben ist eine eigene Zeile: es war das Recht fuer Stammdaten
|
||||
#: ("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:
|
||||
with transaction() as conn:
|
||||
audit.log(
|
||||
conn,
|
||||
actor=current_user.username,
|
||||
actor_id=current_user.id,
|
||||
action=action,
|
||||
target=target,
|
||||
detail=detail,
|
||||
ip=client_ip(),
|
||||
severity=severity,
|
||||
)
|
||||
|
||||
|
||||
def _targets(conn: Any) -> list[dict[str, Any]]:
|
||||
"""Alle Ziele mit SSH-Adresse, jeweils mit ihren freigegebenen Schluesseln."""
|
||||
rows: list[dict[str, Any]] = []
|
||||
for switch in inventory.list_switches(conn):
|
||||
rows.append(
|
||||
{
|
||||
"kind": KIND_SWITCH,
|
||||
"id": int(switch["id"]),
|
||||
"label": str(switch["hostname"]),
|
||||
"host": str(switch["ip"]),
|
||||
"port": int(switch["ssh_port"] or 22),
|
||||
"detail_endpoint": "switches.detail",
|
||||
"credential": switch["credential_name"] if "credential_name" in switch.keys()
|
||||
else "",
|
||||
}
|
||||
)
|
||||
for device in inventory.list_devices(conn):
|
||||
# Windows-Geraete werden nicht ueber SSH bedient -- sie haetten hier
|
||||
# ewig den Zustand "Freigabe offen", ohne dass es je etwas freizugeben
|
||||
# gaebe.
|
||||
if not inventory.is_ssh_managed(device["credential_category"]):
|
||||
continue
|
||||
rows.append(
|
||||
{
|
||||
"kind": KIND_DEVICE,
|
||||
"id": int(device["id"]),
|
||||
"label": str(device["name"]),
|
||||
"host": str(device["ip"]),
|
||||
"port": int(device["ssh_port"] or 22),
|
||||
"detail_endpoint": "devices.detail",
|
||||
"credential": device["credential_name"] or "",
|
||||
}
|
||||
)
|
||||
for row in rows:
|
||||
# Nicht "keys" nennen: Jinja loest ``row.keys`` auf die dict-Methode
|
||||
# auf, nicht auf den Eintrag -- die Vorlage bekaeme dann eine Funktion.
|
||||
found = ssh.all_known_keys(conn, row["host"], row["port"])
|
||||
row["host_keys"] = found
|
||||
row["trusted"] = bool(found)
|
||||
rows.sort(key=lambda item: (item["trusted"], item["label"].lower()))
|
||||
return rows
|
||||
|
||||
|
||||
def _orphans(conn: Any, targets: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Freigaben, zu denen kein Ziel mehr passt."""
|
||||
used = {(row["host"], row["port"]) for row in targets}
|
||||
return [
|
||||
dict(row)
|
||||
for row in Database.all(
|
||||
conn,
|
||||
"SELECT host, port, key_type, fingerprint, added_at, added_by "
|
||||
"FROM switch_host_keys ORDER BY host, port, key_type",
|
||||
)
|
||||
if (str(row["host"]), int(row["port"])) not in used
|
||||
]
|
||||
|
||||
|
||||
@bp.get("/")
|
||||
@permission_required("hostkeys.read")
|
||||
def overview():
|
||||
conn = db()
|
||||
targets = _targets(conn)
|
||||
return render_template(
|
||||
"tesm/hostkeys.html",
|
||||
targets=targets,
|
||||
orphans=_orphans(conn, targets),
|
||||
open_count=sum(1 for row in targets if not row["trusted"]),
|
||||
edit_right=EDIT_RIGHT,
|
||||
probed_host=request.args.get("probed_host", ""),
|
||||
probed_port=request.args.get("probed_port", ""),
|
||||
probed_type=request.args.get("probed_type", ""),
|
||||
probed_fingerprint=request.args.get("probed_fingerprint", ""),
|
||||
)
|
||||
|
||||
|
||||
def _load(conn: Any, kind: str, target_id: int) -> dict[str, Any] | None:
|
||||
for row in _targets(conn):
|
||||
if row["kind"] == kind and row["id"] == target_id:
|
||||
return row
|
||||
return None
|
||||
|
||||
|
||||
def _require(kind: str) -> None:
|
||||
right = EDIT_RIGHT.get(kind)
|
||||
if right is None or not current_user.has(right):
|
||||
abort(403)
|
||||
|
||||
|
||||
@bp.post("/<kind>/<int:target_id>/probe")
|
||||
@permission_required("hostkeys.read")
|
||||
def probe(kind: str, target_id: int):
|
||||
conn = db()
|
||||
_require(kind)
|
||||
target = _load(conn, kind, target_id)
|
||||
if target is None:
|
||||
abort(404)
|
||||
try:
|
||||
record = ssh.probe_host_key(target["host"], target["port"])
|
||||
except ssh.SshError as exc:
|
||||
flash(str(exc), "danger")
|
||||
return redirect(url_for("hostkeys.overview"))
|
||||
|
||||
level, message = ssh.describe_probe(conn, record)
|
||||
flash(f"{target['label']}: {message}", level)
|
||||
return redirect(
|
||||
url_for(
|
||||
"hostkeys.overview",
|
||||
probed_host=record.host,
|
||||
probed_port=record.port,
|
||||
probed_type=record.key_type,
|
||||
probed_fingerprint=record.fingerprint,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/<kind>/<int:target_id>/trust")
|
||||
@permission_required("hostkeys.read")
|
||||
@reauth_required
|
||||
def trust(kind: str, target_id: int):
|
||||
conn = db()
|
||||
_require(kind)
|
||||
target = _load(conn, kind, target_id)
|
||||
if target is None:
|
||||
abort(404)
|
||||
key_type = (request.form.get("key_type") or "").strip()
|
||||
fingerprint = (request.form.get("fingerprint") or "").strip()
|
||||
if not key_type or not ssh.valid_fingerprint(fingerprint):
|
||||
flash("Es wurde kein gültiger Fingerprint übergeben.", "danger")
|
||||
return redirect(url_for("hostkeys.overview"))
|
||||
|
||||
with transaction():
|
||||
ssh.trust_key(
|
||||
conn,
|
||||
host=target["host"],
|
||||
port=target["port"],
|
||||
key_type=key_type,
|
||||
fingerprint=fingerprint,
|
||||
actor=current_user.username,
|
||||
)
|
||||
_audit(
|
||||
f"{'switch' if kind == KIND_SWITCH else 'device'}.host_key_trusted",
|
||||
target["label"],
|
||||
f"{key_type} {fingerprint}",
|
||||
severity="notice",
|
||||
)
|
||||
flash(f"Host-Schlüssel von {target['label']} freigegeben.", "success")
|
||||
return redirect(url_for("hostkeys.overview"))
|
||||
|
||||
|
||||
@bp.post("/<kind>/<int:target_id>/forget")
|
||||
@permission_required("hostkeys.read")
|
||||
def forget(kind: str, target_id: int):
|
||||
conn = db()
|
||||
_require(kind)
|
||||
target = _load(conn, kind, target_id)
|
||||
if target is None:
|
||||
abort(404)
|
||||
with transaction():
|
||||
ssh.forget_key(conn, target["host"], target["port"])
|
||||
_audit(
|
||||
f"{'switch' if kind == KIND_SWITCH else 'device'}.host_key_forgotten",
|
||||
target["label"],
|
||||
severity="warning",
|
||||
)
|
||||
flash(f"Gespeicherte Schlüssel von {target['label']} entfernt.", "info")
|
||||
return redirect(url_for("hostkeys.overview"))
|
||||
|
||||
|
||||
@bp.post("/orphaned/forget")
|
||||
@permission_required("hostkeys.execute")
|
||||
def forget_orphan():
|
||||
"""Entfernt eine Freigabe, zu der es kein Ziel mehr gibt."""
|
||||
host = (request.form.get("host") or "").strip()
|
||||
try:
|
||||
port = int(request.form.get("port") or 0)
|
||||
except ValueError:
|
||||
port = 0
|
||||
if not host or not 1 <= port <= 65535:
|
||||
flash("Ungültiges Ziel.", "danger")
|
||||
return redirect(url_for("hostkeys.overview"))
|
||||
|
||||
conn = db()
|
||||
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 --
|
||||
# sonst umginge dieser Weg die Rechtepruefung je Objektart.
|
||||
flash("Zu diesem Eintrag gibt es ein Ziel – bitte dort verwerfen.", "warning")
|
||||
return redirect(url_for("hostkeys.overview"))
|
||||
|
||||
with transaction():
|
||||
ssh.forget_key(conn, host, port)
|
||||
_audit("host_key.orphan_forgotten", f"{host}:{port}", severity="warning")
|
||||
flash(f"Verwaiste Freigabe für {host}:{port} entfernt.", "info")
|
||||
return redirect(url_for("hostkeys.overview"))
|
||||
@@ -0,0 +1,250 @@
|
||||
"""Lizenzverwaltung auf Kundenseite.
|
||||
|
||||
Diese Seite bleibt **immer** erreichbar -- auch ohne gueltige Lizenz. Sonst
|
||||
liesse sich eine neue Lizenz nie einspielen (Henne-Ei).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import socket
|
||||
|
||||
from flask import Blueprint, flash, redirect, render_template, request, url_for
|
||||
from tesm_core import audit
|
||||
from tesm_core.auth.models import (
|
||||
admin_required,
|
||||
current_user,
|
||||
permission_required,
|
||||
reauth_required,
|
||||
)
|
||||
from tesm_core.auth.service import client_ip
|
||||
from tesm_core.db import Database
|
||||
from tesm_core.extension import core, db, transaction
|
||||
from tesm_core.licensing_client import LICENSED_FEATURES, LicenseError
|
||||
|
||||
import tesm_licensing as lic
|
||||
|
||||
bp = Blueprint("license", __name__, url_prefix="/administration/license")
|
||||
|
||||
MAX_UPLOAD_BYTES = 256 * 1024
|
||||
|
||||
|
||||
def _audit(action: str, target: str = "", detail: str = "", severity: str = "info") -> None:
|
||||
with transaction() as conn:
|
||||
audit.log(
|
||||
conn,
|
||||
actor=current_user.username,
|
||||
actor_id=current_user.id,
|
||||
action=action,
|
||||
target=target,
|
||||
detail=detail,
|
||||
ip=client_ip(),
|
||||
severity=severity,
|
||||
)
|
||||
|
||||
|
||||
@bp.get("/")
|
||||
@permission_required("settings_license.read")
|
||||
def overview():
|
||||
extension = core()
|
||||
conn = db()
|
||||
manager = extension.license
|
||||
state = manager.reload(conn) if manager else None
|
||||
pending = manager._pending(conn) if manager else {} # noqa: SLF001 - eigenes Paket
|
||||
return render_template(
|
||||
"tesm/license.html",
|
||||
state=state,
|
||||
pending=pending,
|
||||
server_certificate=extension.settings.get(conn, "license_server_ca") or "",
|
||||
modules=lic.ALL_MODULES,
|
||||
module_labels=lic.MODULE_LABELS,
|
||||
type_labels=lic.LICENSE_TYPE_LABELS,
|
||||
role_labels=lic.ROLE_LABELS,
|
||||
features=LICENSED_FEATURES,
|
||||
trusted_keys=Database.all(
|
||||
conn, "SELECT * FROM trusted_issuer_keys ORDER BY added_at DESC"
|
||||
),
|
||||
fingerprint=lic.system_fingerprint(),
|
||||
hostname=socket.gethostname(),
|
||||
grace_days=lic.GRACE_PERIOD_DAYS,
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/install")
|
||||
@permission_required("settings_license.write")
|
||||
def upload():
|
||||
extension = core()
|
||||
manager = extension.license
|
||||
if manager is None:
|
||||
flash("Diese Installation nutzt keine Lizenzierung.", "info")
|
||||
return redirect(url_for("license.overview"))
|
||||
|
||||
upload_file = request.files.get("license_file")
|
||||
if upload_file is None or not upload_file.filename:
|
||||
flash("Keine Datei ausgewählt.", "danger")
|
||||
return redirect(url_for("license.overview"))
|
||||
raw = upload_file.stream.read(MAX_UPLOAD_BYTES + 1)
|
||||
if len(raw) > MAX_UPLOAD_BYTES:
|
||||
flash("Die Datei ist zu groß für eine Lizenzdatei.", "danger")
|
||||
return redirect(url_for("license.overview"))
|
||||
|
||||
allow_new_issuer = request.form.get("allow_new_issuer") == "1" and current_user.is_admin
|
||||
try:
|
||||
with transaction() as conn:
|
||||
state = manager.import_bundle(
|
||||
conn, raw, actor=current_user.username, allow_new_issuer=allow_new_issuer
|
||||
)
|
||||
except LicenseError as exc:
|
||||
flash(str(exc), "danger")
|
||||
return redirect(url_for("license.overview"))
|
||||
|
||||
flash(
|
||||
"Lizenz eingespielt. Bitte jetzt aktivieren, damit die Funktionen freigeschaltet werden.",
|
||||
"success",
|
||||
)
|
||||
if state.status and state.status.role != "customer":
|
||||
flash(
|
||||
f"Achtung: diese Lizenz hat die Rolle {state.status.role!r}. "
|
||||
"In einer TESM-Kundeninstanz ist das ungewöhnlich.",
|
||||
"warning",
|
||||
)
|
||||
return redirect(url_for("license.overview"))
|
||||
|
||||
|
||||
@bp.post("/activate")
|
||||
@permission_required("settings_license.write")
|
||||
def activate():
|
||||
return _handshake("activate")
|
||||
|
||||
|
||||
@bp.post("/deactivate")
|
||||
@permission_required("settings_license.write")
|
||||
@reauth_required
|
||||
def deactivate():
|
||||
"""Gibt die Lizenz frei, damit sie auf einer anderen Maschine genutzt werden kann.
|
||||
|
||||
Verlangt eine frische Passwortbestaetigung: eine versehentliche
|
||||
Deaktivierung schaltet lizenzpflichtige Funktionen ab.
|
||||
"""
|
||||
return _handshake("deactivate")
|
||||
|
||||
|
||||
@bp.post("/heartbeat")
|
||||
@permission_required("settings_license.write")
|
||||
def heartbeat():
|
||||
return _handshake("heartbeat")
|
||||
|
||||
|
||||
def _handshake(action: str):
|
||||
extension = core()
|
||||
manager = extension.license
|
||||
if manager is None:
|
||||
flash("Diese Installation nutzt keine Lizenzierung.", "info")
|
||||
return redirect(url_for("license.overview"))
|
||||
try:
|
||||
with transaction() as conn:
|
||||
result = manager.handshake(
|
||||
conn, action, actor=current_user.username, hostname=socket.gethostname()
|
||||
)
|
||||
except LicenseError as exc:
|
||||
flash(str(exc), "danger")
|
||||
return redirect(url_for("license.overview"))
|
||||
|
||||
if result["ok"]:
|
||||
flash(result["message"], "success")
|
||||
elif result["transport"] == "offline":
|
||||
flash(result["message"], "warning")
|
||||
else:
|
||||
flash(result["message"], "danger")
|
||||
return redirect(url_for("license.overview"))
|
||||
|
||||
|
||||
@bp.post("/offline-response")
|
||||
@permission_required("settings_license.write")
|
||||
def offline_response():
|
||||
extension = core()
|
||||
manager = extension.license
|
||||
if manager is None:
|
||||
return redirect(url_for("license.overview"))
|
||||
try:
|
||||
with transaction() as conn:
|
||||
result = manager.submit_offline_response(
|
||||
conn, request.form.get("code", ""), actor=current_user.username
|
||||
)
|
||||
except LicenseError as exc:
|
||||
flash(str(exc), "danger")
|
||||
return redirect(url_for("license.overview"))
|
||||
flash(result["message"], "success" if result["ok"] else "danger")
|
||||
return redirect(url_for("license.overview"))
|
||||
|
||||
|
||||
@bp.post("/server-certificate")
|
||||
@permission_required("settings_license.write")
|
||||
def server_certificate():
|
||||
"""Hinterlegt das Zertifikat des Lizenzservers.
|
||||
|
||||
Ein Lizenzserver im eigenen Netz hat meist ein selbstsigniertes
|
||||
Zertifikat. Ohne diesen Anker scheitert jeder Online-Handshake an der
|
||||
Pruefung und faellt auf den Offline-Weg zurueck. Die Pruefung deshalb
|
||||
abzuschalten waere der falsche Weg -- hier wird sie stattdessen auf genau
|
||||
dieses Zertifikat gerichtet.
|
||||
"""
|
||||
extension = core()
|
||||
conn = db()
|
||||
pem = (request.form.get("certificate") or "").strip()
|
||||
if pem and "BEGIN CERTIFICATE" not in pem:
|
||||
flash(
|
||||
"Das sieht nicht nach einem Zertifikat aus. Erwartet wird PEM, beginnend mit "
|
||||
"'-----BEGIN CERTIFICATE-----'.",
|
||||
"danger",
|
||||
)
|
||||
return redirect(url_for("license.overview"))
|
||||
if pem:
|
||||
# Lieber jetzt scheitern als beim naechsten Handshake.
|
||||
try:
|
||||
extension.license._tls_context(pem) # noqa: SLF001 - eigenes Paket
|
||||
except ValueError as exc:
|
||||
flash(str(exc), "danger")
|
||||
return redirect(url_for("license.overview"))
|
||||
|
||||
with transaction():
|
||||
extension.settings.set(conn, "license_server_ca", pem, actor=current_user.username)
|
||||
_audit(
|
||||
"license.server_certificate_set" if pem else "license.server_certificate_cleared",
|
||||
detail="PEM hinterlegt" if pem else "entfernt",
|
||||
severity="notice",
|
||||
)
|
||||
flash(
|
||||
"Zertifikat hinterlegt. Der nächste Versuch läuft wieder über den Online-Weg."
|
||||
if pem
|
||||
else "Zertifikat entfernt – es gelten wieder die Systemanker.",
|
||||
"success",
|
||||
)
|
||||
return redirect(url_for("license.overview"))
|
||||
|
||||
|
||||
@bp.post("/issuer/<key_id>/remove")
|
||||
@admin_required
|
||||
@reauth_required
|
||||
def remove_trusted_key(key_id: str):
|
||||
"""Entfernt einen gepinnten Aussteller-Schluessel.
|
||||
|
||||
Nur echte Administratoren, nur mit frischer Bestaetigung: das ist die
|
||||
Vertrauensgrundlage der Lizenzpruefung.
|
||||
"""
|
||||
with transaction() as conn:
|
||||
conn.execute("DELETE FROM trusted_issuer_keys WHERE key_id=?", (key_id,))
|
||||
audit.log(
|
||||
conn,
|
||||
actor=current_user.username,
|
||||
actor_id=current_user.id,
|
||||
action="license.issuer_unpinned",
|
||||
target=key_id,
|
||||
ip=client_ip(),
|
||||
severity="warning",
|
||||
)
|
||||
manager = core().license
|
||||
if manager is not None:
|
||||
with core().database.session() as conn:
|
||||
manager.reload(conn)
|
||||
flash("Aussteller-Schlüssel entfernt.", "info")
|
||||
return redirect(url_for("license.overview"))
|
||||
@@ -0,0 +1,69 @@
|
||||
"""TESM-eigene Protokollansichten: Kea-DHCP und Neustarts.
|
||||
|
||||
Die generischen Ansichten (laufend, Verlauf, Aenderungen) liegen im Kern --
|
||||
sie sind in beiden Anwendungen identisch.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from flask import Blueprint, abort, render_template, request
|
||||
from tesm_core.auth.models import permission_required
|
||||
from tesm_core.db import Database
|
||||
from tesm_core.extension import core, db
|
||||
|
||||
from ..services import kea, monitor
|
||||
|
||||
bp = Blueprint("tesmlogs", __name__, url_prefix="/logs")
|
||||
|
||||
MAX_TAIL_LINES = 2000
|
||||
|
||||
|
||||
@bp.get("/dhcp")
|
||||
@permission_required("logs_dhcp.read")
|
||||
def dhcp():
|
||||
extension = core()
|
||||
if extension.license is not None and not extension.license.module_active("dhcp"):
|
||||
abort(404)
|
||||
sysops = extension.sysops
|
||||
lines: list[str] = []
|
||||
error = ""
|
||||
if sysops is not None and sysops.available:
|
||||
result = sysops.read_privileged_file(kea.LOG_PATH)
|
||||
if result.ok:
|
||||
lines = result.stdout.splitlines()[-MAX_TAIL_LINES:]
|
||||
else:
|
||||
error = result.message
|
||||
else:
|
||||
error = "Systemhelfer nicht verfügbar."
|
||||
return render_template("tesm/logs_kea.html", lines=lines, path=kea.LOG_PATH, error=error)
|
||||
|
||||
|
||||
@bp.get("/restarts")
|
||||
@permission_required("devices.read")
|
||||
def restarts():
|
||||
"""Alle Neustarts -- ueber den PoE-Port und per Befehl aus der Wartung.
|
||||
|
||||
Frueher hiess diese Seite "PoE-Vorgaenge" und zeigte nur die eine Haelfte.
|
||||
Ein Geraet, das die Wartung per ``reboot`` neu gestartet hatte, fehlte in
|
||||
der Neustart-Historie vollstaendig -- obwohl genau dort jemand nachsieht,
|
||||
der wissen will, warum ein Geraet weg war.
|
||||
"""
|
||||
method = (request.args.get("method") or "").strip()
|
||||
if method and method not in monitor.METHOD_LABELS:
|
||||
method = ""
|
||||
sql = (
|
||||
"SELECT e.*, d.name AS device_name FROM restart_events e "
|
||||
"LEFT JOIN devices d ON d.id = e.device_id "
|
||||
)
|
||||
params: tuple[object, ...] = ()
|
||||
if method:
|
||||
sql += "WHERE e.method = ? "
|
||||
params = (method,)
|
||||
sql += "ORDER BY e.ts DESC LIMIT 300"
|
||||
return render_template(
|
||||
"tesm/logs_restarts.html",
|
||||
rows=Database.all(db(), sql, params),
|
||||
method=method,
|
||||
methods=tuple(monitor.METHOD_LABELS.items()),
|
||||
method_labels=monitor.METHOD_LABELS,
|
||||
)
|
||||
@@ -0,0 +1,291 @@
|
||||
"""Wartung: Paketaktualisierung und Neustart ueber SSH.
|
||||
|
||||
Lizenzpflichtiges Vollmodul. Es erscheinen Geraete, deren Zugangsdaten die
|
||||
Kategorie ``linux`` oder ``windows`` tragen:
|
||||
|
||||
* **Linux** -- Paketaktualisierung (apt) und Neustart.
|
||||
* **Windows** -- nur Neustart, ueber ``shutdown.exe``. Das Geraet braucht dafuer
|
||||
den OpenSSH-Server. Paketaktualisierung bleibt bewusst aus: dafuer braeuchte
|
||||
es PSWindowsUpdate oder WSUS, und beides darf man nicht voraussetzen.
|
||||
|
||||
Die Kategorie setzt der Administrator ausdruecklich, sie wird bewusst nicht per
|
||||
Ping/TTL erraten (das war im Vorgaenger schon so und ist richtig: eine falsch
|
||||
geratene Kategorie schickt ein ``reboot`` an das falsche Geraet).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from flask import Blueprint, abort, flash, jsonify, redirect, render_template, request, url_for
|
||||
from tesm_core.security.sessions import fragment
|
||||
from tesm_core import audit
|
||||
from tesm_core.auth.models import current_user, permission_required
|
||||
from tesm_core.auth.service import client_ip
|
||||
from tesm_core.db import Database
|
||||
from tesm_core.extension import core, db, transaction
|
||||
|
||||
from ..permissions import SERVICING_PAGE
|
||||
from ..services import inventory, maintenance as service, ssh, windowsops, monitor
|
||||
|
||||
bp = Blueprint("maintenance", __name__, url_prefix="/maintenance")
|
||||
|
||||
|
||||
@bp.before_request
|
||||
def _require_module() -> None:
|
||||
manager = core().license
|
||||
if manager is not None and not manager.module_active("maintenance"):
|
||||
abort(404)
|
||||
|
||||
|
||||
def _eligible_devices(conn) -> list[dict]: # type: ignore[no-untyped-def]
|
||||
"""Geraete, auf denen die Wartung ueberhaupt arbeiten kann.
|
||||
|
||||
Zusaetzlich zur Auswahl steht am Ergebnis, ob der SSH-Host-Schluessel schon
|
||||
freigegeben ist. Ohne diese Angabe lief man in eine Sackgasse: die Wartung
|
||||
brach mit "Schluessel unbekannt" ab, und auf der Seite war nicht zu sehen,
|
||||
woran es lag oder wo man es beheben kann.
|
||||
"""
|
||||
rows = _query_devices(conn)
|
||||
for row in rows:
|
||||
row["ssh_managed"] = inventory.is_ssh_managed(row["category"])
|
||||
# Windows braucht keinen Host-Schluessel -- dort waere "offen" eine
|
||||
# Warnung, die sich nie abstellen laesst.
|
||||
row["host_key_ready"] = (
|
||||
ssh.is_trusted(conn, str(row["ip"]), int(row["ssh_port"] or 22))
|
||||
if row["ssh_managed"]
|
||||
else True
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def _query_devices(conn) -> list[dict]: # type: ignore[no-untyped-def]
|
||||
return Database.all(
|
||||
conn,
|
||||
"""
|
||||
SELECT d.id, d.name, d.ip, d.ssh_port, c.name AS credential_name, c.id AS credential_id,
|
||||
c.category AS category,
|
||||
COALESCE(st.state,'unknown') AS state
|
||||
FROM devices d
|
||||
JOIN credentials c ON c.id = d.credential_id AND c.deleted_at IS NULL
|
||||
AND c.category IN ({placeholders})
|
||||
LEFT JOIN device_status st ON st.device_id = d.id
|
||||
WHERE d.deleted_at IS NULL AND d.is_active = 1
|
||||
ORDER BY d.name COLLATE NOCASE
|
||||
""".format(placeholders=",".join("?" * len(inventory.MAINTENANCE_CATEGORIES))),
|
||||
inventory.MAINTENANCE_CATEGORIES,
|
||||
)
|
||||
|
||||
|
||||
@bp.get("/")
|
||||
@permission_required(*SERVICING_PAGE, mode="any")
|
||||
def overview():
|
||||
conn = db()
|
||||
return render_template(
|
||||
"tesm/maintenance.html",
|
||||
devices=_eligible_devices(conn),
|
||||
jobs=service.list_jobs(conn),
|
||||
category_labels=dict(inventory.CREDENTIAL_CATEGORIES),
|
||||
category_effects=inventory.CATEGORY_EFFECTS,
|
||||
update_category=inventory.MAINTENANCE_CATEGORY,
|
||||
terminal_enabled=bool(core().settings.get(conn, "terminal_clients_enabled")),
|
||||
windows_tool_missing=_windows_tool_missing(conn),
|
||||
windows_package=windowsops.REQUIRED_PACKAGE,
|
||||
)
|
||||
|
||||
|
||||
def _windows_tool_missing(conn) -> bool: # type: ignore[no-untyped-def]
|
||||
"""Fehlt ``net``, obwohl Windows-Geraete gepflegt werden?
|
||||
|
||||
Nur dann ist der Hinweis angebracht. Auf einem Server ohne Windows-Geraete
|
||||
waere er Laerm, und das Paket waere unnoetig installiert.
|
||||
"""
|
||||
if windowsops.available():
|
||||
return False
|
||||
return any(
|
||||
str(device.get("category") or "") == inventory.NON_SSH_CATEGORY
|
||||
for device in _eligible_devices(conn)
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/install-package")
|
||||
@permission_required("servicing_tool.execute")
|
||||
def install_windows_tool():
|
||||
"""Installiert das Paket, das der Fehlermeldung nach fehlt.
|
||||
|
||||
Ohne diesen Weg endete der Neustart eines Windows-Geraetes in einer
|
||||
Sackgasse: die Meldung nannte das fehlende Paket, aus der Oberflaeche liess
|
||||
es sich aber nicht beschaffen.
|
||||
"""
|
||||
sysops = core().sysops
|
||||
if sysops is None or not sysops.available:
|
||||
flash("Systemaktionen sind in dieser Umgebung nicht verfügbar.", "warning")
|
||||
return redirect(url_for("maintenance.overview"))
|
||||
|
||||
result = sysops.apt_install(windowsops.REQUIRED_PACKAGE)
|
||||
if result.ok:
|
||||
flash(f"{windowsops.REQUIRED_PACKAGE} installiert.", "success")
|
||||
else:
|
||||
flash(f"Installation fehlgeschlagen: {result.message}", "danger")
|
||||
with transaction() as conn:
|
||||
audit.log(
|
||||
conn,
|
||||
actor=current_user.username,
|
||||
actor_id=current_user.id,
|
||||
action="maintenance.package_installed",
|
||||
target=windowsops.REQUIRED_PACKAGE,
|
||||
detail="" if result.ok else result.message,
|
||||
ip=client_ip(),
|
||||
severity="notice" if result.ok else "warning",
|
||||
)
|
||||
return redirect(url_for("maintenance.overview"))
|
||||
|
||||
|
||||
@bp.get("/status")
|
||||
@permission_required(*SERVICING_PAGE, mode="any")
|
||||
def status():
|
||||
"""Zustand der Auftraege -- aus der Datenbank, nicht aus dem Prozessspeicher."""
|
||||
conn = db()
|
||||
jobs = service.list_jobs(conn, limit=30)
|
||||
running = any(job["state"] in {"pending", "running"} for job in jobs)
|
||||
return jsonify(
|
||||
{
|
||||
"running": running,
|
||||
"poll_ms": 2000 if running else 10000,
|
||||
"jobs": [
|
||||
{
|
||||
"id": job["id"],
|
||||
"kind": job["kind"],
|
||||
"subject": job["subject"],
|
||||
"state": job["state"],
|
||||
"progress": job["progress"],
|
||||
"message": job["message"],
|
||||
"updated_at": job["updated_at"],
|
||||
}
|
||||
for job in jobs
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@bp.get("/job/<job_id>")
|
||||
@permission_required(*SERVICING_PAGE, mode="any")
|
||||
def job_detail(job_id: str):
|
||||
conn = db()
|
||||
job = service.get_job(conn, job_id)
|
||||
if job is None:
|
||||
abort(404)
|
||||
return render_template("tesm/maintenance_job.html", job=job)
|
||||
|
||||
|
||||
@bp.get("/job/<job_id>/output")
|
||||
@permission_required(*SERVICING_PAGE, mode="any")
|
||||
@fragment
|
||||
def job_output(job_id: str):
|
||||
conn = db()
|
||||
job = service.get_job(conn, job_id)
|
||||
if job is None:
|
||||
abort(404)
|
||||
return render_template("tesm/_maintenance_output.html", job=job)
|
||||
|
||||
|
||||
def _start(kind: str) -> None:
|
||||
extension = core()
|
||||
conn = db()
|
||||
device_ids = [int(value) for value in request.form.getlist("device_ids") if value.isdigit()]
|
||||
if not device_ids:
|
||||
flash("Es wurde kein Gerät ausgewählt.", "warning")
|
||||
return
|
||||
|
||||
eligible = {int(row["id"]): row for row in _eligible_devices(conn)}
|
||||
started = 0
|
||||
for device_id in device_ids:
|
||||
device = eligible.get(device_id)
|
||||
if device is None:
|
||||
continue
|
||||
# Vor dem Anlegen pruefen, nicht danach: sonst bliebe ein Auftrag
|
||||
# zurueck, der nie gestartet wird und ewig auf "wartet" steht.
|
||||
if kind == service.JOB_KIND_UPDATE and str(device["category"]) != "linux":
|
||||
flash(
|
||||
f"{device['name']}: Paketaktualisierung gibt es nur für Linux-Clients. "
|
||||
"Windows-Geräte lassen sich hier nur neu starten.",
|
||||
"warning",
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
username, password = inventory.credential_secret(
|
||||
conn, extension.keystore, int(device["credential_id"])
|
||||
)
|
||||
except inventory.ValidationError as exc:
|
||||
flash(f"{device['name']}: {exc}", "danger")
|
||||
continue
|
||||
|
||||
with transaction():
|
||||
handle = service.create_job(
|
||||
conn,
|
||||
kind=kind,
|
||||
subject=str(device["name"]),
|
||||
actor=current_user.username,
|
||||
message="Wartet auf Start",
|
||||
)
|
||||
audit.log(
|
||||
conn,
|
||||
actor=current_user.username,
|
||||
actor_id=current_user.id,
|
||||
action=kind,
|
||||
target=str(device["name"]),
|
||||
detail=f"Auftrag {handle.id}",
|
||||
ip=client_ip(),
|
||||
severity="notice",
|
||||
)
|
||||
|
||||
if kind == service.JOB_KIND_UPDATE:
|
||||
service.start_background(
|
||||
service.run_update,
|
||||
database=extension.database,
|
||||
job_id=handle.id,
|
||||
device=dict(device),
|
||||
username=username,
|
||||
password=password,
|
||||
timeout=int(extension.settings.get(conn, "maintenance_command_timeout") or 900),
|
||||
)
|
||||
else:
|
||||
# Sofort sichtbar, nicht erst wenn das Geraet zurueck ist.
|
||||
with transaction() as verbindung:
|
||||
monitor.mark_restarting(verbindung, int(device["id"]))
|
||||
service.start_background(
|
||||
service.run_reboot,
|
||||
database=extension.database,
|
||||
job_id=handle.id,
|
||||
device=dict(device),
|
||||
username=username,
|
||||
password=password,
|
||||
wait_seconds=int(extension.settings.get(conn, "maintenance_reboot_wait") or 300),
|
||||
actor=current_user.username,
|
||||
# Hier steht in ``category`` die Kategorie der *Zugangsdaten*
|
||||
# (siehe _query_devices) -- daraus folgt der Weg. Uebergeben
|
||||
# statt in run_reboot erraten: dort bedeutet derselbe
|
||||
# Schluessel je Herkunft etwas anderes.
|
||||
method=(
|
||||
service.METHOD_RPC
|
||||
if str(device["category"]) == inventory.NON_SSH_CATEGORY
|
||||
else service.METHOD_SSH
|
||||
),
|
||||
)
|
||||
started += 1
|
||||
|
||||
if started:
|
||||
flash(f"{started} Auftrag/Aufträge gestartet.", "success")
|
||||
|
||||
|
||||
@bp.post("/update")
|
||||
@permission_required("servicing_update.execute")
|
||||
def start_update():
|
||||
_start(service.JOB_KIND_UPDATE)
|
||||
return redirect(url_for("maintenance.overview"))
|
||||
|
||||
|
||||
@bp.post("/restart")
|
||||
@permission_required("servicing_reboot.execute")
|
||||
def start_reboot():
|
||||
_start(service.JOB_KIND_REBOOT)
|
||||
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",
|
||||
)
|
||||
@@ -0,0 +1,330 @@
|
||||
"""Systemeinstellungen von TESM.
|
||||
|
||||
Netzwerk, Webserver/TLS und Verzeichnisdienst liegen im Kern
|
||||
(``tesm_core.views.hostsettings``) -- sie sind in beiden Anwendungen identisch.
|
||||
Hier steht nur, was TESM eigen ist: Ueberwachung, Konsole, Protokolle, Host.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from flask import Blueprint, flash, redirect, render_template, request, url_for
|
||||
from tesm_core import audit
|
||||
from tesm_core.auth.models import current_user, permission_required
|
||||
from tesm_core.auth.service import client_ip
|
||||
from tesm_core.extension import core, db, transaction
|
||||
from tesm_core.licensing_client import LicenseError
|
||||
|
||||
import tesm_licensing as lic
|
||||
|
||||
bp = Blueprint("settings", __name__, url_prefix="/administration")
|
||||
|
||||
|
||||
def _audit(action: str, target: str = "", detail: str = "", severity: str = "info") -> None:
|
||||
with transaction() as conn:
|
||||
audit.log(
|
||||
conn,
|
||||
actor=current_user.username,
|
||||
actor_id=current_user.id,
|
||||
action=action,
|
||||
target=target,
|
||||
detail=detail,
|
||||
ip=client_ip(),
|
||||
severity=severity,
|
||||
)
|
||||
|
||||
|
||||
def _license_hint(feature: str) -> str:
|
||||
manager = core().license
|
||||
if manager is None:
|
||||
return ""
|
||||
try:
|
||||
manager.require(feature)
|
||||
except LicenseError as exc:
|
||||
return str(exc)
|
||||
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:
|
||||
sysops = core().sysops
|
||||
if sysops is None:
|
||||
return ""
|
||||
return sysops.read_command(
|
||||
["timedatectl", "show", "--property=Timezone", "--value"]
|
||||
).stdout.strip()
|
||||
|
||||
|
||||
def _timezone_choices() -> list[str]:
|
||||
try:
|
||||
from zoneinfo import available_timezones
|
||||
|
||||
return sorted(available_timezones())
|
||||
except Exception: # noqa: BLE001
|
||||
return ["Europe/Berlin", "UTC"]
|
||||
|
||||
|
||||
@bp.get("/system")
|
||||
@permission_required("settings_system.read")
|
||||
def system():
|
||||
extension = core()
|
||||
conn = db()
|
||||
sysops = extension.sysops
|
||||
return render_template(
|
||||
"tesm/settings_system.html",
|
||||
values=extension.settings.get_many(
|
||||
conn,
|
||||
[
|
||||
"monitor_interval_seconds",
|
||||
"monitor_failures_before_restart",
|
||||
"monitor_auto_restart",
|
||||
"dashboard_public",
|
||||
"vnc_enabled",
|
||||
"vnc_port",
|
||||
"vnc_warn_unencrypted_off",
|
||||
"terminal_switches_enabled",
|
||||
"terminal_clients_enabled",
|
||||
"terminal_idle_timeout",
|
||||
"signature_witness_enabled",
|
||||
"trash_retention_days",
|
||||
"session_idle_minutes",
|
||||
"session_absolute_hours",
|
||||
"log_rotate_interval",
|
||||
"log_rotate_keep",
|
||||
],
|
||||
),
|
||||
specs={spec.key: spec for spec in extension.settings.specs()},
|
||||
hostname=(sysops.read_command(["hostname"]).stdout.strip() if sysops else ""),
|
||||
timezone=_current_timezone(),
|
||||
timezones=_timezone_choices(),
|
||||
# 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_child_order=extension.settings.get(conn, "nav_child_order") or {},
|
||||
license_hint=_license_hint("monitor_interval"),
|
||||
sysops_available=bool(sysops and sysops.available),
|
||||
module_rows=_module_rows(conn),
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/system")
|
||||
@permission_required("settings_system.write")
|
||||
def system_save():
|
||||
extension = core()
|
||||
conn = db()
|
||||
form = request.form
|
||||
|
||||
values: dict[str, Any] = {}
|
||||
for key in (
|
||||
"monitor_failures_before_restart",
|
||||
"monitor_auto_restart",
|
||||
"dashboard_public",
|
||||
"vnc_enabled",
|
||||
"vnc_port",
|
||||
"vnc_warn_unencrypted_off",
|
||||
"terminal_switches_enabled",
|
||||
"terminal_clients_enabled",
|
||||
"terminal_idle_timeout",
|
||||
"signature_witness_enabled",
|
||||
"trash_retention_days",
|
||||
"session_idle_minutes",
|
||||
"session_absolute_hours",
|
||||
"log_rotate_interval",
|
||||
"log_rotate_keep",
|
||||
):
|
||||
spec = extension.settings.spec(key)
|
||||
if spec.kind == "bool":
|
||||
# Nur beruecksichtigen, wenn das Formular diesen Bereich ueberhaupt
|
||||
# enthaelt -- sonst wuerde ein Teilformular fremde Schalter loeschen.
|
||||
if f"{key}__present" in form or key in form:
|
||||
values[key] = form.get(key) == "1"
|
||||
elif key in form:
|
||||
values[key] = form.get(key)
|
||||
|
||||
interval_changed = False
|
||||
if "monitor_interval_seconds" in form:
|
||||
problem = _license_hint("monitor_interval")
|
||||
current = str(extension.settings.get(conn, "monitor_interval_seconds") or 300)
|
||||
wanted = str(form.get("monitor_interval_seconds"))
|
||||
if current != wanted:
|
||||
if problem:
|
||||
flash(problem, "danger")
|
||||
else:
|
||||
values["monitor_interval_seconds"] = wanted
|
||||
interval_changed = True
|
||||
|
||||
with transaction():
|
||||
changed = extension.settings.set_many(conn, values, actor=current_user.username)
|
||||
if changed:
|
||||
audit.log(
|
||||
conn,
|
||||
actor=current_user.username,
|
||||
actor_id=current_user.id,
|
||||
action="settings.system_saved",
|
||||
detail=", ".join(changed),
|
||||
ip=client_ip(),
|
||||
severity="notice",
|
||||
)
|
||||
if changed:
|
||||
message = "Einstellungen gespeichert."
|
||||
if interval_changed:
|
||||
message += " Das Prüfintervall greift beim nächsten Durchlauf."
|
||||
flash(message, "success")
|
||||
else:
|
||||
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"))
|
||||
|
||||
|
||||
@bp.post("/system/hostname")
|
||||
@permission_required("settings_system.execute")
|
||||
def set_hostname():
|
||||
extension = core()
|
||||
sysops = extension.sysops
|
||||
hostname = (request.form.get("hostname") or "").strip()
|
||||
if not hostname or len(hostname) > 63 or not hostname.replace("-", "").isalnum():
|
||||
flash("Der Hostname ist ungültig.", "danger")
|
||||
return redirect(url_for("settings.system"))
|
||||
if sysops is None or not sysops.available:
|
||||
flash("Systemaktionen sind in dieser Umgebung nicht verfügbar.", "warning")
|
||||
return redirect(url_for("settings.system"))
|
||||
result = sysops.set_hostname(hostname)
|
||||
if result.ok:
|
||||
with transaction() as conn:
|
||||
extension.settings.set(conn, "hostname", hostname, actor=current_user.username)
|
||||
_audit("settings.hostname_changed", hostname, severity="notice")
|
||||
flash(f"Hostname auf {hostname} gesetzt.", "success")
|
||||
else:
|
||||
flash(f"Hostname konnte nicht gesetzt werden: {result.message}", "danger")
|
||||
return redirect(url_for("settings.system"))
|
||||
|
||||
|
||||
@bp.post("/system/timezone")
|
||||
@permission_required("settings_system.execute")
|
||||
def set_timezone():
|
||||
extension = core()
|
||||
sysops = extension.sysops
|
||||
timezone = (request.form.get("timezone") or "").strip()
|
||||
if timezone not in _timezone_choices():
|
||||
flash("Unbekannte Zeitzone.", "danger")
|
||||
return redirect(url_for("settings.system"))
|
||||
if sysops is None or not sysops.available:
|
||||
flash("Systemaktionen sind in dieser Umgebung nicht verfügbar.", "warning")
|
||||
return redirect(url_for("settings.system"))
|
||||
result = sysops.set_timezone(timezone)
|
||||
if result.ok:
|
||||
# Sofort auf den laufenden Prozess wirken lassen -- sonst zeigt die
|
||||
# Oberflaeche bis zum naechsten Neustart die alte Zeit.
|
||||
try:
|
||||
time.tzset() # type: ignore[attr-defined]
|
||||
except AttributeError: # pragma: no cover - Windows
|
||||
pass
|
||||
with transaction() as conn:
|
||||
extension.settings.set(conn, "timezone", timezone, actor=current_user.username)
|
||||
_audit("settings.timezone_changed", timezone, severity="notice")
|
||||
flash(f"Zeitzone auf {timezone} gesetzt.", "success")
|
||||
else:
|
||||
flash(f"Zeitzone konnte nicht gesetzt werden: {result.message}", "danger")
|
||||
return redirect(url_for("settings.system"))
|
||||
|
||||
|
||||
@bp.post("/system/check-now")
|
||||
@permission_required("settings_system.execute")
|
||||
def run_check_now():
|
||||
"""Startet einen Ueberwachungsdurchlauf sofort, im Hintergrund."""
|
||||
extension = core()
|
||||
|
||||
def worker() -> None:
|
||||
from ..services import inventory, monitor, vnc
|
||||
|
||||
allowed = extension.license is None or extension.license.feature_allowed(
|
||||
"auto_poe_restart"
|
||||
)
|
||||
monitor.run_sweep(
|
||||
database=extension.database,
|
||||
live=extension.registry["live_log"],
|
||||
keystore=extension.keystore,
|
||||
license_allows_auto_restart=allowed,
|
||||
vnc_port=vnc.port_aus_einstellungen(extension),
|
||||
)
|
||||
|
||||
threading.Thread(target=worker, name="manual-sweep", daemon=True).start()
|
||||
_audit("monitor.manual_sweep")
|
||||
flash("Durchlauf gestartet. Das Ergebnis erscheint im laufenden Protokoll.", "success")
|
||||
return redirect(request.referrer or url_for("settings.system"))
|
||||
@@ -0,0 +1,291 @@
|
||||
"""Switche: Bestand pflegen, Host-Schluessel freigeben, Zustand abfragen."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from flask import Blueprint, abort, flash, redirect, render_template, request, url_for
|
||||
from tesm_core import audit
|
||||
from tesm_core.auth.models import (
|
||||
current_user,
|
||||
permission_required,
|
||||
reauth_required,
|
||||
)
|
||||
from tesm_core.auth.service import client_ip
|
||||
from tesm_core.db import Database
|
||||
from tesm_core.extension import core, db, transaction
|
||||
|
||||
from ..services import inventory, ssh, switchcli
|
||||
|
||||
bp = Blueprint("switches", __name__, url_prefix="/monitoring/switches")
|
||||
|
||||
|
||||
def _audit(conn: Any, action: str, target: str, detail: str = "", severity: str = "info") -> None:
|
||||
audit.log(
|
||||
conn,
|
||||
actor=current_user.username,
|
||||
actor_id=current_user.id,
|
||||
action=action,
|
||||
target=target,
|
||||
detail=detail,
|
||||
ip=client_ip(),
|
||||
severity=severity,
|
||||
)
|
||||
|
||||
|
||||
def _form_values() -> dict[str, Any]:
|
||||
return {
|
||||
"hostname": request.form.get("hostname", ""),
|
||||
"ip": request.form.get("ip", ""),
|
||||
"ssh_port": request.form.get("ssh_port") or 22,
|
||||
"credential_id": request.form.get("credential_id") or None,
|
||||
"model": request.form.get("model", ""),
|
||||
"location": request.form.get("location", ""),
|
||||
"notes": request.form.get("notes", ""),
|
||||
"actor": current_user.username,
|
||||
}
|
||||
|
||||
|
||||
@bp.get("/")
|
||||
@permission_required("switches.read")
|
||||
def overview():
|
||||
conn = db()
|
||||
return render_template(
|
||||
"tesm/switches.html",
|
||||
switches=inventory.list_switches(conn),
|
||||
credentials=inventory.list_credentials(conn),
|
||||
terminal_enabled=bool(core().settings.get(conn, "terminal_switches_enabled")),
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/new")
|
||||
@permission_required("switches.create")
|
||||
def create():
|
||||
try:
|
||||
with transaction() as conn:
|
||||
switch_id = inventory.create_switch(conn, **_form_values())
|
||||
_audit(
|
||||
conn,
|
||||
"switch.created",
|
||||
request.form.get("hostname", ""),
|
||||
f"IP {request.form.get('ip')}",
|
||||
severity="notice",
|
||||
)
|
||||
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))
|
||||
except inventory.ValidationError as exc:
|
||||
flash(str(exc), "danger")
|
||||
return redirect(url_for("switches.overview"))
|
||||
|
||||
|
||||
@bp.post("/<int:switch_id>")
|
||||
@permission_required("switches.write")
|
||||
def update(switch_id: int):
|
||||
try:
|
||||
with transaction() as conn:
|
||||
changes = inventory.update_switch(conn, switch_id, **_form_values())
|
||||
if changes:
|
||||
_audit(
|
||||
conn,
|
||||
"switch.updated",
|
||||
request.form.get("hostname", str(switch_id)),
|
||||
"; ".join(changes),
|
||||
severity="notice",
|
||||
)
|
||||
flash("Änderungen gespeichert." if changes else "Keine Änderungen.", "success" if changes else "info")
|
||||
except inventory.ValidationError as exc:
|
||||
flash(str(exc), "danger")
|
||||
return redirect(request.referrer or url_for("switches.overview"))
|
||||
|
||||
|
||||
@bp.post("/<int:switch_id>/delete")
|
||||
@permission_required("switches.delete")
|
||||
def delete(switch_id: int):
|
||||
try:
|
||||
with transaction() as conn:
|
||||
switch = inventory.get_switch(conn, switch_id)
|
||||
if switch is None:
|
||||
abort(404)
|
||||
inventory.delete_switch(conn, switch_id)
|
||||
_audit(conn, "switch.deleted", str(switch["hostname"]), severity="warning")
|
||||
flash("Switch in den Papierkorb verschoben.", "success")
|
||||
except inventory.ValidationError as exc:
|
||||
flash(str(exc), "danger")
|
||||
return redirect(url_for("switches.overview"))
|
||||
|
||||
|
||||
@bp.get("/<int:switch_id>")
|
||||
@permission_required("switches.read")
|
||||
def detail(switch_id: int):
|
||||
conn = db()
|
||||
switch = inventory.get_switch(conn, switch_id)
|
||||
if switch is None:
|
||||
abort(404)
|
||||
host_key = ssh.known_key(conn, str(switch["ip"]), int(switch["ssh_port"]), "ssh-ed25519")
|
||||
all_keys = Database.all(
|
||||
conn,
|
||||
"SELECT * FROM switch_host_keys WHERE host=? AND port=? ORDER BY key_type",
|
||||
(str(switch["ip"]), int(switch["ssh_port"])),
|
||||
)
|
||||
devices = Database.all(
|
||||
conn,
|
||||
"SELECT d.id, d.name, d.port, d.ip, COALESCE(st.state,'unknown') AS state "
|
||||
"FROM devices d LEFT JOIN device_status st ON st.device_id = d.id "
|
||||
"WHERE d.switch_id=? AND d.deleted_at IS NULL ORDER BY d.port, d.name",
|
||||
(switch_id,),
|
||||
)
|
||||
return render_template(
|
||||
"tesm/switch_detail.html",
|
||||
switch=switch,
|
||||
host_key=host_key,
|
||||
host_keys=all_keys,
|
||||
devices=devices,
|
||||
credentials=inventory.list_credentials(conn),
|
||||
safe_commands=switchcli.SAFE_COMMANDS,
|
||||
terminal_enabled=bool(core().settings.get(conn, "terminal_switches_enabled")),
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Host-Schluessel
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@bp.post("/<int:switch_id>/host-key/probe")
|
||||
@permission_required("switches.write")
|
||||
def probe_host_key(switch_id: int):
|
||||
conn = db()
|
||||
switch = inventory.get_switch(conn, switch_id)
|
||||
if switch is None:
|
||||
abort(404)
|
||||
try:
|
||||
record = ssh.probe_host_key(str(switch["ip"]), int(switch["ssh_port"]))
|
||||
except ssh.SshError as exc:
|
||||
flash(str(exc), "danger")
|
||||
return redirect(url_for("switches.detail", switch_id=switch_id))
|
||||
|
||||
level, message = ssh.describe_probe(conn, record)
|
||||
flash(message, level)
|
||||
return redirect(
|
||||
url_for(
|
||||
"switches.detail",
|
||||
switch_id=switch_id,
|
||||
probed_type=record.key_type,
|
||||
probed_fingerprint=record.fingerprint,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/<int:switch_id>/host-key/trust")
|
||||
@permission_required("switches.write")
|
||||
@reauth_required
|
||||
def trust_host_key(switch_id: int):
|
||||
"""Gibt einen Host-Schluessel ausdruecklich frei.
|
||||
|
||||
Nur ein Mensch mit frischer Passwortbestaetigung kann das -- kein
|
||||
Hintergrundprozess und kein Automatismus.
|
||||
"""
|
||||
conn = db()
|
||||
switch = inventory.get_switch(conn, switch_id)
|
||||
if switch is None:
|
||||
abort(404)
|
||||
key_type = (request.form.get("key_type") or "").strip()
|
||||
fingerprint = (request.form.get("fingerprint") or "").strip()
|
||||
if not key_type or not ssh.valid_fingerprint(fingerprint):
|
||||
flash("Es wurde kein gültiger Fingerprint übergeben.", "danger")
|
||||
return redirect(url_for("switches.detail", switch_id=switch_id))
|
||||
|
||||
with transaction():
|
||||
ssh.trust_key(
|
||||
conn,
|
||||
host=str(switch["ip"]),
|
||||
port=int(switch["ssh_port"]),
|
||||
key_type=key_type,
|
||||
fingerprint=fingerprint,
|
||||
actor=current_user.username,
|
||||
)
|
||||
_audit(
|
||||
conn,
|
||||
"switch.host_key_trusted",
|
||||
str(switch["hostname"]),
|
||||
f"{key_type} {fingerprint}",
|
||||
severity="notice",
|
||||
)
|
||||
flash("Host-Schlüssel freigegeben.", "success")
|
||||
return redirect(url_for("switches.detail", switch_id=switch_id))
|
||||
|
||||
|
||||
@bp.post("/<int:switch_id>/host-key/forget")
|
||||
@permission_required("switches.write")
|
||||
def forget_host_key(switch_id: int):
|
||||
conn = db()
|
||||
switch = inventory.get_switch(conn, switch_id)
|
||||
if switch is None:
|
||||
abort(404)
|
||||
with transaction():
|
||||
ssh.forget_key(conn, str(switch["ip"]), int(switch["ssh_port"]))
|
||||
_audit(conn, "switch.host_key_forgotten", str(switch["hostname"]), severity="warning")
|
||||
flash("Gespeicherte Host-Schlüssel entfernt.", "info")
|
||||
return redirect(url_for("switches.detail", switch_id=switch_id))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Kommandos
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@bp.post("/<int:switch_id>/command")
|
||||
@permission_required("switch_console.execute")
|
||||
@reauth_required
|
||||
def run_command(switch_id: int):
|
||||
"""Fuehrt ein Kommando aus der Positivliste aus.
|
||||
|
||||
Der schnelle, sichere Weg fuer den Alltag: kein interaktives Terminal, nur
|
||||
lesende Kommandos aus einer festen Liste. Wer wirklich konfigurieren muss,
|
||||
nutzt das Terminal -- das ebenfalls Recht und Bestaetigung verlangt und
|
||||
zusaetzlich vollstaendig mitgeschrieben wird.
|
||||
"""
|
||||
extension = core()
|
||||
conn = db()
|
||||
switch = inventory.get_switch(conn, switch_id)
|
||||
if switch is None:
|
||||
abort(404)
|
||||
command = (request.form.get("command") or "").strip()
|
||||
allowed = {entry[0] for entry in switchcli.SAFE_COMMANDS}
|
||||
if command not in allowed:
|
||||
flash("Dieses Kommando steht nicht auf der Positivliste.", "danger")
|
||||
return redirect(url_for("switches.detail", switch_id=switch_id))
|
||||
if not switch["credential_id"]:
|
||||
flash("Für diesen Switch sind keine Zugangsdaten hinterlegt.", "danger")
|
||||
return redirect(url_for("switches.detail", switch_id=switch_id))
|
||||
|
||||
try:
|
||||
username, password = inventory.credential_secret(
|
||||
conn, extension.keystore, int(switch["credential_id"])
|
||||
)
|
||||
with switchcli.SwitchCli(
|
||||
conn,
|
||||
host=str(switch["ip"]),
|
||||
port=int(switch["ssh_port"]),
|
||||
username=username,
|
||||
password=password,
|
||||
) as cli:
|
||||
output = cli.send(command, tolerate_error=True)
|
||||
except (ssh.SshError, inventory.ValidationError) as exc:
|
||||
flash(str(exc), "danger")
|
||||
return redirect(url_for("switches.detail", switch_id=switch_id))
|
||||
|
||||
with transaction():
|
||||
_audit(
|
||||
conn,
|
||||
"switch.command",
|
||||
str(switch["hostname"]),
|
||||
command,
|
||||
severity="notice",
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE switches SET last_seen_at=datetime('now') WHERE id=?", (switch_id,)
|
||||
)
|
||||
return render_template(
|
||||
"tesm/switch_command.html", switch=switch, command=command, output=output
|
||||
)
|
||||
@@ -0,0 +1,437 @@
|
||||
"""Konsole im Browser -- fuer Switche und fuer Clients, zeilenorientiert.
|
||||
|
||||
Bewusste Entscheidung gegen ein vollstaendiges Terminal-Emulat: der Vorgaenger
|
||||
lieferte dafuer xterm.js als Fremdcode im Repo mit (mehrere hundert Kilobyte,
|
||||
eigene Aktualisierungspflicht) und brauchte einen dauerhaft blockierten
|
||||
Worker-Thread pro Sitzung. Eine Zeile hinein, eine Antwort heraus deckt beide
|
||||
Faelle ab -- die ArubaOS-Kommandozeile ist ohnehin zeilenorientiert, und auf
|
||||
einem Linux-Client ist eine eigene ``exec``-Sitzung je Kommando sogar
|
||||
robuster als eine geteilte Shell.
|
||||
|
||||
Zwei Ziele, ein Ablauf:
|
||||
|
||||
* **Switch** -- ``switches.execute``. Interaktive ArubaOS-Sitzung ueber
|
||||
:class:`~tesm.services.switchcli.SwitchCli`.
|
||||
* **Client** -- ``device_console.execute`` und das lizenzpflichtige Modul
|
||||
``maintenance``. Ein Kommando je ``exec``-Kanal ueber
|
||||
:class:`~tesm.services.shellcli.ShellSession`.
|
||||
|
||||
Die Rechte sind absichtlich verschieden: ``devices.execute`` erlaubt heute
|
||||
einen PoE-Neustart. Wer ein Geraet neu starten darf, soll damit nicht
|
||||
automatisch eine Shell darauf bekommen -- das waere genau die stille
|
||||
Rechteausweitung, die dieses Projekt an anderer Stelle ausschliesst.
|
||||
|
||||
Gemeinsame Sicherheitsmerkmale:
|
||||
|
||||
* Frische Passwortbestaetigung, zusaetzlich zum Recht.
|
||||
* 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
|
||||
keine unbekannten Schluessel (anders als der Vorgaenger, der sie hier
|
||||
interaktiv uebernahm). Freigegeben wird auf der jeweiligen Detailseite.
|
||||
* Jede Sitzung wird vollstaendig mitgeschrieben (Datei unter
|
||||
``<log>/terminal/``) und im Aenderungsprotokoll vermerkt.
|
||||
* Leerlauf-Zeitlimit trennt die Verbindung.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from flask import Blueprint, abort, render_template
|
||||
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, shellcli, ssh, switchcli
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
bp = Blueprint("terminal", __name__)
|
||||
|
||||
MAX_LINE_LENGTH = 400
|
||||
|
||||
KIND_SWITCH = "switch"
|
||||
KIND_DEVICE = "device"
|
||||
|
||||
#: Vorschlaege fuer die Auswahlliste auf einem Linux-Client. Reine
|
||||
#: Lesekommandos -- die Liste ist eine Bequemlichkeit, keine Schranke: das
|
||||
#: Eingabefeld nimmt jedes Kommando an. Eine Positivliste waere hier
|
||||
#: Sicherheitstheater, denn wer eine Shell hat, hat eine Shell.
|
||||
CLIENT_COMMANDS: tuple[tuple[str, str], ...] = (
|
||||
("uptime", "Laufzeit und Last"),
|
||||
("df -h", "Belegung der Dateisysteme"),
|
||||
("free -h", "Arbeitsspeicher"),
|
||||
("ip -brief address", "Netzwerkadressen"),
|
||||
("systemctl --failed", "Fehlgeschlagene Dienste"),
|
||||
("journalctl -p err -n 50 --no-pager", "Letzte Fehler im Journal"),
|
||||
("apt list --upgradable 2>/dev/null", "Verfügbare Aktualisierungen"),
|
||||
("uname -a", "Kernel und Architektur"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Target:
|
||||
"""Alles, was die Konsole ueber ihr Gegenueber wissen muss."""
|
||||
|
||||
kind: str
|
||||
id: int
|
||||
label: str
|
||||
host: str
|
||||
port: int
|
||||
credential_id: int
|
||||
back_endpoint: str
|
||||
back_label: str
|
||||
|
||||
@property
|
||||
def is_switch(self) -> bool:
|
||||
return self.kind == KIND_SWITCH
|
||||
|
||||
@property
|
||||
def audit_prefix(self) -> str:
|
||||
return "switch" if self.is_switch else "device"
|
||||
|
||||
|
||||
def _load_target(conn: Any, kind: str, target_id: int) -> Target | None:
|
||||
if kind == KIND_SWITCH:
|
||||
row = inventory.get_switch(conn, target_id)
|
||||
if row is None:
|
||||
return None
|
||||
return Target(
|
||||
kind=KIND_SWITCH,
|
||||
id=int(row["id"]),
|
||||
label=str(row["hostname"]),
|
||||
host=str(row["ip"]),
|
||||
port=int(row["ssh_port"] or 22),
|
||||
credential_id=int(row["credential_id"] or 0),
|
||||
back_endpoint="switches.detail",
|
||||
back_label="Switche",
|
||||
)
|
||||
row = inventory.get_device(conn, target_id)
|
||||
if row is None or row["deleted_at"]:
|
||||
return None
|
||||
return Target(
|
||||
kind=KIND_DEVICE,
|
||||
id=int(row["id"]),
|
||||
label=str(row["name"]),
|
||||
host=str(row["ip"]),
|
||||
port=int(row["ssh_port"] or 22),
|
||||
credential_id=int(row["credential_id"] or 0),
|
||||
back_endpoint="devices.detail",
|
||||
back_label="Clients",
|
||||
)
|
||||
|
||||
|
||||
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:
|
||||
manager = core().license
|
||||
return manager is None or manager.module_active("maintenance")
|
||||
|
||||
|
||||
def _render_console(target: Target): # type: ignore[no-untyped-def]
|
||||
extension = core()
|
||||
conn = db()
|
||||
if not _konsole_erlaubt(conn, target.kind):
|
||||
abort(404)
|
||||
if not target.credential_id:
|
||||
abort(409, description="Für dieses Ziel sind keine Zugangsdaten hinterlegt.")
|
||||
|
||||
return render_template(
|
||||
"tesm/console.html",
|
||||
target=target,
|
||||
host_key_ready=ssh.is_trusted(conn, target.host, target.port),
|
||||
presets=switchcli.SAFE_COMMANDS if target.is_switch else CLIENT_COMMANDS,
|
||||
idle_timeout=int(extension.settings.get(conn, "terminal_idle_timeout") or 300),
|
||||
websocket_path=f"/ws/{target.kind}/{target.id}",
|
||||
)
|
||||
|
||||
|
||||
@bp.get("/monitoring/switches/<int:switch_id>/console")
|
||||
@permission_required("switch_console.execute")
|
||||
@reauth_required
|
||||
def switch_console(switch_id: int):
|
||||
target = _load_target(db(), KIND_SWITCH, switch_id)
|
||||
if target is None:
|
||||
abort(404)
|
||||
return _render_console(target)
|
||||
|
||||
|
||||
@bp.get("/monitoring/clients/<int:device_id>/console")
|
||||
@permission_required("device_console.execute")
|
||||
@reauth_required
|
||||
def device_console(device_id: int):
|
||||
if not _client_module_active():
|
||||
abort(404)
|
||||
target = _load_target(db(), KIND_DEVICE, device_id)
|
||||
if target is None:
|
||||
abort(404)
|
||||
return _render_console(target)
|
||||
|
||||
|
||||
def init_app(app) -> None: # type: ignore[no-untyped-def]
|
||||
"""Meldet die HTTP-Routen und -- falls verfuegbar -- die WebSockets an."""
|
||||
app.register_blueprint(bp)
|
||||
|
||||
try:
|
||||
from flask_sock import Sock
|
||||
except ImportError: # pragma: no cover - optionale Abhaengigkeit
|
||||
logger.info("flask-sock ist nicht installiert – die Konsole bleibt deaktiviert.")
|
||||
return
|
||||
|
||||
sock = Sock(app)
|
||||
app.config.setdefault("SOCK_SERVER_OPTIONS", {"ping_interval": 25})
|
||||
|
||||
@sock.route("/ws/switch/<int:target_id>")
|
||||
def switch_socket(ws, target_id: int): # type: ignore[no-untyped-def]
|
||||
_handle_socket(ws, KIND_SWITCH, target_id)
|
||||
|
||||
@sock.route("/ws/device/<int:target_id>")
|
||||
def device_socket(ws, target_id: int): # type: ignore[no-untyped-def]
|
||||
_handle_socket(ws, KIND_DEVICE, target_id)
|
||||
|
||||
|
||||
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}))
|
||||
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:
|
||||
"""Dateiname aus dem Ziel -- ohne alles, was einen Pfad ergeben koennte."""
|
||||
return re.sub(r"[^A-Za-z0-9._-]", "_", label)[:60] or "unbenannt"
|
||||
|
||||
|
||||
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()
|
||||
conn = db()
|
||||
_send(ws, "info", "Sitzung wird geöffnet …")
|
||||
|
||||
# -- Berechtigungen: dieselben Pruefungen wie in der HTTP-Route. Ein
|
||||
# WebSocket umgeht Dekoratoren, also stehen sie hier ausdruecklich.
|
||||
required = (
|
||||
"switch_console.execute" if kind == KIND_SWITCH else "device_console.execute"
|
||||
)
|
||||
if not current_user.is_authenticated or not current_user.has(required):
|
||||
_send(ws, "error", "Keine Berechtigung für diese Konsole.")
|
||||
return
|
||||
if not reauth_is_fresh():
|
||||
_send(ws, "error", "Die Passwortbestätigung ist abgelaufen. Bitte Seite neu laden.")
|
||||
return
|
||||
if not _konsole_erlaubt(conn, kind):
|
||||
_send(ws, "error", "Die Konsole ist in dieser Installation abgeschaltet.")
|
||||
return
|
||||
if kind == KIND_DEVICE and not _client_module_active():
|
||||
_send(ws, "error", "Die Client-Konsole gehört zum Modul Wartung – es ist nicht aktiv.")
|
||||
return
|
||||
|
||||
target = _load_target(conn, kind, target_id)
|
||||
if target is None or not target.credential_id:
|
||||
_send(ws, "error", "Ziel oder Zugangsdaten nicht gefunden.")
|
||||
return
|
||||
|
||||
idle_timeout = int(extension.settings.get(conn, "terminal_idle_timeout") or 300)
|
||||
transcript_dir = extension.config.log_dir / "terminal"
|
||||
transcript_dir.mkdir(parents=True, exist_ok=True)
|
||||
stamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
|
||||
transcript_path = (
|
||||
transcript_dir
|
||||
/ f"{stamp}-{_transcript_name(current_user.username)}-{_transcript_name(target.label)}.log"
|
||||
)
|
||||
|
||||
_send(ws, "info", f"Ziel: {target.label} ({target.host}:{target.port})")
|
||||
try:
|
||||
username, password = inventory.credential_secret(
|
||||
conn, extension.keystore, target.credential_id
|
||||
)
|
||||
except inventory.ValidationError as exc:
|
||||
_send(ws, "error", str(exc))
|
||||
return
|
||||
_send(ws, "info", f"Anmeldung als {username} …")
|
||||
|
||||
with extension.database.transaction(conn):
|
||||
audit.log(
|
||||
conn,
|
||||
actor=current_user.username,
|
||||
actor_id=current_user.id,
|
||||
action=f"{target.audit_prefix}.console_opened",
|
||||
target=target.label,
|
||||
detail=f"Mitschrift: {transcript_path.name}",
|
||||
ip=client_ip(),
|
||||
severity="warning",
|
||||
)
|
||||
|
||||
commands = 0
|
||||
try:
|
||||
with transcript_path.open("a", encoding="utf-8", newline="\n") as transcript:
|
||||
transcript.write(
|
||||
f"# Sitzung {stamp} von {current_user.username} auf {target.label} "
|
||||
f"({target.host}:{target.port})\n"
|
||||
)
|
||||
if target.is_switch:
|
||||
commands = _run_switch_session(
|
||||
ws, conn, target, username, password, transcript, idle_timeout
|
||||
)
|
||||
else:
|
||||
commands = _run_client_session(
|
||||
ws, conn, target, username, password, transcript, idle_timeout
|
||||
)
|
||||
except ssh.UnknownHostKey as exc:
|
||||
page = "Switch-Seite" if target.is_switch else "Client-Seite"
|
||||
_send(
|
||||
ws,
|
||||
"error",
|
||||
f"{exc} Die Konsole akzeptiert keine unbekannten Schlüssel – bitte auf der "
|
||||
f"{page} ausdrücklich freigeben.",
|
||||
)
|
||||
except ssh.SshError as exc:
|
||||
_send(ws, "error", str(exc))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.exception("Konsolensitzung abgebrochen")
|
||||
_send(ws, "error", f"Unerwarteter Fehler: {exc}")
|
||||
finally:
|
||||
with extension.database.session() as fresh, extension.database.transaction(fresh):
|
||||
audit.log(
|
||||
fresh,
|
||||
actor=current_user.username,
|
||||
actor_id=current_user.id,
|
||||
action=f"{target.audit_prefix}.console_closed",
|
||||
target=target.label,
|
||||
detail=f"{commands} Kommandos, Mitschrift {transcript_path.name}",
|
||||
ip=client_ip(),
|
||||
severity="notice",
|
||||
)
|
||||
|
||||
|
||||
def _read_command(ws, idle_timeout: int) -> str | None: # type: ignore[no-untyped-def]
|
||||
"""Naechstes Kommando, oder ``None`` wenn die Sitzung enden soll."""
|
||||
raw = ws.receive(timeout=idle_timeout)
|
||||
if raw is None:
|
||||
_send(ws, "info", "Verbindung wegen Untätigkeit getrennt.")
|
||||
return None
|
||||
try:
|
||||
message = json.loads(raw)
|
||||
except ValueError:
|
||||
return ""
|
||||
command = str(message.get("command") or "").strip()
|
||||
if not command:
|
||||
return ""
|
||||
if len(command) > MAX_LINE_LENGTH:
|
||||
_send(ws, "error", "Die Zeile ist zu lang.")
|
||||
return ""
|
||||
if command.lower() in {"exit", "quit", "logout"}:
|
||||
_send(ws, "info", "Sitzung beendet.")
|
||||
return None
|
||||
return command
|
||||
|
||||
|
||||
def _run_switch_session( # type: ignore[no-untyped-def]
|
||||
ws, conn, target: Target, username: str, password: str, transcript, idle_timeout: int
|
||||
) -> int:
|
||||
commands = 0
|
||||
_send(ws, "info", "Warte auf die Eingabeaufforderung des Switches …")
|
||||
with switchcli.SwitchCli(
|
||||
conn, host=target.host, port=target.port, username=username, password=password
|
||||
) as cli:
|
||||
_send(ws, "ready", f"Verbunden mit {target.label} ({target.host}).")
|
||||
_send(ws, "output", cli.full_transcript)
|
||||
while True:
|
||||
command = _read_command(ws, idle_timeout)
|
||||
if command is None:
|
||||
break
|
||||
if not command:
|
||||
continue
|
||||
transcript.write(f"$ {command}\n")
|
||||
started = time.monotonic()
|
||||
try:
|
||||
output = cli.send(command, tolerate_error=True)
|
||||
except ssh.SshError as exc:
|
||||
_send(ws, "error", str(exc))
|
||||
transcript.write(f"! {exc}\n")
|
||||
break
|
||||
duration = int((time.monotonic() - started) * 1000)
|
||||
transcript.write(output + "\n")
|
||||
transcript.flush()
|
||||
commands += 1
|
||||
_send(ws, "output", output)
|
||||
_send(ws, "done", f"{duration} ms")
|
||||
return commands
|
||||
|
||||
|
||||
def _run_client_session( # type: ignore[no-untyped-def]
|
||||
ws, conn, target: Target, username: str, password: str, transcript, idle_timeout: int
|
||||
) -> int:
|
||||
commands = 0
|
||||
_send(ws, "info", "Verbindung wird aufgebaut …")
|
||||
with shellcli.ShellSession(
|
||||
conn, host=target.host, port=target.port, username=username, password=password
|
||||
) as session:
|
||||
_send(ws, "ready", f"Verbunden mit {target.label} ({target.host}) als {username}.")
|
||||
banner = session.banner()
|
||||
if banner:
|
||||
_send(ws, "output", banner)
|
||||
transcript.write(banner + "\n")
|
||||
while True:
|
||||
command = _read_command(ws, idle_timeout)
|
||||
if command is None:
|
||||
break
|
||||
if not command:
|
||||
continue
|
||||
transcript.write(f"$ {command}\n")
|
||||
try:
|
||||
result = session.run(command)
|
||||
except ssh.SshError as exc:
|
||||
_send(ws, "error", str(exc))
|
||||
transcript.write(f"! {exc}\n")
|
||||
break
|
||||
transcript.write(result["output"] + "\n")
|
||||
transcript.flush()
|
||||
commands += 1
|
||||
if result["output"]:
|
||||
_send(ws, "output", result["output"])
|
||||
# Der Rueckgabewert ist auf einem Client die eigentliche Antwort:
|
||||
# ein Kommando ohne Ausgabe kann trotzdem fehlgeschlagen sein.
|
||||
if result["status"] == 0:
|
||||
_send(ws, "done", f"{result['duration_ms']} ms")
|
||||
else:
|
||||
_send(
|
||||
ws,
|
||||
"error",
|
||||
f"Rückgabewert {result['status']} nach {result['duration_ms']} ms.",
|
||||
)
|
||||
return commands
|
||||
@@ -0,0 +1,658 @@
|
||||
"""Kommandozeilenwerkzeuge: Administrator anlegen, Zustand pruefen, Wartung.
|
||||
|
||||
Ersetzt ``create_db.py``/``create_admin.py`` des Vorgaengers. Die Datenbank wird
|
||||
nicht mehr von einem eigenen Skript erzeugt -- das Schema entsteht beim ersten
|
||||
Start durch die Migrationen.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import getpass
|
||||
import json
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
from tesm_core import audit
|
||||
from tesm_core.cli_web import add_web_setup, run_web_setup
|
||||
from tesm_core.db import Database
|
||||
from tesm_core.extension import core
|
||||
from tesm_core.rbac import service as rbac
|
||||
from tesm_core.security import passwords
|
||||
|
||||
from . import create_app
|
||||
|
||||
|
||||
def _app_and_core() -> tuple[Any, Any]:
|
||||
app = create_app()
|
||||
return app, core(app)
|
||||
|
||||
|
||||
def cmd_create_admin(args: argparse.Namespace) -> int:
|
||||
app, extension = _app_and_core()
|
||||
username = args.username or input("Benutzername: ").strip()
|
||||
if not username:
|
||||
print("Kein Benutzername angegeben.", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
password = args.password
|
||||
if not password:
|
||||
password = getpass.getpass("Passwort: ")
|
||||
if password != getpass.getpass("Passwort wiederholen: "):
|
||||
print("Die Passwörter stimmen nicht überein.", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
problems = extension.password_policy.check(password, username=username)
|
||||
if problems and not args.force:
|
||||
for problem in problems:
|
||||
print(f"- {problem}", file=sys.stderr)
|
||||
print("Mit --force trotzdem setzen.", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
with app.app_context(), extension.database.session() as conn:
|
||||
existing = rbac.find_user_by_username(conn, username, include_deleted=True)
|
||||
with extension.database.transaction(conn):
|
||||
if existing is not None:
|
||||
conn.execute(
|
||||
"UPDATE users SET password_hash=?, is_admin=1, is_locked=0, deleted_at=NULL, "
|
||||
"must_change_password=0, updated_at=datetime('now') WHERE id=?",
|
||||
(passwords.hash_password(password), existing["id"]),
|
||||
)
|
||||
action = "aktualisiert"
|
||||
user_id = int(existing["id"])
|
||||
else:
|
||||
cursor = conn.execute(
|
||||
"INSERT INTO users (username, password_hash, is_admin, auth_source, "
|
||||
"created_at, updated_at, password_changed_at) "
|
||||
"VALUES (?,?,1,'local',datetime('now'),datetime('now'),datetime('now'))",
|
||||
(username, passwords.hash_password(password)),
|
||||
)
|
||||
user_id = int(cursor.lastrowid or 0)
|
||||
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(
|
||||
conn,
|
||||
actor="cli",
|
||||
actor_id=user_id,
|
||||
action="user.admin_created",
|
||||
target=username,
|
||||
detail=(
|
||||
f"über die Kommandozeile {action}, Gruppe "
|
||||
f"{FUNKTIONEN_VOLLZUGRIFF.name} zugewiesen"
|
||||
),
|
||||
severity="warning",
|
||||
)
|
||||
print(f"Administrator {username!r} {action}.")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_status(args: argparse.Namespace) -> int:
|
||||
app, extension = _app_and_core()
|
||||
with app.app_context(), extension.database.session() as conn:
|
||||
chain = audit.verify_chain(conn, limit=5000)
|
||||
counts = {
|
||||
"benutzer": Database.value(
|
||||
conn, "SELECT COUNT(*) FROM users WHERE deleted_at IS NULL", default=0
|
||||
),
|
||||
"administratoren": rbac.count_active_admins(conn),
|
||||
"gruppen": Database.value(
|
||||
conn, "SELECT COUNT(*) FROM groups WHERE deleted_at IS NULL", default=0
|
||||
),
|
||||
"geraete": Database.value(
|
||||
conn, "SELECT COUNT(*) FROM devices WHERE deleted_at IS NULL", default=0
|
||||
),
|
||||
"switche": Database.value(
|
||||
conn, "SELECT COUNT(*) FROM switches WHERE deleted_at IS NULL", default=0
|
||||
),
|
||||
"protokollzeilen": audit.count(conn),
|
||||
}
|
||||
license_state = None
|
||||
if extension.license is not None:
|
||||
state = extension.license.reload(conn)
|
||||
license_state = {
|
||||
"vorhanden": state.present,
|
||||
"gueltig": state.valid,
|
||||
"aktiviert": state.activated,
|
||||
"rolle": state.role,
|
||||
"module": sorted(state.modules),
|
||||
"fehler": state.error,
|
||||
}
|
||||
|
||||
payload = {
|
||||
"datenbank": str(extension.config.db_path),
|
||||
"protokolle": str(extension.config.log_dir),
|
||||
"migrationen": extension.migration_status(),
|
||||
"audit_kette": chain,
|
||||
"bestand": counts,
|
||||
"lizenz": license_state,
|
||||
"systemhelfer": bool(extension.sysops and extension.sysops.available),
|
||||
}
|
||||
if args.json:
|
||||
print(json.dumps(payload, indent=2, ensure_ascii=False, default=str))
|
||||
return 0
|
||||
|
||||
print(f"Datenbank : {payload['datenbank']}")
|
||||
print(f"Protokolle : {payload['protokolle']}")
|
||||
print(f"Systemhelfer : {'verfuegbar' if payload['systemhelfer'] else 'nicht verfuegbar'}")
|
||||
print(
|
||||
"Audit-Kette : "
|
||||
+ ("unversehrt" if chain["ok"] else f"GEBROCHEN bei Eintrag {chain['broken_at']}")
|
||||
+ f" ({chain['checked']} geprüft)"
|
||||
)
|
||||
for key, value in counts.items():
|
||||
print(f"{key.capitalize():<15}: {value}")
|
||||
if license_state:
|
||||
print(
|
||||
"Lizenz : "
|
||||
+ ("aktiv" if license_state["aktiviert"] and license_state["gueltig"] else "nicht aktiv")
|
||||
+ f", Rolle {license_state['rolle']}, Module {', '.join(license_state['module']) or '-'}"
|
||||
)
|
||||
if license_state["fehler"]:
|
||||
print(f"Lizenzfehler : {license_state['fehler']}")
|
||||
if not counts["administratoren"]:
|
||||
print("\nWARNUNG: es existiert kein entsperrtes Administratorkonto.", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_housekeeping(_args: argparse.Namespace) -> int:
|
||||
app, extension = _app_and_core()
|
||||
from tesm_core.security import ratelimit
|
||||
from tesm_core.security import sessions as session_store
|
||||
|
||||
with app.app_context(), extension.database.session() as conn:
|
||||
with extension.database.transaction(conn):
|
||||
sessions = session_store.prune(conn)
|
||||
events = ratelimit.prune(conn)
|
||||
archived = audit.archive_old_rows(conn, extension.audit_archive_dir)
|
||||
print(
|
||||
f"{sessions} Sitzungen entfernt, {events} Ratenereignisse entfernt, "
|
||||
f"{len(archived)} Protokolltage archiviert."
|
||||
)
|
||||
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:
|
||||
app, extension = _app_and_core()
|
||||
from .services import inventory, monitor, vnc
|
||||
|
||||
with app.app_context():
|
||||
allowed = extension.license is None or extension.license.feature_allowed(
|
||||
"auto_poe_restart"
|
||||
)
|
||||
report = monitor.run_sweep(
|
||||
database=extension.database,
|
||||
live=extension.registry["live_log"],
|
||||
keystore=extension.keystore,
|
||||
license_allows_auto_restart=allowed,
|
||||
vnc_port=vnc.port_aus_einstellungen(extension),
|
||||
)
|
||||
print(
|
||||
f"{report.checked} geprüft, {report.online} online, {report.offline} offline, "
|
||||
f"{report.restarts_triggered} Neustarts ({report.restarts_ok} erfolgreich), "
|
||||
f"{report.duration_ms} ms"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_web_setup(args: argparse.Namespace) -> int:
|
||||
app, extension = _app_and_core()
|
||||
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:
|
||||
"""Uebersetzt einen Export des alten TESM in das neue Format.
|
||||
|
||||
Bewusst ohne Datenbankzugriff: heraus kommt eine Datei, die ueber den
|
||||
vorhandenen Import eingelesen wird. So sieht man vorher, was ankommt, und
|
||||
es gibt nur einen Schreibpfad in die Datenbank.
|
||||
"""
|
||||
from getpass import getpass
|
||||
from pathlib import Path
|
||||
|
||||
from .services import legacy_transfer as legacy
|
||||
from .services import transfer
|
||||
|
||||
quelle = Path(args.datei)
|
||||
if not quelle.is_file():
|
||||
print(f"Datei nicht gefunden: {quelle}")
|
||||
return 2
|
||||
ziel = Path(args.ausgabe) if args.ausgabe else quelle.with_name(f"{quelle.stem}-neu.json")
|
||||
if ziel.exists() and not args.force:
|
||||
print(f"{ziel} existiert bereits. Mit --force überschreiben.")
|
||||
return 2
|
||||
|
||||
alt_pass = args.passphrase or getpass("Passphrase der alten Datei: ")
|
||||
try:
|
||||
rohdaten = legacy.open_legacy(quelle.read_bytes(), alt_pass)
|
||||
nutzlast, bericht = legacy.convert(rohdaten)
|
||||
except legacy.LegacyTransferError as exc:
|
||||
print(f"Fehlgeschlagen: {exc}")
|
||||
return 1
|
||||
|
||||
print(bericht.as_text())
|
||||
if args.dry_run:
|
||||
print()
|
||||
print("--dry-run: keine Datei geschrieben.")
|
||||
return 0
|
||||
|
||||
neu_pass = args.neue_passphrase or getpass("Passphrase für die neue Datei: ")
|
||||
try:
|
||||
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
|
||||
print(f"Schreiben fehlgeschlagen: {exc}")
|
||||
return 1
|
||||
print()
|
||||
print(f"Geschrieben: {ziel}")
|
||||
print("Einlesen unter Verwaltung -> Sicherung mit derselben Passphrase.")
|
||||
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:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="tesm-admin", description="Verwaltungswerkzeuge für TESM"
|
||||
)
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
admin = sub.add_parser("create-admin", help="Administratorkonto anlegen oder zurücksetzen")
|
||||
admin.add_argument("--username")
|
||||
admin.add_argument("--password", help="Nur für automatisierte Installationen")
|
||||
admin.add_argument("--force", action="store_true", help="Passwortregeln ignorieren")
|
||||
admin.set_defaults(func=cmd_create_admin)
|
||||
|
||||
status = sub.add_parser("status", help="Zustand der Installation ausgeben")
|
||||
status.add_argument("--json", action="store_true")
|
||||
status.set_defaults(func=cmd_status)
|
||||
|
||||
house = sub.add_parser("housekeeping", help="Sitzungen, Ratenereignisse, Protokolle aufräumen")
|
||||
house.set_defaults(func=cmd_housekeeping)
|
||||
|
||||
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)
|
||||
|
||||
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(
|
||||
"convert-legacy",
|
||||
help="Export des alten TESM in das neue Format übersetzen",
|
||||
description=(
|
||||
"Liest eine Exportdatei des Vorgängers und schreibt eine Datei im "
|
||||
"Format dieser Anwendung. Übersetzt werden Clients, Switche, "
|
||||
"Zugangsdaten und die DHCP-Konfiguration."
|
||||
),
|
||||
)
|
||||
legacy.add_argument("datei", help="Exportdatei des alten TESM")
|
||||
legacy.add_argument("--ausgabe", help="Zieldatei (Vorgabe: <name>-neu.json)")
|
||||
legacy.add_argument(
|
||||
"--passphrase", help="Passphrase der alten Datei (sonst wird gefragt)"
|
||||
)
|
||||
legacy.add_argument(
|
||||
"--neue-passphrase", dest="neue_passphrase", help="Passphrase der neuen Datei"
|
||||
)
|
||||
legacy.add_argument(
|
||||
"--dry-run", action="store_true", help="Nur den Bericht zeigen, nichts schreiben"
|
||||
)
|
||||
legacy.add_argument("--force", action="store_true", help="Zieldatei überschreiben")
|
||||
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)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
return int(args.func(args))
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,247 @@
|
||||
"""Navigationsbaum von TESM.
|
||||
|
||||
Sichtbarkeit steht am Eintrag selbst -- nicht in einer zentralen if/elif-Kette
|
||||
wie im Vorgaenger. Lizenzpflichtige Vollmodule verschwinden vollstaendig aus der
|
||||
Navigation; die zugehoerigen Routen pruefen dasselbe zusaetzlich serverseitig
|
||||
(ein direkter Aufruf per URL wird ebenso abgewiesen).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from tesm_core.extension import core
|
||||
from tesm_core.nav import NavItem, NavTree
|
||||
|
||||
from .permissions import DHCP_PAGE, SERVICING_PAGE
|
||||
|
||||
|
||||
def _module(name: str):
|
||||
"""Bedingung: Vollmodul lizenziert?"""
|
||||
|
||||
def check(_user: Any) -> bool:
|
||||
manager = core().license
|
||||
return manager is None or manager.module_active(name)
|
||||
|
||||
return check
|
||||
|
||||
|
||||
def _has_fileshare_mounts(user: Any) -> bool:
|
||||
"""Dateifreigaben erscheinen, wenn es etwas zu sehen oder zu tun gibt.
|
||||
|
||||
Zu sehen: eingebundene Freigaben dieses Benutzers. Zu tun: die Verwaltung
|
||||
der Zuordnungen. Der zweite Fall fehlte -- damit war die einzige Seite, auf
|
||||
der Zuordnungen entstehen, nur ueber die Adresszeile erreichbar, und wer sie
|
||||
nicht kannte, hielt die Funktion fuer nicht vorhanden.
|
||||
"""
|
||||
if not user.is_authenticated:
|
||||
return False
|
||||
if not _module("fileshare")(user):
|
||||
return False
|
||||
if user.has("fileshare.write"):
|
||||
return True
|
||||
from .services import fileshare
|
||||
|
||||
return fileshare.has_mounts(user.id)
|
||||
|
||||
|
||||
NAVIGATION = NavTree(
|
||||
[
|
||||
NavItem(key="index", label="Statusübersicht", icon="grid", endpoint="dashboard.index"),
|
||||
NavItem(
|
||||
key="monitoring",
|
||||
label="Überwachung",
|
||||
icon="cpu",
|
||||
children=(
|
||||
NavItem(
|
||||
key="devices",
|
||||
label="Clients",
|
||||
icon="cpu",
|
||||
endpoint="devices.overview",
|
||||
permissions=("devices.read",),
|
||||
),
|
||||
NavItem(
|
||||
key="switches",
|
||||
label="Switche",
|
||||
icon="switch",
|
||||
endpoint="switches.overview",
|
||||
permissions=("switches.read",),
|
||||
),
|
||||
NavItem(
|
||||
key="credentials",
|
||||
label="Zugangsdaten",
|
||||
icon="key",
|
||||
endpoint="credentials.overview",
|
||||
permissions=("credentials.read",),
|
||||
),
|
||||
NavItem(
|
||||
key="hostkeys",
|
||||
label="Host-Schlüssel",
|
||||
icon="shield",
|
||||
endpoint="hostkeys.overview",
|
||||
permissions=("devices.read", "switches.read"),
|
||||
),
|
||||
),
|
||||
),
|
||||
NavItem(
|
||||
key="maintenance",
|
||||
label="Wartung",
|
||||
icon="wrench",
|
||||
endpoint="maintenance.overview",
|
||||
permissions=SERVICING_PAGE,
|
||||
condition=_module("maintenance"),
|
||||
),
|
||||
NavItem(
|
||||
key="dhcp",
|
||||
label="DHCP-Server",
|
||||
icon="network",
|
||||
endpoint="dhcp.overview",
|
||||
permissions=DHCP_PAGE,
|
||||
condition=_module("dhcp"),
|
||||
),
|
||||
NavItem(
|
||||
key="fileshare",
|
||||
label="Dateifreigaben",
|
||||
icon="folder",
|
||||
condition=_has_fileshare_mounts,
|
||||
children=(
|
||||
NavItem(
|
||||
key="fileshare_browse",
|
||||
label="Meine Freigaben",
|
||||
icon="folder",
|
||||
endpoint="fileshare.browse",
|
||||
permissions=("fileshare.read",),
|
||||
),
|
||||
NavItem(
|
||||
key="fileshare_mappings",
|
||||
label="Zuordnungen",
|
||||
icon="link",
|
||||
endpoint="fileshare.mappings",
|
||||
permissions=("fileshare.write",),
|
||||
),
|
||||
),
|
||||
),
|
||||
NavItem(
|
||||
key="observability",
|
||||
label="Protokolle",
|
||||
icon="terminal",
|
||||
children=(
|
||||
NavItem(
|
||||
key="logs_live",
|
||||
label="Laufend",
|
||||
icon="terminal",
|
||||
endpoint="logs.live",
|
||||
permissions=("logs_live.read",),
|
||||
),
|
||||
NavItem(
|
||||
key="logs_history",
|
||||
label="Verlauf",
|
||||
icon="clock",
|
||||
endpoint="logs.history",
|
||||
permissions=("logs_history.read", "logs_history.export"),
|
||||
),
|
||||
NavItem(
|
||||
key="logs_audit",
|
||||
label="Audit",
|
||||
icon="history",
|
||||
endpoint="logs.audit_view",
|
||||
permissions=("logs_audit.read", "logs_audit.export"),
|
||||
),
|
||||
NavItem(
|
||||
key="logs_dhcp",
|
||||
label="DHCP",
|
||||
icon="network",
|
||||
endpoint="tesmlogs.dhcp",
|
||||
permissions=("logs_dhcp.read",),
|
||||
condition=_module("dhcp"),
|
||||
),
|
||||
NavItem(
|
||||
key="restarts",
|
||||
label="Neustarts",
|
||||
icon="power",
|
||||
endpoint="tesmlogs.restarts",
|
||||
permissions=("devices.read",),
|
||||
),
|
||||
),
|
||||
),
|
||||
NavItem(
|
||||
key="administration",
|
||||
label="Verwaltung",
|
||||
icon="sliders",
|
||||
children=(
|
||||
NavItem(
|
||||
key="users",
|
||||
label="Benutzer",
|
||||
icon="users",
|
||||
endpoint="admin.users",
|
||||
permissions=("users.read",),
|
||||
),
|
||||
NavItem(
|
||||
key="groups",
|
||||
label="Gruppen und Rechte",
|
||||
icon="shield-users",
|
||||
endpoint="admin.groups",
|
||||
permissions=("groups.read",),
|
||||
),
|
||||
NavItem(
|
||||
key="settings_system",
|
||||
label="System",
|
||||
icon="sliders",
|
||||
endpoint="settings.system",
|
||||
permissions=("settings_system.read",),
|
||||
),
|
||||
NavItem(
|
||||
key="settings_network",
|
||||
label="Netzwerk",
|
||||
icon="network",
|
||||
endpoint="hostsettings.network",
|
||||
permissions=("settings_network.read",),
|
||||
),
|
||||
NavItem(
|
||||
key="settings_web",
|
||||
label="Webserver und TLS",
|
||||
icon="server",
|
||||
endpoint="hostsettings.web",
|
||||
permissions=("settings_web.read",),
|
||||
),
|
||||
NavItem(
|
||||
key="settings_directory",
|
||||
label="Verzeichnisdienst",
|
||||
icon="users",
|
||||
endpoint="hostsettings.directory_settings",
|
||||
permissions=("settings_directory.read",),
|
||||
),
|
||||
NavItem(
|
||||
key="settings_license",
|
||||
label="Lizenz",
|
||||
icon="key",
|
||||
endpoint="license.overview",
|
||||
# Bewusst immer erreichbar, auch ohne gueltige Lizenz --
|
||||
# sonst liesse sich eine neue Lizenz nie einspielen.
|
||||
permissions=("settings_license.read",),
|
||||
),
|
||||
NavItem(
|
||||
key="backup",
|
||||
label="Sicherung",
|
||||
icon="transfer",
|
||||
endpoint="backup.overview",
|
||||
permissions=("backup_export.read", "backup_import.read"),
|
||||
),
|
||||
NavItem(
|
||||
key="trash",
|
||||
label="Papierkorb",
|
||||
icon="trash",
|
||||
endpoint="trash.overview",
|
||||
permissions=("trash.read",),
|
||||
),
|
||||
NavItem(
|
||||
key="diagnostics",
|
||||
label="Diagnose",
|
||||
icon="info",
|
||||
endpoint="diagnostics.overview",
|
||||
permissions=("diagnostics.read",),
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
)
|
||||
@@ -0,0 +1,462 @@
|
||||
"""Rechtematrix von TESM.
|
||||
|
||||
Bewusste Aenderungen gegenueber dem Vorgaenger (dort: R/W/E/D/X mit
|
||||
bereichsabhaengiger Bedeutung):
|
||||
|
||||
* **Loeschen ist ein eigenes Recht.** Vorher deckte "Aendern" das Loeschen mit
|
||||
ab -- wer Geraete pflegen sollte, durfte sie zwangslaeufig auch entfernen.
|
||||
* **Jede Spalte bedeutet ueberall dasselbe.** "Ausfuehren" ist immer "eine
|
||||
Aktion ausloesen", nicht mal Neustart und mal Export.
|
||||
* **Geheimnisse sind ein eigenes Recht.** Wer Zugangsdaten *pflegen* darf, muss
|
||||
sie nicht im Klartext *sehen* duerfen. Zusaetzlich verlangt jede Anzeige eine
|
||||
frische Passwortbestaetigung.
|
||||
* **Jede Einstellungsseite hat ihr eigenes Rechtepaar** -- im Vorgaenger lief
|
||||
die Lizenzseite ueber ``settings_system`` mit, was sich nicht getrennt
|
||||
vergeben liess.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from tesm_core.rbac.common import (
|
||||
LOGS_HISTORY,
|
||||
LOGS_LIVE,
|
||||
administration_area,
|
||||
observability_area,
|
||||
)
|
||||
from tesm_core.rbac.model import Action, Area, PermissionTree, Resource, RolePreset
|
||||
|
||||
A = Action
|
||||
|
||||
DEVICES = Resource(
|
||||
key="devices",
|
||||
label="Clients",
|
||||
actions=(A.READ, A.CREATE, A.WRITE, A.DELETE),
|
||||
hint="Stammdaten überwachter PoE-Endgeräte: Name, MAC, IP, Switch und Port",
|
||||
action_hints={
|
||||
A.DELETE: "Gerät 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(
|
||||
key="switches",
|
||||
label="Switche",
|
||||
actions=(A.READ, A.CREATE, A.WRITE, A.DELETE),
|
||||
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={
|
||||
A.EXECUTE: "Sitzung öffnen. Verlangt eine frische Passwortbestätigung; "
|
||||
"jede Sitzung wird vollständig mitgeschrieben.",
|
||||
},
|
||||
)
|
||||
|
||||
#: 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(
|
||||
key="credentials",
|
||||
label="Zugangsdaten",
|
||||
actions=(A.READ, A.CREATE, A.WRITE, A.DELETE, A.SECRETS),
|
||||
hint="Wiederverwendbare SSH-Logins",
|
||||
action_hints={
|
||||
A.READ: "Namen und Benutzer sehen – nicht das Passwort.",
|
||||
A.SECRETS: "Passwort im Klartext anzeigen. Verlangt eine frische "
|
||||
"Passwortbestätigung und wird protokolliert.",
|
||||
},
|
||||
)
|
||||
|
||||
#: Aus einer Zeile ("Aktualisierung und Neustart") wurden drei. Sie deckte
|
||||
#: vier Funktionen ab: Pakete aktualisieren, reihenweise neu starten, das
|
||||
#: Windows-Werkzeug installieren -- und die Client-Konsole, die jetzt bei den
|
||||
#: Clients steht, wo sie hingehoert.
|
||||
SERVICING_UPDATE = Resource(
|
||||
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."},
|
||||
)
|
||||
|
||||
SERVICING_REBOOT = Resource(
|
||||
key="servicing_reboot",
|
||||
label="Neustart als Auftrag",
|
||||
actions=(A.READ, A.EXECUTE),
|
||||
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={
|
||||
A.EXECUTE: "Schreiben und neu laden. Bis dahin ändert sich am laufenden "
|
||||
"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(
|
||||
key="fileshare",
|
||||
label="Dateifreigaben",
|
||||
actions=(A.READ, A.CREATE, A.WRITE, A.DELETE, A.EXPORT),
|
||||
hint="SMB-Freigaben aus dem Verzeichnisdienst (lizenzpflichtiges Modul)",
|
||||
action_hints={
|
||||
A.CREATE: "Ordner anlegen und Dateien hochladen.",
|
||||
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(
|
||||
key="logs_dhcp",
|
||||
label="DHCP-Protokoll",
|
||||
actions=(A.READ,),
|
||||
)
|
||||
|
||||
MONITORING_AREA = Area(
|
||||
key="monitoring",
|
||||
label="Überwachung",
|
||||
icon="cpu",
|
||||
hint="Geräte, Switche und Zugangsdaten. Ohne diesen Zugang bleibt der "
|
||||
"gesamte Bereich verborgen.",
|
||||
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(
|
||||
key="network",
|
||||
label="Netzwerkdienste",
|
||||
icon="network",
|
||||
resources=(DHCP_SUBNETS, DHCP_OPTIONS, DHCP_RESERVATIONS, DHCP_CONFIG, DHCP_SERVICE),
|
||||
)
|
||||
|
||||
FILES_AREA = Area(
|
||||
key="files",
|
||||
label="Dateifreigaben",
|
||||
icon="folder",
|
||||
resources=(FILESHARE, FILESHARE_PDF, FILESHARE_SIGNATURE, FILESHARE_MAPPINGS),
|
||||
)
|
||||
|
||||
PERMISSIONS = PermissionTree(
|
||||
[
|
||||
MONITORING_AREA,
|
||||
MAINTENANCE_AREA,
|
||||
NETWORK_AREA,
|
||||
FILES_AREA,
|
||||
observability_area(LOGS_DHCP),
|
||||
administration_area(),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
#: 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, ...] = (
|
||||
RolePreset(
|
||||
key="viewer",
|
||||
name="Benutzer",
|
||||
description="Standardgruppe: Geräte und Switche ansehen, laufendes Protokoll lesen.",
|
||||
grants=(
|
||||
("devices", (A.READ,)),
|
||||
("switches", (A.READ,)),
|
||||
("logs_live", (A.READ,)),
|
||||
),
|
||||
is_default=True,
|
||||
),
|
||||
RolePreset(
|
||||
key="administration",
|
||||
name="Administration",
|
||||
description="Benutzer, Gruppen und Einstellungen – ohne Admin-Kennzeichen.",
|
||||
grants=(
|
||||
("users", "*"),
|
||||
("groups", "*"),
|
||||
("settings_system", "*"),
|
||||
("settings_nav", "*"),
|
||||
("settings_license", "*"),
|
||||
("settings_directory", (A.READ, A.WRITE, A.EXECUTE)),
|
||||
("settings_web", "*"),
|
||||
("settings_network", "*"),
|
||||
("backup_export", (A.READ, A.EXPORT)),
|
||||
("backup_import", "*"),
|
||||
("trash", "*"),
|
||||
("diagnostics", (A.READ,)),
|
||||
("logs_audit", (A.READ,)),
|
||||
),
|
||||
),
|
||||
FUNKTIONEN_VOLLZUGRIFF,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"PERMISSIONS",
|
||||
"ROLE_PRESETS",
|
||||
"LOGS_DHCP",
|
||||
"SERVICING_PAGE",
|
||||
"DHCP_PAGE",
|
||||
]
|
||||
@@ -0,0 +1,588 @@
|
||||
"""Migrationen der Komponente ``tesm``.
|
||||
|
||||
Frische Zeitlinie ab Version 1 -- kein Nachbau der 35 gewachsenen
|
||||
``ALTER TABLE``-Schritte des Vorgaengers. Inhaltliche Aenderungen am Datenmodell:
|
||||
|
||||
* **Kuenstliche Schluessel.** Switche hatten den Hostnamen als Primaerschluessel,
|
||||
Geraete die MAC. Ein Umbenennen bzw. ein Tausch der Netzwerkkarte zog damit
|
||||
jede Referenz nach sich. Jetzt: ``id`` als Schluessel, Hostname/MAC bleiben
|
||||
eindeutig -- aber nur unter den nicht geloeschten Zeilen (Teilindex), damit ein
|
||||
Eintrag im Papierkorb den Namen nicht auf Dauer blockiert.
|
||||
* **Status getrennt von Stammdaten.** Erreichbarkeit, Fehlerzaehler und
|
||||
Neustart-Historie liegen in ``device_status``/``restart_events``, nicht in der
|
||||
Geraetetabelle. Der Ueberwachungsprozess schreibt damit ausschliesslich in
|
||||
Tabellen, die niemand von Hand pflegt.
|
||||
* **Nachvollziehbare Neustarts.** Der Vorgaenger protokollierte sie
|
||||
ausdruecklich *nicht* im Aenderungslog, nur in einer Textdatei. Jetzt gibt es
|
||||
``restart_events`` -- mit Ergebnis, Dauer, Ausloeser und **Methode**: ein
|
||||
Neustart ueber den PoE-Port des Switches und einer per ``reboot`` ueber SSH
|
||||
sind fuer den Betrieb dasselbe Ereignis und gehoeren in dieselbe Liste.
|
||||
* **Host-Schluessel des Switches** werden gespeichert und geprueft
|
||||
(``switch_host_keys``), statt sie im Terminal blind zu akzeptieren.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from tesm_core.migrations import Migration
|
||||
|
||||
V1 = """
|
||||
-- Wiederverwendbare Zugangsdaten. Das Passwort liegt AES-GCM-verschluesselt vor,
|
||||
-- gebunden an "credentials.secret:<id>" als AAD.
|
||||
CREATE TABLE IF NOT EXISTS credentials (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL COLLATE NOCASE,
|
||||
username TEXT NOT NULL DEFAULT '',
|
||||
secret_encrypted TEXT NOT NULL DEFAULT '',
|
||||
category TEXT NOT NULL DEFAULT 'switch',
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
updated_by TEXT NOT NULL DEFAULT '',
|
||||
deleted_at TEXT
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_credentials_name_live
|
||||
ON credentials(name) WHERE deleted_at IS NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS switches (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
hostname TEXT NOT NULL COLLATE NOCASE,
|
||||
ip TEXT NOT NULL,
|
||||
ssh_port INTEGER NOT NULL DEFAULT 22,
|
||||
credential_id INTEGER REFERENCES credentials(id) ON DELETE SET NULL,
|
||||
model TEXT NOT NULL DEFAULT '',
|
||||
location TEXT NOT NULL DEFAULT '',
|
||||
notes TEXT NOT NULL DEFAULT '',
|
||||
last_seen_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
updated_by TEXT NOT NULL DEFAULT '',
|
||||
deleted_at TEXT
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_switches_hostname_live
|
||||
ON switches(hostname) WHERE deleted_at IS NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS devices (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
mac TEXT NOT NULL COLLATE NOCASE,
|
||||
ip TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
switch_id INTEGER REFERENCES switches(id) ON DELETE SET NULL,
|
||||
port TEXT NOT NULL DEFAULT '',
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
credential_id INTEGER REFERENCES credentials(id) ON DELETE SET NULL,
|
||||
ssh_port INTEGER NOT NULL DEFAULT 22,
|
||||
category TEXT NOT NULL DEFAULT '',
|
||||
location TEXT NOT NULL DEFAULT '',
|
||||
notes TEXT NOT NULL DEFAULT '',
|
||||
auto_restart INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
updated_by TEXT NOT NULL DEFAULT '',
|
||||
deleted_at TEXT
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_devices_mac_live
|
||||
ON devices(mac) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_devices_switch ON devices(switch_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_devices_active ON devices(is_active, deleted_at);
|
||||
|
||||
-- Laufender Zustand der Ueberwachung. Getrennt von den Stammdaten, damit der
|
||||
-- Ueberwachungsprozess nie in vom Menschen gepflegte Spalten schreibt.
|
||||
CREATE TABLE IF NOT EXISTS device_status (
|
||||
device_id INTEGER PRIMARY KEY REFERENCES devices(id) ON DELETE CASCADE,
|
||||
state TEXT NOT NULL DEFAULT 'unknown',
|
||||
latency_ms REAL,
|
||||
fail_count INTEGER NOT NULL DEFAULT 0,
|
||||
last_check_at TEXT,
|
||||
last_ok_at TEXT,
|
||||
last_change_at TEXT,
|
||||
last_restart_at TEXT,
|
||||
restart_count INTEGER NOT NULL DEFAULT 0,
|
||||
detail TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
-- Jeder PoE-Vorgang, automatisch oder manuell, mit Ergebnis.
|
||||
CREATE TABLE IF NOT EXISTS poe_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
device_id INTEGER REFERENCES devices(id) ON DELETE CASCADE,
|
||||
ts TEXT NOT NULL,
|
||||
trigger TEXT NOT NULL DEFAULT 'auto',
|
||||
actor TEXT NOT NULL DEFAULT 'system',
|
||||
action TEXT NOT NULL,
|
||||
result TEXT NOT NULL,
|
||||
duration_ms INTEGER,
|
||||
detail TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_poe_events_device ON poe_events(device_id, ts);
|
||||
CREATE INDEX IF NOT EXISTS idx_poe_events_ts ON poe_events(ts);
|
||||
|
||||
-- Gespeicherte Host-Schluessel der Switche. Ein Wechsel wird gemeldet, statt
|
||||
-- stillschweigend akzeptiert zu werden.
|
||||
CREATE TABLE IF NOT EXISTS switch_host_keys (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
host TEXT NOT NULL,
|
||||
port INTEGER NOT NULL DEFAULT 22,
|
||||
key_type TEXT NOT NULL,
|
||||
fingerprint TEXT NOT NULL,
|
||||
added_at TEXT NOT NULL,
|
||||
added_by TEXT NOT NULL DEFAULT '',
|
||||
UNIQUE(host, port, key_type)
|
||||
);
|
||||
|
||||
-- DHCP (Kea)
|
||||
CREATE TABLE IF NOT EXISTS dhcp_subnets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
interface TEXT NOT NULL,
|
||||
range_start TEXT NOT NULL,
|
||||
range_end TEXT NOT NULL,
|
||||
gateway TEXT NOT NULL DEFAULT '',
|
||||
dns TEXT NOT NULL DEFAULT '',
|
||||
comment TEXT NOT NULL DEFAULT '',
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS dhcp_options (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
code INTEGER NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
type TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
is_standard INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS dhcp_option_values (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
option_id INTEGER NOT NULL REFERENCES dhcp_options(id) ON DELETE CASCADE,
|
||||
device_id INTEGER REFERENCES devices(id) ON DELETE CASCADE,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
-- Ein globaler Wert je Option (device_id IS NULL) und hoechstens ein Wert je
|
||||
-- Option und Geraet. Zwei Teilindizes, weil NULL in SQLite in einem UNIQUE
|
||||
-- nicht mit sich selbst kollidiert.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_dhcp_value_global
|
||||
ON dhcp_option_values(option_id) WHERE device_id IS NULL;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_dhcp_value_device
|
||||
ON dhcp_option_values(option_id, device_id) WHERE device_id IS NOT NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS dhcp_reservations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
mac TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||
ip TEXT NOT NULL,
|
||||
hostname TEXT NOT NULL DEFAULT '',
|
||||
comment TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Dateifreigaben: Zuordnung Verzeichnisgruppe -> UNC-Pfad.
|
||||
CREATE TABLE IF NOT EXISTS fileshare_mappings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ad_group_dn TEXT NOT NULL,
|
||||
ad_group_name TEXT NOT NULL,
|
||||
label TEXT NOT NULL,
|
||||
unc TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE(ad_group_dn, unc)
|
||||
);
|
||||
|
||||
-- Aktive Mounts je Sitzung. Der Sweep-Prozess raeumt hierueber auf, statt das
|
||||
-- Dateisystem zu durchsuchen.
|
||||
CREATE TABLE IF NOT EXISTS fileshare_mounts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
session_id TEXT NOT NULL DEFAULT '',
|
||||
label TEXT NOT NULL,
|
||||
unc TEXT NOT NULL,
|
||||
mount_path TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
released_at TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_fileshare_mounts_user ON fileshare_mounts(user_id, released_at);
|
||||
"""
|
||||
|
||||
|
||||
def seed_dhcp_options(conn) -> None: # type: ignore[no-untyped-def]
|
||||
"""Legt den kuratierten Katalog der DHCP-Standardoptionen an.
|
||||
|
||||
56 Eintraege, gegen eine echte Kea-Instanz verifiziert. Bewusst *nicht*
|
||||
enthalten:
|
||||
|
||||
* Optionen mit eigenen Feldern in der Oberflaeche: Router (3), DNS (6),
|
||||
Domain-Name (15), Lease-Zeiten (51/58/59).
|
||||
* ``host-name`` (12): Kea beantwortet die Anfrage bereits ueber das
|
||||
``hostname``-Feld der Reservierung. Ein zusaetzlicher Override ist laut
|
||||
Kea-Dokumentation eine Widerspruchsquelle -- der Vorgaenger hatte den
|
||||
Eintrag zeitweise drin und musste ihn per Migration wieder entfernen.
|
||||
* Client- und protokollinterne Optionen.
|
||||
"""
|
||||
from .services.kea import STANDARD_OPTIONS
|
||||
|
||||
conn.executemany(
|
||||
"INSERT INTO dhcp_options (code, name, type, description, is_standard) "
|
||||
"VALUES (?,?,?,?,1) ON CONFLICT(code) DO NOTHING",
|
||||
STANDARD_OPTIONS,
|
||||
)
|
||||
|
||||
|
||||
# Version 3 -----------------------------------------------------------------
|
||||
#
|
||||
# ``poe_events`` hiess so, weil es anfangs nur PoE-Neustarts gab. Inzwischen
|
||||
# startet auch die Wartung Geraete neu -- per ``reboot`` ueber SSH. Beides ist
|
||||
# aus Betriebssicht dasselbe Ereignis ("dieses Geraet wurde neu gestartet"),
|
||||
# und beides gehoert in dieselbe Liste. Ein Name, der nur die halbe Wahrheit
|
||||
# sagt, faellt spaetestens dem naechsten auf die Fuesse, der die Datenbank
|
||||
# liest.
|
||||
#
|
||||
# Die Spalte ``method`` haelt fest, *wie* neu gestartet wurde. Bestehende
|
||||
# Zeilen sind ausnahmslos PoE-Vorgaenge -- daher der Vorgabewert.
|
||||
V3 = """
|
||||
ALTER TABLE poe_events RENAME TO restart_events;
|
||||
ALTER TABLE restart_events ADD COLUMN method TEXT NOT NULL DEFAULT 'poe';
|
||||
|
||||
DROP INDEX IF EXISTS idx_poe_events_device;
|
||||
DROP INDEX IF EXISTS idx_poe_events_ts;
|
||||
CREATE INDEX IF NOT EXISTS idx_restart_events_device ON restart_events(device_id, ts);
|
||||
CREATE INDEX IF NOT EXISTS idx_restart_events_ts ON restart_events(ts);
|
||||
CREATE INDEX IF NOT EXISTS idx_restart_events_method ON restart_events(method, ts);
|
||||
"""
|
||||
|
||||
|
||||
# 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, ...] = (
|
||||
Migration(version=1, name="baseline", sql=V1),
|
||||
Migration(version=2, name="seed-dhcp-options", fn=seed_dhcp_options),
|
||||
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),
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
"""Fachdienste von TESM -- Netzwerk, SSH, DHCP, Dateifreigaben, Wartung, Sicherung."""
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,773 @@
|
||||
"""Bestandsdaten: Zugangsdaten, Switche, Geraete.
|
||||
|
||||
Zugangsdaten sind -- wie im Vorgaenger -- eine eigene, wiederverwendbare
|
||||
Entitaet und stehen nicht als Benutzer/Passwort-Paar an jedem Switch. Neu ist,
|
||||
wie das Geheimnis liegt: AES-256-GCM mit rotierbarem Schluessel und dem
|
||||
Verwendungszweck als zugeordnete Daten (``credentials.secret:<id>``). Damit ist
|
||||
ein Chiffrat nicht an anderer Stelle einspielbar.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import re
|
||||
import sqlite3
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Iterable
|
||||
|
||||
from tesm_core.db import Database
|
||||
from tesm_core.keystore import DecryptionError, Keystore
|
||||
|
||||
MAC_RE = re.compile(r"^[0-9a-f]{2}(:[0-9a-f]{2}){5}$")
|
||||
PORT_RE = re.compile(r"^[A-Za-z0-9/.:-]{1,24}$")
|
||||
HOSTNAME_RE = re.compile(r"^[A-Za-z0-9]([A-Za-z0-9._-]{0,62})$")
|
||||
|
||||
CREDENTIAL_CATEGORIES: tuple[tuple[str, str], ...] = (
|
||||
("switch", "Switch"),
|
||||
("linux", "Linux-Client"),
|
||||
("windows", "Windows-Client"),
|
||||
("router", "Router/Sonstiges"),
|
||||
)
|
||||
|
||||
#: Diese Kategorien erscheinen auf der Wartungsseite. Der Weg dorthin ist
|
||||
#: verschieden: Linux ueber SSH, Windows ueber RPC.
|
||||
MAINTENANCE_CATEGORIES: tuple[str, ...] = ("linux", "windows")
|
||||
|
||||
#: Rueckwaertskompatibler Name -- die Vorgabe, wenn nur eine gemeint ist.
|
||||
MAINTENANCE_CATEGORY = "linux"
|
||||
|
||||
#: Diese Kategorie wird **nicht** ueber SSH bedient. Windows-Geraete haben in
|
||||
#: aller Regel keinen SSH-Dienst und damit auch keinen Host-Schluessel, den
|
||||
#: jemand freigeben koennte. Alles, was mit Host-Schluesseln zu tun hat, laesst
|
||||
#: sie deshalb aus -- sonst stuenden sie fuer immer auf "Freigabe offen".
|
||||
NON_SSH_CATEGORY = "windows"
|
||||
|
||||
|
||||
def is_ssh_managed(category: str | None) -> bool:
|
||||
"""Wird ein Geraet dieser Kategorie ueber SSH bedient?"""
|
||||
return str(category or "") != NON_SSH_CATEGORY
|
||||
|
||||
|
||||
#: Was eine Kategorie tatsaechlich bewirkt. Steht in der Oberflaeche, damit
|
||||
#: niemand eine Auswahl trifft, die stillschweigend folgenlos bleibt: genau das
|
||||
#: war "Windows-Client" vorher -- waehlbar, aber ohne jede Wirkung.
|
||||
CATEGORY_EFFECTS: dict[str, str] = {
|
||||
"switch": "Anmeldung an Switchen: PoE schalten, Konsole, Statusabfragen.",
|
||||
"linux": "Wartung über SSH: Paketaktualisierung (apt) und Neustart. Konsole.",
|
||||
"windows": (
|
||||
"Neustart über RPC (wie 'shutdown /m'). Kein SSH, kein Host-Schlüssel. Das "
|
||||
"Gerät braucht offene Datei- und Druckerfreigabe (Port 445); das Konto braucht "
|
||||
"das Recht, aus der Ferne herunterzufahren. Paketaktualisierung gibt es hier nicht."
|
||||
),
|
||||
"router": "Nur Dokumentation – diese Kategorie löst keine Aktion aus.",
|
||||
}
|
||||
|
||||
DEVICE_CATEGORIES: tuple[tuple[str, str], ...] = (
|
||||
("", "nicht angegeben"),
|
||||
("kamera", "Kamera"),
|
||||
("accesspoint", "Access Point"),
|
||||
("telefon", "Telefon"),
|
||||
("sensor", "Sensor"),
|
||||
("rechner", "Rechner"),
|
||||
("sonstiges", "Sonstiges"),
|
||||
)
|
||||
|
||||
|
||||
class ValidationError(Exception):
|
||||
"""Eingabefehler mit Klartextmeldung."""
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def aad_for_credential(credential_id: int) -> str:
|
||||
return f"credentials.secret:{credential_id}"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Validierung
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def normalize_mac(value: str) -> str:
|
||||
cleaned = re.sub(r"[^0-9a-fA-F]", "", value or "")
|
||||
if len(cleaned) != 12:
|
||||
raise ValidationError(
|
||||
"Die MAC-Adresse muss zwölf hexadezimale Zeichen enthalten "
|
||||
"(z. B. aa:bb:cc:dd:ee:ff)."
|
||||
)
|
||||
return ":".join(cleaned[index : index + 2] for index in range(0, 12, 2)).lower()
|
||||
|
||||
|
||||
def validate_ip(value: str, *, field: str = "IP-Adresse") -> str:
|
||||
text = (value or "").strip()
|
||||
try:
|
||||
ipaddress.ip_address(text)
|
||||
except ValueError as exc:
|
||||
raise ValidationError(f"{field}: {text!r} ist keine gültige IP-Adresse.") from exc
|
||||
return text
|
||||
|
||||
|
||||
def validate_port_name(value: str) -> str:
|
||||
text = (value or "").strip()
|
||||
if not text:
|
||||
return ""
|
||||
if not PORT_RE.match(text):
|
||||
raise ValidationError(
|
||||
"Die Portbezeichnung enthält unerlaubte Zeichen (erlaubt: Buchstaben, "
|
||||
"Ziffern, / . : -)."
|
||||
)
|
||||
return text
|
||||
|
||||
|
||||
def validate_hostname(value: str) -> str:
|
||||
text = (value or "").strip()
|
||||
if not HOSTNAME_RE.match(text):
|
||||
raise ValidationError("Der Hostname enthält unerlaubte Zeichen oder ist leer.")
|
||||
return text
|
||||
|
||||
|
||||
def validate_ssh_port(value: Any) -> int:
|
||||
try:
|
||||
port = int(value or 22)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValidationError("Der SSH-Port muss eine Zahl sein.") from exc
|
||||
if not 1 <= port <= 65535:
|
||||
raise ValidationError("Der SSH-Port muss zwischen 1 und 65535 liegen.")
|
||||
return port
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Zugangsdaten
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Credential:
|
||||
id: int
|
||||
name: str
|
||||
username: str
|
||||
category: str
|
||||
description: str
|
||||
has_secret: bool
|
||||
in_use: int
|
||||
|
||||
|
||||
def list_credentials(
|
||||
conn: sqlite3.Connection, *, include_deleted: bool = False
|
||||
) -> list[Credential]:
|
||||
where = "" if include_deleted else "WHERE c.deleted_at IS NULL"
|
||||
rows = Database.all(
|
||||
conn,
|
||||
f"""
|
||||
SELECT c.*,
|
||||
(SELECT COUNT(*) FROM switches s
|
||||
WHERE s.credential_id = c.id AND s.deleted_at IS NULL) +
|
||||
(SELECT COUNT(*) FROM devices d
|
||||
WHERE d.credential_id = c.id AND d.deleted_at IS NULL) AS in_use
|
||||
FROM credentials c {where}
|
||||
ORDER BY c.name COLLATE NOCASE
|
||||
""",
|
||||
)
|
||||
return [
|
||||
Credential(
|
||||
id=int(row["id"]),
|
||||
name=str(row["name"]),
|
||||
username=str(row["username"] or ""),
|
||||
category=str(row["category"] or "switch"),
|
||||
description=str(row["description"] or ""),
|
||||
has_secret=bool(row["secret_encrypted"]),
|
||||
in_use=int(row["in_use"] or 0),
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
def create_credential(
|
||||
conn: sqlite3.Connection,
|
||||
keystore: Keystore,
|
||||
*,
|
||||
name: str,
|
||||
username: str,
|
||||
secret: str,
|
||||
category: str,
|
||||
description: str = "",
|
||||
actor: str = "",
|
||||
) -> int:
|
||||
clean_name = (name or "").strip()
|
||||
if not clean_name:
|
||||
raise ValidationError("Der Name darf nicht leer sein.")
|
||||
if category not in dict(CREDENTIAL_CATEGORIES):
|
||||
raise ValidationError("Unbekannte Kategorie.")
|
||||
existing = Database.one(
|
||||
conn, "SELECT id, deleted_at FROM credentials WHERE name=? COLLATE NOCASE", (clean_name,)
|
||||
)
|
||||
if existing is not None:
|
||||
if existing["deleted_at"]:
|
||||
raise ValidationError(
|
||||
f"Zugangsdaten namens {clean_name!r} liegen im Papierkorb. Stellen Sie sie "
|
||||
"wieder her oder löschen Sie sie endgültig."
|
||||
)
|
||||
raise ValidationError(f"Zugangsdaten namens {clean_name!r} existieren bereits.")
|
||||
|
||||
cursor = conn.execute(
|
||||
"INSERT INTO credentials (name, username, secret_encrypted, category, description, "
|
||||
"created_at, updated_at, updated_by) VALUES (?,?,'',?,?,?,?,?)",
|
||||
(clean_name, (username or "").strip(), category, (description or "").strip(), _now(), _now(), actor),
|
||||
)
|
||||
credential_id = int(cursor.lastrowid or 0)
|
||||
if secret:
|
||||
conn.execute(
|
||||
"UPDATE credentials SET secret_encrypted=? WHERE id=?",
|
||||
(keystore.encrypt(secret, aad=aad_for_credential(credential_id)), credential_id),
|
||||
)
|
||||
return credential_id
|
||||
|
||||
|
||||
def update_credential(
|
||||
conn: sqlite3.Connection,
|
||||
keystore: Keystore,
|
||||
credential_id: int,
|
||||
*,
|
||||
name: str,
|
||||
username: str,
|
||||
secret: str | None,
|
||||
category: str,
|
||||
description: str = "",
|
||||
actor: str = "",
|
||||
) -> list[str]:
|
||||
row = Database.one(
|
||||
conn, "SELECT * FROM credentials WHERE id=? AND deleted_at IS NULL", (credential_id,)
|
||||
)
|
||||
if row is None:
|
||||
raise ValidationError("Diese Zugangsdaten existieren nicht (mehr).")
|
||||
clean_name = (name or "").strip()
|
||||
if not clean_name:
|
||||
raise ValidationError("Der Name darf nicht leer sein.")
|
||||
clash = Database.one(
|
||||
conn,
|
||||
"SELECT id FROM credentials WHERE name=? COLLATE NOCASE AND id<>? AND deleted_at IS NULL",
|
||||
(clean_name, credential_id),
|
||||
)
|
||||
if clash is not None:
|
||||
raise ValidationError(f"Zugangsdaten namens {clean_name!r} existieren bereits.")
|
||||
|
||||
changes: list[str] = []
|
||||
if clean_name != str(row["name"]):
|
||||
changes.append(f"Name: {row['name']} -> {clean_name}")
|
||||
if (username or "").strip() != str(row["username"] or ""):
|
||||
changes.append("Benutzer geändert")
|
||||
if category != str(row["category"]):
|
||||
changes.append(f"Kategorie: {row['category']} -> {category}")
|
||||
if (description or "").strip() != str(row["description"] or ""):
|
||||
changes.append("Beschreibung geändert")
|
||||
|
||||
conn.execute(
|
||||
"UPDATE credentials SET name=?, username=?, category=?, description=?, updated_at=?, "
|
||||
"updated_by=? WHERE id=?",
|
||||
(clean_name, (username or "").strip(), category, (description or "").strip(), _now(), actor, credential_id),
|
||||
)
|
||||
if secret:
|
||||
conn.execute(
|
||||
"UPDATE credentials SET secret_encrypted=? WHERE id=?",
|
||||
(keystore.encrypt(secret, aad=aad_for_credential(credential_id)), credential_id),
|
||||
)
|
||||
changes.append("Passwort geändert")
|
||||
return changes
|
||||
|
||||
|
||||
def credential_secret(
|
||||
conn: sqlite3.Connection, keystore: Keystore, credential_id: int
|
||||
) -> tuple[str, str]:
|
||||
"""``(benutzer, passwort)`` -- entschluesselt. Nur fuer den Verbindungsaufbau."""
|
||||
row = Database.one(
|
||||
conn,
|
||||
"SELECT username, secret_encrypted FROM credentials WHERE id=? AND deleted_at IS NULL",
|
||||
(credential_id,),
|
||||
)
|
||||
if row is None:
|
||||
raise ValidationError("Die hinterlegten Zugangsdaten existieren nicht mehr.")
|
||||
encrypted = str(row["secret_encrypted"] or "")
|
||||
if not encrypted:
|
||||
return str(row["username"] or ""), ""
|
||||
try:
|
||||
secret = keystore.decrypt(encrypted, aad=aad_for_credential(int(credential_id)))
|
||||
except DecryptionError as exc:
|
||||
raise ValidationError(
|
||||
"Das gespeicherte Passwort konnte nicht entschlüsselt werden. Wurde der "
|
||||
"Datenschlüssel ausgetauscht? Bitte die Zugangsdaten neu eingeben."
|
||||
) from exc
|
||||
return str(row["username"] or ""), secret
|
||||
|
||||
|
||||
def delete_credential(conn: sqlite3.Connection, credential_id: int) -> None:
|
||||
row = Database.one(
|
||||
conn,
|
||||
"""
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM switches WHERE credential_id=? AND deleted_at IS NULL) AS switches,
|
||||
(SELECT COUNT(*) FROM devices WHERE credential_id=? AND deleted_at IS NULL) AS devices
|
||||
""",
|
||||
(credential_id, credential_id),
|
||||
) or {}
|
||||
if int(row.get("switches") or 0) or int(row.get("devices") or 0):
|
||||
raise ValidationError(
|
||||
f"Diese Zugangsdaten werden noch von {row.get('switches')} Switch(en) und "
|
||||
f"{row.get('devices')} Gerät(en) verwendet."
|
||||
)
|
||||
conn.execute("UPDATE credentials SET deleted_at=? WHERE id=?", (_now(), credential_id))
|
||||
|
||||
|
||||
def reencrypt_secrets(conn: sqlite3.Connection, keystore: Keystore) -> int:
|
||||
"""Verschluesselt alle Geheimnisse unter dem aktuellen Schluessel neu.
|
||||
|
||||
Wird von der Schluesselrotation auf der Diagnoseseite aufgerufen.
|
||||
"""
|
||||
migrated = 0
|
||||
for row in Database.all(
|
||||
conn, "SELECT id, secret_encrypted FROM credentials WHERE secret_encrypted <> ''"
|
||||
):
|
||||
token = str(row["secret_encrypted"])
|
||||
if not keystore.needs_reencrypt(token):
|
||||
continue
|
||||
aad = aad_for_credential(int(row["id"]))
|
||||
try:
|
||||
plain = keystore.decrypt(token, aad=aad)
|
||||
except DecryptionError:
|
||||
continue
|
||||
conn.execute(
|
||||
"UPDATE credentials SET secret_encrypted=? WHERE id=?",
|
||||
(keystore.encrypt(plain, aad=aad), row["id"]),
|
||||
)
|
||||
migrated += 1
|
||||
|
||||
# Bind-Passwort des Verzeichnisdienstes mitnehmen.
|
||||
from tesm_core.auth.directory import AAD_LDAP_BIND, SERVICE_PURPOSE
|
||||
|
||||
row = Database.one(
|
||||
conn, "SELECT password_encrypted FROM service_accounts WHERE purpose=?", (SERVICE_PURPOSE,)
|
||||
)
|
||||
if row and str(row["password_encrypted"] or ""):
|
||||
token = str(row["password_encrypted"])
|
||||
if keystore.needs_reencrypt(token):
|
||||
try:
|
||||
plain = keystore.decrypt(token, aad=AAD_LDAP_BIND)
|
||||
except DecryptionError:
|
||||
plain = ""
|
||||
if plain:
|
||||
conn.execute(
|
||||
"UPDATE service_accounts SET password_encrypted=? WHERE purpose=?",
|
||||
(keystore.encrypt(plain, aad=AAD_LDAP_BIND), SERVICE_PURPOSE),
|
||||
)
|
||||
migrated += 1
|
||||
return migrated
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Switche
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def list_switches(conn: sqlite3.Connection, *, include_deleted: bool = False) -> list[dict[str, Any]]:
|
||||
where = "" if include_deleted else "WHERE s.deleted_at IS NULL"
|
||||
return Database.all(
|
||||
conn,
|
||||
f"""
|
||||
SELECT s.*, c.name AS credential_name, c.username AS credential_username,
|
||||
(SELECT COUNT(*) FROM devices d
|
||||
WHERE d.switch_id = s.id AND d.deleted_at IS NULL) AS device_count,
|
||||
(SELECT COUNT(*) FROM switch_host_keys k
|
||||
WHERE k.host = s.ip AND k.port = s.ssh_port) AS host_key_known
|
||||
FROM switches s
|
||||
LEFT JOIN credentials c ON c.id = s.credential_id AND c.deleted_at IS NULL
|
||||
{where}
|
||||
ORDER BY s.hostname COLLATE NOCASE
|
||||
""",
|
||||
)
|
||||
|
||||
|
||||
def get_switch(conn: sqlite3.Connection, switch_id: int) -> dict[str, Any] | None:
|
||||
return Database.one(
|
||||
conn,
|
||||
"SELECT s.*, c.name AS credential_name FROM switches s "
|
||||
"LEFT JOIN credentials c ON c.id = s.credential_id "
|
||||
"WHERE s.id=? AND s.deleted_at IS NULL",
|
||||
(switch_id,),
|
||||
)
|
||||
|
||||
|
||||
def create_switch(
|
||||
conn: sqlite3.Connection,
|
||||
*,
|
||||
hostname: str,
|
||||
ip: str,
|
||||
ssh_port: Any,
|
||||
credential_id: Any,
|
||||
model: str = "",
|
||||
location: str = "",
|
||||
notes: str = "",
|
||||
actor: str = "",
|
||||
) -> int:
|
||||
clean_host = validate_hostname(hostname)
|
||||
clean_ip = validate_ip(ip)
|
||||
port = validate_ssh_port(ssh_port)
|
||||
existing = Database.one(
|
||||
conn, "SELECT id, deleted_at FROM switches WHERE hostname=? COLLATE NOCASE", (clean_host,)
|
||||
)
|
||||
if existing is not None:
|
||||
if existing["deleted_at"]:
|
||||
raise ValidationError(
|
||||
f"Ein Switch namens {clean_host!r} liegt im Papierkorb."
|
||||
)
|
||||
raise ValidationError(f"Ein Switch namens {clean_host!r} existiert bereits.")
|
||||
|
||||
cursor = conn.execute(
|
||||
"INSERT INTO switches (hostname, ip, ssh_port, credential_id, model, location, notes, "
|
||||
"created_at, updated_at, updated_by) VALUES (?,?,?,?,?,?,?,?,?,?)",
|
||||
(
|
||||
clean_host,
|
||||
clean_ip,
|
||||
port,
|
||||
int(credential_id) if credential_id else None,
|
||||
(model or "").strip(),
|
||||
(location or "").strip(),
|
||||
(notes or "").strip(),
|
||||
_now(),
|
||||
_now(),
|
||||
actor,
|
||||
),
|
||||
)
|
||||
return int(cursor.lastrowid or 0)
|
||||
|
||||
|
||||
def update_switch(
|
||||
conn: sqlite3.Connection,
|
||||
switch_id: int,
|
||||
*,
|
||||
hostname: str,
|
||||
ip: str,
|
||||
ssh_port: Any,
|
||||
credential_id: Any,
|
||||
model: str = "",
|
||||
location: str = "",
|
||||
notes: str = "",
|
||||
actor: str = "",
|
||||
) -> list[str]:
|
||||
row = get_switch(conn, switch_id)
|
||||
if row is None:
|
||||
raise ValidationError("Dieser Switch existiert nicht (mehr).")
|
||||
clean_host = validate_hostname(hostname)
|
||||
clean_ip = validate_ip(ip)
|
||||
port = validate_ssh_port(ssh_port)
|
||||
clash = Database.one(
|
||||
conn,
|
||||
"SELECT id FROM switches WHERE hostname=? COLLATE NOCASE AND id<>? AND deleted_at IS NULL",
|
||||
(clean_host, switch_id),
|
||||
)
|
||||
if clash is not None:
|
||||
raise ValidationError(f"Ein Switch namens {clean_host!r} existiert bereits.")
|
||||
|
||||
changes: list[str] = []
|
||||
for label, before, after in (
|
||||
("Hostname", str(row["hostname"]), clean_host),
|
||||
("IP", str(row["ip"]), clean_ip),
|
||||
("SSH-Port", str(row["ssh_port"]), str(port)),
|
||||
("Zugangsdaten", str(row["credential_id"] or ""), str(credential_id or "")),
|
||||
("Modell", str(row["model"] or ""), (model or "").strip()),
|
||||
("Standort", str(row["location"] or ""), (location or "").strip()),
|
||||
):
|
||||
if before != after:
|
||||
changes.append(f"{label}: {before or '-'} -> {after or '-'}")
|
||||
|
||||
conn.execute(
|
||||
"UPDATE switches SET hostname=?, ip=?, ssh_port=?, credential_id=?, model=?, location=?, "
|
||||
"notes=?, updated_at=?, updated_by=? WHERE id=?",
|
||||
(
|
||||
clean_host,
|
||||
clean_ip,
|
||||
port,
|
||||
int(credential_id) if credential_id else None,
|
||||
(model or "").strip(),
|
||||
(location or "").strip(),
|
||||
(notes or "").strip(),
|
||||
_now(),
|
||||
actor,
|
||||
switch_id,
|
||||
),
|
||||
)
|
||||
return changes
|
||||
|
||||
|
||||
def delete_switch(conn: sqlite3.Connection, switch_id: int) -> None:
|
||||
count = Database.value(
|
||||
conn,
|
||||
"SELECT COUNT(*) FROM devices WHERE switch_id=? AND deleted_at IS NULL",
|
||||
(switch_id,),
|
||||
default=0,
|
||||
)
|
||||
if int(count or 0):
|
||||
raise ValidationError(
|
||||
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))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Geraete
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def list_devices(
|
||||
conn: sqlite3.Connection,
|
||||
*,
|
||||
include_deleted: bool = False,
|
||||
only_active: bool = False,
|
||||
) -> list[dict[str, Any]]:
|
||||
clauses = [] if include_deleted else ["d.deleted_at IS NULL"]
|
||||
if only_active:
|
||||
clauses.append("d.is_active = 1")
|
||||
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
|
||||
return Database.all(
|
||||
conn,
|
||||
f"""
|
||||
SELECT d.*, s.hostname AS switch_hostname, s.ip AS switch_ip,
|
||||
s.ssh_port AS switch_ssh_port, s.credential_id AS switch_credential_id,
|
||||
c.name AS credential_name, c.category AS credential_category,
|
||||
-- Wie bei den Switchen: der Zustand der Host-Schluessel-Freigabe
|
||||
-- gehoert in die Liste. Ob eine Wartung ueberhaupt laufen kann,
|
||||
-- soll man sehen, ohne jede Detailseite zu oeffnen.
|
||||
(SELECT COUNT(*) FROM switch_host_keys k
|
||||
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,
|
||||
st.last_ok_at, st.last_change_at, st.last_restart_at, st.restart_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
|
||||
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 device_status st ON st.device_id = d.id
|
||||
{where}
|
||||
ORDER BY d.name COLLATE NOCASE
|
||||
""",
|
||||
)
|
||||
|
||||
|
||||
def get_device(conn: sqlite3.Connection, device_id: int) -> dict[str, Any] | None:
|
||||
rows = [
|
||||
row
|
||||
for row in list_devices(conn, include_deleted=True)
|
||||
if int(row["id"]) == int(device_id)
|
||||
]
|
||||
return rows[0] if rows else None
|
||||
|
||||
|
||||
def create_device(
|
||||
conn: sqlite3.Connection,
|
||||
*,
|
||||
name: str,
|
||||
mac: str,
|
||||
ip: str,
|
||||
switch_id: Any,
|
||||
port: str,
|
||||
credential_id: Any,
|
||||
ssh_port: Any,
|
||||
category: str = "",
|
||||
location: str = "",
|
||||
notes: str = "",
|
||||
is_active: bool = True,
|
||||
auto_restart: bool = True,
|
||||
actor: str = "",
|
||||
) -> int:
|
||||
clean_name = (name or "").strip()
|
||||
if not clean_name:
|
||||
raise ValidationError("Der Name darf nicht leer sein.")
|
||||
clean_mac = normalize_mac(mac)
|
||||
clean_ip = validate_ip(ip)
|
||||
clean_port = validate_port_name(port)
|
||||
existing = Database.one(
|
||||
conn, "SELECT id, deleted_at FROM devices WHERE mac=? COLLATE NOCASE", (clean_mac,)
|
||||
)
|
||||
if existing is not None:
|
||||
if existing["deleted_at"]:
|
||||
raise ValidationError(f"Ein Gerät mit der MAC {clean_mac} liegt im Papierkorb.")
|
||||
raise ValidationError(f"Ein Gerät mit der MAC {clean_mac} existiert bereits.")
|
||||
|
||||
if switch_id and not clean_port:
|
||||
raise ValidationError(
|
||||
"Zu einem zugeordneten Switch gehört eine Portbezeichnung – sonst ist kein "
|
||||
"PoE-Neustart möglich."
|
||||
)
|
||||
|
||||
cursor = conn.execute(
|
||||
"INSERT INTO devices (mac, ip, name, switch_id, port, is_active, credential_id, ssh_port, "
|
||||
"category, location, notes, auto_restart, created_at, updated_at, updated_by) "
|
||||
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(
|
||||
clean_mac,
|
||||
clean_ip,
|
||||
clean_name,
|
||||
int(switch_id) if switch_id else None,
|
||||
clean_port,
|
||||
int(bool(is_active)),
|
||||
int(credential_id) if credential_id else None,
|
||||
validate_ssh_port(ssh_port),
|
||||
(category or "").strip(),
|
||||
(location or "").strip(),
|
||||
(notes or "").strip(),
|
||||
int(bool(auto_restart)),
|
||||
_now(),
|
||||
_now(),
|
||||
actor,
|
||||
),
|
||||
)
|
||||
device_id = int(cursor.lastrowid or 0)
|
||||
conn.execute(
|
||||
"INSERT INTO device_status (device_id, state, last_change_at) VALUES (?,'unknown',?)",
|
||||
(device_id, _now()),
|
||||
)
|
||||
return device_id
|
||||
|
||||
|
||||
def update_device(
|
||||
conn: sqlite3.Connection,
|
||||
device_id: int,
|
||||
*,
|
||||
name: str,
|
||||
mac: str,
|
||||
ip: str,
|
||||
switch_id: Any,
|
||||
port: str,
|
||||
credential_id: Any,
|
||||
ssh_port: Any,
|
||||
category: str = "",
|
||||
location: str = "",
|
||||
notes: str = "",
|
||||
is_active: bool = True,
|
||||
auto_restart: bool = True,
|
||||
actor: str = "",
|
||||
) -> list[str]:
|
||||
row = Database.one(
|
||||
conn, "SELECT * FROM devices WHERE id=? AND deleted_at IS NULL", (device_id,)
|
||||
)
|
||||
if row is None:
|
||||
raise ValidationError("Dieses Gerät existiert nicht (mehr).")
|
||||
clean_name = (name or "").strip()
|
||||
if not clean_name:
|
||||
raise ValidationError("Der Name darf nicht leer sein.")
|
||||
clean_mac = normalize_mac(mac)
|
||||
clean_ip = validate_ip(ip)
|
||||
clean_port = validate_port_name(port)
|
||||
clash = Database.one(
|
||||
conn,
|
||||
"SELECT id FROM devices WHERE mac=? COLLATE NOCASE AND id<>? AND deleted_at IS NULL",
|
||||
(clean_mac, device_id),
|
||||
)
|
||||
if clash is not None:
|
||||
raise ValidationError(f"Ein Gerät mit der MAC {clean_mac} existiert bereits.")
|
||||
if switch_id and not clean_port:
|
||||
raise ValidationError("Zu einem zugeordneten Switch gehört eine Portbezeichnung.")
|
||||
|
||||
changes: list[str] = []
|
||||
for label, before, after in (
|
||||
("Name", str(row["name"]), clean_name),
|
||||
("MAC", str(row["mac"]), clean_mac),
|
||||
("IP", str(row["ip"]), clean_ip),
|
||||
("Switch", str(row["switch_id"] or ""), str(switch_id or "")),
|
||||
("Port", str(row["port"] or ""), clean_port),
|
||||
("Aktiv", str(bool(row["is_active"])), str(bool(is_active))),
|
||||
("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:
|
||||
changes.append(f"{label}: {before or '-'} -> {after or '-'}")
|
||||
|
||||
conn.execute(
|
||||
"UPDATE devices SET mac=?, ip=?, name=?, switch_id=?, port=?, is_active=?, "
|
||||
"credential_id=?, ssh_port=?, category=?, location=?, notes=?, auto_restart=?, "
|
||||
"updated_at=?, updated_by=? WHERE id=?",
|
||||
(
|
||||
clean_mac,
|
||||
clean_ip,
|
||||
clean_name,
|
||||
int(switch_id) if switch_id else None,
|
||||
clean_port,
|
||||
int(bool(is_active)),
|
||||
int(credential_id) if credential_id else None,
|
||||
validate_ssh_port(ssh_port),
|
||||
(category or "").strip(),
|
||||
(location or "").strip(),
|
||||
(notes or "").strip(),
|
||||
int(bool(auto_restart)),
|
||||
_now(),
|
||||
actor,
|
||||
device_id,
|
||||
),
|
||||
)
|
||||
return changes
|
||||
|
||||
|
||||
def set_device_active(conn: sqlite3.Connection, device_id: int, active: bool) -> None:
|
||||
conn.execute(
|
||||
"UPDATE devices SET is_active=?, updated_at=? WHERE id=? AND deleted_at IS NULL",
|
||||
(int(bool(active)), _now(), device_id),
|
||||
)
|
||||
|
||||
|
||||
def delete_device(conn: sqlite3.Connection, device_id: int) -> None:
|
||||
conn.execute("UPDATE devices SET deleted_at=? WHERE id=?", (_now(), device_id))
|
||||
|
||||
|
||||
def restore_row(conn: sqlite3.Connection, table: str, row_id: int, *, unique_column: str) -> None:
|
||||
"""Wiederherstellung mit Konfliktpruefung auf dem eindeutigen Feld."""
|
||||
if table not in {"devices", "switches", "credentials"}:
|
||||
raise ValidationError("Unbekannte Ressource.")
|
||||
row = Database.one(conn, f"SELECT {unique_column} FROM {table} WHERE id=?", (row_id,))
|
||||
if row is None:
|
||||
raise ValidationError("Dieser Eintrag existiert nicht.")
|
||||
value = row[unique_column]
|
||||
clash = Database.one(
|
||||
conn,
|
||||
f"SELECT id FROM {table} WHERE {unique_column}=? COLLATE NOCASE AND id<>? "
|
||||
"AND deleted_at IS NULL",
|
||||
(value, row_id),
|
||||
)
|
||||
if clash is not None:
|
||||
raise ValidationError(
|
||||
f"Es existiert bereits ein aktiver Eintrag mit {unique_column} = {value!r}."
|
||||
)
|
||||
conn.execute(f"UPDATE {table} SET deleted_at=NULL WHERE id=?", (row_id,))
|
||||
|
||||
|
||||
def purge_row(conn: sqlite3.Connection, table: str, row_id: int) -> None:
|
||||
if table not in {"devices", "switches", "credentials"}:
|
||||
raise ValidationError("Unbekannte Ressource.")
|
||||
conn.execute(f"DELETE FROM {table} WHERE id=?", (row_id,))
|
||||
|
||||
|
||||
def trash_entries(
|
||||
conn: sqlite3.Connection, table: str, *, label_column: str, detail_columns: Iterable[str]
|
||||
) -> list[dict[str, Any]]:
|
||||
columns = ", ".join([label_column, *detail_columns])
|
||||
rows = Database.all(
|
||||
conn,
|
||||
f"SELECT id, {columns}, deleted_at FROM {table} "
|
||||
"WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC",
|
||||
)
|
||||
return [
|
||||
{
|
||||
"id": str(row["id"]),
|
||||
"label": str(row[label_column]),
|
||||
"detail": " / ".join(str(row[column] or "-") for column in detail_columns),
|
||||
"deleted_at": row["deleted_at"],
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
@@ -0,0 +1,661 @@
|
||||
"""Kea-DHCP: Optionskatalog, Konfigurationserzeugung, Lease-Auswertung.
|
||||
|
||||
Backend ist ``kea-dhcp4-server`` (isc-dhcp-server ist seit 2022 abgekuendigt).
|
||||
|
||||
Die im Vorgaengerprojekt teuer erarbeiteten Kea-Eigenheiten sind hier von
|
||||
Anfang an beruecksichtigt und im Code vermerkt:
|
||||
|
||||
* Der Schluessel der Lease-Datei heisst ``name``, nicht ``lease-file``.
|
||||
* ``loggers`` gehoert **unter** ``Dhcp4``, nicht daneben -- sonst Syntaxfehler.
|
||||
* Standardoptionen bekommen **kein** eigenes ``option-def`` (Kea kennt sie
|
||||
nativ); nur eigene Optionen werden definiert.
|
||||
* Die Lease-CSV wird mit ``\\n`` geschrieben; Pythons csv-Default ``\\r\\n``
|
||||
laesst Kea die Datei mit "invalid column" ablehnen.
|
||||
* Das Kea-Protokoll darf nicht unter dem Protokollverzeichnis der Anwendung
|
||||
liegen: das AppArmor-Profil von Kea erlaubt ausschliesslich den fest
|
||||
einprogrammierten Pfad ``/var/log/kea/kea-dhcp4.log``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import sqlite3
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Iterable, Sequence
|
||||
|
||||
from tesm_core.db import Database
|
||||
from tesm_core.sysops import SysOps
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SERVICE_UNIT = "kea-dhcp4-server"
|
||||
PACKAGE_NAME = "kea-dhcp4-server"
|
||||
LEASE_FILE = "/var/lib/kea/kea-leases4.csv"
|
||||
LOG_PATH = "/var/log/kea/kea-dhcp4.log"
|
||||
DEFAULT_CONFIG_PATH = "/etc/kea/kea-dhcp4.conf"
|
||||
|
||||
#: ``(code, name, type, description)`` -- kuratierte Standardoptionen.
|
||||
STANDARD_OPTIONS: tuple[tuple[int, str, str, str], ...] = (
|
||||
(4, "time-servers", "ipv4-address", "Zeitserver (RFC 868)"),
|
||||
(7, "log-servers", "ipv4-address", "Log-Server (MIT-LCS UDP)"),
|
||||
(8, "cookie-servers", "ipv4-address", "Cookie-Server"),
|
||||
(9, "lpr-servers", "ipv4-address", "LPR-Druckserver"),
|
||||
(10, "impress-servers", "ipv4-address", "Impress-Server"),
|
||||
(11, "resource-location-servers", "ipv4-address", "Resource Location Server"),
|
||||
(13, "boot-size", "uint16", "Boot-Image-Größe (Blöcke)"),
|
||||
(14, "merit-dump", "string", "Crash-Dump-Datei"),
|
||||
(16, "swap-server", "ipv4-address", "Swap-Server"),
|
||||
(17, "root-path", "string", "Root-Pfad (NFS)"),
|
||||
(18, "extensions-path", "string", "Extensions-Pfad (BOOTP)"),
|
||||
(19, "ip-forwarding", "boolean", "IP-Forwarding aktivieren"),
|
||||
(20, "non-local-source-routing", "boolean", "Non-Local Source Routing"),
|
||||
(21, "policy-filter", "string", "Policy Filter (Ziel,Maske,...)"),
|
||||
(22, "max-dgram-reassembly", "uint16", "Maximale Datagramm-Reassemblierungsgröße"),
|
||||
(23, "default-ip-ttl", "uint8", "Standard-TTL für IP"),
|
||||
(24, "path-mtu-aging-timeout", "uint32", "Path-MTU-Aging-Timeout (Sek.)"),
|
||||
(25, "path-mtu-plateau-table", "uint16", "Path-MTU-Plateau-Tabelle"),
|
||||
(26, "interface-mtu", "uint16", "MTU des Interfaces"),
|
||||
(27, "all-subnets-local", "boolean", "Alle Subnetze lokal erreichbar"),
|
||||
(28, "broadcast-address", "ipv4-address", "Broadcast-Adresse"),
|
||||
(29, "perform-mask-discovery", "boolean", "Subnetzmasken-Discovery"),
|
||||
(30, "mask-supplier", "boolean", "Subnetzmasken-Anbieter"),
|
||||
(31, "router-discovery", "boolean", "Router Discovery (RFC 1256)"),
|
||||
(32, "router-solicitation-address", "ipv4-address", "Router-Solicitation-Adresse"),
|
||||
(33, "static-routes", "string", "Statische Routen (Ziel,Gateway,...)"),
|
||||
(34, "trailer-encapsulation", "boolean", "Trailer Encapsulation"),
|
||||
(35, "arp-cache-timeout", "uint32", "ARP-Cache-Timeout (Sek.)"),
|
||||
(36, "ieee802-3-encapsulation", "boolean", "Ethernet-Encapsulation (IEEE 802.3)"),
|
||||
(37, "default-tcp-ttl", "uint8", "Standard-TTL für TCP"),
|
||||
(38, "tcp-keepalive-interval", "uint32", "TCP-Keepalive-Intervall (Sek.)"),
|
||||
(40, "nis-domain", "string", "NIS-Domain"),
|
||||
(41, "nis-servers", "ipv4-address", "NIS-Server"),
|
||||
(42, "ntp-servers", "ipv4-address", "NTP-Server"),
|
||||
(44, "netbios-name-servers", "ipv4-address", "WINS/NBNS-Server"),
|
||||
(45, "netbios-dd-server", "ipv4-address", "NetBIOS Datagram Distribution Server"),
|
||||
(46, "netbios-node-type", "uint8", "WINS/NBT-Knotentyp"),
|
||||
(47, "netbios-scope", "string", "NetBIOS-Scope-ID"),
|
||||
(48, "font-servers", "ipv4-address", "X-Window-Font-Server"),
|
||||
(49, "x-display-manager", "ipv4-address", "X-Window Display Manager"),
|
||||
(60, "vendor-class-identifier", "string", "Vendor Class Identifier"),
|
||||
(64, "nisplus-domain-name", "string", "NIS+-Domain"),
|
||||
(65, "nisplus-servers", "ipv4-address", "NIS+-Server"),
|
||||
(66, "tftp-server-name", "string", "Boot-Server (TFTP-Hostname)"),
|
||||
(67, "boot-file-name", "string", "Bootdatei-Name"),
|
||||
(68, "mobile-ip-home-agent", "ipv4-address", "Mobile-IP-Home-Agent"),
|
||||
(69, "smtp-server", "ipv4-address", "SMTP-Server"),
|
||||
(70, "pop-server", "ipv4-address", "POP3-Server"),
|
||||
(71, "nntp-server", "ipv4-address", "NNTP-Server"),
|
||||
(72, "www-server", "ipv4-address", "WWW-Server"),
|
||||
(73, "finger-server", "ipv4-address", "Finger-Server"),
|
||||
(74, "irc-server", "ipv4-address", "IRC-Server"),
|
||||
(75, "streettalk-server", "ipv4-address", "StreetTalk-Server"),
|
||||
(76, "streettalk-directory-assistance-server", "ipv4-address", "StreetTalk Directory Assistance Server"),
|
||||
(118, "subnet-selection", "ipv4-address", "Subnet Selection (RFC 3011)"),
|
||||
(119, "domain-search", "string", "DNS-Suchliste (Domain Search)"),
|
||||
)
|
||||
|
||||
OPTION_TYPES = (
|
||||
"ipv4-address",
|
||||
"string",
|
||||
"boolean",
|
||||
"uint8",
|
||||
"uint16",
|
||||
"uint32",
|
||||
"int8",
|
||||
"int16",
|
||||
"int32",
|
||||
)
|
||||
|
||||
_HOSTNAME_CLEAN = re.compile(r"[^a-z0-9-]+")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Netzwerkerkennung
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class InterfaceNetwork:
|
||||
interface: str
|
||||
cidr: str
|
||||
|
||||
@property
|
||||
def network(self) -> ipaddress.IPv4Network:
|
||||
return ipaddress.ip_network(self.cidr, strict=False) # type: ignore[return-value]
|
||||
|
||||
@property
|
||||
def address(self) -> str:
|
||||
return self.cidr.split("/")[0]
|
||||
|
||||
|
||||
def detect_networks(sysops: SysOps) -> list[InterfaceNetwork]:
|
||||
"""Liest alle IPv4-Adressen inkl. Alias-Adressen aus ``ip -4 addr show``.
|
||||
|
||||
Subnetz und Maske werden nie von Hand eingegeben -- sie kommen immer vom
|
||||
laufenden System. Das war schon im Vorgaenger die richtige Entscheidung.
|
||||
"""
|
||||
result = sysops.read_command(["ip", "-4", "-o", "addr", "show", "scope", "global"])
|
||||
if not result.ok:
|
||||
return []
|
||||
networks: list[InterfaceNetwork] = []
|
||||
for line in result.stdout.splitlines():
|
||||
parts = line.split()
|
||||
if len(parts) < 4 or parts[2] != "inet":
|
||||
continue
|
||||
networks.append(InterfaceNetwork(interface=parts[1], cidr=parts[3]))
|
||||
return networks
|
||||
|
||||
|
||||
def default_gateway(sysops: SysOps) -> str:
|
||||
result = sysops.read_command(["ip", "route", "show", "default"])
|
||||
if not result.ok:
|
||||
return ""
|
||||
for line in result.stdout.splitlines():
|
||||
parts = line.split()
|
||||
if "via" in parts:
|
||||
return parts[parts.index("via") + 1]
|
||||
return ""
|
||||
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
def matching_network(
|
||||
networks: Iterable[InterfaceNetwork], interface: str, range_start: str, range_end: str
|
||||
) -> InterfaceNetwork | None:
|
||||
"""Findet das Netz, in das ein Adressbereich tatsaechlich passt."""
|
||||
try:
|
||||
start = ipaddress.ip_address(range_start)
|
||||
end = ipaddress.ip_address(range_end)
|
||||
except ValueError:
|
||||
return None
|
||||
if int(end) < int(start):
|
||||
return None
|
||||
for candidate in networks:
|
||||
if candidate.interface != interface:
|
||||
continue
|
||||
network = candidate.network
|
||||
if start in network and end in network:
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def validate_subnet(
|
||||
networks: Iterable[InterfaceNetwork], interface: str, range_start: str, range_end: str
|
||||
) -> str:
|
||||
"""Rueckgabe: leerer String bei Erfolg, sonst die Fehlermeldung."""
|
||||
if not interface:
|
||||
return "Es ist kein Interface ausgewählt."
|
||||
try:
|
||||
start = ipaddress.ip_address(range_start)
|
||||
end = ipaddress.ip_address(range_end)
|
||||
except ValueError:
|
||||
return "Start- und Endadresse müssen gültige IPv4-Adressen sein."
|
||||
if int(end) < int(start):
|
||||
return "Die Endadresse liegt vor der Startadresse."
|
||||
if matching_network(networks, interface, range_start, range_end) is None:
|
||||
return (
|
||||
f"Der Bereich {range_start}-{range_end} passt in kein Netz von {interface}. "
|
||||
"Subnetz und Maske werden vom laufenden System gelesen."
|
||||
)
|
||||
return ""
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Reservierungen
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def normalize_mac(value: str) -> str:
|
||||
"""Vereinheitlicht MAC-Adressen auf ``aa:bb:cc:dd:ee:ff``."""
|
||||
cleaned = re.sub(r"[^0-9a-fA-F]", "", value or "")
|
||||
if len(cleaned) != 12:
|
||||
return ""
|
||||
return ":".join(cleaned[index : index + 2] for index in range(0, 12, 2)).lower()
|
||||
|
||||
|
||||
def hostname_from_name(name: str, taken: set[str]) -> str:
|
||||
"""Leitet einen DNS-taugliche Hostnamen ab und loest Kollisionen mit -2/-3."""
|
||||
base = _HOSTNAME_CLEAN.sub("-", (name or "").lower()).strip("-") or "client"
|
||||
base = base[:60]
|
||||
candidate = base
|
||||
suffix = 2
|
||||
while candidate in taken:
|
||||
candidate = f"{base}-{suffix}"
|
||||
suffix += 1
|
||||
taken.add(candidate)
|
||||
return candidate
|
||||
|
||||
|
||||
def configured_networks(
|
||||
conn: sqlite3.Connection, networks: Iterable[InterfaceNetwork]
|
||||
) -> list[ipaddress.IPv4Network | ipaddress.IPv6Network]:
|
||||
"""Die Netze, fuer die tatsaechlich ein Subnetz eingerichtet ist.
|
||||
|
||||
Nur diese landen in der Konfiguration. Eine Reservierung ausserhalb wird von
|
||||
Kea nie ausgeliefert -- sie in der Oberflaeche trotzdem zu zeigen, hat
|
||||
Reservierungen versprochen, die es nicht gab.
|
||||
"""
|
||||
treffer = []
|
||||
for subnet in Database.all(
|
||||
conn, "SELECT * FROM dhcp_subnets WHERE enabled=1 ORDER BY sort_order, id"
|
||||
):
|
||||
match = matching_network(
|
||||
networks,
|
||||
str(subnet["interface"]),
|
||||
str(subnet["range_start"]),
|
||||
str(subnet["range_end"]),
|
||||
)
|
||||
if match is not None:
|
||||
treffer.append(match.network)
|
||||
return treffer
|
||||
|
||||
|
||||
def in_configured_network(
|
||||
ip: str, networks: Iterable[ipaddress.IPv4Network | ipaddress.IPv6Network]
|
||||
) -> bool:
|
||||
try:
|
||||
adresse = ipaddress.ip_address(ip)
|
||||
except ValueError:
|
||||
return False
|
||||
return any(adresse in netz for netz in networks)
|
||||
|
||||
|
||||
def collect_reservations(
|
||||
conn: sqlite3.Connection,
|
||||
*,
|
||||
only_networks: Iterable[ipaddress.IPv4Network | ipaddress.IPv6Network] | None = None,
|
||||
skipped: list[dict[str, str]] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Automatische Reservierungen aus dem Geraetebestand plus manuelle Eintraege.
|
||||
|
||||
Bei einer MAC-Kollision gewinnt die automatische Reservierung -- ein
|
||||
ueberwachtes Geraet ist die verlaesslichere Quelle.
|
||||
|
||||
``only_networks`` grenzt auf Adressen in eingerichteten Bereichen ein. Ohne
|
||||
diese Eingrenzung entstanden Eintraege fuer Adressen, fuer die es kein
|
||||
Subnetz gibt: in der Liste sichtbar, in der Kea-Konfiguration nie
|
||||
vorhanden. Was dabei wegfaellt, wird in ``skipped`` gesammelt, damit die
|
||||
Oberflaeche es benennen kann statt es zu verschweigen.
|
||||
"""
|
||||
grenzen = list(only_networks) if only_networks is not None else None
|
||||
taken: set[str] = set()
|
||||
reservations: list[dict[str, Any]] = []
|
||||
seen_macs: set[str] = set()
|
||||
|
||||
for row in Database.all(
|
||||
conn,
|
||||
"SELECT id, mac, ip, name FROM devices "
|
||||
"WHERE deleted_at IS NULL AND is_active=1 AND ip <> '' ORDER BY name COLLATE NOCASE",
|
||||
):
|
||||
mac = normalize_mac(str(row["mac"]))
|
||||
if not mac or mac in seen_macs:
|
||||
continue
|
||||
try:
|
||||
ipaddress.ip_address(str(row["ip"]))
|
||||
except ValueError:
|
||||
continue
|
||||
if grenzen is not None and not in_configured_network(str(row["ip"]), grenzen):
|
||||
if skipped is not None:
|
||||
skipped.append({"name": str(row["name"]), "ip": str(row["ip"]), "source": "auto"})
|
||||
continue
|
||||
seen_macs.add(mac)
|
||||
reservations.append(
|
||||
{
|
||||
"device_id": int(row["id"]),
|
||||
"mac": mac,
|
||||
"ip": str(row["ip"]),
|
||||
"hostname": hostname_from_name(str(row["name"]), taken),
|
||||
"source": "auto",
|
||||
"name": str(row["name"]),
|
||||
}
|
||||
)
|
||||
|
||||
for row in Database.all(
|
||||
conn, "SELECT id, mac, ip, hostname, comment FROM dhcp_reservations ORDER BY ip"
|
||||
):
|
||||
mac = normalize_mac(str(row["mac"]))
|
||||
if not mac or mac in seen_macs:
|
||||
continue
|
||||
if grenzen is not None and not in_configured_network(str(row["ip"]), grenzen):
|
||||
if skipped is not None:
|
||||
skipped.append(
|
||||
{
|
||||
"name": str(row["hostname"] or row["comment"] or mac),
|
||||
"ip": str(row["ip"]),
|
||||
"source": "manuell",
|
||||
}
|
||||
)
|
||||
continue
|
||||
seen_macs.add(mac)
|
||||
reservations.append(
|
||||
{
|
||||
"device_id": None,
|
||||
"reservation_id": int(row["id"]),
|
||||
"mac": mac,
|
||||
"ip": str(row["ip"]),
|
||||
"hostname": hostname_from_name(
|
||||
str(row["hostname"] or row["comment"] or mac.replace(":", "")), taken
|
||||
),
|
||||
"source": "manuell",
|
||||
"name": str(row["comment"] or ""),
|
||||
}
|
||||
)
|
||||
return reservations
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Konfiguration
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def render_config(
|
||||
conn: sqlite3.Connection,
|
||||
*,
|
||||
networks: list[InterfaceNetwork],
|
||||
settings: dict[str, Any],
|
||||
) -> tuple[dict[str, Any], list[str]]:
|
||||
"""Erzeugt die Kea-Konfiguration. Rueckgabe: ``(config, warnungen)``."""
|
||||
warnings: list[str] = []
|
||||
|
||||
subnets = Database.all(
|
||||
conn,
|
||||
"SELECT * FROM dhcp_subnets WHERE enabled=1 ORDER BY sort_order, id",
|
||||
)
|
||||
reservations = collect_reservations(conn)
|
||||
option_rows = Database.all(conn, "SELECT * FROM dhcp_options ORDER BY code")
|
||||
options_by_id = {int(row["id"]): row for row in option_rows}
|
||||
|
||||
global_values: dict[int, str] = {}
|
||||
device_values: dict[int, dict[int, str]] = {}
|
||||
for row in Database.all(conn, "SELECT * FROM dhcp_option_values"):
|
||||
option_id = int(row["option_id"])
|
||||
value = str(row["value"] or "").strip()
|
||||
if not value:
|
||||
continue
|
||||
if row["device_id"] is None:
|
||||
global_values[option_id] = value
|
||||
else:
|
||||
device_values.setdefault(int(row["device_id"]), {})[option_id] = value
|
||||
|
||||
def option_data(values: dict[int, str]) -> list[dict[str, Any]]:
|
||||
entries: list[dict[str, Any]] = []
|
||||
for option_id, value in sorted(values.items()):
|
||||
option = options_by_id.get(option_id)
|
||||
if option is None:
|
||||
continue
|
||||
entries.append({"name": str(option["name"]), "data": value})
|
||||
return entries
|
||||
|
||||
subnet_entries: list[dict[str, Any]] = []
|
||||
interfaces: set[str] = set()
|
||||
for index, subnet in enumerate(subnets, start=1):
|
||||
interface = str(subnet["interface"])
|
||||
match = matching_network(
|
||||
networks, interface, str(subnet["range_start"]), str(subnet["range_end"])
|
||||
)
|
||||
if match is None:
|
||||
# Nicht passende Subnetze werden uebersprungen, nicht die ganze
|
||||
# Aktion abgebrochen -- eine geaenderte Host-IP soll nicht dazu
|
||||
# fuehren, dass gar keine Konfiguration mehr geschrieben wird.
|
||||
warnings.append(
|
||||
f"Subnetz {subnet['range_start']}-{subnet['range_end']} auf {interface} "
|
||||
"passt in kein aktuelles Netz und wurde übersprungen."
|
||||
)
|
||||
continue
|
||||
interfaces.add(interface)
|
||||
network = match.network
|
||||
|
||||
subnet_options: list[dict[str, Any]] = []
|
||||
if str(subnet["gateway"] or "").strip():
|
||||
subnet_options.append({"name": "routers", "data": str(subnet["gateway"]).strip()})
|
||||
if str(subnet["dns"] or "").strip():
|
||||
subnet_options.append(
|
||||
{"name": "domain-name-servers", "data": str(subnet["dns"]).strip()}
|
||||
)
|
||||
|
||||
subnet_reservations = []
|
||||
for reservation in reservations:
|
||||
if ipaddress.ip_address(reservation["ip"]) not in network:
|
||||
continue
|
||||
entry: dict[str, Any] = {
|
||||
"hw-address": reservation["mac"],
|
||||
"ip-address": reservation["ip"],
|
||||
"hostname": reservation["hostname"],
|
||||
}
|
||||
overrides = device_values.get(reservation.get("device_id") or -1, {})
|
||||
if overrides:
|
||||
entry["option-data"] = option_data(overrides)
|
||||
subnet_reservations.append(entry)
|
||||
|
||||
subnet_entries.append(
|
||||
{
|
||||
"id": index,
|
||||
"subnet": str(network),
|
||||
"pools": [{"pool": f"{subnet['range_start']} - {subnet['range_end']}"}],
|
||||
**({"option-data": subnet_options} if subnet_options else {}),
|
||||
"reservations": subnet_reservations,
|
||||
}
|
||||
)
|
||||
|
||||
if not subnet_entries:
|
||||
warnings.append("Es ist kein gültiges Subnetz konfiguriert | Kea würde nicht starten.")
|
||||
|
||||
global_option_data = option_data(global_values)
|
||||
domain = str(settings.get("dhcp_domain") or "").strip()
|
||||
if domain:
|
||||
global_option_data.insert(0, {"name": "domain-name", "data": domain})
|
||||
|
||||
# Eigene (nicht-Standard-) Optionen brauchen eine Definition, sonst
|
||||
# verweigert Kea den Start: "definition for the option 'dhcp4.<name>' does
|
||||
# not exist". Deklariert werden **alle** eigenen Optionen, nicht nur die
|
||||
# mit globalem Wert -- ein Wert kann auch nur in einer einzelnen
|
||||
# Reservierung stehen, und genau das hat den Dienst umgebracht: die
|
||||
# Definition fehlte, weil hier nur die globalen Werte geprueft wurden.
|
||||
# Eine ungenutzte Definition ist fuer Kea harmlos; eine fehlende nicht.
|
||||
custom_defs = [
|
||||
{
|
||||
"name": str(row["name"]),
|
||||
"code": int(row["code"]),
|
||||
"type": str(row["type"]),
|
||||
"space": "dhcp4",
|
||||
}
|
||||
for row in option_rows
|
||||
if not int(row["is_standard"] or 0)
|
||||
]
|
||||
|
||||
config = {
|
||||
"Dhcp4": {
|
||||
"interfaces-config": {"interfaces": sorted(interfaces)},
|
||||
"lease-database": {
|
||||
"type": "memfile",
|
||||
"lfc-interval": 3600,
|
||||
# Schluessel heisst "name" -- nicht "lease-file".
|
||||
"name": LEASE_FILE,
|
||||
},
|
||||
"valid-lifetime": int(settings.get("dhcp_lease_default") or 600),
|
||||
"max-valid-lifetime": int(settings.get("dhcp_lease_max") or 7200),
|
||||
**({"option-def": custom_defs} if custom_defs else {}),
|
||||
**({"option-data": global_option_data} if global_option_data else {}),
|
||||
"subnet4": subnet_entries,
|
||||
# loggers gehoert UNTER Dhcp4 -- daneben ist es ein Syntaxfehler.
|
||||
"loggers": [
|
||||
{
|
||||
"name": "kea-dhcp4",
|
||||
"output_options": [{"output": LOG_PATH, "pattern": "%D{%Y-%m-%d %H:%M:%S} %-5p %m\n"}],
|
||||
"severity": "INFO",
|
||||
"debuglevel": 0,
|
||||
}
|
||||
],
|
||||
}
|
||||
}
|
||||
return config, warnings
|
||||
|
||||
|
||||
def config_to_text(config: dict[str, Any]) -> str:
|
||||
return json.dumps(config, indent=2, ensure_ascii=False) + "\n"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Leases
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Lease:
|
||||
address: str
|
||||
mac: str
|
||||
hostname: str
|
||||
expires_at: int
|
||||
state: str
|
||||
|
||||
@property
|
||||
def is_active(self) -> bool:
|
||||
return self.state == "0"
|
||||
|
||||
@property
|
||||
def is_expired(self) -> bool:
|
||||
return self.expires_at > 0 and self.expires_at <= int(_now().timestamp())
|
||||
|
||||
@property
|
||||
def expires_text(self) -> str:
|
||||
"""Ablauf als Datum und Uhrzeit in der Zeitzone des Servers.
|
||||
|
||||
Kea schreibt einen Unix-Zeitstempel in die Lease-Datei. Der stand hier
|
||||
vorher unveraendert in der Tabelle -- eine zehnstellige Zahl, aus der
|
||||
niemand ablesen kann, ob eine Lease in zehn Minuten oder erst morgen
|
||||
ablaeuft.
|
||||
"""
|
||||
if self.expires_at <= 0:
|
||||
return "unbegrenzt"
|
||||
return datetime.fromtimestamp(self.expires_at).strftime("%d.%m.%Y %H:%M")
|
||||
|
||||
@property
|
||||
def remaining_text(self) -> str:
|
||||
"""Restlaufzeit in Worten -- die eigentlich interessierende Angabe."""
|
||||
if self.expires_at <= 0:
|
||||
return ""
|
||||
sekunden = self.expires_at - int(_now().timestamp())
|
||||
if sekunden <= 0:
|
||||
return "abgelaufen"
|
||||
if sekunden < 60:
|
||||
return "unter einer Minute"
|
||||
if sekunden < 3600:
|
||||
return f"in {sekunden // 60} min"
|
||||
if sekunden < 86400:
|
||||
stunden, rest = divmod(sekunden, 3600)
|
||||
return f"in {stunden} h {rest // 60} min" if rest >= 60 else f"in {stunden} h"
|
||||
tage, rest = divmod(sekunden, 86400)
|
||||
return f"in {tage} Tg {rest // 3600} h" if rest >= 3600 else f"in {tage} Tg"
|
||||
|
||||
|
||||
#: Zustand einer Reservierung -- wie im Windows-DHCP, wo eine Reservierung
|
||||
#: "aktiv" oder "inaktiv" ist, je nachdem ob der Client sie gerade benutzt.
|
||||
#: Ohne diese Angabe sieht eine nie abgeholte Reservierung genauso aus wie eine
|
||||
#: benutzte, und ein Geraet, das noch auf einer alten Adresse haengt, faellt
|
||||
#: ueberhaupt nicht auf.
|
||||
RESERVATION_STATES: dict[str, tuple[str, str]] = {
|
||||
"aktiv": ("online", "Das Gerät benutzt diese Adresse gerade."),
|
||||
"abgelaufen": ("", "Lease auf dieser Adresse ist abgelaufen | Gerät derzeit nicht da."),
|
||||
"andere_adresse": (
|
||||
"warning",
|
||||
"Das Gerät hält noch eine Lease auf einer anderen Adresse. Es übernimmt die "
|
||||
"Reservierung erst nach Ablauf oder Neuanforderung.",
|
||||
),
|
||||
"unbenutzt": ("", "Für dieses Gerät liegt keine Lease vor."),
|
||||
}
|
||||
|
||||
|
||||
def annotate_reservations(
|
||||
reservations: Sequence[dict[str, Any]], leases: Sequence[Lease]
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Ergaenzt jede Reservierung um ihren Zustand aus den laufenden Leases.
|
||||
|
||||
Reine Funktion ueber zwei Listen -- damit pruefbar, ohne Kea zu starten.
|
||||
"""
|
||||
nach_mac: dict[str, Lease] = {}
|
||||
for lease in leases:
|
||||
mac = normalize_mac(lease.mac)
|
||||
if not mac:
|
||||
continue
|
||||
vorhanden = nach_mac.get(mac)
|
||||
# Bei mehreren Leases je MAC gewinnt die, die spaeter ablaeuft.
|
||||
if vorhanden is None or lease.expires_at > vorhanden.expires_at:
|
||||
nach_mac[mac] = lease
|
||||
|
||||
ergebnis: list[dict[str, Any]] = []
|
||||
for reservation in reservations:
|
||||
eintrag = dict(reservation)
|
||||
lease = nach_mac.get(normalize_mac(str(reservation["mac"])))
|
||||
if lease is None:
|
||||
zustand = "unbenutzt"
|
||||
elif lease.address != str(reservation["ip"]):
|
||||
zustand = "andere_adresse"
|
||||
elif lease.is_expired or not lease.is_active:
|
||||
zustand = "abgelaufen"
|
||||
else:
|
||||
zustand = "aktiv"
|
||||
eintrag["state"] = zustand
|
||||
eintrag["state_hint"] = RESERVATION_STATES[zustand][1]
|
||||
eintrag["state_style"] = RESERVATION_STATES[zustand][0]
|
||||
eintrag["lease"] = lease
|
||||
ergebnis.append(eintrag)
|
||||
return ergebnis
|
||||
|
||||
|
||||
def read_leases(sysops: SysOps, *, path: str = LEASE_FILE) -> list[Lease]:
|
||||
"""Liest die memfile-Lease-Datei.
|
||||
|
||||
Die Datei haengt bei jeder Verlaengerung neue Zeilen an; es gewinnt die
|
||||
letzte Zeile je Adresse. Gelesen wird ueber den privilegierten Helfer, weil
|
||||
``/var/lib/kea`` dem Kea-Dienstbenutzer gehoert.
|
||||
"""
|
||||
result = sysops.read_privileged_file(path)
|
||||
if not result.ok:
|
||||
return []
|
||||
lines = result.stdout.splitlines()
|
||||
if not lines:
|
||||
return []
|
||||
header = [name.strip().strip('"') for name in lines[0].split(",")]
|
||||
try:
|
||||
index = {
|
||||
"address": header.index("address"),
|
||||
"hwaddr": header.index("hwaddr"),
|
||||
"expire": header.index("expire"),
|
||||
"state": header.index("state"),
|
||||
"hostname": header.index("hostname"),
|
||||
}
|
||||
except ValueError:
|
||||
logger.warning("Kea-Lease-Datei hat unerwartete Spalten: %s", header)
|
||||
return []
|
||||
|
||||
latest: dict[str, Lease] = {}
|
||||
for line in lines[1:]:
|
||||
fields = [field.strip().strip('"') for field in line.split(",")]
|
||||
if len(fields) < len(header):
|
||||
continue
|
||||
try:
|
||||
lease = Lease(
|
||||
address=fields[index["address"]],
|
||||
mac=normalize_mac(fields[index["hwaddr"]]),
|
||||
hostname=fields[index["hostname"]],
|
||||
expires_at=int(fields[index["expire"]] or 0),
|
||||
state=fields[index["state"]] or "0",
|
||||
)
|
||||
except (ValueError, IndexError):
|
||||
continue
|
||||
latest[lease.address] = lease
|
||||
return sorted(latest.values(), key=lambda entry: tuple(int(p) for p in entry.address.split(".") if p.isdigit()))
|
||||
|
||||
|
||||
def service_state(sysops: SysOps) -> dict[str, str]:
|
||||
state = sysops.service_state(SERVICE_UNIT)
|
||||
import shutil
|
||||
|
||||
state["binary"] = "vorhanden" if shutil.which("kea-dhcp4") else "fehlt"
|
||||
return state
|
||||
@@ -0,0 +1,947 @@
|
||||
"""Uebersetzt einen Export des alten TESM in das Format dieser Anwendung.
|
||||
|
||||
Der Vorgaenger (``gitea:alientim/tesm``) schreibt seine Exportdatei so::
|
||||
|
||||
{"app": "tesm", "version": 2, "exported_at": "...",
|
||||
"salt": "<b64url, 16 Byte>", "payload": "<Fernet-Token>"}
|
||||
|
||||
Der Schluessel entsteht aus Passphrase und Salt per PBKDF2-HMAC-SHA256 mit
|
||||
390000 Runden, die Nutzlast ist ein Fernet-Token ueber JSON. Das neue Format
|
||||
verwendet Argon2id und AES-GCM und bindet seinen Kopf als AAD ein. Die
|
||||
Umschlaege sind also nicht ineinander ueberfuehrbar, wohl aber ihr Inhalt.
|
||||
|
||||
Absichtlich ein **Uebersetzer** und kein zweiter Importweg: die Daten werden
|
||||
in das neue Format geschrieben und dann vom vorhandenen, geprueften Import
|
||||
eingelesen. Ein eigener Schreibpfad fuer Altdaten waere eine zweite Stelle, an
|
||||
der Fremdeingaben in die Datenbank gelangen -- und die zweite Stelle ist die,
|
||||
die niemand pflegt.
|
||||
|
||||
Uebersetzt werden Clients, Switche, Zugangsdaten, die DHCP-Konfiguration, die
|
||||
Freigabe-Zuordnungen und die Anbindung an das Verzeichnis (Server, Transport,
|
||||
Basis-DN, Anmeldeattribut, Bind-Konto samt Passwort, und die Zuordnung
|
||||
"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
|
||||
|
||||
import base64
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
#: Runden der alten Schluesselableitung. Steht so im Vorgaenger und darf sich
|
||||
#: nicht aendern -- sonst laesst sich keine alte Datei mehr oeffnen.
|
||||
LEGACY_KDF_ITERATIONS = 390000
|
||||
LEGACY_APP_TAG = "tesm"
|
||||
|
||||
#: Diese Abschnitte werden uebersetzt.
|
||||
SUPPORTED = ("credentials", "switches", "devices", "dhcp", "ldap")
|
||||
|
||||
#: Was der alte Export sonst enthaelt. Wird gemeldet, nicht uebersetzt.
|
||||
KNOWN_UNSUPPORTED: dict[str, str] = {
|
||||
"users": (
|
||||
"Lokale Konten: der alte Passwort-Hash ist bcrypt, diese Anwendung "
|
||||
"verwendet Argon2id. Ein Hash lässt sich nicht umrechnen – das ist sein "
|
||||
"Zweck. Konten neu anlegen."
|
||||
),
|
||||
"groups": "Gruppen und Rechte: die Rechteschlüssel wurden neu geschnitten.",
|
||||
# Der Abschnitt "ldap" wird uebersetzt (siehe _verzeichnis) -- was dabei
|
||||
# nicht mitkommt, benennt die Uebersetzung einzeln und genauer, als eine
|
||||
# pauschale Zeile hier es koennte.
|
||||
"nginx": "Webserver: Domain, Ports und Zertifikat von Hand übertragen.",
|
||||
"logs": "Auditlog: bleibt bewusst beim alten System.",
|
||||
"known_hosts": (
|
||||
"SSH-Host-Schlüssel: werden hier je Gerät mit Fingerprint geführt und "
|
||||
"über die Seite 'Host-Schlüssel' freigegeben."
|
||||
),
|
||||
}
|
||||
|
||||
#: Kategorien der Zugangsdaten -- in beiden Anwendungen dieselben vier Werte.
|
||||
VALID_CATEGORIES = ("switch", "linux", "windows", "router")
|
||||
FALLBACK_CATEGORY = "switch"
|
||||
|
||||
#: DHCP-Einstellungen, die in beiden Anwendungen gleich heissen.
|
||||
DHCP_SETTING_KEYS = ("dhcp_domain", "dhcp_lease_default", "dhcp_lease_max", "dhcp_output_path")
|
||||
|
||||
|
||||
class LegacyTransferError(Exception):
|
||||
"""Fehler mit Klartextmeldung fuer Oberflaeche und Kommandozeile."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class Report:
|
||||
"""Was uebernommen wurde -- und was nicht, mit Grund."""
|
||||
|
||||
counts: dict[str, int] = field(default_factory=dict)
|
||||
notes: list[str] = field(default_factory=list)
|
||||
|
||||
def took(self, category: str, amount: int = 1) -> None:
|
||||
if amount:
|
||||
self.counts[category] = self.counts.get(category, 0) + amount
|
||||
|
||||
def note(self, text: str) -> None:
|
||||
if text not in self.notes:
|
||||
self.notes.append(text)
|
||||
|
||||
def as_text(self, titel: str = "Uebersetzt") -> str:
|
||||
"""Bericht als Text. Der Titel gehoert dem Aufrufer.
|
||||
|
||||
Beim Lesen einer alten Installation ist noch nichts uebersetzt --
|
||||
"Uebersetzt: devices 4" waere an dieser Stelle einfach falsch.
|
||||
"""
|
||||
zeilen = [f"{titel}:"]
|
||||
zeilen += [f" {key}: {value}" for key, value in sorted(self.counts.items())] or [
|
||||
" nichts"
|
||||
]
|
||||
if self.notes:
|
||||
zeilen.append("")
|
||||
zeilen.append("Zu beachten:")
|
||||
zeilen += [f" - {note}" for note in self.notes]
|
||||
return "\n".join(zeilen)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Alten Umschlag oeffnen
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def open_legacy(raw: bytes | str, passphrase: str) -> dict[str, Any]:
|
||||
"""Entschluesselt eine Exportdatei des Vorgaengers."""
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
||||
|
||||
text = raw.decode("utf-8") if isinstance(raw, bytes) else raw
|
||||
try:
|
||||
envelope = json.loads(text)
|
||||
except ValueError as exc:
|
||||
raise LegacyTransferError("Die Datei ist kein JSON.") from exc
|
||||
if not isinstance(envelope, dict):
|
||||
raise LegacyTransferError("Die Datei enthält kein Objekt.")
|
||||
if str(envelope.get("app") or "") != LEGACY_APP_TAG:
|
||||
raise LegacyTransferError(
|
||||
f"Diese Datei gehört zu {envelope.get('app') or 'einer unbekannten Anwendung'!r}, "
|
||||
"nicht zu TESM."
|
||||
)
|
||||
for schluessel in ("salt", "payload"):
|
||||
if not envelope.get(schluessel):
|
||||
raise LegacyTransferError(f"Im Umschlag fehlt {schluessel!r}.")
|
||||
|
||||
try:
|
||||
salt = base64.urlsafe_b64decode(str(envelope["salt"]))
|
||||
except (ValueError, TypeError) as exc:
|
||||
raise LegacyTransferError("Das Salt ist unlesbar.") from exc
|
||||
|
||||
kdf = PBKDF2HMAC(
|
||||
algorithm=hashes.SHA256(), length=32, salt=salt, iterations=LEGACY_KDF_ITERATIONS
|
||||
)
|
||||
key = base64.urlsafe_b64encode(kdf.derive(passphrase.encode("utf-8")))
|
||||
try:
|
||||
klartext = Fernet(key).decrypt(str(envelope["payload"]).encode("utf-8"))
|
||||
except (InvalidToken, ValueError) as exc:
|
||||
raise LegacyTransferError(
|
||||
"Entschlüsselung fehlgeschlagen – falsche Passphrase oder beschädigte Datei."
|
||||
) from exc
|
||||
|
||||
try:
|
||||
payload = json.loads(klartext.decode("utf-8"))
|
||||
except ValueError as exc:
|
||||
raise LegacyTransferError("Die entschlüsselte Nutzlast ist kein JSON.") from exc
|
||||
if not isinstance(payload, dict):
|
||||
raise LegacyTransferError("Die Nutzlast enthält kein Objekt.")
|
||||
return payload
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Uebersetzen
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _liste(value: Any) -> list[dict[str, Any]]:
|
||||
return [entry for entry in value if isinstance(entry, dict)] if isinstance(value, list) else []
|
||||
|
||||
|
||||
def _category(value: Any, report: Report, subject: str) -> str:
|
||||
kategorie = str(value or "").strip().lower()
|
||||
if kategorie in VALID_CATEGORIES:
|
||||
return kategorie
|
||||
if kategorie:
|
||||
report.note(
|
||||
f"Unbekannte Kategorie {kategorie!r} bei {subject} auf {FALLBACK_CATEGORY!r} gesetzt."
|
||||
)
|
||||
return FALLBACK_CATEGORY
|
||||
|
||||
|
||||
def _credentials(items: Any, report: Report) -> list[dict[str, Any]]:
|
||||
ergebnis: list[dict[str, Any]] = []
|
||||
ohne_passwort = 0
|
||||
for entry in _liste(items):
|
||||
name = str(entry.get("name") or "").strip()
|
||||
if not name:
|
||||
report.note("Zugangsdaten ohne Namen übersprungen.")
|
||||
continue
|
||||
umgesetzt: dict[str, Any] = {
|
||||
"name": name,
|
||||
"username": str(entry.get("username") or ""),
|
||||
"category": _category(entry.get("category"), report, f"Zugangsdaten {name!r}"),
|
||||
# Der Vorgaenger kennt kein Beschreibungsfeld.
|
||||
"description": "aus altem TESM übernommen",
|
||||
}
|
||||
# Der alte Export entschluesselt das Passwort und legt es im Klartext in
|
||||
# die -- als Ganzes passphrasenverschluesselte -- Datei. Beim Import
|
||||
# verschluesselt es der Schluesselspeicher sofort wieder.
|
||||
if entry.get("password"):
|
||||
umgesetzt["secret"] = str(entry["password"])
|
||||
else:
|
||||
ohne_passwort += 1
|
||||
ergebnis.append(umgesetzt)
|
||||
if ohne_passwort:
|
||||
report.note(
|
||||
f"{ohne_passwort} Zugangsdaten ohne Passwort in der Datei – "
|
||||
"sie müssen nachgetragen werden."
|
||||
)
|
||||
report.took("credentials", len(ergebnis))
|
||||
return ergebnis
|
||||
|
||||
|
||||
def _switches(items: Any, report: Report) -> list[dict[str, Any]]:
|
||||
ergebnis: list[dict[str, Any]] = []
|
||||
for entry in _liste(items):
|
||||
hostname = str(entry.get("hostname") or "").strip()
|
||||
if not hostname:
|
||||
report.note("Switch ohne Hostnamen übersprungen.")
|
||||
continue
|
||||
ergebnis.append(
|
||||
{
|
||||
"hostname": hostname,
|
||||
"ip": str(entry.get("ip") or ""),
|
||||
"ssh_port": int(entry.get("ssh_port") or 22),
|
||||
"credential_name": entry.get("credential_name"),
|
||||
# Diese Felder gibt es im Vorgaenger nicht.
|
||||
"model": "",
|
||||
"location": "",
|
||||
"notes": "",
|
||||
}
|
||||
)
|
||||
report.took("switches", len(ergebnis))
|
||||
return ergebnis
|
||||
|
||||
|
||||
def _devices(items: Any, credentials: list[dict[str, Any]], report: Report) -> list[dict[str, Any]]:
|
||||
# Der Vorgaenger kennt keine Geraetekategorie. Sie steuert hier, was mit
|
||||
# einem Geraet moeglich ist (Linux ueber SSH, Windows ueber RPC), und laesst
|
||||
# sich aus den zugeordneten Zugangsdaten ableiten. Das ist besser als leer:
|
||||
# ohne Kategorie erscheint das Geraet auf der Wartungsseite gar nicht.
|
||||
kategorie_je_zugang = {str(c["name"]): str(c["category"]) for c in credentials}
|
||||
ergebnis: list[dict[str, Any]] = []
|
||||
abgeleitet = 0
|
||||
for entry in _liste(items):
|
||||
mac = str(entry.get("mac") or "").strip()
|
||||
if not mac:
|
||||
report.note("Client ohne MAC-Adresse übersprungen.")
|
||||
continue
|
||||
zugang = entry.get("credential_name")
|
||||
kategorie = kategorie_je_zugang.get(str(zugang or ""), "")
|
||||
if kategorie:
|
||||
abgeleitet += 1
|
||||
ergebnis.append(
|
||||
{
|
||||
"mac": mac,
|
||||
"ip": str(entry.get("ip") or ""),
|
||||
"name": str(entry.get("name") or mac),
|
||||
"port": str(entry.get("port") or ""),
|
||||
"is_active": int(bool(entry.get("is_active", 1))),
|
||||
"ssh_port": int(entry.get("ssh_port") or 22),
|
||||
"switch_hostname": entry.get("switch_hostname"),
|
||||
"credential_name": zugang,
|
||||
"category": kategorie,
|
||||
"location": "",
|
||||
"notes": "",
|
||||
# Im Vorgaenger war der automatische Neustart nicht abschaltbar.
|
||||
"auto_restart": 1,
|
||||
}
|
||||
)
|
||||
if abgeleitet:
|
||||
report.note(
|
||||
f"Kategorie von {abgeleitet} Client(s) aus den zugeordneten Zugangsdaten "
|
||||
"abgeleitet – der Vorgänger kennt kein solches Feld. Bitte prüfen."
|
||||
)
|
||||
report.took("devices", len(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]]:
|
||||
"""DHCP-Teil uebersetzen. Rueckgabe: ``(dhcp, settings)``."""
|
||||
if not isinstance(data, dict):
|
||||
return {}, {}
|
||||
|
||||
subnets = [
|
||||
{
|
||||
"interface": str(entry.get("interface") or ""),
|
||||
"range_start": str(entry.get("range_start") or ""),
|
||||
"range_end": str(entry.get("range_end") or ""),
|
||||
"gateway": str(entry.get("gateway") or ""),
|
||||
"dns": str(entry.get("dns") or ""),
|
||||
# Der Vorgaenger hat kein Kommentarfeld am Subnetz.
|
||||
"comment": "",
|
||||
"enabled": int(bool(entry.get("enabled", 1))),
|
||||
"sort_order": int(entry.get("sort_order") or 0),
|
||||
}
|
||||
for entry in _liste(data.get("subnets"))
|
||||
]
|
||||
|
||||
# Der Vorgaenger fuehrt am Eintrag ein Feld "name"; hier heisst es
|
||||
# "hostname", weil genau das im Kea-Eintrag landet.
|
||||
reservations = []
|
||||
for entry in _liste(data.get("reservations")):
|
||||
mac = str(entry.get("mac") or "").strip()
|
||||
if not mac:
|
||||
report.note("Manuelle Reservierung ohne MAC-Adresse übersprungen.")
|
||||
continue
|
||||
reservations.append(
|
||||
{
|
||||
"mac": mac,
|
||||
"ip": str(entry.get("ip") or ""),
|
||||
"hostname": str(entry.get("name") or ""),
|
||||
"comment": "aus altem TESM übernommen",
|
||||
}
|
||||
)
|
||||
|
||||
# Der Vorgaenger schachtelt die Werte unter der Definition, hier steht je
|
||||
# Wert eine Zeile mit den Feldern der Definition daneben.
|
||||
options: list[dict[str, Any]] = []
|
||||
ohne_wert = 0
|
||||
for definition in _liste(data.get("options")):
|
||||
gemeinsam = {
|
||||
"code": int(definition.get("code") or 0),
|
||||
"name": str(definition.get("name") or ""),
|
||||
"type": str(definition.get("type") or "string"),
|
||||
"description": str(definition.get("description") or ""),
|
||||
"is_standard": int(bool(definition.get("is_standard"))),
|
||||
}
|
||||
if not gemeinsam["code"] or not gemeinsam["name"]:
|
||||
report.note("DHCP-Option ohne Code oder Namen übersprungen.")
|
||||
continue
|
||||
werte = _liste(definition.get("values"))
|
||||
if not werte:
|
||||
ohne_wert += 1
|
||||
continue
|
||||
for wert in werte:
|
||||
options.append(
|
||||
{
|
||||
**gemeinsam,
|
||||
"value": str(wert.get("value") or ""),
|
||||
# Leerer Wert = globale Geltung; sonst die MAC des Geraets.
|
||||
"device_mac": str(wert.get("device_mac") or "") or None,
|
||||
}
|
||||
)
|
||||
if ohne_wert:
|
||||
report.note(
|
||||
f"{ohne_wert} eigene DHCP-Option(en) ohne Wert übersprungen – ohne Wert "
|
||||
"gibt es nichts zu senden. Bei Bedarf von Hand anlegen."
|
||||
)
|
||||
|
||||
dhcp: dict[str, Any] = {}
|
||||
if subnets:
|
||||
dhcp["subnets"] = subnets
|
||||
if reservations:
|
||||
dhcp["reservations"] = reservations
|
||||
if options:
|
||||
dhcp["options"] = options
|
||||
|
||||
report.took("dhcp-Subnetze", len(subnets))
|
||||
report.took("dhcp-Reservierungen", len(reservations))
|
||||
report.took("dhcp-Optionswerte", len(options))
|
||||
|
||||
alt_settings = data.get("settings") if isinstance(data.get("settings"), dict) else {}
|
||||
settings = {
|
||||
key: str(alt_settings[key])
|
||||
for key in DHCP_SETTING_KEYS
|
||||
if alt_settings.get(key) not in (None, "")
|
||||
}
|
||||
report.took("Einstellungen", len(settings))
|
||||
return dhcp, settings
|
||||
|
||||
|
||||
def convert(legacy: dict[str, Any]) -> tuple[dict[str, Any], Report]:
|
||||
"""Baut aus der alten Nutzlast eine Nutzlast im neuen Format."""
|
||||
report = Report()
|
||||
payload: dict[str, Any] = {}
|
||||
|
||||
credentials = _credentials(legacy.get("credentials"), report)
|
||||
if credentials:
|
||||
payload["credentials"] = credentials
|
||||
|
||||
switches = _switches(legacy.get("switches"), report)
|
||||
if switches:
|
||||
payload["switches"] = switches
|
||||
|
||||
devices = _devices(legacy.get("devices"), credentials, report)
|
||||
if 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)
|
||||
if dhcp:
|
||||
payload["dhcp"] = dhcp
|
||||
if settings:
|
||||
# Nur die vier DHCP-Schluessel, nichts sonst: alles andere heisst hier
|
||||
# anders oder bedeutet etwas anderes.
|
||||
payload["settings"] = settings
|
||||
|
||||
# Zugeordnete Zugangsdaten, die nicht mitexportiert wurden, laufen beim
|
||||
# Import ins Leere -- er loest sie ueber den Namen auf und setzt sonst NULL.
|
||||
# Lieber vorher sagen als hinterher suchen.
|
||||
bekannt = {str(c["name"]) for c in credentials}
|
||||
fehlend = sorted(
|
||||
{
|
||||
str(entry["credential_name"])
|
||||
for entry in (*switches, *devices)
|
||||
if entry.get("credential_name") and str(entry["credential_name"]) not in bekannt
|
||||
}
|
||||
)
|
||||
if fehlend:
|
||||
report.note(
|
||||
"Diese Zugangsdaten sind zugeordnet, fehlen aber in der Datei und bleiben "
|
||||
f"deshalb leer: {', '.join(fehlend)}. Beim Export im alten TESM die "
|
||||
"Kategorie 'Zugangsdaten' mitwählen."
|
||||
)
|
||||
|
||||
for schluessel, grund in KNOWN_UNSUPPORTED.items():
|
||||
if legacy.get(schluessel):
|
||||
report.note(grund)
|
||||
unbekannt = sorted(set(legacy) - set(SUPPORTED) - set(KNOWN_UNSUPPORTED))
|
||||
if unbekannt:
|
||||
report.note(f"Unbekannte Abschnitte übersprungen: {', '.join(unbekannt)}.")
|
||||
|
||||
if not payload:
|
||||
raise LegacyTransferError(
|
||||
"Die Datei enthält keine Clients, Switche, Zugangsdaten, DHCP-Daten oder "
|
||||
"Freigaben. Beim Export im alten TESM diese Kategorien mitwählen."
|
||||
)
|
||||
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"
|
||||
@@ -0,0 +1,726 @@
|
||||
"""Wartung: Paketaktualisierung und Neustart, als nachvollziehbare Auftraege.
|
||||
|
||||
Klar getrennt vom PoE-Neustart: das Dashboard schaltet immer den Switch-Port,
|
||||
die Wartung spricht immer direkt mit dem Geraet.
|
||||
|
||||
Der Weg dorthin haengt am Betriebssystem:
|
||||
|
||||
* **Linux** -- SSH. Host-Schluessel muss freigegeben sein, ``sudo`` bekommt bei
|
||||
Bedarf das hinterlegte Passwort ueber stdin.
|
||||
* **Windows** -- RPC ueber :mod:`tesm.services.windowsops`. Kein SSH, kein
|
||||
Host-Schluessel; nur Neustart, keine Paketaktualisierung.
|
||||
|
||||
Aenderung gegenueber dem Vorgaenger: Auftragszustand lag dort ausschliesslich im
|
||||
Prozessspeicher. Ein Dienst-Neustart verlor die Anzeige komplett -- inklusive der
|
||||
Information, ob ueberhaupt etwas gelaufen war. Hier liegt jeder Auftrag in der
|
||||
Tabelle ``jobs``: ueberlebt Neustarts, ist im Verlauf einsehbar und kann nach
|
||||
einem Abbruch als "unklar beendet" erkannt werden.
|
||||
|
||||
Host-Schluessel werden hier **immer** streng geprueft. Bei einem Hintergrundlauf
|
||||
sieht niemand zu; ein automatisches Akzeptieren waere ein Einfallstor.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import secrets
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Callable
|
||||
|
||||
from tesm_core import audit
|
||||
from tesm_core.db import Database
|
||||
|
||||
from . import windowsops
|
||||
from .monitor import METHOD_SSH, record_restart, tcp_reachable
|
||||
from .restart import METHOD_RPC
|
||||
from .ssh import SshError, open_transport
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
JOB_KIND_UPDATE = "maintenance.update"
|
||||
JOB_KIND_REBOOT = "maintenance.reboot"
|
||||
|
||||
MAX_OUTPUT_LINES = 400
|
||||
CONNECT_TIMEOUT = 25
|
||||
REBOOT_GRACE_SECONDS = 15
|
||||
REBOOT_POLL_SECONDS = 5
|
||||
|
||||
#: Die Wartungsschritte. ``{sudo}`` wird zur Laufzeit eingesetzt -- je nachdem,
|
||||
#: ob das Zielgeraet fuer ``sudo`` ein Passwort verlangt.
|
||||
UPDATE_COMMANDS: tuple[tuple[str, str], ...] = (
|
||||
(
|
||||
"Paketlisten aktualisieren",
|
||||
"env DEBIAN_FRONTEND=noninteractive {sudo} apt-get update",
|
||||
),
|
||||
(
|
||||
"Pakete aktualisieren",
|
||||
"env DEBIAN_FRONTEND=noninteractive {sudo} apt-get -y "
|
||||
'-o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold" upgrade',
|
||||
),
|
||||
("Aufraeumen", "env DEBIAN_FRONTEND=noninteractive {sudo} apt-get -y autoremove"),
|
||||
)
|
||||
|
||||
REBOOT_COMMAND = "{sudo} reboot"
|
||||
|
||||
#: Windows wird **nicht** ueber SSH bedient -- siehe :mod:`tesm.services.windowsops`.
|
||||
#: Diese Geraete haben in aller Regel keinen SSH-Dienst und damit auch keinen
|
||||
#: Host-Schluessel, den jemand freigeben koennte.
|
||||
WINDOWS_CATEGORY = "windows"
|
||||
|
||||
#: Woran ein Windows-Geraet als "wieder da" erkannt wird: Port 445 (SMB/RPC) --
|
||||
#: derselbe Dienst, ueber den der Neustart ausgeloest wurde.
|
||||
WINDOWS_PROBE_PORT = 445
|
||||
|
||||
#: ``-S`` liest das Passwort von stdin, ``-p ''`` unterdrueckt die Aufforderung
|
||||
#: (sie landete sonst in der Ausgabe), ``-k`` verwirft einen zwischen-
|
||||
#: gespeicherten sudo-Zeitstempel, damit der Ablauf reproduzierbar bleibt.
|
||||
SUDO_WITH_PASSWORD = "sudo -S -k -p ''"
|
||||
SUDO_PASSWORDLESS = "sudo -n"
|
||||
|
||||
COMPLETION_MARKER = "### TESM-WARTUNG-ENDE ###"
|
||||
|
||||
#: Fortschrittsgeschwaetz von dpkg. Ohne Terminal kann dpkg die Zeile nicht
|
||||
#: ueberschreiben und schreibt stattdessen jede Stufe einzeln -- zwanzig Zeilen
|
||||
#: "(Reading database ... 45%)" je Paket. Das blaeht das Protokoll so weit auf,
|
||||
#: dass die eigentlichen Meldungen darin untergehen und die Zeilenbegrenzung
|
||||
#: alles Interessante hinausschiebt.
|
||||
NOISE_PATTERNS: tuple[re.Pattern[str], ...] = (
|
||||
re.compile(r"^\(?Reading database \.\.\.\s*\d+%\)?\s*$"),
|
||||
re.compile(r"^\(?Reading database \.\.\.\s*$"),
|
||||
re.compile(r"^Progress: \[\s*\d+%\]"),
|
||||
re.compile(r"^\s*$"),
|
||||
)
|
||||
|
||||
|
||||
def is_noise(line: str) -> bool:
|
||||
"""Reine Fortschrittsanzeige ohne Informationsgehalt?
|
||||
|
||||
Die Abschlusszeile mit der Dateizahl bleibt erhalten -- sie sagt etwas aus.
|
||||
"""
|
||||
return any(pattern.match(line) for pattern in NOISE_PATTERNS)
|
||||
|
||||
|
||||
#: Fehlerbilder, die auf dem Zielgeraet liegen und dort behoben werden muessen.
|
||||
#: Die rohe Meldung sagt zwar die Wahrheit, aber nicht, was zu tun ist.
|
||||
HINTS: tuple[tuple[str, str], ...] = (
|
||||
(
|
||||
"incorrect password attempt",
|
||||
"Das Passwort aus den Zugangsdaten wurde von 'sudo' abgewiesen. Für die "
|
||||
"SSH-Anmeldung hat es gereicht – 'sudo' auf diesem Gerät verlangt also ein "
|
||||
"anderes Passwort, oder das Konto steht nicht in der sudoers-Datei.",
|
||||
),
|
||||
(
|
||||
"is not in the sudoers file",
|
||||
"Das Wartungskonto darf auf diesem Gerät kein 'sudo' ausführen. Auf dem "
|
||||
"Zielgerät eintragen, z. B. in /etc/sudoers.d/tesm-wartung: "
|
||||
"'<benutzer> ALL=(root) /usr/bin/apt-get, /sbin/reboot'. Mit vorangestelltem "
|
||||
"NOPASSWD: entfällt die Passwortabfrage ganz.",
|
||||
),
|
||||
(
|
||||
"a password is required",
|
||||
"'sudo' verlangt ein Passwort, und in den Zugangsdaten dieses Geräts ist keines "
|
||||
"hinterlegt. Tragen Sie dort das Passwort des Wartungskontos ein | TESM reicht "
|
||||
"es über die bereits verschlüsselte SSH-Verbindung an 'sudo' weiter.",
|
||||
),
|
||||
(
|
||||
"no tty present",
|
||||
"Das Wartungskonto verlangt für 'sudo' ein Terminal. In der sudoers-Datei des "
|
||||
"Zielgeräts 'Defaults requiretty' entfernen oder für dieses Konto abschalten.",
|
||||
),
|
||||
(
|
||||
"command not found",
|
||||
"Das Kommando gibt es auf dem Zielgerät nicht. Die Wartung erwartet ein "
|
||||
"Debian- oder Ubuntu-System mit apt-get.",
|
||||
),
|
||||
(
|
||||
"Access is denied",
|
||||
"Das Konto darf auf diesem Windows-Gerät keinen Neustart auslösen. Es muss "
|
||||
"Mitglied der lokalen Administratoren sein.",
|
||||
),
|
||||
(
|
||||
"is not recognized as an internal or external command",
|
||||
"Das Kommando gibt es auf diesem Gerät nicht. Ist bei den Zugangsdaten die "
|
||||
"richtige Kategorie gewählt (Linux-Client bzw. Windows-Client)?",
|
||||
),
|
||||
(
|
||||
"Could not get lock",
|
||||
"Ein anderer Paketvorgang läuft bereits auf dem Zielgerät (etwa "
|
||||
"unattended-upgrades). Später erneut versuchen.",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def hint_for(output: str) -> str:
|
||||
"""Sucht zu einer Fehlerausgabe den passenden Hinweis -- oder gibt "" zurueck."""
|
||||
for needle, hint in HINTS:
|
||||
if needle in output:
|
||||
return hint
|
||||
return ""
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class JobHandle:
|
||||
id: str
|
||||
kind: str
|
||||
subject: str
|
||||
|
||||
|
||||
def create_job(
|
||||
conn: sqlite3.Connection, *, kind: str, subject: str, actor: str, message: str = ""
|
||||
) -> JobHandle:
|
||||
job_id = secrets.token_urlsafe(12)
|
||||
conn.execute(
|
||||
"INSERT INTO jobs (id, kind, subject, state, progress, message, output, started_by, "
|
||||
"created_at, updated_at) VALUES (?,?,?,'pending',0,?,'',?,?,?)",
|
||||
(job_id, kind, subject, message, actor, _now(), _now()),
|
||||
)
|
||||
return JobHandle(id=job_id, kind=kind, subject=subject)
|
||||
|
||||
|
||||
def update_job(
|
||||
database: Any,
|
||||
job_id: str,
|
||||
*,
|
||||
state: str | None = None,
|
||||
progress: int | None = None,
|
||||
message: str | None = None,
|
||||
append_output: str = "",
|
||||
finished: bool = False,
|
||||
) -> None:
|
||||
with database.session() as conn, database.transaction(conn):
|
||||
fields: list[str] = ["updated_at=?"]
|
||||
params: list[Any] = [_now()]
|
||||
if state is not None:
|
||||
fields.append("state=?")
|
||||
params.append(state)
|
||||
if progress is not None:
|
||||
fields.append("progress=?")
|
||||
params.append(max(0, min(100, progress)))
|
||||
if message is not None:
|
||||
fields.append("message=?")
|
||||
params.append(message[:500])
|
||||
if finished:
|
||||
fields.append("finished_at=?")
|
||||
params.append(_now())
|
||||
if append_output:
|
||||
current = str(
|
||||
Database.value(conn, "SELECT output FROM jobs WHERE id=?", (job_id,), default="")
|
||||
or ""
|
||||
)
|
||||
combined = current + append_output
|
||||
lines = combined.splitlines()
|
||||
trimmed = "\n".join(lines[-MAX_OUTPUT_LINES:])
|
||||
# splitlines() wirft ein abschliessendes Zeilenende weg. Ohne diese
|
||||
# Zeile klebte jede neue Ausgabe an der vorherigen, und das ganze
|
||||
# Protokoll stand am Ende auf einer einzigen Zeile.
|
||||
if combined.endswith("\n"):
|
||||
trimmed += "\n"
|
||||
fields.append("output=?")
|
||||
params.append(trimmed)
|
||||
params.append(job_id)
|
||||
conn.execute(f"UPDATE jobs SET {', '.join(fields)} WHERE id=?", tuple(params))
|
||||
|
||||
|
||||
def list_jobs(
|
||||
conn: sqlite3.Connection, *, kinds: tuple[str, ...] = (JOB_KIND_UPDATE, JOB_KIND_REBOOT), limit: int = 60
|
||||
) -> list[dict[str, Any]]:
|
||||
placeholders = ",".join("?" * len(kinds))
|
||||
return Database.all(
|
||||
conn,
|
||||
f"SELECT * FROM jobs WHERE kind IN ({placeholders}) ORDER BY created_at DESC LIMIT ?",
|
||||
(*kinds, limit),
|
||||
)
|
||||
|
||||
|
||||
def get_job(conn: sqlite3.Connection, job_id: str) -> dict[str, Any] | None:
|
||||
return Database.one(conn, "SELECT * FROM jobs WHERE id=?", (job_id,))
|
||||
|
||||
|
||||
def mark_orphaned_jobs(conn: sqlite3.Connection) -> int:
|
||||
"""Auftraege, die beim Dienststart noch als "laufend" gelten, ehrlich kennzeichnen.
|
||||
|
||||
Ein abgebrochener Webprozess weiss nicht, was auf dem Zielgeraet passiert
|
||||
ist -- also wird der Auftrag als "unklar" markiert statt als erfolgreich
|
||||
oder fehlgeschlagen.
|
||||
"""
|
||||
cursor = conn.execute(
|
||||
"UPDATE jobs SET state='unknown', message=?, finished_at=?, updated_at=? "
|
||||
"WHERE state IN ('pending','running')",
|
||||
(
|
||||
"Der Dienst wurde während des Laufs neu gestartet. Was auf dem Zielgerät "
|
||||
"passiert ist, lässt sich von hier nicht mehr feststellen.",
|
||||
_now(),
|
||||
_now(),
|
||||
),
|
||||
)
|
||||
return cursor.rowcount or 0
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Ausfuehrung
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _run_remote_command(
|
||||
transport: Any,
|
||||
command: str,
|
||||
*,
|
||||
timeout: int,
|
||||
on_line: Callable[[str], None],
|
||||
stdin_text: str = "",
|
||||
) -> int:
|
||||
"""Fuehrt ein Kommando aus und liefert dessen Rueckgabewert.
|
||||
|
||||
``stdin_text`` geht ueber den Kanal an das Kommando -- so und nur so bekommt
|
||||
``sudo -S`` sein Passwort. Auf der Kommandozeile stuende es auf dem
|
||||
Zielgeraet in ``ps``, in der Umgebung in ``/proc/<pid>/environ``.
|
||||
"""
|
||||
channel = transport.open_session()
|
||||
channel.settimeout(timeout)
|
||||
channel.set_combine_stderr(True)
|
||||
channel.exec_command(command)
|
||||
if stdin_text:
|
||||
try:
|
||||
channel.sendall(stdin_text.encode("utf-8"))
|
||||
channel.shutdown_write()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
on_line(f"[Eingabe konnte nicht übergeben werden: {exc}]")
|
||||
deadline = time.monotonic() + timeout
|
||||
pending = b""
|
||||
while True:
|
||||
if channel.recv_ready():
|
||||
chunk = channel.recv(32768)
|
||||
if not chunk:
|
||||
break
|
||||
pending += chunk
|
||||
while b"\n" in pending:
|
||||
line, pending = pending.split(b"\n", 1)
|
||||
on_line(line.decode("utf-8", errors="replace"))
|
||||
elif channel.exit_status_ready():
|
||||
break
|
||||
elif time.monotonic() > deadline:
|
||||
on_line("[Zeitlimit überschritten | Verbindung wird getrennt]")
|
||||
channel.close()
|
||||
return 124
|
||||
else:
|
||||
time.sleep(0.1)
|
||||
if pending:
|
||||
on_line(pending.decode("utf-8", errors="replace"))
|
||||
status = channel.recv_exit_status()
|
||||
channel.close()
|
||||
return int(status)
|
||||
|
||||
|
||||
def _sudo_mode(
|
||||
transport: Any, password: str, on_line: Callable[[str], None]
|
||||
) -> tuple[str, str]:
|
||||
"""Bestimmt, wie ``sudo`` auf diesem Geraet aufzurufen ist.
|
||||
|
||||
Rueckgabe: ``(praefix, stdin)`` -- entweder ``sudo -n`` ohne Eingabe oder
|
||||
``sudo -S`` mit dem Passwort auf stdin.
|
||||
|
||||
Die Reihenfolge ist Absicht: geht es ohne Passwort (sudoers-Regel mit
|
||||
NOPASSWD), verlaesst das Passwort den Server gar nicht erst. Erst wenn
|
||||
``sudo`` eines verlangt, wird das in den Zugangsdaten hinterlegte
|
||||
weitergereicht -- ueber stdin der bereits verschluesselten Verbindung.
|
||||
"""
|
||||
swallowed: list[str] = []
|
||||
try:
|
||||
status = _run_remote_command(
|
||||
transport, f"{SUDO_PASSWORDLESS} true", timeout=20, on_line=swallowed.append
|
||||
)
|
||||
except Exception: # noqa: BLE001 - dann eben der Weg mit Passwort
|
||||
status = 1
|
||||
if status == 0:
|
||||
on_line("sudo ist ohne Passwort möglich.")
|
||||
return SUDO_PASSWORDLESS, ""
|
||||
if not password:
|
||||
on_line(
|
||||
"sudo verlangt ein Passwort, in den Zugangsdaten ist keines hinterlegt."
|
||||
)
|
||||
return SUDO_PASSWORDLESS, ""
|
||||
on_line("sudo verlangt ein Passwort – es wird aus den Zugangsdaten übergeben.")
|
||||
return SUDO_WITH_PASSWORD, password + "\n"
|
||||
|
||||
|
||||
def run_update(
|
||||
database: Any,
|
||||
*,
|
||||
job_id: str,
|
||||
device: dict[str, Any],
|
||||
username: str,
|
||||
password: str,
|
||||
timeout: int,
|
||||
) -> None:
|
||||
"""Fuehrt die Paketaktualisierung aus. Laeuft in einem eigenen Thread."""
|
||||
name = str(device.get("name") or device.get("ip"))
|
||||
|
||||
def emit(line: str) -> None:
|
||||
stamp = datetime.now().strftime("%H:%M:%S")
|
||||
update_job(database, job_id, append_output=f"{stamp} {line}\n")
|
||||
|
||||
update_job(database, job_id, state="running", progress=5, message="Verbindung wird aufgebaut")
|
||||
try:
|
||||
with database.session() as conn:
|
||||
transport = open_transport(
|
||||
conn,
|
||||
host=str(device["ip"]),
|
||||
port=int(device.get("ssh_port") or 22),
|
||||
username=username,
|
||||
password=password,
|
||||
)
|
||||
except SshError as exc:
|
||||
emit(f"FEHLER: {exc}")
|
||||
update_job(database, job_id, state="failed", message=str(exc), finished=True)
|
||||
return
|
||||
|
||||
failed = False
|
||||
problem = ""
|
||||
sudo, sudo_stdin = _sudo_mode(transport, password, emit)
|
||||
try:
|
||||
total = len(UPDATE_COMMANDS)
|
||||
for index, (label, template) in enumerate(UPDATE_COMMANDS, start=1):
|
||||
update_job(
|
||||
database,
|
||||
job_id,
|
||||
progress=int(5 + (index - 1) / total * 90),
|
||||
message=f"Schritt {index}/{total}: {label}",
|
||||
)
|
||||
emit(f"--- {label} ---")
|
||||
collected: list[str] = []
|
||||
|
||||
def capture(line: str) -> None:
|
||||
# Fuer die Fehlersuche wird alles gesammelt, ins Protokoll
|
||||
# kommt nur, was etwas aussagt.
|
||||
collected.append(line)
|
||||
if not is_noise(line):
|
||||
emit(line)
|
||||
|
||||
command = template.format(sudo=sudo)
|
||||
status = _run_remote_command(
|
||||
transport, command, timeout=timeout, on_line=capture, stdin_text=sudo_stdin
|
||||
)
|
||||
if status != 0:
|
||||
failed = True
|
||||
emit(f"Schritt {label!r} endete mit Rückgabewert {status}.")
|
||||
problem = hint_for("\n".join(collected))
|
||||
if problem:
|
||||
emit("")
|
||||
emit(f"Hinweis: {problem}")
|
||||
break
|
||||
finally:
|
||||
try:
|
||||
transport.close()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
emit(COMPLETION_MARKER)
|
||||
update_job(
|
||||
database,
|
||||
job_id,
|
||||
state="failed" if failed else "succeeded",
|
||||
progress=100,
|
||||
message=(
|
||||
(problem or "Ein Schritt ist fehlgeschlagen.")
|
||||
if failed
|
||||
else "Aktualisierung abgeschlossen."
|
||||
)[:500],
|
||||
finished=True,
|
||||
)
|
||||
with database.session() as conn, database.transaction(conn):
|
||||
audit.log(
|
||||
conn,
|
||||
actor="system",
|
||||
action="maintenance.update_finished",
|
||||
target=name,
|
||||
detail="fehlgeschlagen" if failed else "erfolgreich",
|
||||
severity="warning" if failed else "notice",
|
||||
)
|
||||
|
||||
|
||||
def _reboot_windows(
|
||||
database: Any,
|
||||
*,
|
||||
job_id: str,
|
||||
device: dict[str, Any],
|
||||
username: str,
|
||||
password: str,
|
||||
actor: str,
|
||||
emit: Callable[[str], None],
|
||||
elapsed: Callable[[], int],
|
||||
) -> bool:
|
||||
"""Neustart eines Windows-Geraets ueber RPC. ``True`` = weiter warten.
|
||||
|
||||
Getrennt gehalten, weil hier fast nichts gemeinsam ist: keine
|
||||
SSH-Verbindung, kein Host-Schluessel, kein sudo. Nur das Warten auf die
|
||||
Rueckkehr danach ist dasselbe -- und das steht beim Aufrufer.
|
||||
"""
|
||||
host = str(device["ip"])
|
||||
emit(f"Windows-Gerät | Neustart über RPC an {host} (Port {WINDOWS_PROBE_PORT}).")
|
||||
result = windowsops.reboot(host, username, password)
|
||||
|
||||
for line in result.output.splitlines():
|
||||
if not is_noise(line):
|
||||
emit(line)
|
||||
if result.ok:
|
||||
return True
|
||||
|
||||
detail = result.hint or result.output or "Der Neustart wurde abgewiesen."
|
||||
if result.hint:
|
||||
emit("")
|
||||
emit(f"Hinweis: {result.hint}")
|
||||
update_job(database, job_id, state="failed", progress=100, message=detail[:500], finished=True)
|
||||
_log_restart(
|
||||
database,
|
||||
device,
|
||||
result="error",
|
||||
actor=actor,
|
||||
duration_ms=elapsed(),
|
||||
detail=detail,
|
||||
method=METHOD_RPC,
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def run_reboot(
|
||||
database: Any,
|
||||
*,
|
||||
job_id: str,
|
||||
device: dict[str, Any],
|
||||
username: str,
|
||||
password: str,
|
||||
wait_seconds: int,
|
||||
actor: str = "system",
|
||||
method: str = METHOD_SSH,
|
||||
) -> None:
|
||||
"""Loest einen Neustart aus und wartet auf die Rueckkehr.
|
||||
|
||||
Der Verbindungsabbruch direkt nach dem Kommando ist der **erwartete** Fall
|
||||
und gilt als Erfolg. Nur ein Fehler *vor* dem Kommando (Anmeldung,
|
||||
Erreichbarkeit) ist ein echter Fehlschlag.
|
||||
"""
|
||||
name = str(device.get("name") or device.get("ip"))
|
||||
started = time.monotonic()
|
||||
|
||||
def emit(line: str) -> None:
|
||||
stamp = datetime.now().strftime("%H:%M:%S")
|
||||
update_job(database, job_id, append_output=f"{stamp} {line}\n")
|
||||
|
||||
def elapsed() -> int:
|
||||
return int((time.monotonic() - started) * 1000)
|
||||
|
||||
host = str(device["ip"])
|
||||
# Der Weg wird **uebergeben**, nicht aus dem Geraet abgeleitet. Vorher stand
|
||||
# hier ``device["category"]`` -- und dieser Schluessel bedeutet je Herkunft
|
||||
# etwas anderes: die Wartungsabfrage legt die Kategorie der *Zugangsdaten*
|
||||
# darunter, ``inventory.get_device`` die des *Geraets*. Ueber die
|
||||
# Statusuebersicht kam damit ein Windows-Geraet mit leerer Geraetekategorie
|
||||
# an, wurde fuer Linux gehalten und ueber SSH auf Port 22 angesprochen --
|
||||
# bis zum Zeitlimit. Die Entscheidung faellt jetzt einmal beim Aufrufer
|
||||
# (``restart.resolve``) und wird hier nur noch befolgt.
|
||||
is_windows = method == METHOD_RPC
|
||||
# Woran erkennt man, dass das Geraet zurueck ist? Am selben Dienst, ueber
|
||||
# den der Neustart lief: SSH bei Linux, SMB/RPC bei Windows.
|
||||
probe_port = WINDOWS_PROBE_PORT if is_windows else int(device.get("ssh_port") or 22)
|
||||
|
||||
update_job(database, job_id, state="running", progress=5, message="Verbindung wird aufgebaut")
|
||||
|
||||
collected: list[str] = []
|
||||
|
||||
def capture(line: str) -> None:
|
||||
collected.append(line)
|
||||
if not is_noise(line):
|
||||
emit(line)
|
||||
|
||||
if is_windows:
|
||||
# Windows wird nicht ueber SSH bedient -- kein Transport, kein
|
||||
# Host-Schluessel, kein sudo.
|
||||
if not _reboot_windows(
|
||||
database,
|
||||
job_id=job_id,
|
||||
device=device,
|
||||
username=username,
|
||||
password=password,
|
||||
actor=actor,
|
||||
emit=emit,
|
||||
elapsed=elapsed,
|
||||
):
|
||||
return
|
||||
else:
|
||||
try:
|
||||
with database.session() as conn:
|
||||
transport = open_transport(
|
||||
conn,
|
||||
host=host,
|
||||
port=int(device.get("ssh_port") or 22),
|
||||
username=username,
|
||||
password=password,
|
||||
)
|
||||
except SshError as exc:
|
||||
emit(f"FEHLER: {exc}")
|
||||
update_job(database, job_id, state="failed", message=str(exc), finished=True)
|
||||
_log_restart(
|
||||
database,
|
||||
device,
|
||||
result="error",
|
||||
actor=actor,
|
||||
duration_ms=elapsed(),
|
||||
detail=str(exc),
|
||||
method=method,
|
||||
)
|
||||
return
|
||||
|
||||
sudo, sudo_stdin = _sudo_mode(transport, password, emit)
|
||||
command = REBOOT_COMMAND.format(sudo=sudo)
|
||||
try:
|
||||
emit(f"Neustart wird ausgelöst ({command})")
|
||||
try:
|
||||
_run_remote_command(
|
||||
transport, command, timeout=20, on_line=capture, stdin_text=sudo_stdin
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 - Abbruch ist hier normal
|
||||
emit(f"Verbindung getrennt ({exc}) – das ist beim Neustart zu erwarten.")
|
||||
finally:
|
||||
try:
|
||||
transport.close()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
# Scheitert schon das Kommando, geht das Geraet gar nicht erst herunter.
|
||||
# Dann waere die Wartezeit reine Zeitverschwendung -- und die Meldung
|
||||
# "nicht zurueckgekehrt" waere irrefuehrend: es war nie weg.
|
||||
problem = hint_for("\n".join(collected))
|
||||
if problem:
|
||||
emit("")
|
||||
emit(f"Hinweis: {problem}")
|
||||
update_job(
|
||||
database,
|
||||
job_id,
|
||||
state="failed",
|
||||
progress=100,
|
||||
message=problem[:500],
|
||||
finished=True,
|
||||
)
|
||||
_log_restart(
|
||||
database,
|
||||
device,
|
||||
result="error",
|
||||
actor=actor,
|
||||
duration_ms=elapsed(),
|
||||
detail=problem,
|
||||
method=method,
|
||||
)
|
||||
return
|
||||
|
||||
update_job(database, job_id, progress=40, message="Warte auf Rückkehr des Geräts")
|
||||
time.sleep(REBOOT_GRACE_SECONDS)
|
||||
deadline = time.monotonic() + wait_seconds
|
||||
ssh_port = probe_port
|
||||
emit(f"Warte darauf, dass {host}:{ssh_port} wieder antwortet.")
|
||||
while time.monotonic() < deadline:
|
||||
# TCP statt ICMP: der Webprozess laeuft ohne CAP_NET_RAW, dort schlaegt
|
||||
# jeder Ping fehl -- der Auftrag lief deshalb immer in die
|
||||
# Zeitueberschreitung, obwohl das Geraet zurueck war. Ausserdem ist
|
||||
# "SSH antwortet wieder" die Aussage, auf die es hier ankommt.
|
||||
if tcp_reachable(host, ssh_port):
|
||||
emit(f"Gerät ist wieder erreichbar (Port {ssh_port} antwortet).")
|
||||
update_job(
|
||||
database,
|
||||
job_id,
|
||||
state="succeeded",
|
||||
progress=100,
|
||||
message="Neustart abgeschlossen.",
|
||||
finished=True,
|
||||
)
|
||||
with database.session() as conn, database.transaction(conn):
|
||||
audit.log(
|
||||
conn,
|
||||
actor="system",
|
||||
action="maintenance.reboot_finished",
|
||||
target=name,
|
||||
detail="erfolgreich",
|
||||
severity="notice",
|
||||
)
|
||||
_log_restart(
|
||||
database,
|
||||
device,
|
||||
result="ok",
|
||||
actor=actor,
|
||||
duration_ms=elapsed(),
|
||||
detail="Neustart über SSH, Gerät ist zurückgekehrt.",
|
||||
method=method,
|
||||
)
|
||||
return
|
||||
time.sleep(REBOOT_POLL_SECONDS)
|
||||
|
||||
emit(f"{host}:{ssh_port} hat innerhalb der Wartezeit nicht wieder geantwortet.")
|
||||
update_job(
|
||||
database,
|
||||
job_id,
|
||||
state="failed",
|
||||
progress=100,
|
||||
message=f"Gerät nicht zurückgekehrt ({host}:{ssh_port} antwortet nicht).",
|
||||
finished=True,
|
||||
)
|
||||
_log_restart(
|
||||
database,
|
||||
device,
|
||||
result="error",
|
||||
actor=actor,
|
||||
duration_ms=elapsed(),
|
||||
detail=(
|
||||
f"Neustart ausgelöst, aber {host}:{ssh_port} hat innerhalb von "
|
||||
f"{wait_seconds}s nicht wieder geantwortet."
|
||||
),
|
||||
method=method,
|
||||
)
|
||||
|
||||
|
||||
def _log_restart(
|
||||
database: Any,
|
||||
device: dict[str, Any],
|
||||
*,
|
||||
result: str,
|
||||
actor: str,
|
||||
duration_ms: int,
|
||||
detail: str,
|
||||
method: str = METHOD_SSH,
|
||||
) -> None:
|
||||
"""Traegt den Neustart in die geraeteweite Neustart-Historie ein.
|
||||
|
||||
Der Wartungsauftrag steht ohnehin in ``jobs`` -- aber wer wissen will,
|
||||
warum ein *Geraet* weg war, sieht in dessen Neustart-Historie nach, nicht
|
||||
in einer Auftragsliste. Vorher tauchten dort nur PoE-Neustarts auf.
|
||||
"""
|
||||
device_id = device.get("id")
|
||||
if not device_id:
|
||||
return
|
||||
try:
|
||||
with database.session() as conn, database.transaction(conn):
|
||||
record_restart(
|
||||
conn,
|
||||
device_id=int(device_id),
|
||||
action="reboot",
|
||||
result=result,
|
||||
trigger="maintenance",
|
||||
actor=actor,
|
||||
duration_ms=duration_ms,
|
||||
detail=detail,
|
||||
method=method,
|
||||
)
|
||||
except Exception: # noqa: BLE001 - Protokollieren darf den Auftrag nie kippen
|
||||
logger.exception("Neustart konnte nicht protokolliert werden (Gerät %s)", device_id)
|
||||
|
||||
|
||||
def start_background(target: Callable[..., None], **kwargs: Any) -> None:
|
||||
thread = threading.Thread(target=target, kwargs=kwargs, daemon=True)
|
||||
thread.start()
|
||||
@@ -0,0 +1,721 @@
|
||||
"""Erreichbarkeitspruefung und automatischer PoE-Neustart.
|
||||
|
||||
Ersetzt drei Bausteine des Vorgaengers durch einen: ``poe.sh`` (Bash/expect),
|
||||
``poe_wrapper.py`` und ``generate_ips.py``. Vorteile, die daraus folgen:
|
||||
|
||||
* **Ein Prozess, eine Sprache.** Kein Datenaustausch mehr ueber eine
|
||||
pipe-getrennte Textzeile pro Geraet, kein zweiter Interpreter, keine
|
||||
Passwoerter in Kommandozeilen.
|
||||
* **Echte Nebenlaeufigkeit mit Grenze.** Der Vorgaenger startete pro Geraet eine
|
||||
Hintergrund-Subshell -- bei vielen Geraeten ein Fork-Sturm. Hier laeuft ein
|
||||
Thread-Pool mit fester Obergrenze.
|
||||
* **Jeder Vorgang ist nachvollziehbar.** Erreichbarkeit landet in
|
||||
``device_status``, jeder PoE-Vorgang mit Ergebnis und Transkript in
|
||||
``restart_events``.
|
||||
* **Zustandswechsel statt Dauerrauschen.** Ins laufende Protokoll wird
|
||||
geschrieben, wenn sich etwas *aendert* -- nicht bei jedem Durchlauf jede Zeile.
|
||||
|
||||
Beibehalten wird die Lizenzregel des Vorgaengers: der automatische Neustart ist
|
||||
lizenzpflichtig, der manuelle Knopf im Dashboard nicht. Und: das Recht
|
||||
``devices.execute`` gilt nur fuer den manuellen Knopf -- der automatische Lauf
|
||||
gehoert dem System.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import platform
|
||||
import re
|
||||
import shutil
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
from tesm_core import audit
|
||||
from tesm_core.db import Database
|
||||
|
||||
from . import inventory
|
||||
from .ssh import SshError
|
||||
from .switchcli import SwitchCli
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_INTERVAL_SECONDS = 300
|
||||
PING_TIMEOUT_SECONDS = 2
|
||||
MAX_PARALLEL_CHECKS = 24
|
||||
#: Ab wie vielen aufeinanderfolgenden Fehlschlaegen ein Neustart ausgeloest wird.
|
||||
DEFAULT_FAILURES_BEFORE_RESTART = 2
|
||||
#: Mindestabstand zwischen zwei automatischen Neustarts desselben Geraets.
|
||||
RESTART_COOLDOWN_SECONDS = 900
|
||||
|
||||
_LATENCY_RE = re.compile(r"time[=<]\s*([\d.]+)\s*ms", re.IGNORECASE)
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PingResult:
|
||||
ok: bool
|
||||
latency_ms: float | None = None
|
||||
detail: str = ""
|
||||
|
||||
|
||||
def tcp_reachable(host: str, port: int, *, timeout: float = 3.0) -> bool:
|
||||
"""Nimmt der Host auf diesem Port eine Verbindung an?
|
||||
|
||||
Braucht -- anders als ICMP -- keinerlei Sonderrechte. Der Webprozess laeuft
|
||||
mit leerem ``CapabilityBoundingSet``; damit greift die Datei-Capability von
|
||||
``/usr/bin/ping`` nicht mehr (``NoNewPrivileges`` schaltet sie ab) und jeder
|
||||
Ping schlaegt dort fehl. Ein Wartungsauftrag, der auf die Rueckkehr eines
|
||||
Geraets wartet, wuerde dadurch **immer** in die Zeitueberschreitung laufen,
|
||||
obwohl das Geraet laengst wieder da ist -- genau so beobachtet.
|
||||
|
||||
Fuer die Wartung ist der TCP-Test ohnehin der treffendere: sie will nicht
|
||||
wissen, ob der Kernel wieder pingbar ist, sondern ob SSH wieder antwortet.
|
||||
"""
|
||||
import socket
|
||||
|
||||
try:
|
||||
with socket.create_connection((host, int(port)), timeout=timeout):
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def ping(host: str, *, timeout: int = PING_TIMEOUT_SECONDS) -> PingResult:
|
||||
"""Ein einzelner ICMP-Versuch, wie im Vorgaenger (kein Retry).
|
||||
|
||||
Ein Retry hier wuerde nur den Ausfall verschleiern; die Entscheidung
|
||||
"Neustart oder nicht" faellt ueber mehrere *Durchlaeufe*, nicht ueber
|
||||
mehrere Pakete.
|
||||
"""
|
||||
binary = shutil.which("ping")
|
||||
if not binary:
|
||||
return PingResult(False, detail="ping ist nicht installiert")
|
||||
if platform.system() == "Windows":
|
||||
command = [binary, "-n", "1", "-w", str(timeout * 1000), host]
|
||||
else:
|
||||
command = [binary, "-c", "1", "-W", str(timeout), host]
|
||||
try:
|
||||
completed = subprocess.run( # noqa: S603 - feste Argumentliste, keine Shell
|
||||
command,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=timeout + 3,
|
||||
check=False,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
return PingResult(False, detail="Zeitlimit überschritten")
|
||||
except OSError as exc:
|
||||
return PingResult(False, detail=str(exc))
|
||||
|
||||
if completed.returncode != 0:
|
||||
return PingResult(False, detail="keine Antwort")
|
||||
match = _LATENCY_RE.search(completed.stdout)
|
||||
latency = float(match.group(1)) if match else None
|
||||
return PingResult(True, latency_ms=latency)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Laufendes Protokoll
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class LiveLog:
|
||||
"""Schreibt das laufende Protokoll.
|
||||
|
||||
Eine Datei, fortlaufend beschrieben; Rotation macht ``logrotate`` mit
|
||||
``copytruncate`` (keiner der schreibenden Prozesse kann ein Reopen-Signal
|
||||
verarbeiten). Zeilen sind bewusst maschinenlesbar aufgebaut:
|
||||
``ZEIT EBENE BEREICH MELDUNG``.
|
||||
"""
|
||||
|
||||
def __init__(self, path: Path) -> None:
|
||||
self.path = Path(path)
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def write(self, level: str, scope: str, message: str) -> None:
|
||||
line = f"{datetime.now().strftime('%Y-%m-%d %H:%M:%S')} {level:<7} {scope:<10} {message}\n"
|
||||
try:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with self._lock, self.path.open("a", encoding="utf-8", newline="\n") as stream:
|
||||
stream.write(line)
|
||||
except OSError as exc: # pragma: no cover - vollgelaufene Platte o. Ae.
|
||||
logger.warning("Konnte nicht ins laufende Protokoll schreiben: %s", exc)
|
||||
|
||||
def info(self, scope: str, message: str) -> None:
|
||||
self.write("INFO", scope, message)
|
||||
|
||||
def warning(self, scope: str, message: str) -> None:
|
||||
self.write("WARNUNG", scope, message)
|
||||
|
||||
def error(self, scope: str, message: str) -> None:
|
||||
self.write("FEHLER", scope, message)
|
||||
|
||||
def tail(self, lines: int = 400, *, needle: str = "") -> list[str]:
|
||||
"""Liest die letzten Zeilen -- ohne die ganze Datei in den Speicher zu holen."""
|
||||
if not self.path.is_file():
|
||||
return []
|
||||
try:
|
||||
size = self.path.stat().st_size
|
||||
block = min(size, max(8192, lines * 200))
|
||||
with self.path.open("rb") as stream:
|
||||
stream.seek(max(0, size - block))
|
||||
content = stream.read().decode("utf-8", errors="replace")
|
||||
except OSError:
|
||||
return []
|
||||
result = content.splitlines()
|
||||
if needle:
|
||||
lowered = needle.lower()
|
||||
result = [line for line in result if lowered in line.lower()]
|
||||
return result[-lines:]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Geraetepruefung
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def load_monitored_devices(conn: sqlite3.Connection) -> list[dict[str, Any]]:
|
||||
"""Alle aktiven Geraete mit ihren Switch- und Zugangsdaten."""
|
||||
return Database.all(
|
||||
conn,
|
||||
"""
|
||||
SELECT d.id, d.mac, d.ip, d.name, d.port, d.auto_restart, d.switch_id,
|
||||
s.hostname AS switch_hostname, s.ip AS switch_ip, s.ssh_port AS switch_ssh_port,
|
||||
s.credential_id AS switch_credential_id,
|
||||
st.state, st.fail_count, st.last_restart_at
|
||||
FROM devices d
|
||||
LEFT JOIN switches s ON s.id = d.switch_id AND s.deleted_at IS NULL
|
||||
LEFT JOIN device_status st ON st.device_id = d.id
|
||||
WHERE d.deleted_at IS NULL AND d.is_active = 1
|
||||
ORDER BY d.name COLLATE NOCASE
|
||||
""",
|
||||
)
|
||||
|
||||
|
||||
def _within_restart_grace(row: Any) -> bool:
|
||||
"""Liegt der letzte Neustart noch im Kulanzfenster?"""
|
||||
if not row:
|
||||
return False
|
||||
moment = (row or {}).get("last_restart_at")
|
||||
if not moment:
|
||||
return False
|
||||
try:
|
||||
stamp = datetime.fromisoformat(str(moment))
|
||||
except ValueError:
|
||||
return False
|
||||
if stamp.tzinfo is None:
|
||||
stamp = stamp.replace(tzinfo=timezone.utc)
|
||||
return (datetime.now(timezone.utc) - stamp).total_seconds() < RESTART_GRACE_SECONDS
|
||||
|
||||
|
||||
def record_status(
|
||||
conn: sqlite3.Connection, device_id: int, result: PingResult
|
||||
) -> tuple[str, str]:
|
||||
"""Schreibt den Zustand fort. Rueckgabe: ``(vorher, nachher)``."""
|
||||
row = Database.one(
|
||||
conn,
|
||||
"SELECT state, fail_count, last_restart_at FROM device_status WHERE device_id=?",
|
||||
(device_id,),
|
||||
)
|
||||
previous = str(row["state"]) if row else "unknown"
|
||||
new_state = "online" if result.ok else "offline"
|
||||
# Ein Geraet, das gerade neu startet, ist nicht "nicht erreichbar" -- es ist
|
||||
# erwartbar kurz weg. Erst wenn es nach dem Kulanzfenster noch schweigt,
|
||||
# wird daraus ein Ausfall.
|
||||
if not result.ok and previous == STATE_RESTARTING and _within_restart_grace(row):
|
||||
new_state = STATE_RESTARTING
|
||||
fail_count = 0 if result.ok else int((row or {}).get("fail_count") or 0) + 1
|
||||
changed = previous != new_state
|
||||
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO device_status (device_id, state, latency_ms, fail_count, last_check_at,
|
||||
last_ok_at, last_change_at, detail)
|
||||
VALUES (?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT(device_id) DO UPDATE SET
|
||||
state=excluded.state,
|
||||
latency_ms=excluded.latency_ms,
|
||||
fail_count=excluded.fail_count,
|
||||
last_check_at=excluded.last_check_at,
|
||||
last_ok_at=COALESCE(excluded.last_ok_at, device_status.last_ok_at),
|
||||
last_change_at=CASE WHEN device_status.state <> excluded.state
|
||||
THEN excluded.last_change_at ELSE device_status.last_change_at END,
|
||||
detail=excluded.detail
|
||||
""",
|
||||
(
|
||||
device_id,
|
||||
new_state,
|
||||
result.latency_ms,
|
||||
fail_count,
|
||||
_now(),
|
||||
_now() if result.ok else None,
|
||||
_now(),
|
||||
result.detail,
|
||||
),
|
||||
)
|
||||
return previous, new_state if changed else previous
|
||||
|
||||
|
||||
#: Wie ein Geraet neu gestartet wurde.
|
||||
METHOD_POE = "poe"
|
||||
METHOD_SSH = "ssh"
|
||||
METHOD_RPC = "rpc"
|
||||
METHOD_LABELS = {
|
||||
METHOD_POE: "PoE-Port",
|
||||
METHOD_SSH: "Neustart über SSH",
|
||||
METHOD_RPC: "Neustart über RPC",
|
||||
}
|
||||
|
||||
#: Zustand zwischen ausgeloestem Neustart und Rueckkehr.
|
||||
#:
|
||||
#: Ohne ihn blieb die Kachel auf "online" stehen: der Zustand wechselt erst
|
||||
#: beim naechsten Pruefdurchlauf, und der laeuft standardmaessig alle fuenf
|
||||
#: Minuten -- ein Neustart ueber SSH ist in vierzig Sekunden durch. Das Geraet
|
||||
#: war also weg und wieder da, ohne dass es je jemand gesehen haette.
|
||||
STATE_RESTARTING = "restarting"
|
||||
|
||||
#: Wie lange ein Geraet als "startet neu" gilt, wenn es nicht antwortet.
|
||||
#: Danach ist es schlicht nicht erreichbar -- ein Neustart, der zehn Minuten
|
||||
#: dauert, ist keiner mehr.
|
||||
RESTART_GRACE_SECONDS = 300
|
||||
|
||||
|
||||
def record_restart(
|
||||
conn: sqlite3.Connection,
|
||||
*,
|
||||
device_id: int,
|
||||
action: str,
|
||||
result: str,
|
||||
trigger: str,
|
||||
actor: str,
|
||||
duration_ms: int,
|
||||
detail: str,
|
||||
method: str = METHOD_POE,
|
||||
) -> None:
|
||||
"""Schreibt einen Neustart ins Protokoll -- unabhaengig davon, wie er lief.
|
||||
|
||||
PoE-Portzyklus und ``reboot`` ueber SSH sind fuer den Betrieb dasselbe
|
||||
Ereignis. Sie landen deshalb in derselben Tabelle und unterscheiden sich
|
||||
nur in ``method``. Vorher gab es nur die PoE-Haelfte, und ein per Wartung
|
||||
neu gestartetes Geraet tauchte in der Neustart-Historie ueberhaupt nicht
|
||||
auf.
|
||||
"""
|
||||
conn.execute(
|
||||
"INSERT INTO restart_events "
|
||||
"(device_id, ts, trigger, actor, action, result, duration_ms, detail, method) "
|
||||
"VALUES (?,?,?,?,?,?,?,?,?)",
|
||||
(device_id, _now(), trigger, actor, action, result, duration_ms, detail[:8000], method),
|
||||
)
|
||||
if result == "ok":
|
||||
# Hier faellt nur noch der Zaehler und der Zeitstempel an. Den Zustand
|
||||
# setzt ``mark_restarting`` schon beim Ausloesen -- dieses Ergebnis
|
||||
# kommt bei SSH und RPC erst, wenn das Geraet zurueck ist, und bis
|
||||
# dahin stand die Kachel weiter auf "online".
|
||||
conn.execute(
|
||||
"UPDATE device_status SET last_restart_at=?, restart_count=restart_count+1, "
|
||||
"state=?, last_change_at=?, fail_count=0 WHERE device_id=?",
|
||||
(_now(), STATE_RESTARTING, _now(), device_id),
|
||||
)
|
||||
|
||||
|
||||
def mark_restarting(conn: sqlite3.Connection, device_id: int) -> None:
|
||||
"""Setzt den Zustand auf "startet neu" -- beim **Ausloesen**, nicht am Ende.
|
||||
|
||||
Ein Neustart ueber SSH oder RPC laeuft als Auftrag: Kommando schicken, dann
|
||||
auf die Rueckkehr warten. Das Ergebnis steht erst Minuten spaeter fest. Wer
|
||||
den Knopf gedrueckt hat, will die Wirkung aber sofort sehen -- sonst sieht
|
||||
die Uebersicht aus wie eine, in der nichts passiert ist.
|
||||
|
||||
Bewusst ohne ``restart_count``: gezaehlt wird das Ereignis, nicht die
|
||||
Absicht. Und ohne ``last_restart_at``: das Kulanzfenster laeuft ab dem
|
||||
Zeitpunkt, an dem der Neustart tatsaechlich ausgeloest wurde
|
||||
(``record_restart``).
|
||||
"""
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO device_status (device_id, state, fail_count, last_check_at, last_change_at)
|
||||
VALUES (?,?,0,?,?)
|
||||
ON CONFLICT(device_id) DO UPDATE SET
|
||||
state=excluded.state,
|
||||
fail_count=0,
|
||||
last_change_at=CASE WHEN device_status.state <> excluded.state
|
||||
THEN excluded.last_change_at ELSE device_status.last_change_at END
|
||||
""",
|
||||
(device_id, STATE_RESTARTING, _now(), _now()),
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# PoE-Neustart
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def restart_device_poe(
|
||||
conn: sqlite3.Connection,
|
||||
device: dict[str, Any],
|
||||
*,
|
||||
keystore: Any,
|
||||
trigger: str,
|
||||
actor: str,
|
||||
live: LiveLog | None = None,
|
||||
database: Any = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Fuehrt einen PoE-Neustart durch und protokolliert ihn.
|
||||
|
||||
Rueckgabe: ``{"ok": bool, "message": str, "transcript": str}``.
|
||||
|
||||
``database`` bestimmt, wie geschrieben wird. Wird es uebergeben, oeffnet
|
||||
jede Schreiboperation ihre **eigene kurze** Transaktion -- die SSH-Sitzung
|
||||
zum Switch laeuft dann ausserhalb. Das ist fuer den Ueberwachungsdienst
|
||||
entscheidend: ein Durchlauf mit drei nicht erreichbaren Switchen dauert
|
||||
knapp 40 Sekunden, und solange lag die Schreibsperre auf der gesamten
|
||||
Datenbank. Die Oberflaeche antwortete in dieser Zeit mit "database is
|
||||
locked".
|
||||
|
||||
Ohne ``database`` (aus einem Request heraus, wo ohnehin schon eine kurze
|
||||
Transaktion offen ist) wird direkt auf ``conn`` geschrieben.
|
||||
"""
|
||||
from contextlib import nullcontext
|
||||
|
||||
def write(): # type: ignore[no-untyped-def]
|
||||
return database.transaction(conn) if database is not None else nullcontext()
|
||||
|
||||
name = str(device.get("name") or device.get("mac") or device.get("id"))
|
||||
port = str(device.get("port") or "")
|
||||
switch_ip = str(device.get("switch_ip") or "")
|
||||
credential_id = device.get("switch_credential_id")
|
||||
|
||||
# Sofort sichtbar machen, dass etwas passiert -- der Portzyklus dauert
|
||||
# Sekunden, das Warten auf die Rueckkehr laenger. Gilt fuer den manuellen
|
||||
# wie fuer den automatischen Neustart, denn beide laufen hier durch.
|
||||
if port and switch_ip:
|
||||
with write():
|
||||
mark_restarting(conn, int(device["id"]))
|
||||
|
||||
if not port or not switch_ip:
|
||||
message = (
|
||||
"Kein Switch oder kein Port zugeordnet – ein PoE-Neustart ist nicht möglich."
|
||||
)
|
||||
with write():
|
||||
record_restart(
|
||||
conn,
|
||||
device_id=int(device["id"]),
|
||||
action="cycle",
|
||||
result="skipped",
|
||||
trigger=trigger,
|
||||
actor=actor,
|
||||
duration_ms=0,
|
||||
detail=message,
|
||||
)
|
||||
return {"ok": False, "message": message, "transcript": ""}
|
||||
|
||||
if not credential_id:
|
||||
message = f"Für Switch {device.get('switch_hostname') or switch_ip} sind keine Zugangsdaten hinterlegt."
|
||||
with write():
|
||||
record_restart(
|
||||
conn,
|
||||
device_id=int(device["id"]),
|
||||
action="cycle",
|
||||
result="error",
|
||||
trigger=trigger,
|
||||
actor=actor,
|
||||
duration_ms=0,
|
||||
detail=message,
|
||||
)
|
||||
return {"ok": False, "message": message, "transcript": ""}
|
||||
|
||||
# Bewusst kein Rueckruf: der wuerde die vorhandene Verbindung nicht kennen
|
||||
# und sich eine neue oeffnen -- mitten in dieser Schreibtransaktion. Genau
|
||||
# daran ist der Ueberwachungsdienst gestorben.
|
||||
username, password = inventory.credential_secret(conn, keystore, int(credential_id))
|
||||
started = time.monotonic()
|
||||
try:
|
||||
with SwitchCli(
|
||||
conn,
|
||||
host=switch_ip,
|
||||
port=int(device.get("switch_ssh_port") or 22),
|
||||
username=username,
|
||||
password=password,
|
||||
) as cli:
|
||||
outcome = cli.cycle_poe(port)
|
||||
except SshError as exc:
|
||||
duration = int((time.monotonic() - started) * 1000)
|
||||
with write():
|
||||
record_restart(
|
||||
conn,
|
||||
device_id=int(device["id"]),
|
||||
action="cycle",
|
||||
result="error",
|
||||
trigger=trigger,
|
||||
actor=actor,
|
||||
duration_ms=duration,
|
||||
detail=str(exc),
|
||||
)
|
||||
if live:
|
||||
live.error("poe", f"{name}: {exc}")
|
||||
return {"ok": False, "message": str(exc), "transcript": ""}
|
||||
|
||||
duration = int((time.monotonic() - started) * 1000)
|
||||
with write():
|
||||
record_restart(
|
||||
conn,
|
||||
device_id=int(device["id"]),
|
||||
action="cycle",
|
||||
result="ok" if outcome.ok else "error",
|
||||
trigger=trigger,
|
||||
actor=actor,
|
||||
duration_ms=duration,
|
||||
detail=(outcome.error or "; ".join(outcome.steps))[:8000],
|
||||
)
|
||||
if live:
|
||||
if outcome.ok:
|
||||
live.info("poe", f"{name}: PoE-Neustart auf {device.get('switch_hostname')} {port} erfolgreich")
|
||||
else:
|
||||
live.error("poe", f"{name}: PoE-Neustart fehlgeschlagen -- {outcome.error}")
|
||||
return {
|
||||
"ok": outcome.ok,
|
||||
"message": outcome.error or "PoE-Neustart durchgeführt.",
|
||||
"transcript": outcome.transcript,
|
||||
}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Durchlauf
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class SweepReport:
|
||||
checked: int = 0
|
||||
online: int = 0
|
||||
offline: int = 0
|
||||
restarts_triggered: int = 0
|
||||
restarts_ok: int = 0
|
||||
skipped_unlicensed: 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(
|
||||
*,
|
||||
database: Any,
|
||||
live: LiveLog,
|
||||
keystore: Any,
|
||||
license_allows_auto_restart: bool,
|
||||
failures_before_restart: int = DEFAULT_FAILURES_BEFORE_RESTART,
|
||||
vnc_port: int = 0,
|
||||
) -> SweepReport:
|
||||
"""Ein vollstaendiger Ueberwachungsdurchlauf.
|
||||
|
||||
Die Pruefungen laufen parallel (I/O-gebunden), die Datenbankschreibvorgaenge
|
||||
danach in **einer** Transaktion -- so gibt es keinen Schreib-Wettlauf und
|
||||
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()
|
||||
started = time.monotonic()
|
||||
|
||||
with database.session() as conn:
|
||||
devices = load_monitored_devices(conn)
|
||||
|
||||
if not devices:
|
||||
report.duration_ms = int((time.monotonic() - started) * 1000)
|
||||
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:
|
||||
results = list(pool.map(pruefen, devices))
|
||||
|
||||
restart_candidates: list[dict[str, Any]] = []
|
||||
with database.session() as conn:
|
||||
with database.transaction(conn):
|
||||
for device, result, vnc_offen in results:
|
||||
report.checked += 1
|
||||
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:
|
||||
report.online += 1
|
||||
else:
|
||||
report.offline += 1
|
||||
|
||||
if previous != current:
|
||||
name = str(device["name"])
|
||||
if current == "online":
|
||||
live.info("ping", f"{name} ({device['ip']}) ist wieder erreichbar")
|
||||
else:
|
||||
live.warning("ping", f"{name} ({device['ip']}) antwortet nicht")
|
||||
|
||||
if result.ok or not int(device.get("auto_restart") or 0):
|
||||
continue
|
||||
fail_count = int((device.get("fail_count") or 0)) + 1
|
||||
if fail_count < failures_before_restart:
|
||||
continue
|
||||
if _in_cooldown(device.get("last_restart_at")):
|
||||
continue
|
||||
restart_candidates.append(device)
|
||||
|
||||
for device in restart_candidates:
|
||||
name = str(device["name"])
|
||||
if not license_allows_auto_restart:
|
||||
report.skipped_unlicensed += 1
|
||||
live.warning(
|
||||
"poe",
|
||||
f"{name}: automatischer PoE-Neustart übersprungen – keine gültige Lizenz.",
|
||||
)
|
||||
continue
|
||||
report.restarts_triggered += 1
|
||||
# Bewusst **keine** umschliessende Transaktion: der Neustart oeffnet eine
|
||||
# SSH-Sitzung zum Switch, und die dauert bei einem nicht erreichbaren
|
||||
# Geraet zehn Sekunden und mehr. Lag die Schreibsperre solange auf der
|
||||
# Datenbank, antwortete die Oberflaeche mit "database is locked". Die
|
||||
# Aufzeichnung schreibt jetzt in eigenen, kurzen Transaktionen.
|
||||
with database.session() as conn:
|
||||
outcome = restart_device_poe(
|
||||
conn,
|
||||
device,
|
||||
keystore=keystore,
|
||||
trigger="auto",
|
||||
actor="system",
|
||||
live=live,
|
||||
database=database,
|
||||
)
|
||||
with database.transaction(conn):
|
||||
audit.log(
|
||||
conn,
|
||||
actor="system",
|
||||
action="device.poe_auto_restart",
|
||||
target=name,
|
||||
detail=outcome["message"],
|
||||
severity="notice" if outcome["ok"] else "warning",
|
||||
)
|
||||
if outcome["ok"]:
|
||||
report.restarts_ok += 1
|
||||
|
||||
report.duration_ms = int((time.monotonic() - started) * 1000)
|
||||
return report
|
||||
|
||||
|
||||
def _in_cooldown(last_restart_at: Any) -> bool:
|
||||
if not last_restart_at:
|
||||
return False
|
||||
try:
|
||||
moment = datetime.fromisoformat(str(last_restart_at))
|
||||
except ValueError:
|
||||
return False
|
||||
if moment.tzinfo is None:
|
||||
moment = moment.replace(tzinfo=timezone.utc)
|
||||
return (datetime.now(timezone.utc) - moment).total_seconds() < RESTART_COOLDOWN_SECONDS
|
||||
|
||||
|
||||
def next_check_at(conn: sqlite3.Connection, interval_seconds: int) -> datetime | None:
|
||||
"""Wann der naechste Ueberwachungsdurchlauf faellig ist.
|
||||
|
||||
Anker ist die letzte tatsaechliche Pruefung, nicht der Prozessstart: nach
|
||||
einem Neustart des Dienstes soll der Zaehler dort weiterlaufen, wo der
|
||||
Durchlauf steht, und nicht bei voller Laenge beginnen.
|
||||
"""
|
||||
zeile = Database.one(
|
||||
conn, "SELECT MAX(last_check_at) AS letzte FROM device_status", ()
|
||||
)
|
||||
letzte = (zeile or {}).get("letzte")
|
||||
if not letzte:
|
||||
return None
|
||||
try:
|
||||
stamp = datetime.fromisoformat(str(letzte))
|
||||
except ValueError:
|
||||
return None
|
||||
if stamp.tzinfo is None:
|
||||
stamp = stamp.replace(tzinfo=timezone.utc)
|
||||
return stamp + timedelta(seconds=max(1, int(interval_seconds)))
|
||||
|
||||
|
||||
#: Zaehlung fuer die Statusuebersicht. Steht als Konstante, weil das
|
||||
#: Anmeldebanner (``deploy/tesm-motd``) dieselben Zahlen zeigt und ohne
|
||||
#: Anwendung laeuft -- es kann diese Datei nicht importieren, also vergleicht
|
||||
#: ein Hygienetest die beiden Zeichenketten. Zwei Wahrheiten ueber "online"
|
||||
#: waeren schlimmer als eine doppelte Zeichenkette.
|
||||
DASHBOARD_COUNT_SQL = """
|
||||
SELECT
|
||||
COUNT(*) AS total,
|
||||
SUM(CASE WHEN d.is_active=0 THEN 1 ELSE 0 END) AS disabled,
|
||||
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 dashboard_counts(conn: sqlite3.Connection) -> dict[str, int]:
|
||||
row = Database.one(conn, DASHBOARD_COUNT_SQL)
|
||||
row = row or {}
|
||||
return {
|
||||
key: int(row.get(key) or 0)
|
||||
for key in ("total", "online", "offline", "unknown", "disabled", "restarting")
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
"""Welche Neustartwege ein Geraet hat -- und welcher davon der beste ist.
|
||||
|
||||
Ein Geraet kann auf mehreren Wegen neu startbar sein: ueber den PoE-Port des
|
||||
Switches, ueber SSH, oder bei Windows ueber RPC. Bisher gab es nur den ersten,
|
||||
und der Knopf verschwand einfach, wenn Switch oder Port fehlten -- ohne zu
|
||||
sagen, dass ein anderer Weg offen gestanden haette.
|
||||
|
||||
Die Rangfolge ist bewusst festgelegt und nicht dem Zufall ueberlassen:
|
||||
|
||||
1. **PoE-Port.** Schaltet die Stromversorgung. Wirkt auch dann noch, wenn das
|
||||
Betriebssystem nicht mehr antwortet -- und genau darum geht es meistens,
|
||||
wenn jemand einen Neustart ausloest.
|
||||
2. **SSH** (``reboot``). Sauberes Herunterfahren, setzt aber ein Geraet
|
||||
voraus, das noch reagiert.
|
||||
3. **RPC** (Windows). Wie SSH, nur ueber einen anderen Weg.
|
||||
|
||||
Stehen mehrere Wege offen, ist PoE vorausgewaehlt; die Oberflaeche laesst die
|
||||
Wahl trotzdem zu. Ein sauberes Herunterfahren ist manchmal das Richtige --
|
||||
etwa bei einem Geraet, das schreibend auf eine Platte zugreift.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable
|
||||
|
||||
METHOD_POE = "poe"
|
||||
METHOD_SSH = "ssh"
|
||||
METHOD_RPC = "rpc"
|
||||
|
||||
#: Rangfolge -- der erste verfuegbare Weg ist die Vorauswahl.
|
||||
PREFERENCE: tuple[str, ...] = (METHOD_POE, METHOD_SSH, METHOD_RPC)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Method:
|
||||
key: str
|
||||
label: str
|
||||
hint: str
|
||||
#: Braucht dieser Weg das lizenzpflichtige Modul "Wartung"?
|
||||
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
|
||||
def is_poe(self) -> bool:
|
||||
return self.key == METHOD_POE
|
||||
|
||||
|
||||
#: Derselbe Weg wie PoE, nur anders benannt: bei einem Geraet, das nicht
|
||||
#: antwortet, ist "Neustart" das falsche Wort. Der Schluessel bleibt ``poe``,
|
||||
#: damit Protokoll und Auswertung unveraendert bleiben.
|
||||
POWER_ON = "poe-einschalten"
|
||||
|
||||
DEFINITIONS: dict[str, Method] = {
|
||||
POWER_ON: Method(
|
||||
key=METHOD_POE,
|
||||
label="Einschalten (PoE)",
|
||||
hint="Schaltet den Switch-Port aus und wieder ein. Bei einem Gerät, das nicht "
|
||||
"antwortet, ist das der einzige Weg, der noch etwas bewirkt.",
|
||||
),
|
||||
METHOD_POE: Method(
|
||||
key=METHOD_POE,
|
||||
label="PoE-Port",
|
||||
hint="Schaltet den Switch-Port kurz ab. Wirkt auch, wenn das Gerät hängt.",
|
||||
),
|
||||
METHOD_SSH: Method(
|
||||
key=METHOD_SSH,
|
||||
label="Neustart über SSH",
|
||||
hint="Fährt das Betriebssystem sauber herunter. Setzt ein antwortendes Gerät "
|
||||
"und einen freigegebenen Host-Schlüssel voraus.",
|
||||
licensed=True,
|
||||
),
|
||||
METHOD_RPC: Method(
|
||||
key=METHOD_RPC,
|
||||
label="Neustart über RPC",
|
||||
hint="Windows: wie 'shutdown /m'. Setzt offene Datei- und Druckerfreigabe voraus.",
|
||||
licensed=True,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
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.
|
||||
|
||||
Ein Weg, der nur *fast* geht -- Switch zugeordnet, aber kein Port --, gilt
|
||||
als nicht verfuegbar. Ein Knopf, der verlaesslich nichts bewirkt, ist
|
||||
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:
|
||||
try:
|
||||
return device[name]
|
||||
except (KeyError, IndexError, TypeError):
|
||||
return None
|
||||
|
||||
methods: list[Method] = []
|
||||
hat_poe = bool(field("switch_id")) and bool(str(field("port") or "").strip())
|
||||
if hat_poe:
|
||||
methods.append(DEFINITIONS[METHOD_POE])
|
||||
|
||||
# Ein Geraet, das nicht antwortet, laesst sich nicht ueber SSH oder RPC neu
|
||||
# starten -- dafuer muesste es erreichbar sein. Angeboten wird dann nur noch
|
||||
# der PoE-Weg, und der heisst dort "Einschalten": er schaltet den Port
|
||||
# wieder ein, statt ein laufendes System neu zu starten.
|
||||
if str(field("state") or "") == "offline":
|
||||
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 "")
|
||||
has_login = bool(field("credential_id"))
|
||||
if has_login and maintenance_active:
|
||||
if category == "windows":
|
||||
methods.append(DEFINITIONS[METHOD_RPC])
|
||||
elif category == "linux":
|
||||
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))
|
||||
|
||||
|
||||
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."""
|
||||
options = available(device, maintenance_active=maintenance_active, darf=darf)
|
||||
return options[0] if options else 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.
|
||||
|
||||
Die Oberflaeche darf vorschlagen; entscheiden tut der Server. Ein Weg, der
|
||||
fuer dieses Geraet nicht offensteht, wird nicht "irgendwie" ersetzt --
|
||||
sonst startet jemand ein Geraet anders neu, als er dachte.
|
||||
"""
|
||||
options = {
|
||||
entry.key: entry
|
||||
for entry in available(device, maintenance_active=maintenance_active, darf=darf)
|
||||
}
|
||||
if not options:
|
||||
return None
|
||||
if not requested:
|
||||
return options[sorted(options, key=PREFERENCE.index)[0]]
|
||||
return options.get(requested)
|
||||
@@ -0,0 +1,149 @@
|
||||
"""Zeilenweise Kommandoausfuehrung auf einem Linux-Client.
|
||||
|
||||
Gegenstueck zu :mod:`switchcli`. Der Unterschied liegt im Verfahren, nicht in
|
||||
der Bedienung:
|
||||
|
||||
* ArubaOS braucht eine **interaktive Shell** mit PTY, und die Antwort endet
|
||||
erst am Prompt -- ``switchcli`` muss den Prompt erkennen.
|
||||
* Ein Linux-Client kann jedes Kommando in einer eigenen ``exec``-Sitzung
|
||||
ausfuehren. Das ist deutlich robuster: kein Prompt-Raten, kein PTY, ein
|
||||
sauberer Rueckgabewert je Kommando, und ein haengendes Kommando reisst die
|
||||
Sitzung nicht mit.
|
||||
|
||||
Warum ueberhaupt eine Konsole fuer Clients? Ohne sie gab es keinen Weg, einen
|
||||
unbekannten Host-Schluessel im laufenden Betrieb zu pruefen und freizugeben --
|
||||
und ohne freigegebenen Schluessel verweigert die Wartung den Dienst. Genau in
|
||||
diese Sackgasse laeuft man sonst: die Wartung sagt "Schluessel unbekannt", und
|
||||
es gibt keine Stelle, an der man ihn ansehen koennte.
|
||||
|
||||
Bewusst **keine** interaktive Shell fuer Clients: eine solche Sitzung braucht
|
||||
einen dauerhaft blockierten Thread je Nutzer und einen Prompt-Parser, der bei
|
||||
jedem abweichenden ``PS1`` bricht. Wer wirklich interaktiv arbeiten will,
|
||||
nimmt einen SSH-Client -- dafuer ist er da.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from .ssh import SshError, open_transport
|
||||
|
||||
DEFAULT_COMMAND_TIMEOUT = 60
|
||||
MAX_OUTPUT_BYTES = 256 * 1024
|
||||
|
||||
|
||||
class ShellSession:
|
||||
"""Offene SSH-Verbindung zu einem Client; ein ``exec``-Kanal je Kommando."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
conn: sqlite3.Connection,
|
||||
*,
|
||||
host: str,
|
||||
port: int,
|
||||
username: str,
|
||||
password: str,
|
||||
allow_unknown_host_key: bool = False,
|
||||
) -> None:
|
||||
self.conn = conn
|
||||
self.host = host
|
||||
self.port = int(port)
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.allow_unknown_host_key = allow_unknown_host_key
|
||||
self._transport: Any = None
|
||||
|
||||
def __enter__(self) -> "ShellSession":
|
||||
self._transport = open_transport(
|
||||
self.conn,
|
||||
host=self.host,
|
||||
port=self.port,
|
||||
username=self.username,
|
||||
password=self.password,
|
||||
allow_unknown=self.allow_unknown_host_key,
|
||||
)
|
||||
return self
|
||||
|
||||
def __exit__(self, *_exc: object) -> None:
|
||||
self.close()
|
||||
|
||||
def close(self) -> None:
|
||||
try:
|
||||
if self._transport is not None:
|
||||
self._transport.close()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
self._transport = None
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def banner(self) -> str:
|
||||
"""Kurze Auskunft ueber das Gegenueber -- als erste Zeile der Sitzung."""
|
||||
try:
|
||||
result = self.run("hostname; uname -sr; id -un", timeout=15)
|
||||
except SshError:
|
||||
return ""
|
||||
return result["output"].strip()
|
||||
|
||||
def run(self, command: str, *, timeout: int = DEFAULT_COMMAND_TIMEOUT) -> dict[str, Any]:
|
||||
"""Fuehrt ein Kommando aus und liefert Ausgabe samt Rueckgabewert.
|
||||
|
||||
Rueckgabe: ``{"output": str, "status": int, "duration_ms": int}``.
|
||||
"""
|
||||
if self._transport is None or not self._transport.is_active():
|
||||
raise SshError("Die Verbindung wurde getrennt.")
|
||||
|
||||
started = time.monotonic()
|
||||
channel = self._transport.open_session()
|
||||
channel.settimeout(timeout)
|
||||
channel.set_combine_stderr(True)
|
||||
try:
|
||||
channel.exec_command(command)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
channel.close()
|
||||
raise SshError(f"Das Kommando konnte nicht gestartet werden ({exc}).") from exc
|
||||
|
||||
chunks: list[bytes] = []
|
||||
size = 0
|
||||
deadline = time.monotonic() + timeout
|
||||
truncated = False
|
||||
try:
|
||||
while True:
|
||||
if channel.recv_ready():
|
||||
chunk = channel.recv(32768)
|
||||
if not chunk:
|
||||
break
|
||||
size += len(chunk)
|
||||
if size > MAX_OUTPUT_BYTES:
|
||||
truncated = True
|
||||
break
|
||||
chunks.append(chunk)
|
||||
elif channel.exit_status_ready():
|
||||
# Rest abholen, bevor der Kanal zugeht.
|
||||
while channel.recv_ready():
|
||||
chunks.append(channel.recv(32768))
|
||||
break
|
||||
elif time.monotonic() > deadline:
|
||||
channel.close()
|
||||
raise SshError(
|
||||
f"Das Kommando hat das Zeitlimit von {timeout}s überschritten."
|
||||
)
|
||||
else:
|
||||
time.sleep(0.05)
|
||||
status = channel.recv_exit_status() if not truncated else -1
|
||||
finally:
|
||||
try:
|
||||
channel.close()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
output = b"".join(chunks).decode("utf-8", errors="replace")
|
||||
if truncated:
|
||||
output += f"\n[Ausgabe nach {MAX_OUTPUT_BYTES // 1024} KB abgeschnitten]"
|
||||
return {
|
||||
"output": output,
|
||||
"status": int(status),
|
||||
"duration_ms": int((time.monotonic() - started) * 1000),
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
"""SSH-Grundlagen: Host-Schluessel-Pruefung und Verbindungsaufbau.
|
||||
|
||||
Der Vorgaenger hatte hier zwei Verfahren nebeneinander: das Browser-Terminal
|
||||
akzeptierte unbekannte Host-Schluessel interaktiv (``AutoAddPolicy``-artig), die
|
||||
Wartung wies sie hart ab (``RejectPolicy``). Die Begruendung war richtig -- beim
|
||||
Terminal sieht ein Mensch zu, bei einem Hintergrundjob nicht -- aber die
|
||||
Umsetzung lag in einer Datei ``known_hosts``, die niemand einsehen konnte.
|
||||
|
||||
Hier liegt die Vertrauensentscheidung in der Datenbank (``switch_host_keys`` --
|
||||
der Name stammt aus der Zeit, als es nur Switche gab; die Tabelle ist nach
|
||||
Host und Port geschluesselt und gilt fuer Switche wie Clients gleichermassen):
|
||||
|
||||
* **Unbekannter Schluessel**: Aufbau wird abgebrochen. Die Oberflaeche zeigt den
|
||||
Fingerprint und erlaubt eine ausdrueckliche, protokollierte Freigabe. Kein
|
||||
Hintergrundprozess kann diese Freigabe erteilen.
|
||||
* **Geaenderter Schluessel**: immer ein Fehler, nie eine stille Uebernahme --
|
||||
das ist der Fall, der bei einem Man-in-the-Middle auftritt.
|
||||
* Jede Freigabe steht mit Zeitpunkt und Benutzer im Aenderungsprotokoll.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import logging
|
||||
import re
|
||||
import socket
|
||||
import sqlite3
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from tesm_core.db import Database
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CONNECT_TIMEOUT = 12
|
||||
BANNER_TIMEOUT = 20
|
||||
AUTH_TIMEOUT = 20
|
||||
|
||||
|
||||
class SshError(Exception):
|
||||
"""Verbindungs- oder Anmeldefehler mit Klartextmeldung fuer die Oberflaeche."""
|
||||
|
||||
|
||||
class UnknownHostKey(SshError):
|
||||
"""Der Host-Schluessel ist unbekannt und muss freigegeben werden."""
|
||||
|
||||
def __init__(self, host: str, port: int, key_type: str, fingerprint: str) -> None:
|
||||
super().__init__(
|
||||
f"Der SSH-Host-Schlüssel von {host}:{port} ist unbekannt "
|
||||
f"({key_type}, {fingerprint}). Bitte prüfen und ausdrücklich freigeben."
|
||||
)
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.key_type = key_type
|
||||
self.fingerprint = fingerprint
|
||||
|
||||
|
||||
class HostKeyChanged(SshError):
|
||||
"""Der Host-Schluessel weicht vom gespeicherten ab."""
|
||||
|
||||
def __init__(self, host: str, port: int, expected: str, received: str) -> None:
|
||||
super().__init__(
|
||||
f"Der SSH-Host-Schlüssel von {host}:{port} hat sich geändert "
|
||||
f"(erwartet {expected}, erhalten {received}). Der Aufbau wurde abgebrochen. "
|
||||
"Das kann ein Gerätetausch sein – oder ein Angriff."
|
||||
)
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.expected = expected
|
||||
self.received = received
|
||||
|
||||
|
||||
#: Ein SHA256-Fingerprint in OpenSSH-Schreibweise: "SHA256:" plus 43 Zeichen
|
||||
#: Base64 ohne Fuellzeichen (32 Byte Hash).
|
||||
FINGERPRINT_RE = re.compile(r"^SHA256:[A-Za-z0-9+/]{43}$")
|
||||
|
||||
|
||||
def valid_fingerprint(value: str) -> bool:
|
||||
"""Prueft die Form eines Fingerprints, bevor er gespeichert wird.
|
||||
|
||||
Vorher wurde nur auf das Praefix ``SHA256:`` geprueft -- ``SHA256:unsinn``
|
||||
kam damit durch. Ein abgeschnittener oder vertippter Wert laesst sich
|
||||
danach von keinem echten Schluessel mehr treffen: die Anwendung meldet bei
|
||||
jeder Verbindung "Schluessel geaendert" und der Verdacht faellt auf das
|
||||
Geraet statt auf den Tippfehler.
|
||||
"""
|
||||
return bool(FINGERPRINT_RE.match((value or "").strip()))
|
||||
|
||||
|
||||
def fingerprint_of(key: Any) -> str:
|
||||
"""OpenSSH-kompatibler SHA256-Fingerprint (``SHA256:...``)."""
|
||||
digest = hashlib.sha256(key.asbytes()).digest()
|
||||
return "SHA256:" + base64.b64encode(digest).decode("ascii").rstrip("=")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class HostKeyRecord:
|
||||
host: str
|
||||
port: int
|
||||
key_type: str
|
||||
fingerprint: str
|
||||
|
||||
|
||||
def known_key(
|
||||
conn: sqlite3.Connection, host: str, port: int, key_type: str
|
||||
) -> HostKeyRecord | None:
|
||||
row = Database.one(
|
||||
conn,
|
||||
"SELECT host, port, key_type, fingerprint FROM switch_host_keys "
|
||||
"WHERE host=? AND port=? AND key_type=?",
|
||||
(host, int(port), key_type),
|
||||
)
|
||||
if row is None:
|
||||
return None
|
||||
return HostKeyRecord(
|
||||
host=str(row["host"]),
|
||||
port=int(row["port"]),
|
||||
key_type=str(row["key_type"]),
|
||||
fingerprint=str(row["fingerprint"]),
|
||||
)
|
||||
|
||||
|
||||
def trust_key( # noqa: PLR0913 - benannte Argumente, bewusst explizit
|
||||
conn: sqlite3.Connection,
|
||||
*,
|
||||
host: str,
|
||||
port: int,
|
||||
key_type: str,
|
||||
fingerprint: str,
|
||||
actor: str,
|
||||
) -> None:
|
||||
conn.execute(
|
||||
"INSERT INTO switch_host_keys (host, port, key_type, fingerprint, added_at, added_by) "
|
||||
"VALUES (?,?,?,?,?,?) ON CONFLICT(host, port, key_type) DO UPDATE SET "
|
||||
"fingerprint=excluded.fingerprint, added_at=excluded.added_at, added_by=excluded.added_by",
|
||||
(
|
||||
host,
|
||||
int(port),
|
||||
key_type,
|
||||
fingerprint,
|
||||
datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||
actor,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def forget_key(conn: sqlite3.Connection, host: str, port: int) -> None:
|
||||
conn.execute("DELETE FROM switch_host_keys WHERE host=? AND port=?", (host, int(port)))
|
||||
|
||||
|
||||
def all_known_keys(conn: sqlite3.Connection, host: str, port: int) -> list[HostKeyRecord]:
|
||||
"""Alle gespeicherten Schluessel eines Ziels, unabhaengig vom Typ."""
|
||||
return [
|
||||
HostKeyRecord(
|
||||
host=str(row["host"]),
|
||||
port=int(row["port"]),
|
||||
key_type=str(row["key_type"]),
|
||||
fingerprint=str(row["fingerprint"]),
|
||||
)
|
||||
for row in Database.all(
|
||||
conn,
|
||||
"SELECT host, port, key_type, fingerprint FROM switch_host_keys "
|
||||
"WHERE host=? AND port=? ORDER BY key_type",
|
||||
(host, int(port)),
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def is_trusted(conn: sqlite3.Connection, host: str, port: int) -> bool:
|
||||
"""Gibt es ueberhaupt einen freigegebenen Schluessel fuer dieses Ziel?"""
|
||||
return bool(all_known_keys(conn, host, int(port)))
|
||||
|
||||
|
||||
def describe_probe(
|
||||
conn: sqlite3.Connection, record: HostKeyRecord
|
||||
) -> tuple[str, str]:
|
||||
"""Formuliert das Ergebnis einer Schluesselpruefung als (Stufe, Text).
|
||||
|
||||
Steht hier und nicht in den Blueprints, weil Switche und Clients denselben
|
||||
Ablauf haben -- und weil die drei Faelle (unbekannt, abweichend, gleich)
|
||||
ueberall gleich beurteilt werden muessen. Zwei Kopien dieser Logik waeren
|
||||
zwei Gelegenheiten, den mittleren Fall zu verharmlosen.
|
||||
"""
|
||||
known = known_key(conn, record.host, record.port, record.key_type)
|
||||
if known is None:
|
||||
return (
|
||||
"info",
|
||||
f"Host-Schlüssel gelesen: {record.key_type} {record.fingerprint}. "
|
||||
"Bitte gegen eine unabhängige Quelle prüfen und dann freigeben.",
|
||||
)
|
||||
if known.fingerprint != record.fingerprint:
|
||||
return (
|
||||
"danger",
|
||||
f"Achtung: der Schlüssel weicht vom gespeicherten ab. Gespeichert "
|
||||
f"{known.fingerprint}, gelesen {record.fingerprint}. Das kann ein "
|
||||
"Gerätetausch sein – oder ein Angriff.",
|
||||
)
|
||||
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:
|
||||
"""Holt den Host-Schluessel, ohne sich anzumelden -- fuer den Freigabedialog."""
|
||||
import paramiko
|
||||
|
||||
try:
|
||||
sock = socket.create_connection((host, int(port)), timeout=timeout)
|
||||
except OSError as exc:
|
||||
raise SshError(f"{host}:{port} ist nicht erreichbar ({exc}).") from exc
|
||||
transport = paramiko.Transport(sock)
|
||||
try:
|
||||
transport.start_client(timeout=timeout)
|
||||
key = transport.get_remote_server_key()
|
||||
return HostKeyRecord(
|
||||
host=host, port=int(port), key_type=key.get_name(), fingerprint=fingerprint_of(key)
|
||||
)
|
||||
except paramiko.SSHException as exc:
|
||||
raise SshError(f"SSH-Handshake mit {host}:{port} fehlgeschlagen ({exc}).") from exc
|
||||
finally:
|
||||
transport.close()
|
||||
try:
|
||||
sock.close()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def open_transport(
|
||||
conn: sqlite3.Connection,
|
||||
*,
|
||||
host: str,
|
||||
port: int,
|
||||
username: str,
|
||||
password: str,
|
||||
allow_unknown: bool = False,
|
||||
) -> Any:
|
||||
"""Baut eine geprueft-vertrauenswuerdige SSH-Verbindung auf.
|
||||
|
||||
Bewusst die Low-Level-``Transport``-API statt ``SSHClient.connect()``: nur so
|
||||
laesst sich der Host-Schluessel *vor* der Passwortuebergabe pruefen -- und nur
|
||||
so kann das Browser-Terminal eine Freigabe zwischen Handshake und Anmeldung
|
||||
einschieben.
|
||||
|
||||
Authentisierungsreihenfolge ``password`` vor ``keyboard-interactive``:
|
||||
mehrere ArubaOS-Varianten bieten letzteres unzuverlaessig an; wer damit
|
||||
beginnt, sieht nie einen Prompt und bekommt sofort "Zugangsdaten falsch".
|
||||
"""
|
||||
import paramiko
|
||||
|
||||
try:
|
||||
sock = socket.create_connection((host, int(port)), timeout=CONNECT_TIMEOUT)
|
||||
except OSError as exc:
|
||||
raise SshError(f"{host}:{port} ist nicht erreichbar ({exc}).") from exc
|
||||
|
||||
transport = paramiko.Transport(sock)
|
||||
transport.banner_timeout = BANNER_TIMEOUT
|
||||
transport.auth_timeout = AUTH_TIMEOUT
|
||||
|
||||
# Wenn fuer dieses Ziel schon Schluessel freigegeben sind, wird die
|
||||
# Aushandlung auf genau diese Typen begrenzt -- so macht es OpenSSH mit
|
||||
# known_hosts auch.
|
||||
#
|
||||
# Ohne diese Begrenzung entsteht ein Zustand, der niemandem zu erklaeren
|
||||
# ist: die Oberflaeche zeigt "freigegeben", der Verbindungsversuch meldet
|
||||
# trotzdem "Schluessel unbekannt". Grund waere schlicht, dass der Server
|
||||
# einen anderen Typ angeboten hat (etwa ssh-rsa statt ssh-ed25519) als den
|
||||
# freigegebenen. Der Fehler steckt dann nicht im Vertrauen, sondern in der
|
||||
# Auswahl -- und die laesst sich hier treffen.
|
||||
trusted_types = [record.key_type for record in all_known_keys(conn, host, int(port))]
|
||||
if trusted_types:
|
||||
options = transport.get_security_options()
|
||||
supported = [name for name in options.key_types if name in trusted_types]
|
||||
if supported:
|
||||
options.key_types = tuple(supported)
|
||||
|
||||
try:
|
||||
transport.start_client(timeout=CONNECT_TIMEOUT)
|
||||
except paramiko.SSHException as exc:
|
||||
transport.close()
|
||||
if trusted_types:
|
||||
raise SshError(
|
||||
f"SSH-Handshake mit {host}:{port} fehlgeschlagen ({exc}). "
|
||||
f"Freigegeben sind nur {', '.join(sorted(set(trusted_types)))} -- "
|
||||
"bietet das Gerät inzwischen einen anderen Schlüsseltyp an, muss der "
|
||||
"Schlüssel neu ausgelesen und freigegeben werden."
|
||||
) from exc
|
||||
raise SshError(f"SSH-Handshake mit {host}:{port} fehlgeschlagen ({exc}).") from exc
|
||||
|
||||
server_key = transport.get_remote_server_key()
|
||||
key_type = server_key.get_name()
|
||||
received = fingerprint_of(server_key)
|
||||
record = known_key(conn, host, int(port), key_type)
|
||||
|
||||
if record is None:
|
||||
if not allow_unknown:
|
||||
transport.close()
|
||||
raise UnknownHostKey(host, int(port), key_type, received)
|
||||
elif record.fingerprint != received:
|
||||
transport.close()
|
||||
raise HostKeyChanged(host, int(port), record.fingerprint, received)
|
||||
|
||||
try:
|
||||
transport.auth_password(username, password)
|
||||
except paramiko.AuthenticationException:
|
||||
try:
|
||||
transport.auth_interactive_dumb(username, lambda *_: [password])
|
||||
except paramiko.SSHException as exc:
|
||||
transport.close()
|
||||
raise SshError("Die Zugangsdaten wurden abgewiesen.") from exc
|
||||
except paramiko.SSHException as exc:
|
||||
transport.close()
|
||||
raise SshError(f"Anmeldung fehlgeschlagen ({exc}).") from exc
|
||||
|
||||
if not transport.is_authenticated():
|
||||
transport.close()
|
||||
raise SshError("Die Zugangsdaten wurden abgewiesen.")
|
||||
return transport
|
||||
@@ -0,0 +1,369 @@
|
||||
"""ArubaOS-Kommandozeile: PoE schalten und Zustand auslesen.
|
||||
|
||||
Der Vorgaenger steuerte den Switch ueber ein ``expect``-Skript. Das brachte drei
|
||||
Probleme mit, die hier verschwinden:
|
||||
|
||||
1. **Fehlererkennung war unmoeglich.** Ein Versuch, harte Zeitlimits pro
|
||||
Schritt einzufuehren, war eine Regression: das Prompt-Muster erkannte nur den
|
||||
Operator-Modus (``switch>``), nicht den privilegierten (``switch#``), den
|
||||
manche Konten direkt nach der Anmeldung haben -- gueltige Anmeldungen wurden
|
||||
als Fehlschlag gewertet.
|
||||
Loesung hier: das Prompt-Muster erkennt **beide** Modi, und der Erfolg wird
|
||||
nicht am Prompt gemessen, sondern am tatsaechlichen Zustand
|
||||
(``show power-over-ethernet``). Damit braucht es kein Raten mehr.
|
||||
2. **Passwoerter standen in der Prozessliste.** ``expect`` bekam sie als
|
||||
Argument. Hier gehen sie nie durch eine Kommandozeile.
|
||||
3. **Kein Transkript.** Jetzt liefert jeder Vorgang die vollstaendige Ausgabe
|
||||
zurueck, die als Ereignis gespeichert wird.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import sqlite3
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from .ssh import SshError, open_transport
|
||||
|
||||
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
|
||||
#: Konfigurationsmodus (``switch(config)#``).
|
||||
#:
|
||||
#: 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)
|
||||
ERROR_RE = re.compile(
|
||||
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
|
||||
DEFAULT_COMMAND_TIMEOUT = 20
|
||||
POE_SETTLE_SECONDS = 2
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class CliResult:
|
||||
ok: bool
|
||||
transcript: str = ""
|
||||
error: str = ""
|
||||
steps: list[str] = field(default_factory=list)
|
||||
|
||||
def add(self, message: str) -> None:
|
||||
self.steps.append(message)
|
||||
|
||||
|
||||
class SwitchCli:
|
||||
"""Interaktive CLI-Sitzung auf einem ArubaOS-Switch."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
conn: sqlite3.Connection,
|
||||
*,
|
||||
host: str,
|
||||
port: int,
|
||||
username: str,
|
||||
password: str,
|
||||
allow_unknown_host_key: bool = False,
|
||||
) -> None:
|
||||
self.conn = conn
|
||||
self.host = host
|
||||
self.port = int(port)
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.allow_unknown_host_key = allow_unknown_host_key
|
||||
self._transport: Any = None
|
||||
self._channel: Any = None
|
||||
self._buffer = b""
|
||||
self.transcript: list[str] = []
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def __enter__(self) -> "SwitchCli":
|
||||
self._transport = open_transport(
|
||||
self.conn,
|
||||
host=self.host,
|
||||
port=self.port,
|
||||
username=self.username,
|
||||
password=self.password,
|
||||
allow_unknown=self.allow_unknown_host_key,
|
||||
)
|
||||
self._channel = self._transport.open_session()
|
||||
self._channel.settimeout(DEFAULT_COMMAND_TIMEOUT)
|
||||
# ArubaOS braucht ein PTY; ohne bleibt die Shell stumm.
|
||||
self._channel.get_pty(term="vt100", width=200, height=1000)
|
||||
self._channel.invoke_shell()
|
||||
self._read_until_prompt(timeout=DEFAULT_COMMAND_TIMEOUT, initial=True)
|
||||
# Seitenweise Ausgabe abschalten -- sonst blockiert jedes "show" am Pager.
|
||||
for command in ("no page", "terminal length 1000"):
|
||||
self.send(command, tolerate_error=True)
|
||||
return self
|
||||
|
||||
def __exit__(self, *_exc: object) -> None:
|
||||
self.close()
|
||||
|
||||
def close(self) -> None:
|
||||
"""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:
|
||||
if closer is not None:
|
||||
closer.close()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
self._channel = None
|
||||
self._transport = None
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
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
|
||||
deadline = time.monotonic() + timeout
|
||||
# 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:
|
||||
if self._channel.recv_ready():
|
||||
chunk = self._channel.recv(READ_CHUNK)
|
||||
if not chunk:
|
||||
break
|
||||
sichtbar = ANSI_RE.sub(b"", sichtbar + chunk)
|
||||
if PAGER_RE.search(sichtbar[-300:]):
|
||||
self._channel.send(" ")
|
||||
sichtbar = PAGER_RE.sub(b"", sichtbar)
|
||||
continue
|
||||
if PROMPT_RE.search(sichtbar[-300:]):
|
||||
prompt_gesehen = True
|
||||
break
|
||||
# Der Login-Banner mancher Geraete verlangt eine Bestaetigung.
|
||||
if initial and sichtbar.rstrip().endswith(b"continue"):
|
||||
self._channel.send("\r\n")
|
||||
else:
|
||||
time.sleep(0.05)
|
||||
text = sichtbar.decode("utf-8", errors="replace")
|
||||
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
|
||||
|
||||
def send(
|
||||
self, command: str, *, timeout: int = DEFAULT_COMMAND_TIMEOUT, tolerate_error: bool = False
|
||||
) -> str:
|
||||
"""Schickt ein Kommando und liest bis zum Prompt.
|
||||
|
||||
Ein erkannter Fehlertext fuehrt zu einer Ausnahme -- ausser bei
|
||||
``tolerate_error`` (z. B. ``no page``, das nicht jede Firmware kennt).
|
||||
"""
|
||||
if self._channel is None:
|
||||
raise SshError("Die Sitzung ist nicht geöffnet.")
|
||||
self.transcript.append(f"\n$ {command}\n")
|
||||
self._channel.send(command + "\r\n")
|
||||
output = self._read_until_prompt(timeout=timeout)
|
||||
if not tolerate_error and ERROR_RE.search(output):
|
||||
raise SshError(f"Der Switch hat das Kommando abgelehnt: {command!r}")
|
||||
return output
|
||||
|
||||
@property
|
||||
def full_transcript(self) -> str:
|
||||
return "".join(self.transcript)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# PoE
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def set_poe(self, port: str, enabled: bool) -> None:
|
||||
self.send("configure terminal")
|
||||
try:
|
||||
self.send(f"interface {port}")
|
||||
self.send("power-over-ethernet" if enabled else "no power-over-ethernet")
|
||||
finally:
|
||||
# Immer zurueck in den privilegierten Modus, auch nach einem Fehler --
|
||||
# sonst bleibt die Sitzung im Konfigurationsmodus haengen.
|
||||
self.send("exit", tolerate_error=True)
|
||||
self.send("exit", tolerate_error=True)
|
||||
|
||||
def poe_state(self, port: str) -> str:
|
||||
"""``enabled`` / ``disabled`` / ``unknown`` -- gelesen, nicht geraten."""
|
||||
output = self.send(f"show power-over-ethernet {port}", tolerate_error=True)
|
||||
lowered = output.lower()
|
||||
if "power enable" in lowered:
|
||||
for line in lowered.splitlines():
|
||||
if "power enable" in line:
|
||||
if "yes" in line or "enabled" in line:
|
||||
return "enabled"
|
||||
if "no" in line or "disabled" in line:
|
||||
return "disabled"
|
||||
if re.search(r"poe port status\s*:\s*enabled", lowered):
|
||||
return "enabled"
|
||||
if re.search(r"poe port status\s*:\s*disabled", lowered):
|
||||
return "disabled"
|
||||
return "unknown"
|
||||
|
||||
def cycle_poe(self, port: str) -> CliResult:
|
||||
"""Schaltet PoE aus, wartet und wieder ein. Prueft das Ergebnis."""
|
||||
result = CliResult(ok=False)
|
||||
try:
|
||||
before = self.poe_state(port)
|
||||
result.add(f"Zustand vor dem Vorgang: {before}")
|
||||
|
||||
self.set_poe(port, enabled=False)
|
||||
result.add("PoE ausgeschaltet")
|
||||
time.sleep(POE_SETTLE_SECONDS)
|
||||
|
||||
off_state = self.poe_state(port)
|
||||
if off_state == "enabled":
|
||||
result.error = (
|
||||
"Der Switch meldet den Port nach dem Abschalten weiter als aktiv – "
|
||||
"der Neustart hat nicht stattgefunden."
|
||||
)
|
||||
result.transcript = self.full_transcript
|
||||
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}")
|
||||
|
||||
self.set_poe(port, enabled=True)
|
||||
result.add("PoE wieder eingeschaltet")
|
||||
|
||||
after = self.poe_state(port)
|
||||
result.add(f"Zustand nach dem Vorgang: {after}")
|
||||
if after == "disabled":
|
||||
result.error = "Der Port ist nach dem Vorgang weiter abgeschaltet."
|
||||
result.transcript = self.full_transcript
|
||||
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
|
||||
except SshError as exc:
|
||||
result.error = str(exc)
|
||||
except Exception as exc: # noqa: BLE001 - Netzabbruch mitten im Vorgang
|
||||
logger.exception("PoE-Vorgang auf %s Port %s fehlgeschlagen", self.host, port)
|
||||
result.error = f"Unerwarteter Fehler: {exc}"
|
||||
result.transcript = self.full_transcript
|
||||
return result
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def interface_overview(self) -> str:
|
||||
return self.send("show power-over-ethernet brief", tolerate_error=True)
|
||||
|
||||
def system_info(self) -> str:
|
||||
return self.send("show system information", tolerate_error=True)
|
||||
|
||||
|
||||
#: Kommandos, die die Oberflaeche ohne freies Eingabefeld anbietet.
|
||||
SAFE_COMMANDS: tuple[tuple[str, str], ...] = (
|
||||
("show system information", "Systeminformationen"),
|
||||
("show power-over-ethernet brief", "PoE-Übersicht aller Ports"),
|
||||
("show interfaces brief", "Portstatus"),
|
||||
("show vlans", "VLAN-Uebersicht"),
|
||||
("show mac-address", "MAC-Adresstabelle"),
|
||||
("show lldp info remote-device", "Nachbargeräte (LLDP)"),
|
||||
("show version", "Firmware-Version"),
|
||||
("show logging", "Switch-Protokoll"),
|
||||
)
|
||||
@@ -0,0 +1,837 @@
|
||||
"""Sicherung von TESM: welche Kategorien es gibt und wie sie gelesen/geschrieben werden.
|
||||
|
||||
Der verschluesselte Umschlag selbst (Argon2id + AES-256-GCM, Zwischenstand des
|
||||
Imports) liegt im Kern -- ``tesm_core.transfer``. Hier steht nur die Fachlogik.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Callable, Iterable
|
||||
|
||||
from tesm_core.db import Database
|
||||
from tesm_core.keystore import Keystore
|
||||
from tesm_core.rbac.legacy_permissions import umgeschrieben
|
||||
from tesm_core.transfer import (
|
||||
MIN_PASSPHRASE_LENGTH,
|
||||
PREVIEW_TTL_MINUTES,
|
||||
Category,
|
||||
TransferError,
|
||||
drop_preview,
|
||||
load_preview,
|
||||
seal as _seal,
|
||||
store_preview,
|
||||
summarize as _summarize,
|
||||
unseal as _unseal,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
APP_TAG = "tesm"
|
||||
|
||||
|
||||
def seal(payload: dict[str, Any], passphrase: str) -> str:
|
||||
return _seal(payload, passphrase, app_tag=APP_TAG)
|
||||
|
||||
|
||||
def unseal(raw: bytes | str, passphrase: str) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
return _unseal(raw, passphrase, app_tag=APP_TAG)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Kategorien
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
CATEGORIES: tuple[Category, ...] = (
|
||||
Category("devices", "Clients", "Gerätestammdaten inklusive Switch- und Portzuordnung"),
|
||||
Category("switches", "Switche", "Switch-Inventar"),
|
||||
Category(
|
||||
"credentials",
|
||||
"Zugangsdaten",
|
||||
"SSH-Logins. Die Passwörter liegen im Export im Klartext – geschützt "
|
||||
"ausschließlich durch die Passphrase.",
|
||||
sensitive=True,
|
||||
),
|
||||
Category("dhcp", "DHCP-Konfiguration", "Subnetze, Optionen und manuelle Reservierungen"),
|
||||
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("groups", "Gruppen und Rechte", "Rechtematrix", 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}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Export
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def build_export(
|
||||
conn: sqlite3.Connection,
|
||||
keystore: Keystore,
|
||||
*,
|
||||
categories: Iterable[str],
|
||||
include_secrets: bool,
|
||||
) -> dict[str, Any]:
|
||||
from .inventory import aad_for_credential
|
||||
|
||||
wanted = [key for key in categories if key in CATEGORY_BY_KEY]
|
||||
payload: dict[str, Any] = {}
|
||||
|
||||
if "devices" in wanted:
|
||||
payload["devices"] = Database.all(
|
||||
conn,
|
||||
"SELECT d.mac, d.ip, d.name, d.port, d.is_active, d.ssh_port, d.category, "
|
||||
"d.location, d.notes, d.auto_restart, s.hostname AS switch_hostname, "
|
||||
"c.name AS credential_name FROM devices d "
|
||||
"LEFT JOIN switches s ON s.id = d.switch_id "
|
||||
"LEFT JOIN credentials c ON c.id = d.credential_id "
|
||||
"WHERE d.deleted_at IS NULL",
|
||||
)
|
||||
if "switches" in wanted:
|
||||
payload["switches"] = Database.all(
|
||||
conn,
|
||||
"SELECT s.hostname, s.ip, s.ssh_port, s.model, s.location, s.notes, "
|
||||
"c.name AS credential_name FROM switches s "
|
||||
"LEFT JOIN credentials c ON c.id = s.credential_id WHERE s.deleted_at IS NULL",
|
||||
)
|
||||
if "credentials" in wanted:
|
||||
rows = Database.all(
|
||||
conn,
|
||||
"SELECT id, name, username, category, description, secret_encrypted FROM credentials "
|
||||
"WHERE deleted_at IS NULL",
|
||||
)
|
||||
entries = []
|
||||
for row in rows:
|
||||
entry = {
|
||||
"name": row["name"],
|
||||
"username": row["username"],
|
||||
"category": row["category"],
|
||||
"description": row["description"],
|
||||
}
|
||||
if include_secrets and str(row["secret_encrypted"] or ""):
|
||||
try:
|
||||
entry["secret"] = keystore.decrypt(
|
||||
str(row["secret_encrypted"]), aad=aad_for_credential(int(row["id"]))
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
entry["secret_error"] = "nicht entschlüsselbar"
|
||||
entries.append(entry)
|
||||
payload["credentials"] = entries
|
||||
if "dhcp" in wanted:
|
||||
payload["dhcp"] = {
|
||||
"subnets": Database.all(
|
||||
conn,
|
||||
"SELECT interface, range_start, range_end, gateway, dns, comment, enabled, "
|
||||
"sort_order FROM dhcp_subnets",
|
||||
),
|
||||
"options": Database.all(
|
||||
conn,
|
||||
"SELECT o.code, o.name, o.type, o.description, o.is_standard, v.value, "
|
||||
"d.mac AS device_mac FROM dhcp_options o "
|
||||
"JOIN dhcp_option_values v ON v.option_id = o.id "
|
||||
"LEFT JOIN devices d ON d.id = v.device_id",
|
||||
),
|
||||
"reservations": Database.all(
|
||||
conn, "SELECT mac, ip, hostname, comment FROM dhcp_reservations"
|
||||
),
|
||||
}
|
||||
if "fileshare" in wanted:
|
||||
payload["fileshare"] = Database.all(
|
||||
conn, "SELECT ad_group_dn, ad_group_name, label, unc FROM fileshare_mappings"
|
||||
)
|
||||
if "users" in wanted:
|
||||
# Verzeichniskonten werden nie exportiert -- sie legen sich am Ziel bei
|
||||
# der ersten Anmeldung selbst an, und ihr Passwort liegt ohnehin nicht hier.
|
||||
payload["users"] = Database.all(
|
||||
conn,
|
||||
"SELECT u.username, u.password_hash, u.is_admin, u.is_locked, u.first_name, "
|
||||
"u.last_name, u.email, u.must_change_password, "
|
||||
"(SELECT group_concat(g.name, char(10)) FROM user_groups ug "
|
||||
" JOIN groups g ON g.id = ug.group_id WHERE ug.user_id = u.id) AS group_names "
|
||||
"FROM users u WHERE u.deleted_at IS NULL AND u.auth_source='local'",
|
||||
)
|
||||
if "groups" in wanted:
|
||||
payload["groups"] = [
|
||||
{
|
||||
"name": row["name"],
|
||||
"description": row["description"],
|
||||
"is_default": row["is_default"],
|
||||
"is_system": row["is_system"],
|
||||
"preset_key": row["preset_key"],
|
||||
"permissions": [
|
||||
str(entry["permission"])
|
||||
for entry in Database.all(
|
||||
conn,
|
||||
"SELECT permission FROM group_permissions WHERE group_id=? ORDER BY permission",
|
||||
(row["id"],),
|
||||
)
|
||||
],
|
||||
}
|
||||
for row in Database.all(conn, "SELECT * FROM groups WHERE deleted_at IS NULL")
|
||||
]
|
||||
if "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:
|
||||
payload["settings"] = {
|
||||
str(row["key"]): str(row["value"])
|
||||
for row in Database.all(conn, "SELECT key, value FROM settings")
|
||||
# Lizenz- und Aktivierungszustand gehoeren zur Maschine, nicht zur
|
||||
# Sicherung -- sonst wuerde ein Restore eine fremde Aktivierung erben.
|
||||
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
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Zwischenstand des Imports
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
# ``store_preview``/``load_preview``/``drop_preview`` kommen aus dem Kern (siehe
|
||||
# Import oben). Hier standen zeichengleiche Kopien, die die Importe
|
||||
# ueberschatteten -- und zwar defekte: ihnen fehlten ``secrets``, ``datetime``,
|
||||
# ``timedelta``, ``timezone``, ``_now`` und ``PREVIEW_JOB_KIND``. Jeder Aufruf
|
||||
# waere mit NameError abgebrochen, also der ganze Importweg. Aufgefallen ist es
|
||||
# erst, als der erste Test ihn wirklich benutzt hat.
|
||||
|
||||
|
||||
def summarize(payload: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
entries = _summarize(payload, CATEGORIES)
|
||||
for entry in entries:
|
||||
if entry["key"] == "credentials":
|
||||
data = payload.get("credentials") or []
|
||||
entry["has_secrets"] = any(
|
||||
isinstance(row, dict) and "secret" in row for row in data
|
||||
)
|
||||
else:
|
||||
entry["has_secrets"] = False
|
||||
return entries
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Uebernahme
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def apply_import(
|
||||
conn: sqlite3.Connection,
|
||||
keystore: Keystore,
|
||||
payload: dict[str, Any],
|
||||
*,
|
||||
categories: Iterable[str],
|
||||
set_setting: Callable[[str, Any], None],
|
||||
) -> dict[str, dict[str, int]]:
|
||||
"""Uebernimmt ausgewaehlte Kategorien. Rueckgabe: ``{kategorie: {angelegt, aktualisiert}}``.
|
||||
|
||||
Abgleich nach natuerlichem Schluessel (Name/Hostname/MAC/Benutzername).
|
||||
Eintraege, die im Papierkorb liegen, werden **nicht** stillschweigend
|
||||
wiederbelebt -- das war im Vorgaenger ein Fehler, der geloeschte Geraete
|
||||
ueber den Import zurueckbrachte.
|
||||
"""
|
||||
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:
|
||||
"""MAC normalisieren, ohne den ganzen Import zu sprengen.
|
||||
|
||||
``normalize_mac`` wirft bei unbrauchbarer Eingabe -- auch bei leerer.
|
||||
Aufgerufen wurde es hier ungeprueft, obwohl der Code danach ``if not
|
||||
mac: continue`` erwartet. Ein einziger Eintrag ohne MAC haette damit
|
||||
den gesamten Import mit einer Meldung ueber Hexzeichen abgebrochen.
|
||||
"""
|
||||
try:
|
||||
return normalize_mac(str(value or ""))
|
||||
except ValidationError:
|
||||
return None
|
||||
|
||||
|
||||
wanted = {key for key in categories if key in CATEGORY_BY_KEY}
|
||||
report: dict[str, dict[str, int]] = {}
|
||||
stamp = _now().isoformat(timespec="seconds")
|
||||
|
||||
def bump(category: str, field: str) -> None:
|
||||
report.setdefault(category, {"created": 0, "updated": 0, "skipped": 0})[field] += 1
|
||||
|
||||
# Reihenfolge ist wichtig: Zugangsdaten vor Switchen, Switche vor Geraeten.
|
||||
if "credentials" in wanted:
|
||||
for entry in payload.get("credentials", []):
|
||||
name = str(entry.get("name") or "").strip()
|
||||
if not name:
|
||||
continue
|
||||
existing = Database.one(
|
||||
conn,
|
||||
"SELECT id FROM credentials WHERE name=? COLLATE NOCASE AND deleted_at IS NULL",
|
||||
(name,),
|
||||
)
|
||||
if existing is None:
|
||||
cursor = conn.execute(
|
||||
"INSERT INTO credentials (name, username, secret_encrypted, category, "
|
||||
"description, created_at, updated_at, updated_by) VALUES (?,?,'',?,?,?,?,'import')",
|
||||
(
|
||||
name,
|
||||
str(entry.get("username") or ""),
|
||||
str(entry.get("category") or "switch"),
|
||||
str(entry.get("description") or ""),
|
||||
stamp,
|
||||
stamp,
|
||||
),
|
||||
)
|
||||
credential_id = int(cursor.lastrowid or 0)
|
||||
bump("credentials", "created")
|
||||
else:
|
||||
credential_id = int(existing["id"])
|
||||
conn.execute(
|
||||
"UPDATE credentials SET username=?, category=?, description=?, updated_at=?, "
|
||||
"updated_by='import' WHERE id=?",
|
||||
(
|
||||
str(entry.get("username") or ""),
|
||||
str(entry.get("category") or "switch"),
|
||||
str(entry.get("description") or ""),
|
||||
stamp,
|
||||
credential_id,
|
||||
),
|
||||
)
|
||||
bump("credentials", "updated")
|
||||
if entry.get("secret"):
|
||||
conn.execute(
|
||||
"UPDATE credentials SET secret_encrypted=? WHERE id=?",
|
||||
(
|
||||
keystore.encrypt(str(entry["secret"]), aad=aad_for_credential(credential_id)),
|
||||
credential_id,
|
||||
),
|
||||
)
|
||||
|
||||
def credential_id_by_name(name: str | None) -> int | None:
|
||||
if not name:
|
||||
return None
|
||||
value = Database.value(
|
||||
conn,
|
||||
"SELECT id FROM credentials WHERE name=? COLLATE NOCASE AND deleted_at IS NULL",
|
||||
(name,),
|
||||
)
|
||||
return int(value) if value is not None else None
|
||||
|
||||
if "switches" in wanted:
|
||||
for entry in payload.get("switches", []):
|
||||
hostname = str(entry.get("hostname") or "").strip()
|
||||
if not hostname:
|
||||
continue
|
||||
existing = Database.one(
|
||||
conn,
|
||||
"SELECT id FROM switches WHERE hostname=? COLLATE NOCASE AND deleted_at IS NULL",
|
||||
(hostname,),
|
||||
)
|
||||
values = (
|
||||
str(entry.get("ip") or ""),
|
||||
int(entry.get("ssh_port") or 22),
|
||||
credential_id_by_name(entry.get("credential_name")),
|
||||
str(entry.get("model") or ""),
|
||||
str(entry.get("location") or ""),
|
||||
str(entry.get("notes") or ""),
|
||||
)
|
||||
if existing is None:
|
||||
conn.execute(
|
||||
"INSERT INTO switches (hostname, ip, ssh_port, credential_id, model, location, "
|
||||
"notes, created_at, updated_at, updated_by) VALUES (?,?,?,?,?,?,?,?,?,'import')",
|
||||
(hostname, *values, stamp, stamp),
|
||||
)
|
||||
bump("switches", "created")
|
||||
else:
|
||||
conn.execute(
|
||||
"UPDATE switches SET ip=?, ssh_port=?, credential_id=?, model=?, location=?, "
|
||||
"notes=?, updated_at=?, updated_by='import' WHERE id=?",
|
||||
(*values, stamp, existing["id"]),
|
||||
)
|
||||
bump("switches", "updated")
|
||||
|
||||
def switch_id_by_hostname(hostname: str | None) -> int | None:
|
||||
if not hostname:
|
||||
return None
|
||||
value = Database.value(
|
||||
conn,
|
||||
"SELECT id FROM switches WHERE hostname=? COLLATE NOCASE AND deleted_at IS NULL",
|
||||
(hostname,),
|
||||
)
|
||||
return int(value) if value is not None else None
|
||||
|
||||
if "devices" in wanted:
|
||||
for entry in payload.get("devices", []):
|
||||
mac = normalize_mac(str(entry.get("mac") or ""))
|
||||
if not mac:
|
||||
bump("devices", "skipped")
|
||||
continue
|
||||
existing = Database.one(
|
||||
conn,
|
||||
"SELECT id, deleted_at FROM devices WHERE mac=? COLLATE NOCASE",
|
||||
(mac,),
|
||||
)
|
||||
if existing is not None and existing["deleted_at"]:
|
||||
# Im Papierkorb liegende Geraete werden nicht wiederbelebt.
|
||||
bump("devices", "skipped")
|
||||
continue
|
||||
values = (
|
||||
str(entry.get("ip") or ""),
|
||||
str(entry.get("name") or mac),
|
||||
switch_id_by_hostname(entry.get("switch_hostname")),
|
||||
str(entry.get("port") or ""),
|
||||
int(bool(entry.get("is_active", 1))),
|
||||
credential_id_by_name(entry.get("credential_name")),
|
||||
int(entry.get("ssh_port") or 22),
|
||||
str(entry.get("category") or ""),
|
||||
str(entry.get("location") or ""),
|
||||
str(entry.get("notes") or ""),
|
||||
int(bool(entry.get("auto_restart", 1))),
|
||||
)
|
||||
if existing is None:
|
||||
cursor = conn.execute(
|
||||
"INSERT INTO devices (mac, ip, name, switch_id, port, is_active, credential_id, "
|
||||
"ssh_port, category, location, notes, auto_restart, created_at, updated_at, "
|
||||
"updated_by) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,'import')",
|
||||
(mac, *values, stamp, stamp),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO device_status (device_id, state, last_change_at) "
|
||||
"VALUES (?,'unknown',?)",
|
||||
(int(cursor.lastrowid or 0), stamp),
|
||||
)
|
||||
bump("devices", "created")
|
||||
else:
|
||||
conn.execute(
|
||||
"UPDATE devices SET ip=?, name=?, switch_id=?, port=?, is_active=?, "
|
||||
"credential_id=?, ssh_port=?, category=?, location=?, notes=?, auto_restart=?, "
|
||||
"updated_at=?, updated_by='import' WHERE id=?",
|
||||
(*values, stamp, existing["id"]),
|
||||
)
|
||||
bump("devices", "updated")
|
||||
|
||||
if "dhcp" in wanted:
|
||||
data = payload.get("dhcp") or {}
|
||||
for entry in data.get("subnets", []):
|
||||
conn.execute(
|
||||
"INSERT INTO dhcp_subnets (interface, range_start, range_end, gateway, dns, "
|
||||
"comment, enabled, sort_order, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?)",
|
||||
(
|
||||
str(entry.get("interface") or ""),
|
||||
str(entry.get("range_start") or ""),
|
||||
str(entry.get("range_end") or ""),
|
||||
str(entry.get("gateway") or ""),
|
||||
str(entry.get("dns") or ""),
|
||||
str(entry.get("comment") or ""),
|
||||
int(bool(entry.get("enabled", 1))),
|
||||
int(entry.get("sort_order") or 0),
|
||||
stamp,
|
||||
stamp,
|
||||
),
|
||||
)
|
||||
bump("dhcp", "created")
|
||||
# Optionen: Definition und Wert stehen je Zeile beieinander. Sie wurden
|
||||
# exportiert, aber nie importiert -- ein Export/Import verlor damit
|
||||
# stillschweigend alle eigenen Optionen und alle Werte, auch die je
|
||||
# Geraet gesetzten.
|
||||
for entry in data.get("options", []):
|
||||
code = int(entry.get("code") or 0)
|
||||
name = str(entry.get("name") or "").strip()
|
||||
if not code or not name:
|
||||
continue
|
||||
conn.execute(
|
||||
"INSERT INTO dhcp_options (code, name, type, description, is_standard) "
|
||||
"VALUES (?,?,?,?,?) ON CONFLICT(code) DO UPDATE SET name=excluded.name, "
|
||||
"type=excluded.type, description=excluded.description",
|
||||
(
|
||||
code,
|
||||
name,
|
||||
str(entry.get("type") or "string"),
|
||||
str(entry.get("description") or ""),
|
||||
int(bool(entry.get("is_standard"))),
|
||||
),
|
||||
)
|
||||
option_id = Database.value(conn, "SELECT id FROM dhcp_options WHERE code=?", (code,))
|
||||
if option_id is None:
|
||||
continue
|
||||
wert = str(entry.get("value") or "")
|
||||
if not wert:
|
||||
continue
|
||||
roh_mac = str(entry.get("device_mac") or "")
|
||||
geraet_mac = mac_or_none(roh_mac) if roh_mac else None
|
||||
if roh_mac and geraet_mac is None:
|
||||
bump("dhcp", "skipped")
|
||||
continue
|
||||
device_id = (
|
||||
Database.value(
|
||||
conn,
|
||||
"SELECT id FROM devices WHERE mac=? AND deleted_at IS NULL",
|
||||
(geraet_mac,),
|
||||
)
|
||||
if geraet_mac
|
||||
else None
|
||||
)
|
||||
# Ein Wert, der zu einem Geraet gehoert, das es hier nicht gibt,
|
||||
# darf nicht als globaler Wert landen -- das wuerde ihn an alle
|
||||
# ausliefern. Er wird uebersprungen.
|
||||
if geraet_mac and device_id is None:
|
||||
bump("dhcp", "skipped")
|
||||
continue
|
||||
if device_id is None:
|
||||
conn.execute(
|
||||
"INSERT INTO dhcp_option_values (option_id, device_id, value) "
|
||||
"VALUES (?,NULL,?) ON CONFLICT(option_id) WHERE device_id IS NULL "
|
||||
"DO UPDATE SET value=excluded.value",
|
||||
(int(option_id), wert),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"INSERT INTO dhcp_option_values (option_id, device_id, value) "
|
||||
"VALUES (?,?,?) ON CONFLICT(option_id, device_id) WHERE device_id IS NOT NULL "
|
||||
"DO UPDATE SET value=excluded.value",
|
||||
(int(option_id), int(device_id), wert),
|
||||
)
|
||||
bump("dhcp", "updated")
|
||||
for entry in data.get("reservations", []):
|
||||
mac = mac_or_none(entry.get("mac"))
|
||||
if not mac:
|
||||
bump("dhcp", "skipped")
|
||||
continue
|
||||
conn.execute(
|
||||
"INSERT INTO dhcp_reservations (mac, ip, hostname, comment, created_at) "
|
||||
"VALUES (?,?,?,?,?) ON CONFLICT(mac) DO UPDATE SET ip=excluded.ip, "
|
||||
"hostname=excluded.hostname, comment=excluded.comment",
|
||||
(
|
||||
mac,
|
||||
str(entry.get("ip") or ""),
|
||||
str(entry.get("hostname") or ""),
|
||||
str(entry.get("comment") or ""),
|
||||
stamp,
|
||||
),
|
||||
)
|
||||
bump("dhcp", "updated")
|
||||
|
||||
if "fileshare" in wanted:
|
||||
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(
|
||||
"INSERT INTO fileshare_mappings (ad_group_dn, ad_group_name, label, unc, created_at) "
|
||||
"VALUES (?,?,?,?,?) ON CONFLICT(ad_group_dn, unc) DO UPDATE SET "
|
||||
"label=excluded.label, ad_group_name=excluded.ad_group_name",
|
||||
(
|
||||
gruppe,
|
||||
str(entry.get("ad_group_name") or ""),
|
||||
str(entry.get("label") or ""),
|
||||
pfad,
|
||||
stamp,
|
||||
),
|
||||
)
|
||||
bump("fileshare", "updated" if vorhanden else "created")
|
||||
|
||||
if "groups" in wanted:
|
||||
for entry in payload.get("groups", []):
|
||||
name = str(entry.get("name") or "").strip()
|
||||
if not name:
|
||||
continue
|
||||
existing = Database.one(
|
||||
conn,
|
||||
"SELECT id FROM groups WHERE name=? COLLATE NOCASE AND deleted_at IS NULL",
|
||||
(name,),
|
||||
)
|
||||
if existing is None:
|
||||
cursor = conn.execute(
|
||||
"INSERT INTO groups (name, description, is_default, is_system, preset_key, "
|
||||
"created_at, updated_at) VALUES (?,?,?,?,?,?,?)",
|
||||
(
|
||||
name,
|
||||
str(entry.get("description") or ""),
|
||||
int(bool(entry.get("is_default"))),
|
||||
int(bool(entry.get("is_system"))),
|
||||
str(entry.get("preset_key") or ""),
|
||||
stamp,
|
||||
stamp,
|
||||
),
|
||||
)
|
||||
group_id = int(cursor.lastrowid or 0)
|
||||
bump("groups", "created")
|
||||
else:
|
||||
group_id = int(existing["id"])
|
||||
bump("groups", "updated")
|
||||
conn.execute("DELETE FROM group_permissions WHERE group_id=?", (group_id,))
|
||||
# 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(
|
||||
"INSERT OR IGNORE INTO group_permissions (group_id, permission) VALUES (?,?)",
|
||||
[(group_id, permission) for permission in neue],
|
||||
)
|
||||
|
||||
if "users" in wanted:
|
||||
for entry in payload.get("users", []):
|
||||
username = str(entry.get("username") or "").strip()
|
||||
if not username:
|
||||
continue
|
||||
existing = Database.one(
|
||||
conn,
|
||||
"SELECT id, deleted_at FROM users WHERE username=? COLLATE NOCASE",
|
||||
(username,),
|
||||
)
|
||||
if existing is not None and existing["deleted_at"]:
|
||||
bump("users", "skipped")
|
||||
continue
|
||||
if existing is None:
|
||||
cursor = conn.execute(
|
||||
"INSERT INTO users (username, password_hash, is_admin, is_locked, auth_source, "
|
||||
"first_name, last_name, email, must_change_password, created_at, updated_at) "
|
||||
"VALUES (?,?,?,?,'local',?,?,?,?,?,?)",
|
||||
(
|
||||
username,
|
||||
str(entry.get("password_hash") or ""),
|
||||
int(bool(entry.get("is_admin"))),
|
||||
int(bool(entry.get("is_locked"))),
|
||||
str(entry.get("first_name") or ""),
|
||||
str(entry.get("last_name") or ""),
|
||||
str(entry.get("email") or ""),
|
||||
int(bool(entry.get("must_change_password"))),
|
||||
stamp,
|
||||
stamp,
|
||||
),
|
||||
)
|
||||
user_id = int(cursor.lastrowid or 0)
|
||||
bump("users", "created")
|
||||
else:
|
||||
user_id = int(existing["id"])
|
||||
conn.execute(
|
||||
"UPDATE users SET first_name=?, last_name=?, email=?, updated_at=? WHERE id=?",
|
||||
(
|
||||
str(entry.get("first_name") or ""),
|
||||
str(entry.get("last_name") or ""),
|
||||
str(entry.get("email") or ""),
|
||||
stamp,
|
||||
user_id,
|
||||
),
|
||||
)
|
||||
bump("users", "updated")
|
||||
names = [
|
||||
name for name in str(entry.get("group_names") or "").split("\n") if name.strip()
|
||||
]
|
||||
if names:
|
||||
conn.execute("DELETE FROM user_groups WHERE user_id=?", (user_id,))
|
||||
for name in names:
|
||||
group_id = Database.value(
|
||||
conn,
|
||||
"SELECT id FROM groups WHERE name=? COLLATE NOCASE AND deleted_at IS NULL",
|
||||
(name.strip(),),
|
||||
)
|
||||
if group_id is not None:
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?,?)",
|
||||
(user_id, int(group_id)),
|
||||
)
|
||||
|
||||
if "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:
|
||||
for key, value in (payload.get("settings") or {}).items():
|
||||
if str(key).startswith("license_"):
|
||||
continue
|
||||
# Ueber den Store, damit Typ und Wertebereich geprueft werden -- und
|
||||
# bewusst mit derselben Verbindung (keine zweite oeffnen!).
|
||||
set_setting(str(key), value)
|
||||
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
|
||||
@@ -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)
|
||||
@@ -0,0 +1,279 @@
|
||||
"""Windows-Clients aus der Ferne neu starten -- ohne SSH.
|
||||
|
||||
Windows-Geraete werden hier **nicht** ueber SSH verwaltet. Sie haben in aller
|
||||
Regel keinen SSH-Dienst, und damit gibt es auch keinen Host-Schluessel, den
|
||||
jemand freigeben koennte. Alles, was in TESM mit Host-Schluesseln zu tun hat,
|
||||
laesst Windows-Geraete deshalb bewusst aus.
|
||||
|
||||
Stattdessen der klassische Weg von einem Linux-Host aus: ``net rpc shutdown``
|
||||
aus ``samba-common-bin``. Das spricht denselben RPC-Dienst an, den auch
|
||||
``shutdown /m \\\\host`` unter Windows benutzt, und funktioniert mit lokalen
|
||||
wie mit Domaenenkonten.
|
||||
|
||||
Zum Passwort: es geht **nie** ueber die Kommandozeile. ``net -U benutzer%pass``
|
||||
waere auf dem Zielsystem in ``ps`` sichtbar -- und auch auf diesem hier. Es
|
||||
wird darum in eine 0600-Datei geschrieben, die nur der Dienstbenutzer lesen
|
||||
kann, und ueber ``--authentication-file`` uebergeben. Die Datei wird danach
|
||||
ueberschrieben und geloescht.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: Das Paket, das ``net`` mitbringt. Steht in der Positivliste des Helfers,
|
||||
#: damit es sich aus der Oberflaeche nachinstallieren laesst.
|
||||
REQUIRED_PACKAGE = "samba-common-bin"
|
||||
|
||||
DEFAULT_TIMEOUT = 45
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RemoteResult:
|
||||
ok: bool
|
||||
output: str
|
||||
hint: str = ""
|
||||
|
||||
|
||||
def available() -> bool:
|
||||
"""Ist ``net`` auf diesem Host installiert?"""
|
||||
return shutil.which("net") is not None
|
||||
|
||||
|
||||
#: Fehlerbilder von ``net rpc``, uebersetzt in etwas Handlungsleitendes.
|
||||
HINTS: tuple[tuple[str, str], ...] = (
|
||||
(
|
||||
"NT_STATUS_LOGON_FAILURE",
|
||||
"Benutzername oder Passwort wurden vom Windows-Gerät abgewiesen. Bei einem "
|
||||
"Domänenkonto die Form DOMAENE\\benutzer verwenden – mit *einem* "
|
||||
"Backslash.",
|
||||
),
|
||||
(
|
||||
"NT_STATUS_ACCESS_DENIED",
|
||||
"Das Konto darf auf diesem Gerät keinen Neustart auslösen. Es braucht das Recht "
|
||||
"'Herunterfahren von einem Remotesystem aus' | Mitglied der lokalen "
|
||||
"Administratoren genügt dafür in der Regel.",
|
||||
),
|
||||
(
|
||||
"NT_STATUS_CONNECTION_REFUSED",
|
||||
"Das Gerät nimmt keine RPC-Verbindung an. Die Datei- und Druckerfreigabe muss "
|
||||
"aktiv und Port 445 in der Windows-Firewall freigegeben sein.",
|
||||
),
|
||||
(
|
||||
"NT_STATUS_IO_TIMEOUT",
|
||||
"Keine Antwort auf Port 445. Ist das Gerät erreichbar und die Firewall offen?",
|
||||
),
|
||||
(
|
||||
"NT_STATUS_HOST_UNREACHABLE",
|
||||
"Das Gerät ist nicht erreichbar.",
|
||||
),
|
||||
# Diese Meldung liest sich wie ein technisches Detail, hat aber fast immer
|
||||
# eine einzige Ursache: das Konto ist auf dem Zielgeraet kein Administrator.
|
||||
# Nachgemessen an einem echten Fall -- die Anmeldung gelang, aber schon
|
||||
# "net rpc info" und der Dienstmanager antworteten mit ACCESS_DENIED, also
|
||||
# bekommt ein Standardkonto die winreg-Pipe gar nicht zu sehen.
|
||||
(
|
||||
"pipe winreg",
|
||||
"Das Konto hat auf diesem Gerät keine Administratorrechte. Ein Neustart über "
|
||||
"RPC braucht sie: die Pipe, über die er läuft, ist für Standardkonten nicht "
|
||||
"sichtbar. Ein Konto der lokalen Administratoren hinterlegen – prüfen lässt "
|
||||
"sich das mit 'net rpc service status RemoteRegistry'.",
|
||||
),
|
||||
(
|
||||
"NT_STATUS_OBJECT_NAME_NOT_FOUND",
|
||||
"Der benötigte RPC-Dienst antwortet auf dem Gerät nicht. Meist fehlen dem "
|
||||
"Konto die Administratorrechte; sonst ist der Dienst 'Remoteregistrierung' "
|
||||
"gestoppt.",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def hint_for(output: str) -> str:
|
||||
for needle, hint in HINTS:
|
||||
if needle in output:
|
||||
return hint
|
||||
return ""
|
||||
|
||||
|
||||
def split_account(username: str, domain: str = "") -> tuple[str, str]:
|
||||
"""Trennt ``DOMAENE\\benutzer`` in Benutzer und Domaene.
|
||||
|
||||
Toleranter Split: mehrfache Backslashes werden zusammengefasst, leere Teile
|
||||
verworfen. Das ist kein Selbstzweck -- der Hinweistext dieser Datei empfahl
|
||||
selbst einmal ``DOMAENE\\\\benutzer`` mit zwei Backslashes, und genau so
|
||||
wurde es dann eingetragen. Ein einfacher ``split()`` schickte daraufhin
|
||||
``\\benutzer`` an Windows, und die Anmeldung scheiterte mit LOGON_FAILURE --
|
||||
bei formal richtigen Zugangsdaten.
|
||||
|
||||
Rueckgabe: ``(benutzer, domaene)``.
|
||||
"""
|
||||
if "\\" in username and not domain:
|
||||
teile = [teil.strip() for teil in username.split("\\") if teil.strip()]
|
||||
if len(teile) >= 2:
|
||||
return teile[-1], teile[0]
|
||||
if teile:
|
||||
return teile[0], domain
|
||||
return username, domain
|
||||
|
||||
|
||||
def _net_with_credentials(
|
||||
args: list[str],
|
||||
*,
|
||||
username: str,
|
||||
password: str,
|
||||
domain: str,
|
||||
timeout: int,
|
||||
) -> RemoteResult | tuple[int, str]:
|
||||
"""Ruft ``net`` mit einer 0600-Anmeldedatei auf und raeumt sie sicher weg.
|
||||
|
||||
Gemeinsam fuer Neustart und Pruefung -- die Datei traegt ein
|
||||
Klartextpasswort, und diese Behandlung will man genau einmal haben.
|
||||
Rueckgabe: ``(exitcode, ausgabe)`` oder ein fertiges ``RemoteResult`` bei
|
||||
einem Fehler noch vor dem Aufruf.
|
||||
"""
|
||||
binary = shutil.which("net")
|
||||
if binary is None:
|
||||
return RemoteResult(
|
||||
False,
|
||||
"",
|
||||
f"Auf diesem Server fehlt das Paket {REQUIRED_PACKAGE}. Ohne 'net' lässt sich "
|
||||
"ein Windows-Gerät von hier nicht ansprechen.",
|
||||
)
|
||||
if not username:
|
||||
return RemoteResult(False, "", "Es sind keine Zugangsdaten hinterlegt.")
|
||||
|
||||
username, domain = split_account(username, domain)
|
||||
handle, path = tempfile.mkstemp(prefix="tesm-winauth-")
|
||||
try:
|
||||
# mkstemp legt die Datei auf POSIX bereits mit 0600 an. Hier trotzdem
|
||||
# ausdruecklich -- die Datei traegt gleich ein Klartextpasswort, das
|
||||
# will man nicht von einer Vorgabe abhaengig machen.
|
||||
try:
|
||||
os.chmod(path, 0o600)
|
||||
except OSError: # pragma: no cover - Windows kennt den Modus nicht
|
||||
pass
|
||||
with os.fdopen(handle, "w", encoding="utf-8", newline="\n") as stream:
|
||||
stream.write(f"username={username}\npassword={password}\n")
|
||||
if domain:
|
||||
stream.write(f"domain={domain}\n")
|
||||
|
||||
command = [binary, *args, "--authentication-file", path]
|
||||
try:
|
||||
completed = subprocess.run( # noqa: S603 - feste Argumentliste, keine Shell
|
||||
command,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=timeout,
|
||||
check=False,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
return RemoteResult(False, "", f"Zeitlimit von {timeout}s überschritten.")
|
||||
except OSError as exc:
|
||||
return RemoteResult(False, "", str(exc))
|
||||
return completed.returncode, (completed.stdout + completed.stderr).strip()
|
||||
finally:
|
||||
# Ueberschreiben, dann loeschen -- die Datei enthaelt ein Klartextpasswort.
|
||||
try:
|
||||
with open(path, "r+b") as stream:
|
||||
length = stream.seek(0, os.SEEK_END)
|
||||
stream.seek(0)
|
||||
stream.write(b"\0" * length)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
except OSError: # pragma: no cover - defensiv
|
||||
logger.warning("Anmeldedatei konnte nicht überschrieben werden: %s", path)
|
||||
try:
|
||||
os.unlink(path)
|
||||
except OSError: # pragma: no cover - defensiv
|
||||
pass
|
||||
|
||||
|
||||
def check(
|
||||
host: str,
|
||||
username: str,
|
||||
password: str,
|
||||
*,
|
||||
domain: str = "",
|
||||
timeout: int = DEFAULT_TIMEOUT,
|
||||
) -> RemoteResult:
|
||||
"""Prueft Zugangsdaten gegen ein Windows-Geraet -- **ohne** etwas zu aendern.
|
||||
|
||||
``net rpc info`` ist rein lesend. Der Sinn: ob Zugangsdaten stimmen, soll
|
||||
man erfahren, ohne einen Neustart auszuloesen und hinterher im Protokoll zu
|
||||
raten. Unterschieden wird ausdruecklich zwischen "Passwort falsch" und
|
||||
"Anmeldung in Ordnung, aber keine Administratorrechte" -- das sind zwei
|
||||
voellig verschiedene Aufgaben fuer den, der es beheben muss.
|
||||
|
||||
Ein Fehlversuch zaehlt fuer die Sperrrichtlinie der Domaene mit. Deshalb
|
||||
genau ein Aufruf je Klick und kein automatisches Wiederholen.
|
||||
"""
|
||||
ergebnis = _net_with_credentials(
|
||||
["rpc", "info", "-I", host],
|
||||
username=username,
|
||||
password=password,
|
||||
domain=domain,
|
||||
timeout=timeout,
|
||||
)
|
||||
if isinstance(ergebnis, RemoteResult):
|
||||
return ergebnis
|
||||
code, output = ergebnis
|
||||
|
||||
if code == 0:
|
||||
return RemoteResult(
|
||||
True, output, "Anmeldung erfolgreich, Administratorrechte vorhanden."
|
||||
)
|
||||
if "ACCESS_DENIED" in output:
|
||||
# Wichtig: die Anmeldung *hat* funktioniert. Nur die Rechte fehlen.
|
||||
return RemoteResult(
|
||||
False,
|
||||
output,
|
||||
"Anmeldung erfolgreich, aber das Konto hat auf diesem Gerät keine "
|
||||
"Administratorrechte. Für einen Neustart über RPC braucht es sie.",
|
||||
)
|
||||
return RemoteResult(False, output, hint_for(output) or "Prüfung fehlgeschlagen.")
|
||||
|
||||
|
||||
def reboot(
|
||||
host: str,
|
||||
username: str,
|
||||
password: str,
|
||||
*,
|
||||
domain: str = "",
|
||||
comment: str = "Neustart durch TESM-Wartung",
|
||||
delay: int = 5,
|
||||
timeout: int = DEFAULT_TIMEOUT,
|
||||
) -> RemoteResult:
|
||||
"""Loest einen Neustart auf einem Windows-Geraet aus."""
|
||||
ergebnis = _net_with_credentials(
|
||||
[
|
||||
"rpc",
|
||||
"shutdown",
|
||||
"-r", # neu starten statt herunterfahren
|
||||
"-f", # laufende Anwendungen nicht blockieren lassen
|
||||
"-t",
|
||||
str(max(0, int(delay))),
|
||||
"-C",
|
||||
comment,
|
||||
"-I",
|
||||
host,
|
||||
],
|
||||
username=username,
|
||||
password=password,
|
||||
domain=domain,
|
||||
timeout=timeout,
|
||||
)
|
||||
if isinstance(ergebnis, RemoteResult):
|
||||
return ergebnis
|
||||
code, output = ergebnis
|
||||
if code == 0:
|
||||
return RemoteResult(True, output or "Neustart ausgelöst.")
|
||||
return RemoteResult(False, output, hint_for(output))
|
||||
@@ -0,0 +1,175 @@
|
||||
"""Laufzeiteinstellungen von TESM."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from tesm_core.settings_store import SettingSpec
|
||||
|
||||
from .services.kea import DEFAULT_CONFIG_PATH
|
||||
|
||||
TESM_SETTINGS: tuple[SettingSpec, ...] = (
|
||||
# -- Ueberwachung ------------------------------------------------------- #
|
||||
SettingSpec(
|
||||
"monitor_interval_seconds",
|
||||
300,
|
||||
"int",
|
||||
"Prüfintervall (Sekunden)",
|
||||
hint="Abstand zwischen zwei Erreichbarkeitsprüfungen. Lizenzpflichtig.",
|
||||
section="monitoring",
|
||||
minimum=30,
|
||||
maximum=86400,
|
||||
),
|
||||
SettingSpec(
|
||||
"monitor_failures_before_restart",
|
||||
2,
|
||||
"int",
|
||||
"Fehlversuche bis zum automatischen PoE-Neustart",
|
||||
hint="Erst nach so vielen aufeinanderfolgenden Fehlschlägen wird geschaltet. "
|
||||
"Der Vorgänger schaltete sofort beim ersten Ausfall.",
|
||||
section="monitoring",
|
||||
minimum=1,
|
||||
maximum=10,
|
||||
),
|
||||
SettingSpec(
|
||||
"monitor_auto_restart",
|
||||
True,
|
||||
"bool",
|
||||
"Automatischen PoE-Neustart bei Ausfall aktivieren",
|
||||
section="monitoring",
|
||||
),
|
||||
SettingSpec(
|
||||
"dashboard_public",
|
||||
True,
|
||||
"bool",
|
||||
"Statusübersicht ohne Anmeldung erreichbar",
|
||||
hint="Zeigt ausschließlich Name und Erreichbarkeit aktiver Geräte – keine "
|
||||
"IP-Adressen, keine Switch-Zuordnung, keine Verwaltungsfunktionen.",
|
||||
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 --------------------------------------------------------------- #
|
||||
SettingSpec("dhcp_domain", "local", "str", "Domainname", section="dhcp"),
|
||||
SettingSpec(
|
||||
"dhcp_lease_default", 600, "int", "Lease-Dauer (Sekunden)", section="dhcp",
|
||||
minimum=60, maximum=604800,
|
||||
),
|
||||
SettingSpec(
|
||||
"dhcp_lease_max", 7200, "int", "Maximale Lease-Dauer (Sekunden)", section="dhcp",
|
||||
minimum=60, maximum=604800,
|
||||
),
|
||||
SettingSpec(
|
||||
"dhcp_output_path",
|
||||
DEFAULT_CONFIG_PATH,
|
||||
"str",
|
||||
"Zielpfad der Kea-Konfiguration",
|
||||
section="dhcp",
|
||||
),
|
||||
# -- Dateifreigaben ----------------------------------------------------- #
|
||||
SettingSpec(
|
||||
"fileshare_mount_root",
|
||||
"/mnt/tesm-shares",
|
||||
"str",
|
||||
"Wurzelverzeichnis der Einbindungen",
|
||||
section="fileshare",
|
||||
),
|
||||
SettingSpec(
|
||||
"fileshare_max_age_hours",
|
||||
12,
|
||||
"int",
|
||||
"Einbindungen automatisch lösen nach (Stunden)",
|
||||
section="fileshare",
|
||||
minimum=1,
|
||||
maximum=168,
|
||||
),
|
||||
# -- Wartung ------------------------------------------------------------ #
|
||||
SettingSpec(
|
||||
"maintenance_command_timeout",
|
||||
900,
|
||||
"int",
|
||||
"Zeitlimit eines Wartungslaufs (Sekunden)",
|
||||
section="maintenance",
|
||||
minimum=60,
|
||||
maximum=7200,
|
||||
),
|
||||
SettingSpec(
|
||||
"maintenance_reboot_wait",
|
||||
300,
|
||||
"int",
|
||||
"Wartezeit auf die Rückkehr nach einem Neustart (Sekunden)",
|
||||
section="maintenance",
|
||||
minimum=30,
|
||||
maximum=1800,
|
||||
),
|
||||
# -- Unterschriften ------------------------------------------------------ #
|
||||
SettingSpec(
|
||||
"signature_witness_enabled",
|
||||
True,
|
||||
"bool",
|
||||
"Unterschriften mit dem ablegenden Konto bezeugen",
|
||||
hint="Eine Zeile unter der **ersten** Unterschrift nennt das Konto, das das "
|
||||
"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",
|
||||
),
|
||||
SettingSpec(
|
||||
"terminal_idle_timeout",
|
||||
300,
|
||||
"int",
|
||||
"Terminal bei Untätigkeit trennen nach (Sekunden)",
|
||||
section="monitoring",
|
||||
minimum=60,
|
||||
maximum=3600,
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,144 @@
|
||||
{# Teilbereich der Statusübersicht -- wird auch einzeln per fetch geladen. #}
|
||||
{% from "tesm_core/macros.html" import pill, empty_state %}
|
||||
|
||||
{# Beide Blöcke in einen Stapel: sonst klebt die erste Gruppe an den
|
||||
Filterkacheln, während die Gruppen untereinander Abstand haben. #}
|
||||
<div class="stack">
|
||||
<div class="grid grid--4" data-behavior="tile-filter" data-filter-scope="tile-sections">
|
||||
<button type="button" class="stat" data-filter-value="" aria-pressed="false">
|
||||
<span class="stat__label">Gesamt</span>
|
||||
<span class="stat__value">{{ counts.total }}</span>
|
||||
<span class="stat__hint">Filter aufheben</span>
|
||||
</button>
|
||||
<button type="button" class="stat stat--ok" data-filter-value="online" aria-pressed="false">
|
||||
<span class="stat__label">Erreichbar</span>
|
||||
<span class="stat__value">{{ counts.online }}</span>
|
||||
</button>
|
||||
<button type="button" class="stat stat--danger" data-filter-value="offline" aria-pressed="false">
|
||||
<span class="stat__label">Nicht erreichbar</span>
|
||||
<span class="stat__value">{{ counts.offline }}</span>
|
||||
</button>
|
||||
{% if detailed %}
|
||||
<button type="button" class="stat stat--muted" data-filter-value="disabled" aria-pressed="false">
|
||||
<span class="stat__label">Deaktiviert</span>
|
||||
<span class="stat__value">{{ counts.disabled }}</span>
|
||||
</button>
|
||||
{% else %}
|
||||
<div class="stat stat--warning">
|
||||
<span class="stat__label">Noch nicht geprüft</span>
|
||||
<span class="stat__value">{{ counts.unknown }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div id="tile-sections" class="stack">
|
||||
{% for section in sections %}
|
||||
{% if section.tiles %}
|
||||
<details class="card" data-tile-section="{{ section.key }}"
|
||||
data-persist="dash-{{ section.key }}" {% if section.key != 'disabled' %}open{% endif %}>
|
||||
<summary class="card__header" style="cursor:pointer">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">
|
||||
{{ pill(section.tiles | length, section.kind if section.kind != 'muted' else '') }}
|
||||
{{ section.label }}
|
||||
</div>
|
||||
</div>
|
||||
{{ icon('chevron-down', size=16) }}
|
||||
</summary>
|
||||
<div class="card__body">
|
||||
<div class="grid grid--tiles">
|
||||
{% for tile in section.tiles %}
|
||||
{# Kopf zuerst Kennzeichen, dann Name: das Auge findet die Kachel
|
||||
über Farbe und Symbol, den genauen Namen liest es danach.
|
||||
Die IP steht gross darunter statt in der Merkmalliste -- sie ist
|
||||
die Angabe, nach der man auf dieser Seite tatsächlich sucht. #}
|
||||
<article class="tile tile--{{ tile.state }}" data-filter-item
|
||||
data-filter-text="{{ tile.name }} {{ tile.get('ip','') }} {{ tile.get('switch','') }} {{ tile.location }}">
|
||||
<div class="tile__head">
|
||||
<span class="tile__badge" aria-hidden="true">{{ icon('cpu', size=17) }}</span>
|
||||
{% if tile.state == 'online' %}{{ pill('Online', 'online') }}
|
||||
{% elif tile.state == 'restarting' %}{{ pill('Startet neu', 'warning') }}
|
||||
{% elif tile.state == 'offline' %}{{ pill('Offline', 'offline') }}
|
||||
{% elif tile.state == 'disabled' %}{{ pill('Deaktiviert', '') }}
|
||||
{% else %}{{ pill('Unbekannt', 'warning') }}{% endif %}
|
||||
</div>
|
||||
<div class="tile__name truncate" title="{{ tile.name }}">{{ tile.name }}</div>
|
||||
{% if detailed %}<div class="tile__ip truncate">{{ tile.ip }}</div>{% endif %}
|
||||
{% if tile.location %}
|
||||
<div class="tile__meta truncate">{{ icon('map-pin', size=12) }} {{ tile.location }}</div>
|
||||
{% endif %}
|
||||
{% if detailed %}
|
||||
<dl class="kv fs-xs mt-3">
|
||||
{% if tile.switch %}
|
||||
<dt>Switch</dt><dd class="truncate">{{ tile.switch }} / {{ tile.port }}</dd>
|
||||
{% endif %}
|
||||
{% if tile.latency_ms %}
|
||||
<dt>Antwortzeit</dt><dd>{{ tile.latency_ms | millis }}</dd>
|
||||
{% endif %}
|
||||
{% if tile.last_change_at %}
|
||||
<dt>Seit</dt>
|
||||
<dd><span data-relative-time="{{ tile.last_change_at }}">{{ tile.last_change_at | dt }}</span></dd>
|
||||
{% endif %}
|
||||
</dl>
|
||||
<div class="row row--tight mt-3">
|
||||
<a class="btn btn--sm btn--ghost" href="{{ url_for('devices.detail', device_id=tile.id) }}">
|
||||
Details
|
||||
</a>
|
||||
{% 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] %}
|
||||
<form method="post" data-no-guard
|
||||
action="{{ url_for('devices.restart_device', device_id=tile.id) }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="method" value="{{ method.key }}">
|
||||
<button class="btn btn--sm" type="submit" title="{{ method.hint }}"
|
||||
data-busy-label="Startet ..."
|
||||
data-confirm="{{ tile.name }} über {{ method.label }} neu starten? {{ method.hint }}"
|
||||
data-confirm-title="Neustart" data-confirm-ok="Neu starten"
|
||||
data-confirm-style="primary">
|
||||
{{ icon('power', size=14) }} {{ method.label }}
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if not sections | map(attribute='tiles') | select | list %}
|
||||
{{ empty_state('Keine Geräte erfasst',
|
||||
'Legen Sie das erste Gerät unter Überwachung an.', 'cpu') }}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1 @@
|
||||
<pre class="log-view" data-tail>{{ job.output or 'Noch keine Ausgabe.' }}</pre>
|
||||
@@ -0,0 +1,161 @@
|
||||
{% extends "tesm_core/base.html" %}
|
||||
{% from "tesm_core/macros.html" import field, checkbox, alert, pill %}
|
||||
|
||||
{% block page_title %}Sicherung und Übertragung{% endblock %}
|
||||
{% block page_sub %}Verschlüsselter Export · zweistufiger Import{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% if license_hint %}{{ alert(license_hint, 'warning') }}{% endif %}
|
||||
|
||||
{{ alert('Der Export wird mit Argon2id aus Ihrer Passphrase verschlüsselt (AES-256-GCM). Ohne
|
||||
diese Passphrase ist die Datei nicht wiederherstellbar -- es gibt keine Hintertür. Bewahren Sie
|
||||
sie getrennt von der Sicherung auf.', 'warning', 'Passphrase') }}
|
||||
|
||||
<div class="grid grid--split">
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('download', size=17) }} Sicherung erstellen</div>
|
||||
<div class="card__sub">Auswahl je Kategorie · verlangt eine frische Passwortbestätigung</div>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" action="{{ url_for('backup.export') }}" data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<div class="card__body stack">
|
||||
<fieldset class="fieldset">
|
||||
<legend>Kategorien</legend>
|
||||
<div class="stack stack--tight">
|
||||
{% for category in all_categories %}
|
||||
{% set allowed = category in categories %}
|
||||
<label class="check {{ 'check--disabled' if not allowed }}">
|
||||
<input type="checkbox" name="categories" value="{{ category.key }}"
|
||||
{% if allowed and not category.sensitive %}checked{% endif %}
|
||||
{% if not allowed %}disabled{% endif %}>
|
||||
<span>
|
||||
{{ category.label }}
|
||||
{% if category.sensitive %}{{ pill('Geheimnisse', 'danger') }}{% endif %}
|
||||
{% if category.admin_only %}{{ pill('nur Admin', 'accent') }}{% endif %}
|
||||
<div class="fs-xs faint">{{ category.hint }}</div>
|
||||
{% if not allowed %}
|
||||
<div class="fs-xs danger-text">Für diese Kategorie fehlt Ihnen das Recht.</div>
|
||||
{% endif %}
|
||||
</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{% if can_secrets %}
|
||||
{{ checkbox('include_secrets', 'Passwörter der Zugangsdaten mit exportieren',
|
||||
hint_text='Sie liegen dann im Klartext innerhalb der verschlüsselten
|
||||
Datei. Ohne dieses Kennzeichen enthält die Sicherung nur Namen und
|
||||
Benutzer.') }}
|
||||
{% endif %}
|
||||
|
||||
<div class="form-grid">
|
||||
{{ field('passphrase', 'Passphrase', type='password', required=True,
|
||||
autocomplete='new-password',
|
||||
hint_text='Mindestens ' ~ min_passphrase ~ ' Zeichen.') }}
|
||||
{{ field('passphrase_repeat', 'Passphrase wiederholen', type='password', required=True,
|
||||
autocomplete='new-password') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card__footer">
|
||||
<button class="btn btn--primary" type="submit" {% if license_hint %}disabled{% endif %}>
|
||||
Sicherung herunterladen
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<div class="stack">
|
||||
{% if preview %}
|
||||
<section class="card card--accent">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('upload', size=17) }} Geöffnete Sicherung</div>
|
||||
<div class="card__sub">
|
||||
Wählen Sie, was übernommen werden soll. Gültig für {{ preview_ttl }} Minuten.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" action="{{ url_for('backup.import_apply') }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<div class="card__body stack">
|
||||
<div class="table-wrap">
|
||||
<table class="table table--compact">
|
||||
<thead><tr><th></th><th>Kategorie</th><th class="numeric">Einträge</th></tr></thead>
|
||||
<tbody>
|
||||
{% for entry in preview.summary %}
|
||||
<tr>
|
||||
<td>
|
||||
<label class="check">
|
||||
<input type="checkbox" name="categories" value="{{ entry.key }}" checked>
|
||||
<span class="sr-only">{{ entry.label }}</span>
|
||||
</label>
|
||||
</td>
|
||||
<td>
|
||||
{{ entry.label }}
|
||||
{% if entry.has_secrets %}{{ pill('mit Passwörtern', 'danger') }}{% endif %}
|
||||
</td>
|
||||
<td class="numeric">{{ entry.count }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{ alert('Bestehende Einträge werden nach ihrem natürlichen Schlüssel abgeglichen
|
||||
(Name, Hostname, MAC, Benutzername) und aktualisiert. Einträge, die im Papierkorb
|
||||
liegen, werden nicht wiederbelebt.', 'info') }}
|
||||
</div>
|
||||
<div class="card__footer modal__footer--split">
|
||||
<button class="btn btn--sm btn--ghost" type="submit"
|
||||
formaction="{{ url_for('backup.import_discard') }}">Verwerfen</button>
|
||||
<button class="btn btn--primary" type="submit"
|
||||
data-confirm="Ausgewählte Kategorien jetzt übernehmen? Bestehende Daten werden aktualisiert."
|
||||
data-confirm-title="Sicherung übernehmen" data-confirm-ok="Übernehmen"
|
||||
data-confirm-style="primary">Übernehmen</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
{% else %}
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('upload', size=17) }} Sicherung einspielen</div>
|
||||
<div class="card__sub">Erst öffnen und prüfen, dann übernehmen</div>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" enctype="multipart/form-data" data-no-guard
|
||||
action="{{ url_for('backup.import_preview') }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<div class="card__body stack">
|
||||
<input class="input" type="file" name="backup_file" accept=".json" required>
|
||||
{{ field('passphrase', 'Passphrase der Datei', type='password', required=True,
|
||||
autocomplete='off') }}
|
||||
</div>
|
||||
<div class="card__footer">
|
||||
<button class="btn btn--primary" type="submit" data-busy-label="Öffne ...">
|
||||
Datei öffnen
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1"><div class="card__title">{{ icon('info', size=17) }} Was gesichert wird</div></div>
|
||||
</div>
|
||||
<div class="card__body">
|
||||
<ul class="stack stack--tight fs-sm muted">
|
||||
<li>Verzeichniskonten werden nie exportiert – sie entstehen am Ziel bei der ersten Anmeldung neu.</li>
|
||||
<li>Lizenz- und Aktivierungszustand bleiben ausgenommen: sie gehören zur Maschine, nicht zur Sicherung.</li>
|
||||
<li>Sitzungen, Ratenbegrenzung und das Änderungsprotokoll sind nicht Teil der Sicherung.</li>
|
||||
<li>Der Zwischenstand des Imports liegt in der Datenbank – ein Dienst-Neustall verliert ihn nicht.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,160 @@
|
||||
{% extends "tesm_core/base.html" %}
|
||||
{% from "tesm_core/macros.html" import alert, pill %}
|
||||
|
||||
{% set is_switch = target.kind == 'switch' %}
|
||||
{% set back_url = url_for(target.back_endpoint, switch_id=target.id) if is_switch
|
||||
else url_for(target.back_endpoint, device_id=target.id) %}
|
||||
|
||||
{% block page_title %}Konsole {{ target.label }}{% endblock %}
|
||||
{% block page_sub %}{{ target.host }}:{{ target.port }} · Sitzung wird mitgeschrieben{% endblock %}
|
||||
|
||||
{% block breadcrumb %}
|
||||
<nav class="breadcrumb">
|
||||
<span><a href="{{ url_for('switches.overview') if is_switch else url_for('devices.overview') }}">{{ target.back_label }}</a></span>
|
||||
<span><a href="{{ back_url }}">{{ target.label }}</a></span>
|
||||
<span>Konsole</span>
|
||||
</nav>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% if not host_key_ready %}
|
||||
{{ alert('Für dieses Ziel ist kein SSH-Host-Schlüssel freigegeben. Die Konsole verweigert
|
||||
den Aufbau, bis das geschehen ist -- die Freigabe erfolgt auf der Detailseite.',
|
||||
'danger', 'Host-Schlüssel fehlt') }}
|
||||
<p><a class="btn btn--primary" href="{{ back_url }}">Zur Detailseite und Schlüssel freigeben</a></p>
|
||||
{% endif %}
|
||||
|
||||
{{ alert('Die Konsole ist zeilenorientiert: ein Kommando pro Zeile, Antwort darunter. Jede
|
||||
Eingabe und jede Ausgabe wird auf dem Server mitgeschrieben und im Änderungsprotokoll
|
||||
vermerkt. Bei Untätigkeit trennt die Verbindung nach ' ~ idle_timeout ~ ' Sekunden.',
|
||||
'warning', 'Mitschrift') }}
|
||||
|
||||
{% if not is_switch %}
|
||||
{{ alert('Jedes Kommando läuft in einer eigenen Sitzung. Das heisst: ein "cd" wirkt nicht
|
||||
auf das nächste Kommando, und interaktive Programme (Editoren, Passwortabfragen) laufen
|
||||
hier nicht. Für solche Arbeiten einen richtigen SSH-Client nehmen.', 'info',
|
||||
'Ein Kommando, eine Sitzung') }}
|
||||
{% endif %}
|
||||
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">
|
||||
{{ icon('terminal', size=17) }} {{ target.label }}
|
||||
<span id="console-state">{{ pill('verbinde ...', 'warning') }}</span>
|
||||
</div>
|
||||
<div class="card__sub" id="console-hint">Warte auf Verbindung</div>
|
||||
</div>
|
||||
<button class="btn btn--sm btn--ghost" type="button" data-copy="#console-output">
|
||||
{{ icon('copy', size=14) }} Mitschrift kopieren
|
||||
</button>
|
||||
</div>
|
||||
<pre class="log-view" data-tail id="console-output" style="max-height:56dvh"></pre>
|
||||
<div class="card__footer" style="justify-content:stretch;gap:var(--sp-2)">
|
||||
<select class="select" id="console-preset" style="max-width:280px">
|
||||
<option value="">Kommando aus der Liste ...</option>
|
||||
{% for command, label in presets %}
|
||||
<option value="{{ command }}">{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<input class="input input--mono" id="console-input" placeholder="Kommando eingeben und Enter"
|
||||
autocomplete="off" spellcheck="false" style="flex:1" {% if not host_key_ready %}disabled{% endif %}>
|
||||
<button class="btn btn--primary" type="button" id="console-send"
|
||||
{% if not host_key_ready %}disabled{% endif %}>Senden</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p class="fs-xs faint">
|
||||
Pfeiltasten hoch/runter blättern durch die letzten Eingaben. <kbd>exit</kbd> beendet die Sitzung.
|
||||
</p>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script type="module" nonce="{{ csp_nonce }}">
|
||||
const output = document.getElementById("console-output");
|
||||
const input = document.getElementById("console-input");
|
||||
const send = document.getElementById("console-send");
|
||||
const preset = document.getElementById("console-preset");
|
||||
const state = document.getElementById("console-state");
|
||||
const hint = document.getElementById("console-hint");
|
||||
const history = [];
|
||||
let historyIndex = -1;
|
||||
|
||||
function append(text, kind = "") {
|
||||
const line = document.createElement("span");
|
||||
if (kind === "command") line.className = "accent-text";
|
||||
if (kind === "error") line.className = "danger-text";
|
||||
if (kind === "info") line.className = "warning-text";
|
||||
line.textContent = text.endsWith("\n") ? text : text + "\n";
|
||||
output.appendChild(line);
|
||||
output.scrollTop = output.scrollHeight;
|
||||
}
|
||||
|
||||
function setState(label, kind) {
|
||||
state.innerHTML = "";
|
||||
const pill = document.createElement("span");
|
||||
pill.className = "pill" + (kind ? " pill--" + kind : "");
|
||||
pill.textContent = label;
|
||||
state.appendChild(pill);
|
||||
}
|
||||
|
||||
const scheme = location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const socket = new WebSocket(`${scheme}//${location.host}{{ websocket_path }}`);
|
||||
|
||||
socket.addEventListener("open", () => {
|
||||
setState("verbunden", "online");
|
||||
hint.textContent = "Bereit";
|
||||
});
|
||||
socket.addEventListener("close", () => {
|
||||
setState("getrennt", "offline");
|
||||
hint.textContent = "Die Verbindung ist beendet.";
|
||||
input.disabled = true;
|
||||
send.disabled = true;
|
||||
});
|
||||
socket.addEventListener("error", () => {
|
||||
setState("Fehler", "danger");
|
||||
});
|
||||
socket.addEventListener("message", (event) => {
|
||||
let message;
|
||||
try {
|
||||
message = JSON.parse(event.data);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (message.type === "output") append(message.data);
|
||||
else if (message.type === "error") { append(message.data, "error"); setState("Fehler", "danger"); }
|
||||
else if (message.type === "info") append(message.data, "info");
|
||||
else if (message.type === "ready") { append(message.data, "info"); }
|
||||
else if (message.type === "done") { hint.textContent = "Antwort in " + message.data; }
|
||||
});
|
||||
|
||||
function submit() {
|
||||
const command = input.value.trim();
|
||||
if (!command || socket.readyState !== WebSocket.OPEN) return;
|
||||
append("$ " + command, "command");
|
||||
socket.send(JSON.stringify({ command }));
|
||||
history.push(command);
|
||||
historyIndex = history.length;
|
||||
input.value = "";
|
||||
hint.textContent = "Warte auf Antwort ...";
|
||||
}
|
||||
|
||||
send.addEventListener("click", submit);
|
||||
input.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter") { event.preventDefault(); submit(); }
|
||||
else if (event.key === "ArrowUp" && history.length) {
|
||||
event.preventDefault();
|
||||
historyIndex = Math.max(0, historyIndex - 1);
|
||||
input.value = history[historyIndex] ?? "";
|
||||
} else if (event.key === "ArrowDown" && history.length) {
|
||||
event.preventDefault();
|
||||
historyIndex = Math.min(history.length, historyIndex + 1);
|
||||
input.value = history[historyIndex] ?? "";
|
||||
}
|
||||
});
|
||||
preset.addEventListener("change", () => {
|
||||
if (preset.value) { input.value = preset.value; input.focus(); preset.value = ""; }
|
||||
});
|
||||
input.focus();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,38 @@
|
||||
{% extends "tesm_core/base.html" %}
|
||||
{% from "tesm_core/macros.html" import alert %}
|
||||
{% block page_title %}{{ entry.name }}{% endblock %}
|
||||
{% block page_sub %}Klartextanzeige – dieser Zugriff wurde protokolliert{% endblock %}
|
||||
{% block breadcrumb %}
|
||||
<nav class="breadcrumb">
|
||||
<span><a href="{{ url_for('credentials.overview') }}">Zugangsdaten</a></span><span>{{ entry.name }}</span>
|
||||
</nav>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
{{ alert('Dieser Aufruf steht mit Zeitpunkt, Benutzer und IP im Änderungsprotokoll.
|
||||
Schliessen Sie die Seite, sobald Sie den Wert übernommen haben.', 'warning') }}
|
||||
<section class="card" style="max-width:560px">
|
||||
<div class="card__header">
|
||||
<div class="flex-1"><div class="card__title">{{ icon('key', size=17) }} {{ entry.name }}</div>
|
||||
<div class="card__sub">{{ entry.category }}</div></div>
|
||||
</div>
|
||||
<div class="card__body stack">
|
||||
<label class="field">
|
||||
<span class="field__label">Benutzer</span>
|
||||
<div class="input-group">
|
||||
<input class="input input--mono" id="reveal-user" value="{{ username }}" readonly>
|
||||
<button class="btn" type="button" data-copy="#reveal-user">{{ icon('copy', size=15) }}</button>
|
||||
</div>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field__label">Passwort</span>
|
||||
<div class="input-group">
|
||||
<input class="input input--mono" id="reveal-secret" value="{{ secret }}" readonly>
|
||||
<button class="btn" type="button" data-copy="#reveal-secret">{{ icon('copy', size=15) }}</button>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div class="card__footer">
|
||||
<a class="btn btn--primary" href="{{ url_for('credentials.overview') }}">Fertig</a>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,138 @@
|
||||
{% extends "tesm_core/base.html" %}
|
||||
{% from "tesm_core/macros.html" import field, textarea_field, pill, search_box, modal,
|
||||
empty_state, alert %}
|
||||
|
||||
{% macro credential_form(entry=None) %}
|
||||
<div class="form-grid">
|
||||
{{ field('name', 'Name', value=entry.name if entry else '', required=True,
|
||||
hint_text='Frei wählbar, z. B. "Switch-Admin" oder "Linux-Wartung".') }}
|
||||
{{ field('username', 'Benutzer', value=entry.username if entry else '', autocomplete='off') }}
|
||||
{{ field('secret', 'Passwort' ~ (' (leer lassen = unverändert)' if entry else ''),
|
||||
type='password', autocomplete='new-password',
|
||||
required=False if entry else True) }}
|
||||
<label class="field">
|
||||
<span class="field__label">Kategorie</span>
|
||||
<select class="select" name="category">
|
||||
{% for value, label in categories %}
|
||||
<option value="{{ value }}" {% if entry and entry.category == value %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
{{ textarea_field('description', 'Beschreibung', value=entry.description if entry else '',
|
||||
rows=2, extra_class='field--full') }}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% block page_title %}Zugangsdaten{% endblock %}
|
||||
{% block page_sub %}{{ credentials | length }} Einträge · verschlüsselt mit AES-256-GCM{% endblock %}
|
||||
|
||||
{% block topbar_extra %}
|
||||
{{ search_box('credentials-table', 'Name, Benutzer ...') }}
|
||||
{% if current_user.has('credentials.create') %}
|
||||
<button class="btn btn--primary btn--sm" type="button" data-dialog-open="credential-new">
|
||||
{{ icon('plus', size=15) }} Anlegen
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{{ alert('Passwörter liegen AES-256-GCM-verschlüsselt und an ihren Verwendungszweck gebunden.
|
||||
Sie im Klartext zu sehen braucht ein eigenes Recht, eine frische Passwortbestätigung und wird
|
||||
protokolliert. Die Kategorie setzen Sie ausdrücklich -- sie wird bewusst nicht geraten, weil
|
||||
davon abhängt, welche Geräte auf der Wartungsseite erscheinen.', 'info') }}
|
||||
|
||||
<section class="card">
|
||||
<div class="table-wrap">
|
||||
<table class="table" id="credentials-table" data-behavior="sortable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="sortable">Name</th>
|
||||
<th class="sortable">Benutzer</th>
|
||||
<th class="sortable">Kategorie</th>
|
||||
<th class="sortable numeric">Verwendet von</th>
|
||||
<th>Passwort</th>
|
||||
<th class="actions">Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for entry in credentials %}
|
||||
<tr data-filter-text="{{ entry.name }} {{ entry.username }} {{ entry.category }} {{ entry.description }}">
|
||||
<td>
|
||||
<div class="fw-600">{{ entry.name }}</div>
|
||||
{% if entry.description %}<div class="fs-xs faint">{{ entry.description }}</div>{% endif %}
|
||||
</td>
|
||||
<td class="mono fs-sm">{{ entry.username or '–' | safe }}</td>
|
||||
<td class="fs-sm">{{ dict(categories).get(entry.category, entry.category) }}</td>
|
||||
<td class="numeric">{{ entry.in_use }}</td>
|
||||
<td>
|
||||
{% if entry.has_secret %}{{ pill('gesetzt', 'online') }}
|
||||
{% else %}{{ pill('fehlt', 'warning') }}{% endif %}
|
||||
</td>
|
||||
<td class="actions">
|
||||
{% if current_user.has('credentials.secrets') and entry.has_secret %}
|
||||
<form method="post" action="{{ url_for('credentials.reveal', credential_id=entry.id) }}"
|
||||
style="display:inline" data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<button class="btn btn--sm btn--ghost" type="submit" aria-label="Klartext anzeigen"
|
||||
data-confirm="Passwort von {{ entry.name }} im Klartext anzeigen? Dieser Zugriff wird protokolliert."
|
||||
data-confirm-title="Klartext anzeigen" data-confirm-ok="Anzeigen"
|
||||
data-confirm-style="primary">{{ icon('eye', size=15) }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if current_user.has('credentials.write') %}
|
||||
<button class="btn btn--sm btn--ghost" type="button"
|
||||
data-dialog-open="credential-edit-{{ entry.id }}" aria-label="Bearbeiten">
|
||||
{{ icon('pencil', size=15) }}
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if current_user.has('credentials.delete') %}
|
||||
<form method="post" action="{{ url_for('credentials.delete', credential_id=entry.id) }}"
|
||||
style="display:inline" data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<button class="btn btn--sm btn--ghost danger-text" type="submit" aria-label="Löschen"
|
||||
{% if entry.in_use %}disabled title="Wird noch von {{ entry.in_use }} Einträgen verwendet"{% endif %}
|
||||
data-confirm="{{ entry.name }} in den Papierkorb verschieben?"
|
||||
data-confirm-title="Zugangsdaten löschen" data-confirm-ok="Löschen">
|
||||
{{ icon('trash', size=15) }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% if not credentials %}
|
||||
{{ empty_state('Keine Zugangsdaten', 'Legen Sie den ersten Eintrag an.', 'key') }}
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
{% block dialogs %}
|
||||
{% if current_user.has('credentials.create') %}
|
||||
{% call modal('credential-new', 'Zugangsdaten anlegen') %}
|
||||
<form method="post" action="{{ url_for('credentials.create') }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<div class="modal__body">{{ credential_form() }}</div>
|
||||
<div class="modal__footer">
|
||||
<button class="btn" type="button" data-dialog-close>Abbrechen</button>
|
||||
<button class="btn btn--primary" type="submit">Anlegen</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endcall %}
|
||||
{% endif %}
|
||||
{% if current_user.has('credentials.write') %}
|
||||
{% for entry in credentials %}
|
||||
{% call modal('credential-edit-' ~ entry.id, entry.name) %}
|
||||
<form method="post" action="{{ url_for('credentials.update', credential_id=entry.id) }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<div class="modal__body">{{ credential_form(entry) }}</div>
|
||||
<div class="modal__footer">
|
||||
<button class="btn" type="button" data-dialog-close>Abbrechen</button>
|
||||
<button class="btn btn--primary" type="submit">Speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endcall %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,22 @@
|
||||
{% extends "tesm_core/base.html" %}
|
||||
{% from "tesm_core/macros.html" import search_box %}
|
||||
|
||||
{% block page_title %}Statusübersicht{% endblock %}
|
||||
{% block page_sub %}
|
||||
{{ counts.total }} Geräte · Prüfintervall {{ interval }} s
|
||||
{% if not detailed %}· öffentliche Ansicht{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block topbar_extra %}
|
||||
{{ search_box('tile-sections', 'Gerät suchen ...') }}
|
||||
{# Der Knopf "Jetzt prüfen" sass hier ein zweites Mal: die Timer-Pille in
|
||||
der Topbar trägt ihn jetzt, und dort gehört er hin -- er setzt genau die
|
||||
Angabe zurück, die daneben steht. #}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div id="dashboard-tiles" data-refresh-url="{{ url_for('dashboard.tiles') }}"
|
||||
data-refresh-interval="20000">
|
||||
{% include "tesm/_dashboard_tiles.html" %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,277 @@
|
||||
{% extends "tesm_core/base.html" %}
|
||||
{% from "tesm_core/macros.html" import pill, empty_state, alert %}
|
||||
|
||||
{% block page_title %}{{ device.name }}{% endblock %}
|
||||
{% block page_sub %}{{ device.ip }} · {{ device.mac }}{% endblock %}
|
||||
|
||||
{% block breadcrumb %}
|
||||
<nav class="breadcrumb">
|
||||
<span><a href="{{ url_for('devices.overview') }}">Clients</a></span><span>{{ device.name }}</span>
|
||||
</nav>
|
||||
{% endblock %}
|
||||
|
||||
{% block topbar_extra %}
|
||||
{% 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) }}">
|
||||
{{ icon('terminal', size=15) }} Konsole
|
||||
</a>
|
||||
{% endif %}
|
||||
{% 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 %}
|
||||
<form method="post" style="display:inline"
|
||||
action="{{ url_for('devices.restart_device', device_id=device.id) }}" data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="method" value="{{ method.key }}">
|
||||
<button class="btn btn--sm {{ 'btn--primary' if loop.first else 'btn--ghost' }}"
|
||||
type="submit" title="{{ method.hint }}" data-busy-label="Startet ..."
|
||||
data-confirm="{{ device.name }} über {{ method.label }} neu starten? {{ method.hint }}"
|
||||
data-confirm-title="Neustart" data-confirm-ok="Neu starten"
|
||||
data-confirm-style="primary">
|
||||
{{ icon('power', size=15) }} {{ method.label }}
|
||||
</button>
|
||||
</form>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% if device.deleted_at %}
|
||||
{{ alert('Dieses Gerät liegt im Papierkorb (gelöscht am ' ~ (device.deleted_at | dt) ~ ').',
|
||||
'warning') }}
|
||||
{% endif %}
|
||||
|
||||
<div class="grid grid--split">
|
||||
<div class="stack">
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">
|
||||
{{ icon('activity', size=17) }} Zustand
|
||||
{% if not device.is_active %}{{ pill('deaktiviert', '') }}
|
||||
{% elif device.state == 'online' %}{{ pill('online', 'online') }}
|
||||
{% elif device.state == 'offline' %}{{ pill('offline', 'offline') }}
|
||||
{% else %}{{ pill('unbekannt', 'warning') }}{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card__body">
|
||||
<div class="grid grid--4">
|
||||
<div class="stat">
|
||||
<span class="stat__label">Antwortzeit</span>
|
||||
<span class="stat__value">{{ device.latency_ms | millis if device.latency_ms else '–' | safe }}</span>
|
||||
<span class="stat__hint">letzte Messung</span>
|
||||
</div>
|
||||
<div class="stat {{ 'stat--danger' if device.fail_count else '' }}">
|
||||
<span class="stat__label">Fehlversuche</span>
|
||||
<span class="stat__value">{{ device.fail_count or 0 }}</span>
|
||||
<span class="stat__hint">in Folge</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat__label">Neustarts</span>
|
||||
<span class="stat__value">{{ device.restart_count or 0 }}</span>
|
||||
<span class="stat__hint">insgesamt</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat__label">Zuletzt erreichbar</span>
|
||||
<span class="stat__value fs-lg">
|
||||
{% if device.last_ok_at %}
|
||||
<span data-relative-time="{{ device.last_ok_at }}">{{ device.last_ok_at | dt }}</span>
|
||||
{% else %}–{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('power', size=17) }} Neustarts</div>
|
||||
<div class="card__sub">
|
||||
Die letzten {{ events | length }} Neustarts – über den PoE-Port wie per SSH
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if events %}
|
||||
<div class="table-wrap">
|
||||
<table class="table table--compact">
|
||||
<thead><tr><th>Zeit</th><th>Methode</th><th>Auslöser</th><th>Ergebnis</th><th class="numeric">Dauer</th><th>Detail</th></tr></thead>
|
||||
<tbody>
|
||||
{% for event in events %}
|
||||
<tr>
|
||||
<td class="fs-sm nowrap">{{ event.ts | dt }}</td>
|
||||
<td class="fs-sm">
|
||||
{# Drei Wege, drei Anzeigen: alles ausser 'ssh' als "PoE-Port"
|
||||
zu zeigen hat RPC-Neustarts als PoE ausgegeben. #}
|
||||
{% if event.method == 'ssh' %}{{ pill('SSH', 'info') }}
|
||||
{% elif event.method == 'rpc' %}{{ pill('RPC', 'accent') }}
|
||||
{% elif event.method == 'pö' %}{{ pill('PoE-Port', '') }}
|
||||
{% else %}{{ pill(event.method or 'unbekannt', 'warning') }}{% endif %}
|
||||
</td>
|
||||
<td class="fs-sm">{{ event.trigger }} <span class="faint">/ {{ event.actor }}</span></td>
|
||||
<td>
|
||||
{% if event.result == 'ok' %}{{ pill('erfolgreich', 'online') }}
|
||||
{% elif event.result == 'skipped' %}{{ pill('übersprungen', 'warning') }}
|
||||
{% else %}{{ pill('fehlgeschlagen', 'danger') }}{% endif %}
|
||||
</td>
|
||||
<td class="numeric fs-sm nowrap">{{ event.duration_ms | millis }}</td>
|
||||
<td class="fs-xs muted break-all">{{ event.detail | audit_detail }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
{{ empty_state('Noch keine Neustarts', 'Dieses Gerät wurde noch nie neu gestartet.', 'power') }}
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section class="card {{ '' if host_keys else 'card--danger' }}">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">
|
||||
{{ icon('shield', size=17) }} SSH-Host-Schlüssel
|
||||
{% if host_keys %}{{ pill('freigegeben', 'online') }}
|
||||
{% else %}{{ pill('offen', 'warning') }}{% endif %}
|
||||
</div>
|
||||
<div class="card__sub">
|
||||
Ohne freigegebenen Schlüssel verweigern Wartung und Konsole die Verbindung.
|
||||
</div>
|
||||
</div>
|
||||
{% if current_user.has('devices.write') %}
|
||||
<form method="post" action="{{ url_for('devices.probe_host_key', device_id=device.id) }}"
|
||||
data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<button class="btn btn--sm" type="submit" data-busy-label="Lese ...">
|
||||
{{ icon('refresh', size=14) }} Auslesen
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card__body stack">
|
||||
{% set probed_type = request.args.get('probed_type') %}
|
||||
{% set probed_fingerprint = request.args.get('probed_fingerprint') %}
|
||||
{% if probed_type and probed_fingerprint and current_user.has('devices.write') %}
|
||||
<div class="alert alert--warning">
|
||||
{{ icon('alert', size=18) }}
|
||||
<div class="alert__body">
|
||||
<div class="alert__title">Gelesener Schlüssel</div>
|
||||
<div class="mono fs-sm break-all">{{ probed_type }} {{ probed_fingerprint }}</div>
|
||||
<p class="fs-sm mt-2">
|
||||
Vergleichen Sie den Fingerprint mit einer unabhängigen Quelle (Konsole am
|
||||
Gerät, Inventardokumentation), bevor Sie ihn freigeben.
|
||||
</p>
|
||||
<form method="post" class="mt-2"
|
||||
action="{{ url_for('devices.trust_host_key', device_id=device.id) }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="key_type" value="{{ probed_type }}">
|
||||
<input type="hidden" name="fingerprint" value="{{ probed_fingerprint }}">
|
||||
<button class="btn btn--sm btn--primary" type="submit"
|
||||
data-confirm="Diesen Fingerprint dauerhaft als vertrauenswürdig hinterlegen?"
|
||||
data-confirm-title="Host-Schlüssel freigeben" data-confirm-ok="Freigeben"
|
||||
data-confirm-style="primary">Freigeben</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if host_keys %}
|
||||
<dl class="kv fs-sm">
|
||||
{% for key in host_keys %}
|
||||
<dt>{{ key.key_type }}</dt>
|
||||
<dd class="mono fs-xs break-all">{{ key.fingerprint }}</dd>
|
||||
{% endfor %}
|
||||
</dl>
|
||||
{% if current_user.has('devices.write') %}
|
||||
<form method="post"
|
||||
action="{{ url_for('devices.forget_host_key', device_id=device.id) }}"
|
||||
data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<button class="btn btn--sm btn--ghost" type="submit"
|
||||
data-confirm="Gespeicherte Host-Schlüssel dieses Geräts entfernen? Danach muss der Schlüssel erneut freigegeben werden."
|
||||
data-confirm-title="Schlüssel verwerfen" data-confirm-ok="Verwerfen">
|
||||
Verwerfen
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<p class="muted fs-sm">
|
||||
Noch kein Schlüssel hinterlegt. Erst lesen, Fingerprint prüfen, dann freigeben.
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1"><div class="card__title">{{ icon('info', size=17) }} Stammdaten</div></div>
|
||||
{% if current_user.has('devices.write') %}
|
||||
<a class="btn btn--sm" href="{{ url_for('devices.overview') }}">Bearbeiten</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card__body">
|
||||
<dl class="kv fs-sm">
|
||||
<dt>Name</dt><dd>{{ device.name }}</dd>
|
||||
<dt>IP</dt><dd class="mono">{{ device.ip }}</dd>
|
||||
<dt>MAC</dt><dd class="mono">{{ device.mac }}</dd>
|
||||
<dt>Switch</dt>
|
||||
<dd>
|
||||
{% if device.switch_hostname %}
|
||||
<a href="{{ url_for('switches.detail', switch_id=device.switch_id) }}">{{ device.switch_hostname }}</a>
|
||||
/ {{ device.port }}
|
||||
{% else %}<span class="faint">nicht zugeordnet</span>{% endif %}
|
||||
</dd>
|
||||
<dt>Kategorie</dt><dd>{{ device.category or '–' | safe }}</dd>
|
||||
<dt>Standort</dt><dd>{{ device.location or '–' | safe }}</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>Angelegt</dt><dd>{{ device.created_at | dt }}</dd>
|
||||
<dt>Geändert</dt>
|
||||
<dd>{{ device.updated_at | dt }} <span class="faint">von {{ device.updated_by or '-' }}</span></dd>
|
||||
</dl>
|
||||
{% if device.notes %}<p class="fs-sm muted mt-3">{{ device.notes }}</p>{% endif %}
|
||||
</div>
|
||||
{% if current_user.has('devices.write') %}
|
||||
<div class="card__footer">
|
||||
<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="active" value="{{ '0' if device.is_active else '1' }}">
|
||||
<button class="btn btn--sm" type="submit">
|
||||
{{ 'Überwachung aussetzen' if device.is_active else 'Überwachung aktivieren' }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,280 @@
|
||||
{% extends "tesm_core/base.html" %}
|
||||
{% from "tesm_core/macros.html" import field, select_field, textarea_field, checkbox, pill,
|
||||
search_box, modal, empty_state, alert %}
|
||||
|
||||
{# Neustart-Bedienelement.
|
||||
|
||||
Ein einziger möglicher Weg: ein Knopf, der ihn benennt. Mehrere: ein Knopf
|
||||
für den bevorzugten Weg (PoE, weil er auch bei hängendem Betriebssystem
|
||||
wirkt) und daneben die Auswahl. Kein Weg: gar nichts -- ein Knopf, der
|
||||
verlässlich nichts bewirkt, ist schlimmer als kein Knopf. #}
|
||||
{% macro restart_control(device, methods, compact=False) %}
|
||||
{% if methods %}
|
||||
{% set first = methods[0] %}
|
||||
<form method="post" class="row row--tight" style="display:inline-flex"
|
||||
action="{{ url_for('devices.restart_device', device_id=device.id) }}" data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="method" value="{{ first.key }}">
|
||||
<button class="btn btn--sm{{ ' btn--ghost' if compact else '' }}" type="submit"
|
||||
title="{{ first.hint }}" data-busy-label="Startet ..."
|
||||
data-confirm="{{ device.name }} über {{ first.label }} neu starten? {{ first.hint }}"
|
||||
data-confirm-title="Neustart" data-confirm-ok="Neu starten"
|
||||
data-confirm-style="primary">
|
||||
{{ icon('power', size=15) }}{% if not compact %} {{ first.label }}{% endif %}
|
||||
</button>
|
||||
</form>
|
||||
{% if methods | length > 1 %}
|
||||
{% for other in methods[1:] %}
|
||||
<form method="post" style="display:inline"
|
||||
action="{{ url_for('devices.restart_device', device_id=device.id) }}" data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="method" value="{{ other.key }}">
|
||||
<button class="btn btn--sm btn--ghost" type="submit" title="{{ other.hint }}"
|
||||
data-busy-label="Startet ..."
|
||||
data-confirm="{{ device.name }} über {{ other.label }} neu starten? {{ other.hint }}"
|
||||
data-confirm-title="Neustart" data-confirm-ok="Neu starten"
|
||||
data-confirm-style="primary">
|
||||
{{ other.label }}
|
||||
</button>
|
||||
</form>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro device_form(device=None, scope='neu') %}
|
||||
<div class="form-grid">
|
||||
{{ field('name', 'Name', value=device.name if device else '', required=True) }}
|
||||
{{ field('mac', 'MAC-Adresse', value=device.mac if device else '', required=True,
|
||||
placeholder='aa:bb:cc:dd:ee:ff', extra_class='') }}
|
||||
{{ field('ip', 'IP-Adresse', value=device.ip if device else '', required=True,
|
||||
placeholder='192.168.1.50') }}
|
||||
<label class="field">
|
||||
<span class="field__label">Switch</span>
|
||||
<select class="select" name="switch_id">
|
||||
<option value="">kein Switch</option>
|
||||
{% for switch in switches %}
|
||||
<option value="{{ switch.id }}"
|
||||
{% if device and device.switch_id == switch.id %}selected{% endif %}>
|
||||
{{ switch.hostname }} ({{ switch.ip }})
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
{{ field('port', 'Port am Switch', value=device.port if device else '',
|
||||
placeholder='1 oder 1/1/3',
|
||||
hint_text='Pflicht, sobald ein Switch zugeordnet ist.') }}
|
||||
<label class="field">
|
||||
<span class="field__label">Zugangsdaten (für Wartung)</span>
|
||||
<select class="select" name="credential_id" data-behavior="toggle-target"
|
||||
data-toggle-value="__neu__" data-toggle-target="#new-credential-{{ scope }}">
|
||||
<option value="">keine</option>
|
||||
{% for credential in credentials %}
|
||||
<option value="{{ credential.id }}"
|
||||
{% if device and device.credential_id == credential.id %}selected{% endif %}>
|
||||
{{ credential.name }} ({{ category_labels.get(credential.category, credential.category) }})
|
||||
</option>
|
||||
{% endfor %}
|
||||
{% if current_user.has('credentials.create') %}
|
||||
<option value="__neu__">-- neue Zugangsdaten anlegen --</option>
|
||||
{% endif %}
|
||||
</select>
|
||||
</label>
|
||||
{% if current_user.has('credentials.create') %}
|
||||
<fieldset class="field field--full" id="new-credential-{{ scope }}" hidden>
|
||||
<legend class="field__label">Neue Zugangsdaten</legend>
|
||||
<div class="form-grid">
|
||||
{{ field('new_credential_name', 'Bezeichnung', placeholder='z. B. Wartung Linux',
|
||||
hint_text='Der Eintrag wird zusätzlich unter Zugangsdaten gespeichert und
|
||||
ist danach für weitere Geräte verwendbar.') }}
|
||||
{{ field('new_credential_username', 'Benutzer') }}
|
||||
{{ field('new_credential_secret', 'Passwort', type='password') }}
|
||||
<label class="field">
|
||||
<span class="field__label">Kategorie</span>
|
||||
<select class="select" name="new_credential_category">
|
||||
{% for value, label in credential_categories %}
|
||||
<option value="{{ value }}" {% if value == 'linux' %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<span class="field__hint" id="category-effect-{{ scope }}"></span>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
{% endif %}
|
||||
{{ field('ssh_port', 'SSH-Port des Geräts', value=device.ssh_port if device else 22, type='number') }}
|
||||
<label class="field">
|
||||
<span class="field__label">Kategorie</span>
|
||||
<select class="select" name="category">
|
||||
{% for value, label in categories %}
|
||||
<option value="{{ value }}" {% if device and device.category == value %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
{{ field('location', 'Standort', value=device.location if device else '') }}
|
||||
{{ textarea_field('notes', 'Notizen', value=device.notes if device else '', rows=2,
|
||||
extra_class='field--full') }}
|
||||
</div>
|
||||
<div class="stack stack--tight">
|
||||
{{ checkbox('is_active', 'Gerät wird überwacht', checked=device.is_active if device else True) }}
|
||||
{{ checkbox('auto_restart', 'Automatischer PoE-Neustart bei Ausfall',
|
||||
checked=device.auto_restart if device else True,
|
||||
hint_text='Greift nur mit gültiger Lizenz und zugeordnetem Switch-Port.') }}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% block page_title %}Clients{% endblock %}
|
||||
{% block page_sub %}
|
||||
{{ devices | length }} Geräte · {{ counts.online }} erreichbar, {{ counts.offline }} nicht erreichbar
|
||||
{% endblock %}
|
||||
|
||||
{% block topbar_extra %}
|
||||
{{ search_box('devices-table', 'Name, IP, MAC, Switch ...', count_id='devices-visible') }}
|
||||
{% if current_user.has('devices.create') %}
|
||||
<button class="btn btn--primary btn--sm" type="button" data-dialog-open="device-new">
|
||||
{{ icon('plus', size=15) }} Gerät anlegen
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="card">
|
||||
<div class="table-toolbar">
|
||||
<span class="muted fs-sm"><span id="devices-visible">{{ devices | length }}</span> sichtbar</span>
|
||||
<span class="table-toolbar__spacer"></span>
|
||||
<a class="btn btn--sm btn--ghost" href="{{ url_for('tesmlogs.restarts') }}">
|
||||
{{ icon('power', size=15) }} Neustarts
|
||||
</a>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table class="table" id="devices-table" data-behavior="sortable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="sortable">Name</th>
|
||||
<th class="sortable">Zustand</th>
|
||||
<th class="sortable">IP</th>
|
||||
<th class="sortable">MAC</th>
|
||||
<th class="sortable">Zugangsdaten</th>
|
||||
<th class="sortable">Switch / Port</th>
|
||||
<th class="sortable">Letzte Prüfung</th>
|
||||
<th class="sortable">Host-Schlüssel</th>
|
||||
<th class="actions">Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for device in devices %}
|
||||
<tr data-filter-text="{{ device.name }} {{ device.ip }} {{ device.mac }} {{ device.switch_hostname or '' }} {{ device.location }} {{ device.category }}">
|
||||
<td>
|
||||
<a class="fw-600" href="{{ url_for('devices.detail', device_id=device.id) }}">{{ device.name }}</a>
|
||||
{% if not device.is_active %}{{ pill('deaktiviert', '') }}{% endif %}
|
||||
{% if not device.auto_restart %}
|
||||
<span class="hint-icon" title="Automatischer PoE-Neustart ist für dieses Gerät abgeschaltet">!</span>
|
||||
{% endif %}
|
||||
{% if device.location %}<div class="fs-xs faint">{{ device.location }}</div>{% endif %}
|
||||
</td>
|
||||
<td data-sort-value="{{ device.state }}">
|
||||
{% if not device.is_active %}{{ pill('aus', '') }}
|
||||
{% elif device.state == 'online' %}{{ pill('online', 'online') }}
|
||||
{% elif device.state == 'restarting' %}{{ pill('startet neu', 'warning') }}
|
||||
{% elif device.state == 'offline' %}{{ pill('offline', 'offline') }}
|
||||
{% else %}{{ pill('unbekannt', 'warning') }}{% endif %}
|
||||
{% if device.fail_count %}<span class="fs-xs faint">({{ device.fail_count }}×)</span>{% endif %}
|
||||
</td>
|
||||
<td class="mono fs-sm">{{ device.ip }}</td>
|
||||
<td class="mono fs-sm">{{ device.mac }}</td>
|
||||
<td class="fs-sm">
|
||||
{% if device.credential_name %}
|
||||
{{ device.credential_name }}
|
||||
<div class="fs-xs faint">
|
||||
{{ category_labels.get(device.credential_category, device.credential_category) }}
|
||||
</div>
|
||||
{% else %}<span class="faint">keine</span>{% endif %}
|
||||
</td>
|
||||
<td class="fs-sm">
|
||||
{% if device.switch_hostname %}
|
||||
{{ device.switch_hostname }} <span class="faint">/</span> {{ device.port }}
|
||||
{% else %}<span class="faint">nicht zugeordnet</span>{% endif %}
|
||||
</td>
|
||||
<td class="fs-sm" data-sort-value="{{ device.last_check_at or '' }}">
|
||||
{% if device.last_check_at %}
|
||||
<span data-relative-time="{{ device.last_check_at }}">{{ device.last_check_at | dt }}</span>
|
||||
{% else %}<span class="faint">nie</span>{% endif %}
|
||||
</td>
|
||||
<td data-sort-value="{{ '1' if device.host_key_known else '0' }}">
|
||||
{% if device.credential_category == 'windows' %}
|
||||
<span class="faint fs-xs" title="Windows wird nicht über SSH verwaltet">
|
||||
nicht nötig
|
||||
</span>
|
||||
{% elif device.host_key_known %}{{ pill('freigegeben', 'online') }}
|
||||
{% else %}
|
||||
<a href="{{ url_for('hostkeys.overview') }}"
|
||||
title="Ohne Freigabe verweigern Wartung und Konsole die Verbindung">
|
||||
{{ pill('offen', 'warning') }}
|
||||
</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="actions">
|
||||
{% if restart_methods.get(device.id) %}
|
||||
{{ restart_control(device, restart_methods.get(device.id, []), compact=True) }}
|
||||
{% endif %}
|
||||
{% if current_user.has('devices.write') %}
|
||||
<button class="btn btn--sm btn--ghost" type="button"
|
||||
data-dialog-open="device-edit-{{ device.id }}" aria-label="Bearbeiten">
|
||||
{{ icon('pencil', size=15) }}
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if current_user.has('devices.delete') %}
|
||||
<form method="post" action="{{ url_for('devices.delete', device_id=device.id) }}"
|
||||
style="display:inline" data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<button class="btn btn--sm btn--ghost danger-text" type="submit" aria-label="Löschen"
|
||||
data-confirm="{{ device.name }} in den Papierkorb verschieben? Die MAC bleibt dabei reserviert."
|
||||
data-confirm-title="Gerät löschen" data-confirm-ok="Löschen">
|
||||
{{ icon('trash', size=15) }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% if not devices %}
|
||||
{{ empty_state('Keine Geräte', 'Legen Sie das erste überwachte Gerät an.', 'cpu') }}
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
{{ alert('Ein PoE-Neustart setzt Switch und Port voraus, ein Neustart über SSH oder
|
||||
RPC setzt passende Zugangsdaten voraus. Ist keine Methode möglich, erscheint der Knopf
|
||||
nicht -- damit kein Neustart vorgetäuscht wird, der nichts bewirkt.', 'info') }}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block dialogs %}
|
||||
{% if current_user.has('devices.create') %}
|
||||
{% call modal('device-new', 'Gerät anlegen', 'MAC und IP sind Pflicht', 'wide') %}
|
||||
<form method="post" action="{{ url_for('devices.create') }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<div class="modal__body">{{ device_form(scope='neu') }}</div>
|
||||
<div class="modal__footer">
|
||||
<button class="btn" type="button" data-dialog-close>Abbrechen</button>
|
||||
<button class="btn btn--primary" type="submit">Anlegen</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endcall %}
|
||||
{% endif %}
|
||||
|
||||
{% if current_user.has('devices.write') %}
|
||||
{% for device in devices %}
|
||||
{% call modal('device-edit-' ~ device.id, device.name, device.mac, 'wide') %}
|
||||
<form method="post" action="{{ url_for('devices.update', device_id=device.id) }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<div class="modal__body">{{ device_form(device, scope=device.id) }}</div>
|
||||
<div class="modal__footer">
|
||||
<button class="btn" type="button" data-dialog-close>Abbrechen</button>
|
||||
<button class="btn btn--primary" type="submit">Speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endcall %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,446 @@
|
||||
{% extends "tesm_core/base.html" %}
|
||||
{% from "tesm_core/macros.html" import field, checkbox, pill, empty_state, alert, search_box %}
|
||||
|
||||
{% block page_title %}DHCP-Server{% endblock %}
|
||||
{% block page_sub %}
|
||||
Kea · {{ subnets | length }} Subnetze · {{ reservations | length }} Reservierungen
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% for warning in warnings %}{{ alert(warning, 'warning') }}{% endfor %}
|
||||
{% if not sysops_available %}
|
||||
{{ alert('Systemhelfer nicht verfügbar -- Konfiguration und Dienstzustand können hier nur
|
||||
angezeigt, nicht geschrieben werden.', 'warning') }}
|
||||
{% endif %}
|
||||
|
||||
<div class="grid grid--4">
|
||||
<div class="stat {{ 'stat--ok' if service.get('active') == 'active' else 'stat--warning' }}">
|
||||
<span class="stat__label">Dienst</span>
|
||||
<span class="stat__value fs-lg">{{ service.get('active', 'unbekannt') }}</span>
|
||||
<span class="stat__hint">Autostart: {{ service.get('enabled', '?') }}</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat__label">Programm</span>
|
||||
<span class="stat__value fs-lg">{{ service.get('binary', '?') }}</span>
|
||||
<span class="stat__hint">kea-dhcp4</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat__label">Reservierungen</span>
|
||||
<span class="stat__value">{{ reservations | length }}</span>
|
||||
<span class="stat__hint">automatisch + manuell</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat__label">Leases</span>
|
||||
<span class="stat__value">{{ leases | rejectattr('is_expired') | list | length }}</span>
|
||||
<span class="stat__hint">
|
||||
gültig{% if leases | selectattr('is_expired') | list %},
|
||||
{{ leases | selectattr('is_expired') | list | length }} abgelaufen{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if current_user.has('dhcp_service.execute') %}
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('power', size=17) }} Dienst</div>
|
||||
<div class="card__sub">
|
||||
Bewusst getrennt vom Speichern: eine abgelegte Konfiguration startet keinen Dienst.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card__body row row--tight">
|
||||
{% for action, label, style, question in [
|
||||
('install', 'Kea installieren', '', 'Kea-DHCP-Server per apt installieren?'),
|
||||
('start', 'Aktivieren und starten', 'primary', 'Dienst aktivieren und starten? Er verteilt danach IP-Adressen im Netz.'),
|
||||
('stop', 'Stoppen und deaktivieren', 'danger', 'Dienst stoppen und deaktivieren? Clients erhalten danach keine Adressen mehr.')] %}
|
||||
<form method="post" action="{{ url_for('dhcp.service_action', action=action) }}" data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<button class="btn btn--sm {{ 'btn--' ~ style if style }}" type="submit"
|
||||
data-busy-label="Arbeitet ..." data-confirm="{{ question }}"
|
||||
data-confirm-title="{{ label }}" data-confirm-ok="Ausführen"
|
||||
data-confirm-style="{{ style or 'primary' }}">{{ label }}</button>
|
||||
</form>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{# -- Subnetze ---------------------------------------------------------- #}
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('network', size=17) }} Subnetze</div>
|
||||
<div class="card__sub">
|
||||
Subnetz und Maske kommen vom laufenden System – eingegeben wird nur der Adressbereich.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if subnets %}
|
||||
<div class="table-wrap">
|
||||
<table class="table table--compact">
|
||||
<thead><tr><th>Interface</th><th>Bereich</th><th>Gateway</th><th>DNS</th><th>Aktiv</th><th class="actions"></th></tr></thead>
|
||||
<tbody>
|
||||
{% for subnet in subnets %}
|
||||
<tr>
|
||||
<td class="mono fs-sm">{{ subnet.interface }}</td>
|
||||
<td class="mono fs-sm">{{ subnet.range_start }} – {{ subnet.range_end }}</td>
|
||||
<td class="mono fs-sm">{{ subnet.gateway 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 class="actions">
|
||||
{% if current_user.has('dhcp_subnets.write') %}
|
||||
<form method="post" action="{{ url_for('dhcp.delete_subnet', subnet_id=subnet.id) }}"
|
||||
style="display:inline" data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<button class="btn btn--sm btn--ghost danger-text" type="submit"
|
||||
data-confirm="Subnetz {{ subnet.range_start }}-{{ subnet.range_end }} entfernen?"
|
||||
data-confirm-title="Subnetz entfernen" data-confirm-ok="Entfernen">
|
||||
{{ icon('trash', size=15) }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
{{ empty_state('Kein Subnetz', 'Ohne Subnetz startet Kea nicht.', 'network') }}
|
||||
{% endif %}
|
||||
|
||||
{% if current_user.has('dhcp_subnets.create') %}
|
||||
<form method="post" action="{{ url_for('dhcp.add_subnet') }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<div class="card__body">
|
||||
<div class="form-grid">
|
||||
<label class="field">
|
||||
<span class="field__label">Interface</span>
|
||||
<select class="select" name="interface" required>
|
||||
{% for network in networks %}
|
||||
<option value="{{ network.interface }}">{{ network.interface }} ({{ network.cidr }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
{{ field('range_start', 'Von', required=True, placeholder='192.168.1.100') }}
|
||||
{{ field('range_end', 'Bis', required=True, placeholder='192.168.1.200') }}
|
||||
{{ field('gateway', 'Gateway', value=default_gateway) }}
|
||||
{{ field('dns', 'DNS-Server', placeholder='192.168.1.1, 9.9.9.9') }}
|
||||
{{ field('comment', 'Kommentar') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card__footer"><button class="btn btn--primary" type="submit">Subnetz anlegen</button></div>
|
||||
</form>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
{# -- Globale Einstellungen --------------------------------------------- #}
|
||||
{% if current_user.has('dhcp_subnets.write') %}
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1"><div class="card__title">{{ icon('sliders', size=17) }} Globale Einstellungen</div></div>
|
||||
</div>
|
||||
<form method="post" action="{{ url_for('dhcp.save_settings') }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<div class="card__body">
|
||||
<div class="form-grid">
|
||||
{{ field('dhcp_domain', 'Domainname', value=values.dhcp_domain) }}
|
||||
{{ field('dhcp_lease_default', 'Lease-Dauer (s)', value=values.dhcp_lease_default, type='number') }}
|
||||
{{ field('dhcp_lease_max', 'Maximale Lease-Dauer (s)', value=values.dhcp_lease_max, type='number') }}
|
||||
{{ field('dhcp_output_path', 'Zielpfad der Konfiguration', value=values.dhcp_output_path,
|
||||
extra_class='field--full') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card__footer">
|
||||
<button class="btn btn--primary" type="submit">Speichern und schreiben</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{# -- Optionen ---------------------------------------------------------- #}
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('puzzle', size=17) }} DHCP-Optionen</div>
|
||||
<div class="card__sub">
|
||||
{{ options | length }} Optionen · Standardoptionen sind nicht löschbar, ihr Wert
|
||||
lässt sich leeren
|
||||
</div>
|
||||
</div>
|
||||
{{ search_box('options-table', 'Option suchen ...') }}
|
||||
</div>
|
||||
<div class="table-wrap" style="max-height:420px;overflow:auto">
|
||||
<table class="table table--compact" id="options-table">
|
||||
<thead><tr><th class="numeric">Code</th><th>Name</th><th>Typ</th><th>Wert</th><th class="actions"></th></tr></thead>
|
||||
<tbody>
|
||||
{% for option in options %}
|
||||
<tr data-filter-text="{{ option.code }} {{ option.name }} {{ option.description }}">
|
||||
<td class="numeric fs-sm">{{ option.code }}</td>
|
||||
<td class="fs-sm">
|
||||
<div class="mono">{{ option.name }}</div>
|
||||
<div class="fs-xs faint">{{ option.description }}</div>
|
||||
</td>
|
||||
<td class="fs-xs mono">{{ option.type }}</td>
|
||||
<td>
|
||||
{% if current_user.has('dhcp_options.write') %}
|
||||
<form method="post" action="{{ url_for('dhcp.set_option_value') }}" class="row row--tight"
|
||||
data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="option_id" value="{{ option.id }}">
|
||||
<input class="input input--mono" type="text" name="value" value="{{ option.value or '' }}"
|
||||
style="min-width:180px" placeholder="leer = nicht senden">
|
||||
<button class="btn btn--sm" type="submit">{{ icon('check', size=14) }}</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<span class="mono fs-sm">{{ option.value or '–' | safe }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="actions">
|
||||
{% 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) }}"
|
||||
data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<button class="btn btn--sm btn--ghost danger-text" type="submit"
|
||||
data-confirm="Eigene Option {{ option.name }} löschen?"
|
||||
data-confirm-title="Option löschen" data-confirm-ok="Löschen">
|
||||
{{ icon('trash', size=14) }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% if current_user.has('dhcp_options.write') %}
|
||||
<form method="post" action="{{ url_for('dhcp.add_custom_option') }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<div class="card__body">
|
||||
<div class="form-grid">
|
||||
{{ field('code', 'Code', type='number', required=True) }}
|
||||
{{ field('name', 'Name', required=True, placeholder='meine-option') }}
|
||||
<label class="field">
|
||||
<span class="field__label">Typ</span>
|
||||
<select class="select" name="type">
|
||||
{% for option_type in option_types %}<option value="{{ option_type }}">{{ option_type }}</option>{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
{{ field('description', 'Beschreibung') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card__footer"><button class="btn" type="submit">Eigene Option anlegen</button></div>
|
||||
</form>
|
||||
|
||||
{# Der Wert einer Option gilt sonst für alle. Genau dafür gibt es
|
||||
``dhcp_option_values.device_id``: ein eigener Wert je Gerät, der in
|
||||
dessen Reservierung geschrieben wird -- etwa eine Terminal-URL, die je
|
||||
Gerät anders lautet. Auswertbar war das von Anfang an, eingeben liess
|
||||
es sich nicht. #}
|
||||
<div class="card__header" style="border-top:1px solid var(--border-subtle)">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('sliders', size=17) }} Option nur für ein Gerät</div>
|
||||
<div class="card__sub">
|
||||
Der Wert landet in der Reservierung dieses Geräts und überschreibt dort den
|
||||
globalen Wert. Leeres Feld entfernt ihn wieder.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" action="{{ url_for('dhcp.set_device_option_value') }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<div class="card__body">
|
||||
<div class="form-grid">
|
||||
<label class="field">
|
||||
<span class="field__label">Gerät<span class="req" aria-hidden="true">*</span></span>
|
||||
<select class="select" name="device_id" required>
|
||||
{% for reservation in reservations if reservation.device_id %}
|
||||
<option value="{{ reservation.device_id }}">
|
||||
{{ reservation.name or reservation.hostname }} — {{ reservation.ip }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field__label">Option<span class="req" aria-hidden="true">*</span></span>
|
||||
<select class="select" name="option_id" required>
|
||||
{% for option in options %}
|
||||
<option value="{{ option.id }}">{{ option.code }} — {{ option.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
{{ field('value', 'Wert', extra_class='field--full',
|
||||
hint_text='Leer = Wert für dieses Gerät entfernen') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card__footer">
|
||||
<button class="btn" type="submit">Für dieses Gerät speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
{# -- Reservierungen und Leases ----------------------------------------- #}
|
||||
<div class="grid grid--2">
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('credit-card', size=17) }} Reservierungen</div>
|
||||
<div class="card__sub">
|
||||
Automatisch aus dem Gerätebestand; bei MAC-Kollision gewinnt der Gerätebestand.
|
||||
Nur Adressen aus eingerichteten Bereichen.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if skipped_reservations %}
|
||||
{# Nicht verschweigen, sondern benennen: vorher standen diese Einträge in
|
||||
der Liste, obwohl Kea sie nie ausgeliefert hat. #}
|
||||
<div class="card__body pt-0">
|
||||
{{ alert(skipped_reservations | length ~ ' Gerät(e) liegen in keinem eingerichteten
|
||||
Bereich und bekommen keine Reservierung. Legen Sie den passenden Bereich an, wenn sie
|
||||
eine bekommen sollen.', 'warning') }}
|
||||
<details class="fs-xs faint">
|
||||
<summary>Welche?</summary>
|
||||
<ul class="mt-1">
|
||||
{% for entry in skipped_reservations %}
|
||||
<li><span class="mono">{{ entry.ip }}</span> – {{ entry.name }}
|
||||
({{ entry.source }})</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</details>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="table-wrap" style="max-height:360px;overflow:auto">
|
||||
<table class="table table--compact">
|
||||
<thead><tr><th>MAC</th><th>IP</th><th>Hostname</th><th>Zustand</th><th>Quelle</th>
|
||||
<th>Eigene Optionen</th><th class="actions"></th></tr></thead>
|
||||
<tbody>
|
||||
{% for reservation in reservations %}
|
||||
<tr>
|
||||
<td class="mono fs-sm">{{ reservation.mac }}</td>
|
||||
<td class="mono fs-sm">{{ reservation.ip }}</td>
|
||||
<td class="fs-sm">{{ reservation.hostname }}</td>
|
||||
<td title="{{ reservation.state_hint }}">
|
||||
{{ pill(reservation.state | replace('_', ' '), reservation.state_style) }}
|
||||
{% if reservation.state == 'andere_adresse' and reservation.lease %}
|
||||
<div class="fs-xs faint mono">jetzt {{ reservation.lease.address }}</div>
|
||||
{% elif reservation.state == 'aktiv' and reservation.lease %}
|
||||
<div class="fs-xs faint">{{ reservation.lease.remaining_text }}</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ pill(reservation.source, 'accent' if reservation.source == 'auto' else '') }}</td>
|
||||
<td class="fs-xs">
|
||||
{% set eigene = device_option_values.get(reservation.device_id) if reservation.device_id else none %}
|
||||
{% if eigene %}
|
||||
{% for wert in eigene %}
|
||||
<div class="mono break-all">{{ wert.code }} {{ wert.name }} = {{ wert.value }}</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<span class="faint">–</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="actions">
|
||||
{% if reservation.source == 'manuell' and current_user.has('dhcp_reservations.delete') %}
|
||||
<form method="post" data-no-guard
|
||||
action="{{ url_for('dhcp.delete_reservation', reservation_id=reservation.reservation_id) }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<button class="btn btn--sm btn--ghost danger-text" type="submit"
|
||||
data-confirm="Reservierung {{ reservation.mac }} entfernen?"
|
||||
data-confirm-title="Reservierung entfernen" data-confirm-ok="Entfernen">
|
||||
{{ icon('trash', size=14) }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% if current_user.has('dhcp_reservations.create') %}
|
||||
<form method="post" action="{{ url_for('dhcp.add_reservation') }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<div class="card__body">
|
||||
<div class="form-grid">
|
||||
{{ field('mac', 'MAC', required=True, placeholder='aa:bb:cc:dd:ee:ff') }}
|
||||
{{ field('ip', 'IP', required=True) }}
|
||||
{{ field('hostname', 'Hostname') }}
|
||||
{{ field('comment', 'Kommentar') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card__footer"><button class="btn" type="submit">Reservierung speichern</button></div>
|
||||
</form>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('clock', size=17) }} Leases</div>
|
||||
<div class="card__sub">
|
||||
Direkt aus {{ lease_file }} gelesen – je Adresse gilt der letzte Eintrag.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if leases and service.get('active') != 'active' %}
|
||||
{# Sonst sieht man alte Einträge und hält sie für den aktüllen Stand:
|
||||
ohne laufenden Dienst wird keine Lease erneuert, und die Datei
|
||||
veraltet still. #}
|
||||
<div class="card__body pt-0">
|
||||
{{ alert('Der DHCP-Dienst läuft nicht (' ~ service.get('active', 'unbekannt') ~ ').
|
||||
Es wird keine Lease erneuert -- die Liste zeigt den letzten Stand der Datei, nicht die
|
||||
aktülle Lage. Der Grund steht im Kea-Protokoll.', 'warning') }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if leases %}
|
||||
<div class="table-wrap" style="max-height:420px;overflow:auto">
|
||||
<table class="table table--compact">
|
||||
<thead><tr><th>IP</th><th>MAC</th><th>Hostname</th><th>Art</th><th>Zustand</th>
|
||||
<th>Läuft ab</th></tr></thead>
|
||||
<tbody>
|
||||
{% for lease in leases %}
|
||||
<tr>
|
||||
<td class="mono fs-sm">{{ lease.address }}</td>
|
||||
<td class="mono fs-sm">{{ lease.mac }}</td>
|
||||
<td class="fs-sm">{{ lease.hostname or '–' | safe }}</td>
|
||||
<td>
|
||||
{% if lease.address in reserved_ips %}{{ pill('Reservierung', 'accent') }}
|
||||
{% else %}{{ pill('dynamisch', '') }}{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if lease.is_expired %}{{ pill('abgelaufen', 'warning') }}
|
||||
{% elif lease.is_active %}{{ pill('gültig', 'online') }}
|
||||
{% else %}{{ pill('inaktiv', '') }}{% endif %}
|
||||
</td>
|
||||
<td class="fs-sm">{{ lease.expires_text }}
|
||||
<div class="fs-xs faint">{{ lease.remaining_text }}</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
{{ empty_state('Keine Leases', 'Es sind keine vergebenen Adressen bekannt.', 'clock') }}
|
||||
{% endif %}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{# -- Vorschau ---------------------------------------------------------- #}
|
||||
<details class="card" data-persist="dhcp-preview">
|
||||
<summary class="card__header" style="cursor:pointer">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('file-text', size=17) }} Erzeugte Kea-Konfiguration</div>
|
||||
<div class="card__sub">Vorschau – so würde die Datei geschrieben</div>
|
||||
</div>
|
||||
{{ icon('chevron-down', size=16) }}
|
||||
</summary>
|
||||
<pre class="log-view">{{ preview }}</pre>
|
||||
{% if current_user.has('dhcp_config.execute') %}
|
||||
<div class="card__footer">
|
||||
<form method="post" action="{{ url_for('dhcp.write_config') }}" data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<button class="btn btn--primary" type="submit">Datei jetzt schreiben</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
</details>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,419 @@
|
||||
{% extends "tesm_core/base.html" %}
|
||||
{% from "tesm_core/macros.html" import field, empty_state, alert, pill, modal %}
|
||||
|
||||
{% block page_title %}Dateifreigaben{% endblock %}
|
||||
{% block page_sub %}{{ mounts | length }} eingebundene Freigabe(n){% endblock %}
|
||||
|
||||
{% block topbar_extra %}
|
||||
{% if current_user.has('fileshare.write') %}
|
||||
<a class="btn btn--sm" href="{{ url_for('fileshare.mappings') }}">
|
||||
{{ icon('settings', size=15) }} Zuordnungen
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% if not mounts %}
|
||||
{{ empty_state('Keine Freigaben eingebunden',
|
||||
'Einbindungen entstehen nach einer Anmeldung gegen den Verzeichnisdienst und richten sich
|
||||
nach Ihrer Gruppenmitgliedschaft.', 'folder') }}
|
||||
{% else %}
|
||||
{% if error %}{{ alert(error, 'danger') }}{% endif %}
|
||||
|
||||
<div class="tabs">
|
||||
{% for mount in mounts %}
|
||||
<a class="tab {{ 'is-active' if mount.token == token else '' }}"
|
||||
href="{{ url_for('fileshare.browse', share=mount.token) }}">{{ mount.label }}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% if listing %}
|
||||
{% set current = (mounts | selectattr('token', 'equalto', token) | list | first) %}
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<nav class="breadcrumb">
|
||||
<span><a href="{{ url_for('fileshare.browse', share=token) }}">{{ current.label }}</a></span>
|
||||
{% for crumb in listing.breadcrumb %}
|
||||
<span><a href="{{ url_for('fileshare.browse', share=token, path=crumb.relative) }}">{{ crumb.name }}</a></span>
|
||||
{% endfor %}
|
||||
</nav>
|
||||
<div class="card__sub mono fs-xs">{{ current.unc }}</div>
|
||||
</div>
|
||||
<span class="fs-xs faint">
|
||||
Einbindung endet <span data-relative-time="{{ current.expires_at }}">{{ current.expires_at | dt }}</span>
|
||||
</span>
|
||||
{% if current_user.has('fileshare.create') %}
|
||||
<button class="btn btn--sm" type="button" data-dialog-open="folder-new">
|
||||
{{ icon('plus', size=14) }} Ordner
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<form method="post" action="{{ url_for('fileshare.delete', token=token) }}" id="entries-form">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="parent" value="{{ listing.relative }}">
|
||||
<div class="table-wrap">
|
||||
<table class="table table--compact">
|
||||
<thead>
|
||||
<tr>
|
||||
{% if current_user.has('fileshare.delete') %}<th style="width:40px"></th>{% endif %}
|
||||
<th>Name</th>
|
||||
<th class="numeric">Grösse</th>
|
||||
<th>Geändert</th>
|
||||
<th class="actions">Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% if listing.breadcrumb %}
|
||||
<tr>
|
||||
{% if current_user.has('fileshare.delete') %}<td></td>{% endif %}
|
||||
<td colspan="4">
|
||||
<a class="link-icon" href="{{ url_for('fileshare.browse', share=token,
|
||||
path=(listing.breadcrumb[-2].relative if listing.breadcrumb | length > 1 else '')) }}">
|
||||
{{ icon('chevron-right', size=14) }} eine Ebene höher
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% for entry in listing.directories %}
|
||||
<tr>
|
||||
{% if current_user.has('fileshare.delete') %}
|
||||
<td><label class="check"><input type="checkbox" name="paths" value="{{ entry.relative }}"><span class="sr-only">auswählen</span></label></td>
|
||||
{% endif %}
|
||||
{# 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) }}
|
||||
<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 class="fs-sm">{{ entry.modified | dt }}</td>
|
||||
<td class="actions"></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% for entry in listing.files %}
|
||||
<tr>
|
||||
{% if current_user.has('fileshare.delete') %}
|
||||
<td><label class="check"><input type="checkbox" name="paths" value="{{ entry.relative }}"><span class="sr-only">auswählen</span></label></td>
|
||||
{% endif %}
|
||||
{# Der Name ist die grösste Fläche der Zeile -- er soll auch
|
||||
das Naheliegende tun. Was sich anzeigen lässt, öffnet die
|
||||
Vorschau; alles andere lädt herunter. #}
|
||||
<td>
|
||||
{% if entry.previewable %}
|
||||
<button class="link-icon file-name" type="button"
|
||||
data-dialog-open="file-preview"
|
||||
data-fill-Src="{{ url_for('fileshare.preview', token=token, 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 }}"
|
||||
{% 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 }}
|
||||
</button>
|
||||
{% else %}
|
||||
{% if current_user.has('fileshare.export') %}
|
||||
<a class="link-icon"
|
||||
href="{{ url_for('fileshare.download', token=token, path=entry.relative) }}">
|
||||
{{ icon('file-text', size=15) }} {{ entry.name }}
|
||||
</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 %}
|
||||
</td>
|
||||
<td class="numeric fs-sm">{{ entry.size | bytes }}</td>
|
||||
<td class="fs-sm">{{ entry.modified | dt }}</td>
|
||||
<td class="actions">
|
||||
{% if entry.previewable %}
|
||||
<button class="btn btn--sm btn--ghost" type="button"
|
||||
data-dialog-open="file-preview"
|
||||
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 }}"
|
||||
{% 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>
|
||||
{% endif %}
|
||||
{% if current_user.has('fileshare.write') %}
|
||||
<button class="btn btn--sm btn--ghost" type="button"
|
||||
data-dialog-open="file-rename"
|
||||
data-fill-Pfad="{{ entry.relative }}"
|
||||
data-fill-Name="{{ entry.stem }}"
|
||||
data-fill-Endung="{{ entry.suffix or '' }}"
|
||||
aria-label="Umbenennen">{{ icon('pencil', size=15) }}</button>
|
||||
{% endif %}
|
||||
{% if current_user.has('fileshare.export') %}
|
||||
<a class="btn btn--sm btn--ghost"
|
||||
href="{{ url_for('fileshare.download', token=token, path=entry.relative) }}"
|
||||
aria-label="Herunterladen">{{ icon('download', size=15) }}</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% if not listing.directories and not listing.files %}
|
||||
{{ empty_state('Leeres Verzeichnis', '', 'folder') }}
|
||||
{% endif %}
|
||||
{% if current_user.has('fileshare.delete') %}
|
||||
<div class="card__footer">
|
||||
<button class="btn btn--sm btn--ghost danger-text" type="submit"
|
||||
data-confirm="Die ausgewählten Einträge endgültig löschen? Auf einer Netzwerkfreigabe gibt es keinen Papierkorb."
|
||||
data-confirm-title="Löschen" data-confirm-ok="Löschen">
|
||||
Ausgewählte löschen
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{% if current_user.has('fileshare.create') %}
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('upload', size=17) }} Dateien hochladen</div>
|
||||
<div class="card__sub">nach {{ listing.relative or 'Wurzelverzeichnis' }} · max. {{ max_upload_mb }} MB je Datei</div>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" enctype="multipart/form-data" data-no-guard
|
||||
action="{{ url_for('fileshare.upload', token=token) }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="path" value="{{ listing.relative }}">
|
||||
<div class="card__body row">
|
||||
<input class="input" type="file" name="files" multiple required style="flex:1">
|
||||
<button class="btn btn--primary" type="submit" data-busy-label="Lädt ...">Hochladen</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block dialogs %}
|
||||
{% if listing and current_user.has('fileshare.create') %}
|
||||
{% call modal('folder-new', 'Ordner anlegen', listing.relative or 'Wurzelverzeichnis', 'narrow') %}
|
||||
<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="path" value="{{ listing.relative }}">
|
||||
<div class="modal__body">{{ field('name', 'Name', required=True) }}</div>
|
||||
<div class="modal__footer">
|
||||
<button class="btn" type="button" data-dialog-close>Abbrechen</button>
|
||||
<button class="btn btn--primary" type="submit">Anlegen</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endcall %}
|
||||
{% endif %}
|
||||
|
||||
{% if listing %}
|
||||
{# Vorschau im Rahmen statt in einem neuen Tab: der Zusammenhang bleibt
|
||||
erhalten, und man ist mit Escape wieder in der Dateiliste. Titel, Quelle
|
||||
und Download-Ziel werden beim Öffnen aus dem Auslöser gefüllt -- ein
|
||||
Dialog für alle Dateien statt einer je Zeile. #}
|
||||
{% call modal('file-preview', 'Vorschau', '', 'wide') %}
|
||||
<div class="modal__caption" data-fill-target="Name"></div>
|
||||
{# Drei Anzeigen, eine je Art. Ein Rahmen für alles zeigte ein Foto vom
|
||||
iPad in Originalgrösse (also einen Ausschnitt) und ein PDF auf iPadOS
|
||||
nur als Standbild -- Formularfelder liessen sich nicht anfassen. #}
|
||||
<div class="modal__body modal__body--flush" data-viewer
|
||||
{# 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 class="modal__footer">
|
||||
{% if current_user.has('fileshare.export') %}
|
||||
<a class="btn btn--sm" data-fill-target="Download" data-fill-as="href" href="#" download>
|
||||
{{ icon('download', size=15) }} Herunterladen
|
||||
</a>
|
||||
{% 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>
|
||||
{% endcall %}
|
||||
{% endif %}
|
||||
|
||||
{% if listing and current_user.has('fileshare.write') %}
|
||||
{% call modal('file-rename', 'Umbenennen', '', 'narrow') %}
|
||||
<form method="post" action="{{ url_for('fileshare.rename', token=token) }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="parent" value="{{ listing.relative }}">
|
||||
<input type="hidden" name="path" data-fill-target="Pfad" value="">
|
||||
<div class="modal__body">
|
||||
{{ field('name', 'Neuer Name (ohne Endung)', required=True,
|
||||
attrs='data-fill-target="Name"') }}
|
||||
<p class="fs-xs faint">
|
||||
Die Endung <code data-fill-target="Endung"></code> bleibt, wie sie ist --
|
||||
eine Datei ohne passende Endung öffnet auf der Freigabe nichts mehr.
|
||||
Umbenannt wird dort direkt; einen Papierkorb gibt es nicht.
|
||||
</p>
|
||||
</div>
|
||||
<div class="modal__footer">
|
||||
<button class="btn" type="button" data-dialog-close>Abbrechen</button>
|
||||
<button class="btn btn--primary" type="submit">Umbenennen</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endcall %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,107 @@
|
||||
{% extends "tesm_core/base.html" %}
|
||||
{% from "tesm_core/_group_picker.html" import group_picker %}
|
||||
{% from "tesm_core/macros.html" import field, empty_state, alert, modal %}
|
||||
{% block page_title %}Freigaben einrichten{% endblock %}
|
||||
{% block page_sub %}{{ mappings | length }} Zuordnungen · Wurzel {{ mount_root }}{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
{{ alert('Eine Freigabe wird bei der Anmeldung eingebunden, wenn der Benutzer Mitglied der
|
||||
zugeordneten Verzeichnisgruppe ist. Eingebunden wird mit dem Konto dieses Benutzers -- deshalb
|
||||
ist die Verzeichnisgruppe das einzige mögliche Ziel, und lokale Konten bekommen keine
|
||||
Einbindungen.', 'info') }}
|
||||
|
||||
{% if not directory_enabled %}
|
||||
{{ alert('Die Verzeichnisanmeldung ist nicht aktiv. Ohne sie kann keine Freigabe eingebunden
|
||||
werden.', 'warning') }}
|
||||
{% endif %}
|
||||
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1"><div class="card__title">{{ icon('folder', size=17) }} Zuordnungen</div></div>
|
||||
</div>
|
||||
{% if mappings %}
|
||||
<div class="table-wrap">
|
||||
<table class="table table--compact">
|
||||
<thead>
|
||||
<tr><th>Anzeigename</th><th>UNC-Pfad</th><th>Verzeichnisgruppe</th><th class="actions"></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for entry in mappings %}
|
||||
<tr>
|
||||
<td class="fw-600">{{ entry.label }}</td>
|
||||
<td class="mono fs-sm break-all">{{ entry.unc }}</td>
|
||||
<td class="fs-sm">{{ entry.ad_group_name }}
|
||||
<div class="fs-xs faint break-all">{{ entry.ad_group_dn }}</div></td>
|
||||
<td class="actions">
|
||||
{% if current_user.has('fileshare.write') %}
|
||||
<button class="btn btn--sm btn--ghost" type="button"
|
||||
data-dialog-open="mapping-edit-{{ entry.id }}"
|
||||
aria-label="Bearbeiten">{{ icon('pencil', size=15) }}</button>
|
||||
{% endif %}
|
||||
<form method="post" action="{{ url_for('fileshare.mapping_delete', mapping_id=entry.id) }}" data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<button class="btn btn--sm btn--ghost danger-text" type="submit"
|
||||
data-confirm="Zuordnung {{ entry.label }} entfernen?"
|
||||
data-confirm-title="Zuordnung entfernen" data-confirm-ok="Entfernen">
|
||||
{{ icon('trash', size=15) }}</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
{{ empty_state('Keine Zuordnungen', 'Legen Sie die erste Freigabe an.', 'folder') }}
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1"><div class="card__title">{{ icon('plus', size=17) }} Freigabe einrichten</div></div>
|
||||
</div>
|
||||
<form method="post" action="{{ url_for('fileshare.mapping_add') }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<div class="card__body">
|
||||
<div class="form-grid">
|
||||
{{ field('label', 'Anzeigename', required=True, placeholder='Abteilung Technik') }}
|
||||
{{ field('unc', 'UNC-Pfad', required=True, placeholder='\\\\dc2022\\daten',
|
||||
hint_text='Schrägstriche gehen auch: //dc2022/daten') }}
|
||||
{{ group_picker('ad_group_name', 'ad_group_dn', 'share', required=True,
|
||||
label='Verzeichnisgruppe, die diese Freigabe bekommt') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card__footer"><button class="btn btn--primary" type="submit">Speichern</button></div>
|
||||
</form>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
{% block dialogs %}
|
||||
{# Bearbeiten statt neu anlegen: bei einem Tippfehler im UNC-Pfad oder im
|
||||
Anzeigenamen soll man ihn korrigieren können. Sonst legt man daneben einen
|
||||
zweiten Eintrag an und vergisst, den ersten zu löschen. #}
|
||||
{% if current_user.has('fileshare.write') %}
|
||||
{% for entry in mappings %}
|
||||
{% call modal('mapping-edit-' ~ entry.id, entry.label, 'Zuordnung ändern') %}
|
||||
<form method="post" action="{{ url_for('fileshare.mapping_update', mapping_id=entry.id) }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<div class="modal__body">
|
||||
<div class="form-grid">
|
||||
{{ field('label', 'Anzeigename', value=entry.label, required=True) }}
|
||||
{{ field('unc', 'UNC-Pfad', value=entry.unc, required=True,
|
||||
hint_text='Schrägstriche gehen auch: //dc2022/daten') }}
|
||||
{{ group_picker('ad_group_name', 'ad_group_dn', 'edit-' ~ entry.id,
|
||||
name_value=entry.ad_group_name, dn_value=entry.ad_group_dn,
|
||||
required=True,
|
||||
label='Verzeichnisgruppe, die diese Freigabe bekommt') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal__footer">
|
||||
<button class="btn" type="button" data-dialog-close>Abbrechen</button>
|
||||
<button class="btn btn--primary" type="submit">Speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endcall %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,183 @@
|
||||
{% extends "tesm_core/base.html" %}
|
||||
{% from "tesm_core/macros.html" import pill, empty_state, alert, search_box %}
|
||||
|
||||
{% block page_title %}Host-Schlüssel{% endblock %}
|
||||
{% block page_sub %}
|
||||
{{ targets | length }} Ziele mit SSH-Zugang
|
||||
{%- if open_count %} · {{ open_count }} ohne Freigabe{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block topbar_extra %}{{ search_box('hostkey-table', 'Gerät, IP, Fingerprint ...') }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% if open_count %}
|
||||
{# Der PoE-Neustart hängt am Schlüssel des *Switches*, nicht am Schlüssel des
|
||||
Clients: geschaltet wird der Port auf dem Switch. Der alte Satz warf beides
|
||||
zusammen und liess vermuten, ein Client ohne freigegebenen Schlüssel sei
|
||||
nicht per PoE neu startbar. #}
|
||||
{{ alert('Für ' ~ open_count ~ ' Ziel(e) ist noch kein Schlüssel freigegeben. Wartung und
|
||||
Konsole brechen dort mit "Schlüssel unbekannt" ab -- absichtlich: ein Hintergrundauftrag
|
||||
darf keinen fremden Schlüssel annehmen. Ein PoE-Neustart braucht dagegen den Schlüssel
|
||||
des Switches, nicht den des Clients.', 'warning', 'Freigaben offen') }}
|
||||
{% else %}
|
||||
{{ alert('Für jedes Ziel mit SSH-Zugang liegt ein freigegebener Schlüssel vor.',
|
||||
'success', 'Vollständig') }}
|
||||
{% endif %}
|
||||
|
||||
{{ alert('Ein Schlüssel wird in zwei Schritten übernommen: erst auslesen, dann -- nach
|
||||
Vergleich mit einer unabhängigen Quelle -- freigeben. Gespeichert wird nach IP und Port;
|
||||
ändert sich die Adresse eines Geräts, ist erneut freizugeben. Ein *geänderter* Schlüssel
|
||||
wird nie stillschweigend übernommen: das ist der Fall, der bei einem Angriff auftritt.',
|
||||
'info', 'So funktioniert die Freigabe') }}
|
||||
|
||||
{% if probed_fingerprint %}
|
||||
<div class="alert alert--warning">
|
||||
{{ icon('alert', size=18) }}
|
||||
<div class="alert__body">
|
||||
<div class="alert__title">Gelesener Schlüssel von {{ probed_host }}:{{ probed_port }}</div>
|
||||
<div class="mono fs-sm break-all">{{ probed_type }} {{ probed_fingerprint }}</div>
|
||||
<p class="fs-sm mt-2">
|
||||
Vergleichen Sie den Fingerprint mit einer unabhängigen Quelle | Konsolenzugang am
|
||||
Gerät, Inventardokumentation --, bevor Sie ihn unten freigeben.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('shield', size=17) }} Ziele</div>
|
||||
<div class="card__sub">Ohne Freigabe zuerst</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table class="table" id="hostkey-table" data-behavior="sortable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="sortable">Ziel</th>
|
||||
<th class="sortable">Art</th>
|
||||
<th class="sortable">Adresse</th>
|
||||
<th class="sortable">Zustand</th>
|
||||
<th>Fingerprint</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in targets %}
|
||||
{% set may_edit = current_user.has(edit_right[row.kind]) %}
|
||||
<tr data-filter-text="{{ row.label }} {{ row.host }} {{ row.kind }}
|
||||
{% for key in row.host_keys %}{{ key.fingerprint }} {{ key.key_type }}{% endfor %}">
|
||||
<td class="fw-600">
|
||||
<a href="{{ url_for(row.detail_endpoint, device_id=row.id) if row.kind == 'client'
|
||||
else url_for(row.detail_endpoint, switch_id=row.id) }}">{{ row.label }}</a>
|
||||
</td>
|
||||
<td class="fs-sm">
|
||||
{% if row.kind == 'switch' %}{{ pill('Switch', 'info') }}
|
||||
{% else %}{{ pill('Client', '') }}{% endif %}
|
||||
</td>
|
||||
<td class="mono fs-sm nowrap">{{ row.host }}:{{ row.port }}</td>
|
||||
<td data-sort-value="{{ '1' if row.trusted else '0' }}">
|
||||
{% if row.trusted %}{{ pill('freigegeben', 'online') }}
|
||||
{% else %}{{ pill('offen', 'danger') }}{% endif %}
|
||||
</td>
|
||||
<td class="fs-xs mono break-all" style="max-width:420px">
|
||||
{% for key in row.host_keys %}
|
||||
<div>{{ key.key_type }} {{ key.fingerprint }}</div>
|
||||
{% else %}
|
||||
<span class="faint">–</span>
|
||||
{% endfor %}
|
||||
</td>
|
||||
<td class="nowrap">
|
||||
{% if may_edit %}
|
||||
<div class="row row--tight">
|
||||
<form method="post" data-no-guard
|
||||
action="{{ url_for('hostkeys.probe', kind=row.kind, target_id=row.id) }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<button class="btn btn--sm" type="submit" data-busy-label="Lese ...">
|
||||
{{ icon('refresh', size=14) }} Auslesen
|
||||
</button>
|
||||
</form>
|
||||
{% if probed_fingerprint and probed_host == row.host
|
||||
and probed_port == row.port | string %}
|
||||
<form method="post"
|
||||
action="{{ url_for('hostkeys.trust', kind=row.kind, target_id=row.id) }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="key_type" value="{{ probed_type }}">
|
||||
<input type="hidden" name="fingerprint" value="{{ probed_fingerprint }}">
|
||||
<button class="btn btn--sm btn--primary" type="submit"
|
||||
data-confirm="Fingerprint {{ probed_fingerprint }} für {{ row.label }} dauerhaft als vertrauenswürdig hinterlegen?"
|
||||
data-confirm-title="Host-Schlüssel freigeben"
|
||||
data-confirm-ok="Freigeben" data-confirm-style="primary">
|
||||
{{ icon('check', size=14) }} Freigeben
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if row.trusted %}
|
||||
<form method="post" data-no-guard
|
||||
action="{{ url_for('hostkeys.forget', kind=row.kind, target_id=row.id) }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<button class="btn btn--sm btn--ghost" type="submit"
|
||||
data-confirm="Freigabe für {{ row.label }} entfernen? Wartung und Konsole verweigern danach die Verbindung, bis erneut freigegeben wurde."
|
||||
data-confirm-title="Freigabe verwerfen" data-confirm-ok="Verwerfen">
|
||||
Verwerfen
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<span class="faint fs-xs">keine Berechtigung</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% if not targets %}
|
||||
{{ empty_state('Keine Ziele', 'Legen Sie zuerst Clients oder Switche an.', 'shield') }}
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
{% if orphans %}
|
||||
<section class="card card--danger">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('alert', size=17) }} Verwaiste Freigaben</div>
|
||||
<div class="card__sub">
|
||||
Zu diesen Einträgen gibt es kein Ziel mehr – gelöscht oder mit neuer IP.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table class="table table--compact">
|
||||
<thead><tr><th>Adresse</th><th>Typ</th><th>Fingerprint</th><th>Freigegeben</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for row in orphans %}
|
||||
<tr>
|
||||
<td class="mono fs-sm nowrap">{{ row.host }}:{{ row.port }}</td>
|
||||
<td class="fs-sm">{{ row.key_type }}</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>
|
||||
{% if current_user.has('devices.write') or current_user.has('switches.write') %}
|
||||
<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="host" value="{{ row.host }}">
|
||||
<input type="hidden" name="port" value="{{ row.port }}">
|
||||
<button class="btn btn--sm btn--ghost" type="submit"
|
||||
data-confirm="Verwaiste Freigabe für {{ row.host }}:{{ row.port }} entfernen?"
|
||||
data-confirm-title="Eintrag entfernen" data-confirm-ok="Entfernen">
|
||||
Entfernen
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,325 @@
|
||||
{% extends "tesm_core/base.html" %}
|
||||
{% from "tesm_core/macros.html" import field, checkbox, alert, pill, empty_state %}
|
||||
|
||||
{% block page_title %}Lizenz{% endblock %}
|
||||
{% block page_sub %}
|
||||
{% if state and state.valid and state.activated %}aktiv
|
||||
{% elif state and state.valid %}gültig, noch nicht aktiviert
|
||||
{% elif state and state.present %}ungültig
|
||||
{% else %}keine Lizenz installiert{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% if state and state.error %}{{ alert(state.error, 'danger') }}{% endif %}
|
||||
{% for warning in (state.warnings if state else []) %}{{ alert(warning, 'warning') }}{% endfor %}
|
||||
|
||||
<div class="grid grid--split">
|
||||
<div class="stack">
|
||||
{% if state and state.valid and state.status %}
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">
|
||||
{{ icon('key', size=17) }} Installierte Lizenz
|
||||
{% if state.activated %}{{ pill('aktiviert', 'online') }}
|
||||
{% else %}{{ pill('nicht aktiviert', 'warning') }}{% endif %}
|
||||
{% if state.issuer_pinned %}{{ pill('Aussteller gepinnt', 'info') }}{% endif %}
|
||||
</div>
|
||||
<div class="card__sub">
|
||||
{{ type_labels.get(state.status.type, state.status.type) }} ·
|
||||
{{ role_labels.get(state.role, state.role) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card__body">
|
||||
<dl class="kv fs-sm">
|
||||
<dt>Kunde</dt><dd>{{ state.customer_name or '–' | safe }}</dd>
|
||||
<dt>Lizenz-ID</dt><dd class="mono fs-xs break-all">{{ state.status.license_id }}</dd>
|
||||
<dt>Revision</dt><dd>{{ state.status.seq }}</dd>
|
||||
<dt>Ausgestellt</dt><dd>{{ state.status.issued_at | dt }}</dd>
|
||||
<dt>Läuft ab</dt>
|
||||
<dd>
|
||||
{% if state.status.lifetime %}{{ pill('unbefristet', 'online') }}
|
||||
{% else %}
|
||||
{{ state.status.expires_at | dt }}
|
||||
{% if state.status.expired %}
|
||||
{{ pill('abgelaufen seit ' ~ state.status.days_since_expiry ~ ' Tagen', 'danger') }}
|
||||
{% if state.status.grace_active %}
|
||||
<div class="fs-xs warning-text">
|
||||
Kulanzfrist läuft ({{ grace_days }} Tage) – Funktionen bleiben vorerst nutzbar.
|
||||
</div>
|
||||
{% endif %}
|
||||
{% elif state.status.expiring_soon %}
|
||||
{{ pill('in ' ~ state.status.days_left ~ ' Tagen', 'warning') }}
|
||||
{% else %}
|
||||
{{ pill('in ' ~ state.status.days_left ~ ' Tagen', 'online') }}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</dd>
|
||||
<dt>Module</dt>
|
||||
<dd>
|
||||
{% if state.modules %}
|
||||
{% for module in modules %}
|
||||
{% 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 %}
|
||||
{% 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 %}
|
||||
</dd>
|
||||
<dt>Lizenzserver</dt>
|
||||
<dd class="break-all">{{ state.status.master_endpoint or 'nicht hinterlegt (nur Offline-Weg)' }}</dd>
|
||||
<dt>Letzter Heartbeat</dt>
|
||||
<dd>
|
||||
{{ state.last_heartbeat_at | dt }}
|
||||
{% if state.heartbeat_error %}
|
||||
<div class="fs-xs danger-text">{{ state.heartbeat_error }}</div>
|
||||
{% endif %}
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
{% if current_user.has('settings_license.write') %}
|
||||
<div class="card__footer">
|
||||
{% if not state.activated %}
|
||||
<form method="post" action="{{ url_for('license.activate') }}" data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<button class="btn btn--primary" type="submit" data-busy-label="Aktiviere ...">
|
||||
Lizenz aktivieren
|
||||
</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<form method="post" action="{{ url_for('license.heartbeat') }}" data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<button class="btn btn--sm" type="submit">{{ icon('refresh', size=14) }} Jetzt prüfen</button>
|
||||
</form>
|
||||
<form method="post" action="{{ url_for('license.deactivate') }}" data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<button class="btn btn--sm btn--ghost danger-text" type="submit"
|
||||
data-confirm="Lizenz auf diesem System deaktivieren? Lizenzpflichtige Funktionen werden abgeschaltet; danach kann die Lizenz auf einem anderen System aktiviert werden."
|
||||
data-confirm-title="Lizenz deaktivieren" data-confirm-ok="Deaktivieren">
|
||||
Deaktivieren (Systemwechsel)
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if pending %}
|
||||
<section class="card card--accent">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('transfer', size=17) }} Offene Anfrage (Offline-Weg)</div>
|
||||
<div class="card__sub">
|
||||
Aktion: {{ pending.action }} · erzeugt {{ pending.created_at | dt }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card__body stack">
|
||||
{% if pending.reason %}
|
||||
{{ alert(pending.reason, 'warning', 'Warum der Online-Weg nicht ging') }}
|
||||
{% endif %}
|
||||
{{ alert('Beide Codes sind signiert -- sie lassen sich unterwegs nicht verändern.
|
||||
Der Anfragecode gehört zu genau dieser Maschine und genau dieser Anfrage; eine
|
||||
Antwort auf einen anderen Code wird abgelehnt.', 'info') }}
|
||||
|
||||
{% if pending.code %}
|
||||
<div class="field">
|
||||
<span class="field__label">
|
||||
Anfragecode – an den Anbieter übermitteln
|
||||
</span>
|
||||
<pre class="log-view" id="offline-request" style="max-height:14dvh">{{ pending.code }}</pre>
|
||||
<div class="row row--end mt-2">
|
||||
<button class="btn btn--sm" type="button" data-copy="#offline-request">
|
||||
{{ icon('copy', size=14) }} Code kopieren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
{{ alert('Zu dieser Anfrage liegt kein Code vor -- sie stammt aus einer älteren
|
||||
Fassung. Lösen Sie die Aktion erneut aus, dann entsteht ein neuer Code.',
|
||||
'warning') }}
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="{{ url_for('license.offline_response') }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<label class="field">
|
||||
<span class="field__label">Antwortcode des Lizenzservers</span>
|
||||
<textarea class="textarea input--mono" name="code" rows="4" required
|
||||
placeholder="Code einfügen ..."></textarea>
|
||||
</label>
|
||||
<div class="row row--end mt-3">
|
||||
<button class="btn btn--primary" type="submit">Antwort übernehmen</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if current_user.has('settings_license.write') %}
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('shield', size=17) }} Zertifikat des Lizenzservers</div>
|
||||
<div class="card__sub">
|
||||
Nur nötig, wenn der Lizenzserver ein eigenes Zertifikat benutzt
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card__body stack">
|
||||
{{ alert('Ein Lizenzserver im eigenen Netz hat meist ein selbstsigniertes
|
||||
Zertifikat. Ohne Anker scheitert die Prüfung, und jeder Versuch fällt auf den
|
||||
Offline-Weg zurück. Die Prüfung abzuschalten wäre der falsche Weg -- hinterlegen
|
||||
Sie stattdessen das Zertifikat, dann wird gegen genau dieses geprüft.', 'info') }}
|
||||
<form method="post" action="{{ url_for('license.server_certificate') }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<label class="field">
|
||||
<span class="field__label">Zertifikat (PEM)</span>
|
||||
<textarea class="textarea input--mono" name="certificate" rows="6"
|
||||
placeholder="-----BEGIN CERTIFICATE----- ... -----END CERTIFICATE-----"
|
||||
>{{ server_certificate }}</textarea>
|
||||
<span class="field__hint">
|
||||
Leer lassen, um wieder gegen die Systemanker zu prüfen. Auf dem Lizenzserver
|
||||
liegt es unter <span class="mono">/etc/tesm/certs/<name>/fullchain.pem</span>.
|
||||
</span>
|
||||
</label>
|
||||
<div class="row row--end mt-3">
|
||||
<button class="btn btn--primary" type="submit">Speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if current_user.has('settings_license.write') %}
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('upload', size=17) }} Lizenzdatei einspielen</div>
|
||||
<div class="card__sub">
|
||||
Die Datei enthält das Lizenzdokument und den zugehörigen Client-Schlüssel
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" enctype="multipart/form-data" data-no-guard
|
||||
action="{{ url_for('license.upload') }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<div class="card__body stack">
|
||||
<input class="input" type="file" name="license_file" accept=".json,.lic" required>
|
||||
{% if trusted_keys and current_user.is_admin %}
|
||||
{{ checkbox('allow_new_issuer', 'Wechsel des Ausstellers ausdrücklich zulassen',
|
||||
hint_text='Nur ankreuzen, wenn die neue Lizenz bewusst von einem anderen
|
||||
Lizenzserver kommt.') }}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card__footer">
|
||||
<button class="btn btn--primary" type="submit">Einspielen</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="stack">
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('hard-drive', size=17) }} Diese Maschine</div>
|
||||
<div class="card__sub">Wird zur Bindung der Lizenz verwendet</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card__body">
|
||||
<dl class="kv fs-sm">
|
||||
<dt>Hostname</dt><dd class="mono">{{ hostname }}</dd>
|
||||
<dt>Fingerprint</dt><dd class="mono fs-xs break-all">{{ fingerprint }}</dd>
|
||||
</dl>
|
||||
<p class="fs-xs faint mt-3">
|
||||
Der Fingerprint stammt aus der stabilen Maschinenidentität. Eine Umbenennung des Hosts
|
||||
ändert ihn <strong>nicht</strong> mehr – im Vorgänger führte genau das zu
|
||||
„bereits anderweitig gebunden“.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('shield', size=17) }} Vertrauenswürdige Aussteller</div>
|
||||
<div class="card__sub">{{ trusted_keys | length }} gepinnte Schlüssel</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if trusted_keys %}
|
||||
<div class="table-wrap">
|
||||
<table class="table table--compact">
|
||||
<thead><tr><th>Schlüssel-ID</th><th>Bezeichnung</th><th>Quelle</th><th class="actions"></th></tr></thead>
|
||||
<tbody>
|
||||
{% for key in trusted_keys %}
|
||||
<tr>
|
||||
<td class="mono fs-xs">{{ key.key_id }}</td>
|
||||
<td class="fs-sm">{{ key.label }}
|
||||
<div class="fs-xs faint">{{ key.added_at | dt }}</div></td>
|
||||
<td class="fs-xs">
|
||||
{{ key.source | issuer_source }}
|
||||
{% set erklaerung = key.source | issuer_source_hint %}
|
||||
{% if erklaerung %}<div class="faint">{{ erklaerung }}</div>{% endif %}
|
||||
</td>
|
||||
<td class="actions">
|
||||
{% if current_user.is_admin %}
|
||||
<form method="post" data-no-guard
|
||||
action="{{ url_for('license.remove_trusted_key', key_id=key.key_id) }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<button class="btn btn--sm btn--ghost danger-text" type="submit"
|
||||
data-confirm="Diesen Aussteller-Schlüssel entfernen? Danach gilt die von ihm ausgestellte Lizenz als nicht vertrauenswürdig."
|
||||
data-confirm-title="Aussteller entfernen" data-confirm-ok="Entfernen">
|
||||
{{ icon('trash', size=14) }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
{{ empty_state('Kein Aussteller gepinnt',
|
||||
'Beim ersten Import wird der Schlüssel des Lizenzservers übernommen und danach
|
||||
erwartet.', 'shield') }}
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('puzzle', size=17) }} Was die Lizenz freischaltet</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card__body">
|
||||
<ul class="stack stack--tight fs-sm">
|
||||
{% for key, label in features.items() %}
|
||||
<li class="row row--tight">
|
||||
{% if feature_allowed(key) %}
|
||||
<span class="success-text">{{ icon('check', size=14) }}</span>
|
||||
{% else %}
|
||||
<span class="faint">{{ icon('lock', size=14) }}</span>
|
||||
{% endif %}
|
||||
<span>{{ label }}</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<p class="fs-xs faint mt-3">
|
||||
Diese Liste ist die einzige Wahrheit: was hier steht, wird serverseitig durchgesetzt --
|
||||
nicht nur in der Oberfläche ausgegraut.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,14 @@
|
||||
{% extends "tesm_core/base.html" %}
|
||||
{% from "tesm_core/macros.html" import alert %}
|
||||
{% block page_title %}DHCP-Protokoll{% endblock %}
|
||||
{% block page_sub %}{{ path }}{% endblock %}
|
||||
{% block content %}
|
||||
{% if error %}{{ alert(error, 'warning') }}{% endif %}
|
||||
{{ alert('Das Protokoll liegt unter /var/log/kea/. Es darf nicht in das
|
||||
Protokollverzeichnis der Anwendung wandern: das AppArmor-Profil von Kea erlaubt
|
||||
ausschliesslich diesen fest einprogrammierten Pfad.', 'info') }}
|
||||
<section class="card">
|
||||
<div class="card__header"><div class="flex-1"><div class="card__title">{{ icon('network', size=17) }} Letzte Zeilen</div></div></div>
|
||||
{% include "tesm_core/_log_lines.html" %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,63 @@
|
||||
{% extends "tesm_core/base.html" %}
|
||||
{% from "tesm_core/macros.html" import pill, empty_state, alert, search_box %}
|
||||
{% block page_title %}Neustarts{% endblock %}
|
||||
{% block page_sub %}{{ rows | length }} Neustarts (neueste zuerst){% endblock %}
|
||||
{% block topbar_extra %}{{ search_box('restart-table', 'Gerät, Ergebnis, Methode ...') }}{% endblock %}
|
||||
{% block content %}
|
||||
{{ alert('Jeder Neustart wird mit Methode, Ergebnis, Dauer und Auslöser festgehalten -- automatisch
|
||||
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') }}
|
||||
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">Verlauf</div>
|
||||
<div class="card__sub">
|
||||
{% for value, label in methods %}
|
||||
<a class="btn btn--sm {{ 'btn--ghost' if method != value else '' }}"
|
||||
href="{{ url_for('tesmlogs.restarts', method=value) }}">{{ label }}</a>
|
||||
{% endfor %}
|
||||
<a class="btn btn--sm {{ 'btn--ghost' if method else '' }}"
|
||||
href="{{ url_for('tesmlogs.restarts') }}">Alle</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table class="table table--compact" id="restart-table" data-behavior="sortable">
|
||||
<thead><tr>
|
||||
<th class="sortable">Zeit</th><th class="sortable">Gerät</th>
|
||||
<th class="sortable">Methode</th><th class="sortable">Auslöser</th>
|
||||
<th class="sortable">Benutzer</th><th class="sortable">Ergebnis</th>
|
||||
<th class="sortable numeric">Dauer</th><th>Detail</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{% for row in rows %}
|
||||
<tr data-filter-text="{{ row.device_name }} {{ row.result }} {{ row.actor }} {{ method_labels.get(row.method, row.method) }} {{ row.detail }}">
|
||||
<td class="fs-sm nowrap" data-sort-value="{{ row.ts }}">{{ row.ts | dt }}</td>
|
||||
<td class="fw-600 fs-sm">{{ row.device_name or '–' | safe }}</td>
|
||||
<td class="fs-sm">
|
||||
{# Kein if/else mit zwei Zweigen: es gibt drei Wege, und alles
|
||||
ausser 'ssh' als "PoE-Port" anzuzeigen hat RPC-Neustarts
|
||||
jahrelang als PoE ausgegeben. #}
|
||||
{% if row.method == 'ssh' %}{{ pill('SSH', 'info') }}
|
||||
{% elif row.method == 'rpc' %}{{ pill('RPC', 'accent') }}
|
||||
{% elif row.method == 'pö' %}{{ pill('PoE-Port', '') }}
|
||||
{% else %}{{ pill(row.method or 'unbekannt', 'warning') }}{% endif %}
|
||||
</td>
|
||||
<td class="fs-sm">{{ row.trigger }}</td>
|
||||
<td class="fs-sm">{{ row.actor }}</td>
|
||||
<td>
|
||||
{% if row.result == 'ok' %}{{ pill('erfolgreich', 'online') }}
|
||||
{% elif row.result == 'skipped' %}{{ pill('übersprungen', 'warning') }}
|
||||
{% else %}{{ pill('fehlgeschlagen', 'danger') }}{% endif %}
|
||||
</td>
|
||||
<td class="numeric fs-sm nowrap">{{ row.duration_ms | millis }}</td>
|
||||
<td class="fs-xs muted break-all" style="max-width:420px">{{ row.detail }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% if not rows %}{{ empty_state('Noch keine Neustarts', '', 'power') }}{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,219 @@
|
||||
{% extends "tesm_core/base.html" %}
|
||||
{% from "tesm_core/macros.html" import pill, empty_state, alert, search_box %}
|
||||
|
||||
{% block page_title %}Wartung{% endblock %}
|
||||
{% block page_sub %}
|
||||
{{ devices | length }} Geräte mit Zugangsdaten der Kategorie
|
||||
„Linux-Client“ oder „Windows-Client“
|
||||
{% endblock %}
|
||||
|
||||
{% block topbar_extra %}{{ search_box('maintenance-table', 'Gerät suchen ...') }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% if devices | rejectattr('host_key_ready') | list %}
|
||||
{{ alert('Für mindestens ein Gerät ist der SSH-Host-Schlüssel noch nicht freigegeben.
|
||||
Die Wartung bricht dort mit "Schlüssel unbekannt" ab. Die Freigabe erfolgt auf der
|
||||
Detailseite des Geräts -- erst auslesen, Fingerprint prüfen, dann freigeben.',
|
||||
'warning', 'Host-Schlüssel offen') }}
|
||||
{% endif %}
|
||||
|
||||
{% if windows_tool_missing %}
|
||||
{# Die Meldung im Auftrag nennt das fehlende Paket -- ohne diesen Knopf wäre sie
|
||||
eine Sackgasse. #}
|
||||
<div class="alert alert--warning">
|
||||
{{ icon('alert', size=18) }}
|
||||
<div class="alert__body">
|
||||
<strong>Windows-Neustart nicht möglich</strong>
|
||||
Auf diesem Server fehlt das Paket <code>{{ windows_package }}</code>. Es bringt
|
||||
<code>net</code> mit, über das der Neustart eines Windows-Gerätes läuft.
|
||||
</div>
|
||||
<form method="post" action="{{ url_for('maintenance.install_windows_tool') }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<button class="btn btn--sm" type="submit"
|
||||
data-confirm="Paket {{ windows_package }} jetzt installieren?"
|
||||
data-confirm-title="Paket nachinstallieren" data-confirm-ok="Installieren">
|
||||
Nachinstallieren
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{{ alert('Welche Geräte hier erscheinen und was mit ihnen möglich ist, entscheidet die
|
||||
Kategorie ihrer Zugangsdaten. Linux-Clients werden über SSH aktualisiert und neu gestartet.
|
||||
Windows-Clients werden nur neu gestartet, und zwar über RPC (wie "shutdown /m") -- sie
|
||||
brauchen dafür weder SSH noch einen Host-Schlüssel, sondern die offene Datei- und
|
||||
Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Port.',
|
||||
'info') }}
|
||||
|
||||
<form method="post" id="maintenance-form" action="{{ url_for('maintenance.start_update') }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<section class="card">
|
||||
<div class="table-toolbar">
|
||||
<label class="check">
|
||||
<input type="checkbox" id="select-all">
|
||||
<span>Alle auswählen</span>
|
||||
</label>
|
||||
<span class="table-toolbar__spacer"></span>
|
||||
{% if current_user.has('servicing_update.execute') %}
|
||||
<button class="btn btn--sm btn--primary" type="submit"
|
||||
formaction="{{ url_for('maintenance.start_update') }}"
|
||||
title="Nur für Linux-Clients -- Windows-Geräte werden dabei übersprungen."
|
||||
data-confirm="Paketaktualisierung auf den ausgewählten Geräten starten?"
|
||||
data-confirm-title="Aktualisieren" data-confirm-ok="Starten" data-confirm-style="primary">
|
||||
{{ icon('download', size=15) }} Aktualisieren
|
||||
</button>
|
||||
<button class="btn btn--sm btn--danger" type="submit"
|
||||
formaction="{{ url_for('maintenance.start_reboot') }}"
|
||||
data-confirm="Die ausgewählten Geräte jetzt neu starten? Sie sind dabei kurzzeitig nicht erreichbar."
|
||||
data-confirm-title="Neustarten" data-confirm-ok="Neu starten">
|
||||
{{ icon('refresh', size=15) }} Neustarten
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<table class="table" id="maintenance-table" data-behavior="sortable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:44px"></th>
|
||||
<th class="sortable">Gerät</th>
|
||||
<th class="sortable">IP</th>
|
||||
<th class="sortable">Zustand</th>
|
||||
<th class="sortable">Zugangsdaten</th>
|
||||
<th class="sortable">Host-Schlüssel</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for device in devices %}
|
||||
<tr data-filter-text="{{ device.name }} {{ device.ip }} {{ device.credential_name }}">
|
||||
<td>
|
||||
<label class="check">
|
||||
<input type="checkbox" name="device_ids" value="{{ device.id }}"
|
||||
class="device-select" data-category="{{ device.category }}"
|
||||
{% if not current_user.has('servicing_update.execute') %}disabled{% endif %}>
|
||||
<span class="sr-only">{{ device.name }} auswählen</span>
|
||||
</label>
|
||||
</td>
|
||||
<td class="fw-600">{{ device.name }}</td>
|
||||
<td class="mono fs-sm">{{ device.ip }}</td>
|
||||
<td>
|
||||
{% if device.state == 'online' %}{{ pill('online', 'online') }}
|
||||
{% elif device.state == 'offline' %}{{ pill('offline', 'offline') }}
|
||||
{% else %}{{ pill('unbekannt', 'warning') }}{% endif %}
|
||||
</td>
|
||||
<td class="fs-sm">
|
||||
{{ device.credential_name }}
|
||||
<div class="fs-xs faint">{{ category_labels.get(device.category, device.category) }}</div>
|
||||
</td>
|
||||
<td>
|
||||
{% if not device.ssh_managed %}
|
||||
<span class="faint fs-xs" title="Windows wird über RPC verwaltet, nicht über SSH">
|
||||
nicht nötig
|
||||
</span>
|
||||
{% elif device.host_key_ready %}{{ pill('freigegeben', 'online') }}
|
||||
{% else %}
|
||||
<a href="{{ url_for('devices.detail', device_id=device.id) }}"
|
||||
title="Ohne freigegebenen Schlüssel bricht die Wartung ab">
|
||||
{{ pill('offen', 'danger') }}
|
||||
</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="nowrap">
|
||||
{% if terminal_enabled and device.ssh_managed
|
||||
and current_user.has('servicing_update.execute') %}
|
||||
<a class="btn btn--sm btn--ghost"
|
||||
href="{{ url_for('terminal.device_console', device_id=device.id) }}">
|
||||
{{ icon('terminal', size=14) }} Konsole
|
||||
</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% if not devices %}
|
||||
{{ empty_state('Keine passenden Geräte',
|
||||
'Setzen Sie bei den Zugangsdaten die Kategorie "Linux-Client" und ordnen Sie
|
||||
sie einem Gerät zu.', 'wrench') }}
|
||||
{% endif %}
|
||||
</section>
|
||||
</form>
|
||||
|
||||
{# Kein data-refresh-url: die Schleife in app.js schreibt die Antwort in
|
||||
innerHTML. Hier zeigte sie auf die Seite selbst -- die komplette
|
||||
Wartungsseite landete damit in dieser Karte, mitsamt Seitenleiste und
|
||||
Suchfeld. Aktualisiert wird über die Abfrage unten, die bei laufenden
|
||||
Aufträgen die ganze Seite neu lädt. #}
|
||||
<section class="card" id="jobs">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('clock', size=17) }} Aufträge</div>
|
||||
<div class="card__sub">
|
||||
Zustand liegt in der Datenbank – ein Dienst-Neustart verliert ihn nicht mehr
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if jobs %}
|
||||
<div class="table-wrap">
|
||||
<table class="table table--compact">
|
||||
<thead><tr><th>Gestartet</th><th>Gerät</th><th>Art</th><th>Zustand</th><th>Fortschritt</th><th>Meldung</th></tr></thead>
|
||||
<tbody>
|
||||
{% for job in jobs %}
|
||||
<tr>
|
||||
<td class="fs-sm nowrap">{{ job.created_at | dt }}</td>
|
||||
<td class="fs-sm fw-600">
|
||||
<a href="{{ url_for('maintenance.job_detail', job_id=job.id) }}">{{ job.subject }}</a>
|
||||
</td>
|
||||
<td class="fs-sm">{{ 'Aktualisierung' if job.kind.endswith('update') else 'Neustart' }}</td>
|
||||
<td>
|
||||
{% if job.state == 'succeeded' %}{{ pill('erfolgreich', 'online') }}
|
||||
{% elif job.state == 'failed' %}{{ pill('fehlgeschlagen', 'danger') }}
|
||||
{% elif job.state == 'running' %}{{ pill('läuft', 'info') }}
|
||||
{% elif job.state == 'unknown' %}{{ pill('unklar', 'warning') }}
|
||||
{% else %}{{ pill('wartet', '') }}{% endif %}
|
||||
</td>
|
||||
<td style="min-width:120px">
|
||||
<div class="progress {{ 'progress--success' if job.state == 'succeeded' else ('progress--danger' if job.state == 'failed' else '') }}">
|
||||
<div class="progress__bar" style="width: {{ job.progress }}%"></div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="fs-xs muted">{{ job.message }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
{{ empty_state('Keine Aufträge', 'Es wurde noch keine Wartung gestartet.', 'clock') }}
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script type="module" nonce="{{ csp_nonce }}">
|
||||
// Auswahl beim Laden bewusst zurücksetzen: Browser stellen den Checked-Zustand
|
||||
// sonst aus dem Verlauf wieder her – und dann wird bei "Neustarten"
|
||||
// versehentlich das falsche Gerät erwischt.
|
||||
const boxes = Array.from(document.querySelectorAll(".device-select"));
|
||||
boxes.forEach((box) => (box.checked = false));
|
||||
|
||||
const all = document.getElementById("select-all");
|
||||
all?.addEventListener("change", () => {
|
||||
boxes.filter((box) => !box.disabled && !box.closest("tr").hidden)
|
||||
.forEach((box) => (box.checked = all.checked));
|
||||
});
|
||||
|
||||
// Auftragsliste nachladen, solange etwas läuft.
|
||||
async function poll() {
|
||||
try {
|
||||
const state = await window.tesm.api("{{ url_for('maintenance.status') }}");
|
||||
if (state.running) window.setTimeout(() => location.reload(), state.poll_ms);
|
||||
} catch {
|
||||
/* stumm bleiben – die Seite ist auch ohne Aktualisierung nutzbar */
|
||||
}
|
||||
}
|
||||
poll();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,52 @@
|
||||
{% extends "tesm_core/base.html" %}
|
||||
{% from "tesm_core/macros.html" import pill %}
|
||||
{% block page_title %}{{ job.subject }}{% endblock %}
|
||||
{% block page_sub %}{{ 'Aktualisierung' if job.kind.endswith('update') else 'Neustart' }} · {{ job.created_at | dt }}{% endblock %}
|
||||
{% block breadcrumb %}
|
||||
<nav class="breadcrumb">
|
||||
<span><a href="{{ url_for('maintenance.overview') }}">Wartung</a></span><span>{{ job.subject }}</span>
|
||||
</nav>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">
|
||||
{{ icon('wrench', size=17) }} Auftrag
|
||||
{% if job.state == 'succeeded' %}{{ pill('erfolgreich', 'online') }}
|
||||
{% elif job.state == 'failed' %}{{ pill('fehlgeschlagen', 'danger') }}
|
||||
{% elif job.state == 'running' %}{{ pill('läuft', 'info') }}
|
||||
{% elif job.state == 'unknown' %}{{ pill('unklar', 'warning') }}
|
||||
{% else %}{{ pill('wartet', '') }}{% endif %}
|
||||
</div>
|
||||
<div class="card__sub">{{ job.message }}</div>
|
||||
</div>
|
||||
<span class="fs-xs faint">gestartet von {{ job.started_by }}</span>
|
||||
</div>
|
||||
<div class="card__body">
|
||||
<div class="progress {{ 'progress--success' if job.state == 'succeeded' else ('progress--danger' if job.state == 'failed' else '') }}">
|
||||
<div class="progress__bar" style="width: {{ job.progress }}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="job-output"
|
||||
{% if job.state in ('pending', 'running') %}
|
||||
data-refresh-url="{{ url_for('maintenance.job_output', job_id=job.id) }}"
|
||||
data-refresh-interval="3000"
|
||||
{% endif %}>
|
||||
{% include "tesm/_maintenance_output.html" %}
|
||||
</div>
|
||||
</section>
|
||||
<div class="row row--end">
|
||||
<a class="btn" href="{{ url_for('maintenance.overview') }}">Zurück</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script type="module" nonce="{{ csp_nonce }}">
|
||||
// Beim Öffnen ans Ende springen: bei einem laufenden Auftrag ist die
|
||||
// jüngste Zeile die interessante, nicht die erste. Das Nachführen beim
|
||||
// Aktualisieren erledigt die Schleife in app.js.
|
||||
const view = document.querySelector("#job-output .log-view");
|
||||
if (view) view.scrollTop = view.scrollHeight;
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -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 %}
|
||||
@@ -0,0 +1,236 @@
|
||||
{% extends "tesm_core/base.html" %}
|
||||
{% from "tesm_core/macros.html" import field, select_field, checkbox, alert, pill, nav_order_card %}
|
||||
|
||||
{% block page_title %}Systemeinstellungen{% endblock %}
|
||||
{% block page_sub %}Überwachung, Protokolle, Host, Navigation{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% if not sysops_available %}
|
||||
{{ alert('Systemhelfer nicht verfügbar -- Hostname und Zeitzone können hier nur angezeigt,
|
||||
nicht geändert werden. In einer Entwicklungsumgebung ist das der erwartete Zustand.',
|
||||
'info') }}
|
||||
{% endif %}
|
||||
|
||||
<div class="grid grid--split">
|
||||
<div class="stack">
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('activity', size=17) }} Überwachung</div>
|
||||
<div class="card__sub">Wie oft geprüft und wann automatisch geschaltet wird</div>
|
||||
</div>
|
||||
{% if current_user.has('settings_system.execute') %}
|
||||
<form method="post" action="{{ url_for('settings.run_check_now') }}" data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<button class="btn btn--sm" type="submit">{{ icon('refresh', size=14) }} Jetzt prüfen</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</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">
|
||||
{% if license_hint %}{{ alert(license_hint, 'warning') }}{% endif %}
|
||||
<div class="form-grid">
|
||||
{{ field('monitor_interval_seconds', 'Prüfintervall (Sekunden)',
|
||||
value=values.monitor_interval_seconds, type='number',
|
||||
disabled=bool(license_hint),
|
||||
hint_text=specs['monitor_interval_seconds'].hint) }}
|
||||
{{ field('monitor_failures_before_restart', 'Fehlversuche bis zum PoE-Neustart',
|
||||
value=values.monitor_failures_before_restart, type='number',
|
||||
hint_text=specs['monitor_failures_before_restart'].hint) }}
|
||||
</div>
|
||||
{{ checkbox('monitor_auto_restart', 'Automatischen PoE-Neustart bei Ausfall aktivieren',
|
||||
checked=values.monitor_auto_restart,
|
||||
hint_text='Zusätzlich lizenzpflichtig. Der manuelle Knopf im Dashboard
|
||||
funktioniert unabhängig davon.') }}
|
||||
{{ checkbox('dashboard_public', 'Statusübersicht ohne Anmeldung erreichbar',
|
||||
checked=values.dashboard_public,
|
||||
hint_text=specs['dashboard_public'].hint) }}
|
||||
{{ 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 class="card__footer"><button class="btn btn--primary" type="submit">Speichern</button></div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('pencil', size=17) }} Unterschriften</div>
|
||||
<div class="card__sub">
|
||||
Der Name in der Unterschrift ist eine Angabe des Unterschreibenden – wie auf
|
||||
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>
|
||||
<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('signature_witness_enabled', 'Unterschriften mit dem ablegenden Konto bezeugen',
|
||||
checked=values.signature_witness_enabled,
|
||||
hint_text=specs['signature_witness_enabled'].hint) }}
|
||||
</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 class="card__footer"><button class="btn btn--primary" type="submit">Speichern</button></div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('file-text', size=17) }} Protokolle und Papierkorb</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">
|
||||
{{ select_field('log_rotate_interval', 'Rotationsintervall',
|
||||
specs['log_rotate_interval'].choices, selected=values.log_rotate_interval) }}
|
||||
{{ field('log_rotate_keep', 'Aufbewahrte Rotationen', value=values.log_rotate_keep,
|
||||
type='number') }}
|
||||
{{ field('trash_retention_days', 'Papierkorb-Aufbewahrung (Tage)',
|
||||
value=values.trash_retention_days, type='number',
|
||||
hint_text=specs['trash_retention_days'].hint) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card__footer"><button class="btn btn--primary" type="submit">Speichern</button></div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="stack">
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1"><div class="card__title">{{ icon('server', size=17) }} Host</div></div>
|
||||
</div>
|
||||
<div class="card__body stack">
|
||||
<form method="post" action="{{ url_for('settings.set_hostname') }}" class="stack stack--tight">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
{{ field('hostname', 'Hostname', value=hostname, disabled=not sysops_available) }}
|
||||
<button class="btn btn--sm" type="submit" {% if not sysops_available %}disabled{% endif %}>
|
||||
Hostname setzen
|
||||
</button>
|
||||
</form>
|
||||
<div class="divider"></div>
|
||||
<form method="post" action="{{ url_for('settings.set_timezone') }}" class="stack stack--tight">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<label class="field">
|
||||
<span class="field__label">Zeitzone</span>
|
||||
<select class="select" name="timezone" {% if not sysops_available %}disabled{% endif %}>
|
||||
{% for zone in timezones %}
|
||||
<option value="{{ zone }}" {% if zone == timezone %}selected{% endif %}>{{ zone }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<span class="field__hint">Wirkt sofort auf die laufende Anwendung.</span>
|
||||
</label>
|
||||
<button class="btn btn--sm" type="submit" {% if not sysops_available %}disabled{% endif %}>
|
||||
Zeitzone setzen
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% if current_user.is_admin %}
|
||||
{{ nav_order_card(nav_baum, nav_order, nav_child_order,
|
||||
url_for('admin.nav_order'), csrf_field_name, csrf_token()) }}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,23 @@
|
||||
{% extends "tesm_core/base.html" %}
|
||||
{% block page_title %}{{ switch.hostname }}{% endblock %}
|
||||
{% block page_sub %}{{ command }}{% endblock %}
|
||||
{% block breadcrumb %}
|
||||
<nav class="breadcrumb">
|
||||
<span><a href="{{ url_for('switches.overview') }}">Switche</a></span>
|
||||
<span><a href="{{ url_for('switches.detail', switch_id=switch.id) }}">{{ switch.hostname }}</a></span>
|
||||
<span>Kommando</span>
|
||||
</nav>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1"><div class="card__title">{{ icon('terminal', size=17) }} Ausgabe</div>
|
||||
<div class="card__sub mono">{{ command }}</div></div>
|
||||
<button class="btn btn--sm" type="button" data-copy="#cmd-output">{{ icon('copy', size=14) }} Kopieren</button>
|
||||
</div>
|
||||
<pre class="log-view" id="cmd-output">{{ output }}</pre>
|
||||
</section>
|
||||
<div class="row row--end">
|
||||
<a class="btn" href="{{ url_for('switches.detail', switch_id=switch.id) }}">Zurück</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,184 @@
|
||||
{% extends "tesm_core/base.html" %}
|
||||
{% from "tesm_core/macros.html" import pill, empty_state, alert, select_field %}
|
||||
|
||||
{% block page_title %}{{ switch.hostname }}{% endblock %}
|
||||
{% block page_sub %}{{ switch.ip }}:{{ switch.ssh_port }}{% if switch.model %} · {{ switch.model }}{% endif %}{% endblock %}
|
||||
|
||||
{% block breadcrumb %}
|
||||
<nav class="breadcrumb">
|
||||
<span><a href="{{ url_for('switches.overview') }}">Switche</a></span><span>{{ switch.hostname }}</span>
|
||||
</nav>
|
||||
{% endblock %}
|
||||
|
||||
{% block topbar_extra %}
|
||||
{% 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) }}">
|
||||
{{ icon('terminal', size=15) }} Konsole
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="grid grid--split">
|
||||
<div class="stack">
|
||||
<section class="card {{ '' if host_keys else 'card--danger' }}">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">
|
||||
{{ icon('lock', size=17) }} SSH-Host-Schlüssel
|
||||
{% if host_keys %}{{ pill('freigegeben', 'online') }}{% else %}{{ pill('offen', 'danger') }}{% endif %}
|
||||
</div>
|
||||
<div class="card__sub">
|
||||
Ohne freigegebenen Schlüssel verweigert die Anwendung jeden Zugriff auf diesen Switch.
|
||||
</div>
|
||||
</div>
|
||||
{% if current_user.has('switches.write') %}
|
||||
<form method="post" action="{{ url_for('switches.probe_host_key', switch_id=switch.id) }}"
|
||||
data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<button class="btn btn--sm" type="submit">{{ icon('refresh', size=14) }} Auslesen</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card__body stack">
|
||||
{% if request.args.get('probed_fingerprint') %}
|
||||
<div class="alert alert--warning">
|
||||
{{ icon('alert', size=18) }}
|
||||
<div class="alert__body">
|
||||
<div class="alert__title">Gelesener Schlüssel</div>
|
||||
<div class="mono fs-sm break-all">
|
||||
{{ request.args.get('probed_type') }} {{ request.args.get('probed_fingerprint') }}
|
||||
</div>
|
||||
<p class="fs-sm mt-2">
|
||||
Vergleichen Sie den Fingerprint mit einer unabhängigen Quelle (Konsolenzugang am
|
||||
Switch, Inventardokumentation), bevor Sie ihn freigeben.
|
||||
</p>
|
||||
{% if current_user.has('switches.write') %}
|
||||
<form method="post" class="mt-2"
|
||||
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="key_type" value="{{ request.args.get('probed_type') }}">
|
||||
<input type="hidden" name="fingerprint" value="{{ request.args.get('probed_fingerprint') }}">
|
||||
<button class="btn btn--sm btn--primary" type="submit"
|
||||
data-confirm="Diesen Fingerprint dauerhaft als vertrauenswürdig hinterlegen?"
|
||||
data-confirm-title="Host-Schlüssel freigeben" data-confirm-ok="Freigeben"
|
||||
data-confirm-style="primary">Freigeben</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if host_keys %}
|
||||
<dl class="kv fs-sm">
|
||||
{% for key in host_keys %}
|
||||
<dt>{{ key.key_type }}</dt>
|
||||
<dd class="mono break-all">{{ key.fingerprint }}
|
||||
<div class="fs-xs faint">freigegeben {{ key.added_at | dt }} von {{ key.added_by }}</div>
|
||||
</dd>
|
||||
{% endfor %}
|
||||
</dl>
|
||||
{% if current_user.has('switches.write') %}
|
||||
<form method="post" action="{{ url_for('switches.forget_host_key', switch_id=switch.id) }}"
|
||||
data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<button class="btn btn--sm btn--ghost danger-text" type="submit"
|
||||
data-confirm="Gespeicherte Host-Schlüssel entfernen? Danach ist kein Zugriff mehr möglich, bis erneut freigegeben wird."
|
||||
data-confirm-title="Schlüssel verwerfen" data-confirm-ok="Verwerfen">
|
||||
Schlüssel verwerfen
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<p class="muted fs-sm">Noch kein Schlüssel hinterlegt. Bitte auslesen und prüfen.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% if current_user.has('switch_console.execute') %}
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('terminal', size=17) }} Kommando ausführen</div>
|
||||
<div class="card__sub">
|
||||
Positivliste lesender Kommandos – verlangt eine frische Passwortbestätigung
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" action="{{ url_for('switches.run_command', switch_id=switch.id) }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<div class="card__body">
|
||||
<label class="field">
|
||||
<span class="field__label">Kommando</span>
|
||||
<select class="select" name="command">
|
||||
{% for command, label in safe_commands %}
|
||||
<option value="{{ command }}">{{ label }} – {{ command }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="card__footer">
|
||||
<button class="btn btn--primary" type="submit" data-busy-label="Führe aus ...">
|
||||
Ausführen
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="stack">
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1"><div class="card__title">{{ icon('info', size=17) }} Stammdaten</div></div>
|
||||
</div>
|
||||
<div class="card__body">
|
||||
<dl class="kv fs-sm">
|
||||
<dt>Hostname</dt><dd>{{ switch.hostname }}</dd>
|
||||
<dt>IP</dt><dd class="mono">{{ switch.ip }}</dd>
|
||||
<dt>SSH-Port</dt><dd>{{ switch.ssh_port }}</dd>
|
||||
<dt>Zugangsdaten</dt><dd>{{ switch.credential_name or 'keine' }}</dd>
|
||||
<dt>Standort</dt><dd>{{ switch.location or '–' | safe }}</dd>
|
||||
<dt>Zuletzt gesehen</dt><dd>{{ switch.last_seen_at | dt }}</dd>
|
||||
<dt>Geändert</dt><dd>{{ switch.updated_at | dt }} <span class="faint">von {{ switch.updated_by or '-' }}</span></dd>
|
||||
</dl>
|
||||
{% if switch.notes %}<p class="fs-sm muted mt-3">{{ switch.notes }}</p>{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('cpu', size=17) }} Angeschlossene Geräte</div>
|
||||
<div class="card__sub">{{ devices | length }} zugeordnet</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if devices %}
|
||||
<div class="table-wrap">
|
||||
<table class="table table--compact">
|
||||
<thead><tr><th>Port</th><th>Gerät</th><th>Zustand</th></tr></thead>
|
||||
<tbody>
|
||||
{% for device in devices %}
|
||||
<tr>
|
||||
<td class="mono fs-sm">{{ device.port or '–' | safe }}</td>
|
||||
<td class="fs-sm">
|
||||
<a href="{{ url_for('devices.detail', device_id=device.id) }}">{{ device.name }}</a>
|
||||
<div class="fs-xs faint mono">{{ device.ip }}</div>
|
||||
</td>
|
||||
<td>
|
||||
{% if device.state == 'online' %}{{ pill('online', 'online') }}
|
||||
{% elif device.state == 'offline' %}{{ pill('offline', 'offline') }}
|
||||
{% else %}{{ pill('unbekannt', 'warning') }}{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
{{ empty_state('Keine Geräte', 'Diesem Switch ist noch kein Gerät zugeordnet.', 'cpu') }}
|
||||
{% endif %}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,152 @@
|
||||
{% extends "tesm_core/base.html" %}
|
||||
{% from "tesm_core/macros.html" import field, textarea_field, pill, search_box, modal,
|
||||
empty_state, alert %}
|
||||
|
||||
{% macro switch_form(switch=None) %}
|
||||
<div class="form-grid">
|
||||
{{ field('hostname', 'Hostname', value=switch.hostname if switch else '', required=True) }}
|
||||
{{ field('ip', 'IP-Adresse', value=switch.ip if switch else '', required=True) }}
|
||||
{{ field('ssh_port', 'SSH-Port', value=switch.ssh_port if switch else 22, type='number') }}
|
||||
<label class="field">
|
||||
<span class="field__label">Zugangsdaten</span>
|
||||
<select class="select" name="credential_id">
|
||||
<option value="">keine</option>
|
||||
{% for credential in credentials %}
|
||||
<option value="{{ credential.id }}"
|
||||
{% if switch and switch.credential_id == credential.id %}selected{% endif %}>
|
||||
{{ credential.name }} ({{ credential.username }})
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
{{ field('model', 'Modell', value=switch.model if switch else '', placeholder='z. B. Aruba 2930M') }}
|
||||
{{ field('location', 'Standort', value=switch.location if switch else '') }}
|
||||
{{ textarea_field('notes', 'Notizen', value=switch.notes if switch else '', rows=2,
|
||||
extra_class='field--full') }}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% block page_title %}Switche{% endblock %}
|
||||
{% block page_sub %}{{ switches | length }} Switche{% endblock %}
|
||||
|
||||
{% block topbar_extra %}
|
||||
{{ search_box('switches-table', 'Hostname, IP, Standort ...') }}
|
||||
{% if current_user.has('switches.create') %}
|
||||
<button class="btn btn--primary btn--sm" type="button" data-dialog-open="switch-new">
|
||||
{{ icon('plus', size=15) }} Switch anlegen
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{{ alert('Vor dem ersten Zugriff muss der SSH-Host-Schlüssel eines Switches ausdrücklich
|
||||
freigegeben werden. Unbekannte oder geänderte Schlüssel werden abgewiesen -- nicht
|
||||
stillschweigend übernommen.', 'info', 'Host-Schlüssel') }}
|
||||
|
||||
<section class="card">
|
||||
<div class="table-wrap">
|
||||
<table class="table" id="switches-table" data-behavior="sortable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="sortable">Hostname</th>
|
||||
<th class="sortable">IP</th>
|
||||
<th class="sortable numeric">Port</th>
|
||||
<th class="sortable">Zugangsdaten</th>
|
||||
<th class="sortable numeric">Geräte</th>
|
||||
<th class="sortable">Host-Schlüssel</th>
|
||||
<th class="actions">Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for switch in switches %}
|
||||
<tr data-filter-text="{{ switch.hostname }} {{ switch.ip }} {{ switch.location }} {{ switch.model }}">
|
||||
<td>
|
||||
<a class="fw-600" href="{{ url_for('switches.detail', switch_id=switch.id) }}">
|
||||
{{ switch.hostname }}
|
||||
</a>
|
||||
{% if switch.model %}<div class="fs-xs faint">{{ switch.model }}</div>{% endif %}
|
||||
</td>
|
||||
<td class="mono fs-sm">{{ switch.ip }}</td>
|
||||
<td class="numeric fs-sm">{{ switch.ssh_port }}</td>
|
||||
<td class="fs-sm">
|
||||
{% if switch.credential_name %}{{ switch.credential_name }}
|
||||
{% else %}<span class="faint">keine</span>{% endif %}
|
||||
</td>
|
||||
<td class="numeric">{{ switch.device_count }}</td>
|
||||
<td data-sort-value="{{ '1' if switch.host_key_known else '0' }}">
|
||||
{% if switch.host_key_known %}{{ pill('freigegeben', 'online') }}
|
||||
{% else %}
|
||||
<a href="{{ url_for('hostkeys.overview') }}"
|
||||
title="Ohne Freigabe verweigern Konsole und PoE-Schaltung die Verbindung">
|
||||
{{ pill('offen', 'warning') }}
|
||||
</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="actions">
|
||||
{% if terminal_enabled and current_user.has('switch_console.execute') and switch.host_key_known %}
|
||||
<a class="btn btn--sm btn--ghost" title="Konsole"
|
||||
href="{{ url_for('terminal.switch_console', switch_id=switch.id) }}">
|
||||
{{ icon('terminal', size=15) }}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if current_user.has('switches.write') %}
|
||||
<button class="btn btn--sm btn--ghost" type="button"
|
||||
data-dialog-open="switch-edit-{{ switch.id }}" aria-label="Bearbeiten">
|
||||
{{ icon('pencil', size=15) }}
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if current_user.has('switches.delete') %}
|
||||
<form method="post" action="{{ url_for('switches.delete', switch_id=switch.id) }}"
|
||||
style="display:inline" data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<button class="btn btn--sm btn--ghost danger-text" type="submit" aria-label="Löschen"
|
||||
data-confirm="{{ switch.hostname }} in den Papierkorb verschieben?"
|
||||
data-confirm-title="Switch löschen" data-confirm-ok="Löschen">
|
||||
{{ icon('trash', size=15) }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% if not switches %}
|
||||
{{ empty_state('Keine Switche', 'Legen Sie den ersten Switch an.', 'switch') }}
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
{# Die freigegebenen Host-Schlüssel standen hier ein zweites Mal. Gepflegt
|
||||
werden sie unter Überwachung -> Host-Schlüssel: dort mit Auslesen,
|
||||
Fingerprint-Vergleich, Freigeben, Verwerfen und Aufräumen. Eine reine
|
||||
Zweitanzeige lädt dazu ein, sie an der falschen Stelle zu suchen. #}
|
||||
{% endblock %}
|
||||
|
||||
{% block dialogs %}
|
||||
{% if current_user.has('switches.create') %}
|
||||
{% call modal('switch-new', 'Switch anlegen', 'Host-Schlüssel wird danach freigegeben') %}
|
||||
<form method="post" action="{{ url_for('switches.create') }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<div class="modal__body">{{ switch_form() }}</div>
|
||||
<div class="modal__footer">
|
||||
<button class="btn" type="button" data-dialog-close>Abbrechen</button>
|
||||
<button class="btn btn--primary" type="submit">Anlegen</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endcall %}
|
||||
{% endif %}
|
||||
{% if current_user.has('switches.write') %}
|
||||
{% for switch in switches %}
|
||||
{% call modal('switch-edit-' ~ switch.id, switch.hostname, switch.ip) %}
|
||||
<form method="post" action="{{ url_for('switches.update', switch_id=switch.id) }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<div class="modal__body">{{ switch_form(switch) }}</div>
|
||||
<div class="modal__footer">
|
||||
<button class="btn" type="button" data-dialog-close>Abbrechen</button>
|
||||
<button class="btn btn--primary" type="submit">Speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endcall %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,133 @@
|
||||
"""Ueberwachungsprozess: prueft Geraete, schaltet PoE, raeumt auf.
|
||||
|
||||
Laeuft als eigene systemd-Unit (``tesm-monitor.service``), getrennt vom
|
||||
Webprozess. Ein Absturz der Weboberflaeche stoppt die Ueberwachung damit nicht --
|
||||
und umgekehrt.
|
||||
|
||||
Ersetzt ``poe.sh`` + ``poe_wrapper.py`` + ``generate_ips.py`` + den
|
||||
``tesm-check-restart``-Timer des Vorgaengers. Den Timer braucht es nicht mehr:
|
||||
das Intervall wird bei **jedem** Durchlauf frisch aus der Datenbank gelesen,
|
||||
eine Aenderung greift also beim naechsten Lauf statt erst nach einem
|
||||
naechtlichen Neustart des Dienstes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import signal
|
||||
import sys
|
||||
import time
|
||||
from types import FrameType
|
||||
|
||||
from tesm_core.extension import core
|
||||
|
||||
from . import create_app
|
||||
from .services import inventory, monitor, vnc
|
||||
|
||||
logger = logging.getLogger("tesm.monitor")
|
||||
|
||||
MIN_INTERVAL = 30
|
||||
|
||||
|
||||
class MonitorDaemon:
|
||||
def __init__(self) -> None:
|
||||
self.app = create_app()
|
||||
self.extension = core(self.app)
|
||||
self.running = True
|
||||
|
||||
def stop(self, signum: int, _frame: FrameType | None) -> None:
|
||||
logger.info("Signal %s empfangen – beende nach dem laufenden Durchlauf.", signum)
|
||||
self.running = False
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def _interval(self) -> int:
|
||||
with self.extension.database.session() as conn:
|
||||
value = int(
|
||||
self.extension.settings.get(conn, "monitor_interval_seconds") or 300
|
||||
)
|
||||
return max(MIN_INTERVAL, value)
|
||||
|
||||
def _auto_restart_allowed(self) -> bool:
|
||||
settings_on = True
|
||||
with self.extension.database.session() as conn:
|
||||
settings_on = bool(self.extension.settings.get(conn, "monitor_auto_restart"))
|
||||
if self.extension.license is not None:
|
||||
self.extension.license.reload(conn)
|
||||
if not settings_on:
|
||||
return False
|
||||
if self.extension.license is None:
|
||||
return True
|
||||
return self.extension.license.feature_allowed("auto_poe_restart")
|
||||
|
||||
def _failures_before_restart(self) -> int:
|
||||
with self.extension.database.session() as conn:
|
||||
return int(
|
||||
self.extension.settings.get(conn, "monitor_failures_before_restart") or 2
|
||||
)
|
||||
|
||||
def run(self) -> int:
|
||||
live = self.extension.registry["live_log"]
|
||||
live.info("monitor", "Überwachungsprozess gestartet")
|
||||
logger.info("Überwachung läuft, Datenbank: %s", self.extension.config.db_path)
|
||||
|
||||
while self.running:
|
||||
interval = self._interval()
|
||||
started = time.monotonic()
|
||||
try:
|
||||
with self.app.app_context():
|
||||
report = monitor.run_sweep(
|
||||
database=self.extension.database,
|
||||
live=live,
|
||||
keystore=self.extension.keystore,
|
||||
license_allows_auto_restart=self._auto_restart_allowed(),
|
||||
failures_before_restart=self._failures_before_restart(),
|
||||
vnc_port=vnc.port_aus_einstellungen(self.extension),
|
||||
)
|
||||
logger.info(
|
||||
"Durchlauf: %s geprüft, %s online, %s offline, %s Neustarts (%s erfolgreich), "
|
||||
"%s ohne Lizenz übersprungen, %sms",
|
||||
report.checked,
|
||||
report.online,
|
||||
report.offline,
|
||||
report.restarts_triggered,
|
||||
report.restarts_ok,
|
||||
report.skipped_unlicensed,
|
||||
report.duration_ms,
|
||||
)
|
||||
except Exception: # noqa: BLE001 - der Dienst darf nie sterben
|
||||
logger.exception("Durchlauf fehlgeschlagen")
|
||||
live.error("monitor", "Durchlauf fehlgeschlagen – siehe app.log")
|
||||
|
||||
# Das Aufraeumen abgelaufener Freigabe-Einbindungen laeuft **nicht**
|
||||
# hier, sondern im Webprozess: eingebunden wird in dessen
|
||||
# Mount-Namensraum, und dieser Dienst sieht diese Einbindungen nicht
|
||||
# einmal. Ein umount von hier waere ein Aufruf ins Leere -- die
|
||||
# Datenbankzeile als geloest markiert, die Freigabe aber weiter
|
||||
# eingebunden. Siehe tesm/__init__.py::_start_fileshare_sweep.
|
||||
|
||||
elapsed = time.monotonic() - started
|
||||
remaining = max(1.0, interval - elapsed)
|
||||
# In Sekundenschritten warten, damit ein Stoppsignal schnell greift.
|
||||
while remaining > 0 and self.running:
|
||||
time.sleep(min(1.0, remaining))
|
||||
remaining -= 1.0
|
||||
|
||||
live.info("monitor", "Überwachungsprozess beendet")
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
|
||||
stream=sys.stderr,
|
||||
)
|
||||
daemon = MonitorDaemon()
|
||||
signal.signal(signal.SIGTERM, daemon.stop)
|
||||
signal.signal(signal.SIGINT, daemon.stop)
|
||||
return daemon.run()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,7 @@
|
||||
"""WSGI-Einstiegspunkt fuer gunicorn: ``gunicorn tesm.wsgi:app``."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from . import create_app
|
||||
|
||||
app = create_app()
|
||||
@@ -0,0 +1,841 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# Installation und Aktualisierung von TESM und TESM-Lizenzserver.
|
||||
#
|
||||
# Aufruf (aus dem entpackten Release-Verzeichnis):
|
||||
# sudo bash deploy/install.sh --app tesm
|
||||
# sudo bash deploy/install.sh --app tesm-license
|
||||
# sudo bash deploy/install.sh --app tesm --instance test --port 5100
|
||||
#
|
||||
# Die im Vorgaengerprojekt real aufgetretenen Fallen sind hier von vornherein
|
||||
# vermieden -- jeweils mit Kommentar an der Stelle, wo es zaehlt:
|
||||
#
|
||||
# * Die nginx-Site-Datei wird bei jedem Lauf **inhaltlich verglichen** und bei
|
||||
# Abweichung erneuert. Der Vorgaenger schrieb sie nur, wenn sie fehlte -- ein
|
||||
# einmal falscher alias-Pfad blieb dadurch ueber Jahre stehen und lieferte
|
||||
# alle statischen Dateien als 404.
|
||||
# * pip laeuft immer ueber "python3 -m pip", nie ueber den venv-Shim: dessen
|
||||
# Shebang baeckt einen absoluten Pfad ein, der bei einer Verzeichnis-
|
||||
# umbenennung bricht (live auf POETEST reproduziert).
|
||||
# * Vor jedem Update ein vollstaendiges Backup -- unabhaengig davon, ob sich
|
||||
# das Schema geaendert hat. Ein reiner Code-Fehler soll durch dasselbe Netz
|
||||
# laufen.
|
||||
# * Nach dem Update ein Health-Check mit automatischem Rollback.
|
||||
# * Instanzdaten (Datenbank, Schluessel, Lizenz) sind vom Abgleich
|
||||
# ausgeschlossen und werden nie ueberschrieben.
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
APP=""
|
||||
INSTANCE=""
|
||||
#: Verzeichnis der Umgebungsdateien. Ueberschreibbar, damit sich die
|
||||
#: Portsuche in einem Test wirklich ausfuehren laesst.
|
||||
ETC_TESM="${TESM_ETC_DIR:-/etc/tesm}"
|
||||
PORT=""
|
||||
HTTP_PORT=""
|
||||
SKIP_PACKAGES=0
|
||||
ASSUME_YES=0
|
||||
HTTPS=0
|
||||
HTTPS_PORT=""
|
||||
DOMAIN=""
|
||||
ACME_EMAIL=""
|
||||
ERZWINGEN=0
|
||||
|
||||
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' "$*"; }
|
||||
|
||||
# -- 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() {
|
||||
cat <<'USAGE'
|
||||
Verwendung: install.sh --app <tesm|tesm-license> [Optionen]
|
||||
|
||||
--app <name> Welche Anwendung installiert wird (Pflicht)
|
||||
--instance <name> Zusaetzliche Instanz neben der Hauptinstallation.
|
||||
Erzeugt /srv/<app>-<name>, <app>-<name>.service usw.
|
||||
Damit laesst sich eine neue Version testen, ohne die
|
||||
laufende anzufassen.
|
||||
--port <nummer> Port des Anwendungsservers (Standard: 5000 bzw. 5001)
|
||||
--http-port <nr> Port, auf dem nginx diese Instanz anbietet (Standard 80).
|
||||
Fuer eine Testinstanz neben einer laufenden Produktion:
|
||||
auf Port 80 kann nur eine Site die Vorgabe sein.
|
||||
--https HTTPS einrichten: Port 443 mit TLS, Port 80 leitet
|
||||
dorthin um (ausser dem ACME-Pfad), HSTS aktiv,
|
||||
Sitzungscookie mit Secure-Flag. Ohne --domain wird ein
|
||||
selbstsigniertes Zertifikat erzeugt (geschlossene Netze);
|
||||
mit --domain wird zusaetzlich versucht, ein
|
||||
Let's-Encrypt-Zertifikat zu holen.
|
||||
--https-port <nr> Wie --https, aber auf abweichendem Port (Testinstanz).
|
||||
--domain <name> Oeffentlicher Name dieser Installation.
|
||||
--acme-email <a> Kontaktadresse fuer Let's Encrypt.
|
||||
--skip-packages Keine Systempakete installieren
|
||||
--yes Rueckfragen ueberspringen
|
||||
--force Auch dann einrichten, wenn dieselbe Fassung schon laeuft
|
||||
USAGE
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--app) APP="${2:?}"; shift 2 ;;
|
||||
--instance) INSTANCE="${2:?}"; shift 2 ;;
|
||||
--port) PORT="${2:?}"; shift 2 ;;
|
||||
--http-port) HTTP_PORT="${2:?}"; shift 2 ;;
|
||||
--https) HTTPS=1; shift ;;
|
||||
--https-port) HTTPS_PORT="${2:?}"; HTTPS=1; shift 2 ;;
|
||||
--domain) DOMAIN="${2:?}"; shift 2 ;;
|
||||
--acme-email) ACME_EMAIL="${2:?}"; shift 2 ;;
|
||||
--skip-packages) SKIP_PACKAGES=1; shift ;;
|
||||
--yes|-y) ASSUME_YES=1; shift ;;
|
||||
--force) ERZWINGEN=1; shift ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) fail "unbekannte Option: $1" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ -n "$APP" ]] || { usage; fail "--app fehlt"; }
|
||||
[[ "$APP" == "tesm" || "$APP" == "tesm-license" ]] || fail "--app muss tesm oder tesm-license sein"
|
||||
# Seit die beiden Anwendungen in getrennten Repositorien liegen, bringt ein
|
||||
# Auscheckout nur die eigene mit. Ohne diese Pruefung waere die Meldung ein
|
||||
# Folgefehler tief im Installationslauf statt eines klaren Satzes hier.
|
||||
[[ -d "$REPO_DIR/apps/$APP" ]] || fail "In diesem Verzeichnis liegt '$APP' nicht ($REPO_DIR/apps/$APP fehlt). Vorhanden: $(
|
||||
ls -1 "$REPO_DIR/apps" 2>/dev/null | tr '
|
||||
' ' ' || echo 'nichts')"
|
||||
[[ "$(id -u)" -eq 0 ]] || fail "bitte mit sudo ausfuehren"
|
||||
[[ -z "$INSTANCE" || "$INSTANCE" =~ ^[a-z0-9-]{1,16}$ ]] || fail "ungueltiger Instanzname"
|
||||
|
||||
# -- Namensraum --------------------------------------------------------------
|
||||
NAME="$APP"
|
||||
[[ -n "$INSTANCE" ]] && NAME="$APP-$INSTANCE"
|
||||
ROOT="/srv/$NAME"
|
||||
LOG_DIR="/var/log/$NAME"
|
||||
SERVICE_USER="$APP"
|
||||
UNIT="$NAME.service"
|
||||
MONITOR_UNIT="$NAME-monitor.service"
|
||||
CLEANUP_UNIT="$NAME-cleanup.service"
|
||||
CLEANUP_TIMER="$NAME-cleanup.timer"
|
||||
NGINX_SITE="/etc/nginx/sites-available/$NAME"
|
||||
ENV_PREFIX="TESM_"
|
||||
[[ "$APP" == "tesm-license" ]] && ENV_PREFIX="TESM_LICENSE_"
|
||||
|
||||
# Ausgabe des Lizenzservers: die Master-Ausgabe bringt das Teilpaket
|
||||
# tesm_license/master mit -- den einzigen Weg, auf dem eine Lizenzdatei die
|
||||
# Rolle "master" bekommt. Das Release fuer Sub-Lizenzserver hat es nicht.
|
||||
# Die Ausgabe haengt an der Anwesenheit des Verzeichnisses, nicht an einer
|
||||
# Einstellung: eine Einstellung liesse sich vor Ort umstellen.
|
||||
MASTER_TEILPAKET="apps/tesm-license/src/tesm_license/master"
|
||||
EDITION="standard"
|
||||
if [[ "$APP" == "tesm-license" ]]; then
|
||||
if [[ -d "$REPO_DIR/$MASTER_TEILPAKET" ]]; then EDITION="master"; else EDITION="sub"; fi
|
||||
fi
|
||||
# Bei einer Aktualisierung gilt zuerst, was schon eingerichtet ist. Sonst
|
||||
# verschiebt ein Update ohne Portangaben die Anwendung: eine benannte Instanz
|
||||
# faellt auf 8080/8443 zurueck, auch wenn sie seit der Installation auf Port 80
|
||||
# lief -- und ein Reverse Proxy davor zeigt dann ins Leere. Dieselbe Regel wie
|
||||
# beim Secure-Flag weiter unten: eine Aktualisierung nimmt nichts weg, was
|
||||
# nicht ausdruecklich neu gesetzt wird.
|
||||
ENV_FILE="$ETC_TESM/$NAME.env"
|
||||
# Wo die Daten liegen, steht in der Umgebungsdatei; ohne sie beim Vorgabeort.
|
||||
# "|| true": bei einer Neuinstallation gibt es die Datei noch nicht, und ein
|
||||
# scheiterndes sed in einer Zuweisung beendet unter "set -e" das ganze Skript.
|
||||
DATEN_DIR="$(sed -n "s/^${ENV_PREFIX}DATA_DIR=//p" "$ENV_FILE" 2>/dev/null || true)"
|
||||
DATEN_DIR="$(printf '%s' "$DATEN_DIR" | tail -1)"
|
||||
DATEN_DIR="${DATEN_DIR:-$ROOT/data}"
|
||||
if [[ -z "$PORT" && -f "$ENV_FILE" ]]; then
|
||||
PORT="$(sed -n "s/^${ENV_PREFIX}BIND=127\.0\.0\.1:\([0-9]\{1,5\}\)$/\1/p" "$ENV_FILE" | head -1)"
|
||||
fi
|
||||
if [[ -f "$NGINX_SITE" ]]; then
|
||||
[[ -z "$HTTP_PORT" ]] && HTTP_PORT="$(sed -n 's/^[[:space:]]*listen[[:space:]]\([0-9]\{1,5\}\);.*/\1/p' "$NGINX_SITE" | head -1)"
|
||||
[[ -z "$HTTPS_PORT" ]] && HTTPS_PORT="$(sed -n 's/^[[:space:]]*listen[[:space:]]\([0-9]\{1,5\}\)[[:space:]]\+ssl.*/\1/p' "$NGINX_SITE" | head -1)"
|
||||
fi
|
||||
|
||||
port_belegt() {
|
||||
local p="$1"
|
||||
# Schon von einer anderen Installation vergeben? Die Umgebungsdateien sind
|
||||
# die Wahrheit -- ein Dienst kann gerade gestoppt sein und den Port trotzdem
|
||||
# beanspruchen.
|
||||
grep -qs "BIND=127\.0\.0\.1:$p\$" "$ETC_TESM"/*.env 2>/dev/null &&
|
||||
return 0
|
||||
# Oder gerade in Benutzung, von was auch immer.
|
||||
command -v ss >/dev/null 2>&1 && [[ -n "$(ss -Hltn "sport = :$p" 2>/dev/null)" ]] && return 0
|
||||
return 1
|
||||
}
|
||||
|
||||
if [[ -z "$PORT" ]]; then
|
||||
BASISPORT=5000
|
||||
[[ "$APP" == "tesm-license" ]] && BASISPORT=5001
|
||||
if [[ -n "$INSTANCE" ]]; then
|
||||
# Frueher bekam **jede** Instanz Basis+100. Die zweite startete nie
|
||||
# ("Address already in use") -- und die Gesundheitspruefung merkte es
|
||||
# nicht, weil die erste auf demselben Port antwortete.
|
||||
PORT=$((BASISPORT + 100))
|
||||
while port_belegt "$PORT"; do PORT=$((PORT + 1)); done
|
||||
else
|
||||
PORT=$BASISPORT
|
||||
fi
|
||||
fi
|
||||
if [[ -z "$HTTP_PORT" ]]; then
|
||||
HTTP_PORT=80
|
||||
[[ -n "$INSTANCE" ]] && HTTP_PORT=8080
|
||||
fi
|
||||
if [[ -z "$HTTPS_PORT" ]]; then
|
||||
HTTPS_PORT=443
|
||||
[[ -n "$INSTANCE" ]] && HTTPS_PORT=8443
|
||||
fi
|
||||
# Das Secure-Flag am Sitzungscookie darf erst gesetzt werden, wenn es
|
||||
# tatsaechlich HTTPS gibt -- sonst kommt das Cookie nie beim Browser an und die
|
||||
# Anmeldung schlaegt ohne erkennbaren Grund fehl.
|
||||
# Bei einer Aktualisierung zaehlt, was schon gilt: wer HTTPS eingerichtet hat,
|
||||
# darf es durch ein Update ohne --https nicht verlieren. Ein zurueckgesetztes
|
||||
# Secure-Flag wuerde die Anmeldung stillschweigend unmoeglich machen -- der
|
||||
# Browser sendet das Cookie dann weiter nur ueber HTTPS, die Anwendung setzt es
|
||||
# aber ohne das Flag neu, und beide reden aneinander vorbei.
|
||||
COOKIE_SECURE=0
|
||||
if [[ -f "$ENV_FILE" ]] && grep -q "^${ENV_PREFIX}COOKIE_SECURE=1" "$ENV_FILE"; then
|
||||
COOKIE_SECURE=1
|
||||
fi
|
||||
[[ $HTTPS -eq 1 ]] && COOKIE_SECURE=1
|
||||
[[ -z "$DOMAIN" || "$DOMAIN" =~ ^[A-Za-z0-9]([A-Za-z0-9.-]{0,251}[A-Za-z0-9])?$ ]] || fail "ungueltige Domain: $DOMAIN"
|
||||
|
||||
TIMESTAMP="$(date +%Y%m%d-%H%M%S)"
|
||||
BACKUP_DIR="/srv/$NAME-backup-$TIMESTAMP"
|
||||
FAILED_DIR="/srv/$NAME-failed-$TIMESTAMP"
|
||||
|
||||
# Einziges Signal fuer "schon installiert": die Datenbank ist da.
|
||||
IS_UPDATE=0
|
||||
[[ -f "$ROOT/data/app.db" ]] && IS_UPDATE=1
|
||||
|
||||
# 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"
|
||||
info "Anwendung : $APP${INSTANCE:+ (Instanz $INSTANCE)}"
|
||||
info "Ziel : $ROOT"
|
||||
info "Dienst : $UNIT auf 127.0.0.1:$PORT"
|
||||
if [[ $HTTPS -eq 1 ]]; then
|
||||
info "nginx : Port $HTTPS_PORT (HTTPS), Port $HTTP_PORT leitet um"
|
||||
elif [[ "$COOKIE_SECURE" == "1" ]]; then
|
||||
info "nginx : HTTPS ist bereits eingerichtet und bleibt unveraendert"
|
||||
if [[ -n "$DOMAIN" ]]; then
|
||||
info "Zertifikat : Lets Encrypt fuer $DOMAIN, sonst selbstsigniert"
|
||||
else
|
||||
info "Zertifikat : selbstsigniert (keine Domain angegeben)"
|
||||
fi
|
||||
else
|
||||
info "nginx : Port $HTTP_PORT (nur HTTP)"
|
||||
fi
|
||||
info "Modus : $([[ $IS_UPDATE -eq 1 ]] && echo Aktualisierung || echo Neuinstallation)"
|
||||
info "Fassung : ${LIEGT%% *} (tesm-core ${LIEGT##* })${LAEUFT:+, bisher ${LAEUFT%% *}}"
|
||||
|
||||
if [[ $ASSUME_YES -eq 0 ]]; then
|
||||
read -r -p "Fortfahren? [j/N] " answer
|
||||
[[ "$answer" =~ ^[jJyY]$ ]] || fail "abgebrochen"
|
||||
fi
|
||||
|
||||
# -- Systempakete ------------------------------------------------------------
|
||||
if [[ $SKIP_PACKAGES -eq 0 ]]; then
|
||||
step "Systempakete"
|
||||
PACKAGES=(python3 python3-venv python3-pip nginx sqlite3 rsync logrotate ca-certificates curl)
|
||||
if [[ "$APP" == "tesm" ]]; then
|
||||
PACKAGES+=(iputils-ping openssh-client cifs-utils)
|
||||
fi
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq "${PACKAGES[@]}"
|
||||
info "${#PACKAGES[@]} Pakete geprueft"
|
||||
else
|
||||
info "Systempakete uebersprungen"
|
||||
fi
|
||||
|
||||
# -- Dienstbenutzer ----------------------------------------------------------
|
||||
step "Dienstbenutzer"
|
||||
if ! id -u "$SERVICE_USER" >/dev/null 2>&1; then
|
||||
useradd --system --home-dir "$ROOT" --shell /usr/sbin/nologin "$SERVICE_USER"
|
||||
info "Benutzer $SERVICE_USER angelegt"
|
||||
else
|
||||
info "Benutzer $SERVICE_USER existiert"
|
||||
fi
|
||||
# Der Vorgaenger lief als root. Hier laeuft der Dienst unprivilegiert; Rootrechte
|
||||
# gibt es ausschliesslich ueber den Helfer.
|
||||
|
||||
# Ein Sub-Paket auf eine Master-Installation waere ein stiller Rueckbau: der
|
||||
# Abgleich unten laeuft mit --delete und wuerde das Teilpaket entfernen. Danach
|
||||
# gaebe es auf dem Master keinen Weg mehr, eine Master-Lizenz auszustellen --
|
||||
# und niemand haette es gesagt.
|
||||
# Die Rolle steht in der eingespielten Lizenz. Das ist die zuverlaessigere
|
||||
# Haelfte der Pruefung: eine Master-Installation aus einer aelteren Fassung
|
||||
# kennt das Teilpaket noch nicht, ist aber trotzdem eine Wurzelinstanz.
|
||||
ROLLE_ALT=""
|
||||
if [[ "$EDITION" == "sub" && -s "$DATEN_DIR/license.json" ]]; then
|
||||
ROLLE_ALT="$(python3 -c '
|
||||
import json, sys
|
||||
try:
|
||||
print(json.load(open(sys.argv[1])).get("role", ""))
|
||||
except Exception:
|
||||
pass
|
||||
' "$DATEN_DIR/license.json" 2>/dev/null || true)"
|
||||
fi
|
||||
|
||||
if [[ "$EDITION" == "sub" && ( -d "$ROOT/$MASTER_TEILPAKET" || "$ROLLE_ALT" == "master" ) ]]; then
|
||||
fail "Hier laeuft eine Wurzelinstanz${ROLLE_ALT:+ (Lizenz mit Rolle $ROLLE_ALT)}, dieses Paket
|
||||
ist die Ausgabe fuer Sub-Lizenzserver.
|
||||
Eine Aktualisierung damit wuerde die Master-Ausgabe entfernen.
|
||||
Passendes Paket: Repository tesm-license-master (privat).
|
||||
Bewusster Rueckbau: eine vom Master ausgestellte Lizenz einspielen (Rolle
|
||||
license_server) und $ROOT/$MASTER_TEILPAKET entfernen -- danach ist dies ein
|
||||
Sub-Lizenzserver und dieses Paket das richtige."
|
||||
fi
|
||||
|
||||
# -- Backup ------------------------------------------------------------------
|
||||
if [[ $IS_UPDATE -eq 1 ]]; then
|
||||
step "Sicherung vor der Aktualisierung"
|
||||
systemctl stop "$UNIT" 2>/dev/null || true
|
||||
[[ "$APP" == "tesm" ]] && systemctl stop "$MONITOR_UNIT" 2>/dev/null || true
|
||||
cp -a "$ROOT" "$BACKUP_DIR"
|
||||
SICHERUNG_STEHT=1
|
||||
info "Sicherung: $BACKUP_DIR"
|
||||
fi
|
||||
|
||||
# -- Dateien abgleichen ------------------------------------------------------
|
||||
step "Anwendungsdateien"
|
||||
mkdir -p "$ROOT"
|
||||
rsync -a --delete \
|
||||
--exclude 'venv' \
|
||||
--exclude 'data' \
|
||||
--exclude '*.pyc' \
|
||||
--exclude '__pycache__' \
|
||||
--exclude '.git' \
|
||||
--exclude 'tests' \
|
||||
"$REPO_DIR/" "$ROOT/"
|
||||
mkdir -p "$ROOT/data" "$LOG_DIR"
|
||||
info "Dateien abgeglichen (Instanzdaten unter data/ bleiben unangetastet)"
|
||||
|
||||
# -- venv --------------------------------------------------------------------
|
||||
step "Python-Umgebung"
|
||||
if [[ ! -x "$ROOT/venv/bin/python3" ]]; then
|
||||
python3 -m venv "$ROOT/venv"
|
||||
info "venv erzeugt"
|
||||
fi
|
||||
# Immer ueber "python3 -m pip" -- der pip-Shim im venv baeckt einen absoluten
|
||||
# Pfad in seinen Shebang, der nach einer Umbenennung des Verzeichnisses bricht.
|
||||
"$ROOT/venv/bin/python3" -m pip install --quiet --upgrade pip setuptools wheel
|
||||
PAKETE=("$ROOT/packages/tesm-licensing" "$ROOT/packages/tesm-core")
|
||||
# Die Serverhaelfte des Lizenzprotokolls -- ausstellen, erneuern, antworten --
|
||||
# gehoert ausschliesslich zum Lizenzserver. Ein Client verifiziert Lizenzen und
|
||||
# stellt Anfragen; den Code zum Ausstellen soll er nicht einmal mitbringen.
|
||||
if [[ "$APP" == "tesm-license" ]]; then
|
||||
[[ -d "$ROOT/packages/tesm-licensing-server" ]] ||
|
||||
fail "packages/tesm-licensing-server fehlt -- ohne sie kann der Lizenzserver nichts ausstellen"
|
||||
PAKETE+=("$ROOT/packages/tesm-licensing-server")
|
||||
fi
|
||||
"$ROOT/venv/bin/python3" -m pip install --quiet "${PAKETE[@]}" "$ROOT/apps/$APP" gunicorn
|
||||
if [[ "$APP" == "tesm" ]]; then
|
||||
"$ROOT/venv/bin/python3" -m pip install --quiet flask-sock
|
||||
fi
|
||||
info "Abhaengigkeiten installiert"
|
||||
|
||||
# -- Statische Dateien fuer nginx --------------------------------------------
|
||||
step "Statische Dateien"
|
||||
rm -rf "$ROOT/static" "$ROOT/static-core"
|
||||
CORE_STATIC="$("$ROOT/venv/bin/python3" -c \
|
||||
'import tesm_core, pathlib; print(pathlib.Path(tesm_core.__file__).parent / "static" / "tesm_core")')"
|
||||
APP_MODULE="$APP"
|
||||
[[ "$APP" == "tesm-license" ]] && APP_MODULE="tesm_license"
|
||||
APP_STATIC="$("$ROOT/venv/bin/python3" -c \
|
||||
"import $APP_MODULE, pathlib; print(pathlib.Path($APP_MODULE.__file__).parent / 'static')")"
|
||||
cp -a "$CORE_STATIC" "$ROOT/static-core"
|
||||
[[ -d "$APP_STATIC" ]] && cp -a "$APP_STATIC" "$ROOT/static" || mkdir -p "$ROOT/static"
|
||||
info "nginx liefert /core-assets/ und /assets/ direkt aus"
|
||||
|
||||
# -- Privilegierter Helfer ---------------------------------------------------
|
||||
step "Privilegierter Helfer und sudoers"
|
||||
install -d -o root -g root -m 0755 /usr/local/lib/tesm
|
||||
install_if_changed() {
|
||||
local src="$1" dst="$2" mode="$3" owner="${4:-root:root}"
|
||||
if [[ -f "$dst" ]] && cmp -s "$src" "$dst"; then
|
||||
return 1
|
||||
fi
|
||||
install -o "${owner%%:*}" -g "${owner##*:}" -m "$mode" "$src" "$dst"
|
||||
return 0
|
||||
}
|
||||
install_if_changed "$ROOT/deploy/tesm-helper" /usr/local/lib/tesm/tesm-helper 0755 && \
|
||||
info "Helfer aktualisiert" || info "Helfer unveraendert"
|
||||
|
||||
# visudo -c prueft die Datei, bevor sie aktiv wird -- eine kaputte sudoers-Datei
|
||||
# waere sonst ein Totalausfall der Administration.
|
||||
TMP_SUDO="$(mktemp)"
|
||||
cp "$ROOT/deploy/sudoers/$APP" "$TMP_SUDO"
|
||||
visudo -c -f "$TMP_SUDO" >/dev/null || fail "sudoers-Datei ist fehlerhaft"
|
||||
install -o root -g root -m 0440 "$TMP_SUDO" "/etc/sudoers.d/$APP"
|
||||
rm -f "$TMP_SUDO"
|
||||
info "sudoers: $SERVICE_USER darf ausschliesslich tesm-helper als root aufrufen"
|
||||
|
||||
# -- Anmeldebanner (MOTD) ----------------------------------------------------
|
||||
# Das Banner liest die Datenbank dieser Installation und laeuft dabei mit dem
|
||||
# System-Python, ohne das venv und ohne die Anwendung zu importieren. Der
|
||||
# Vorgaenger importierte dafuer die ganze Flask-App -- bei jeder Anmeldung, und
|
||||
# nach einem Wechsel des Quellverzeichnisses zeigte er stillschweigend die
|
||||
# Zahlen einer Datenbank, die niemand mehr fuellte.
|
||||
#
|
||||
# "timeout" verhindert, dass ein haengender Dateizugriff die Anmeldung
|
||||
# blockiert; "|| true" sorgt dafuer, dass ein Fehler hier den Login nie stoert.
|
||||
step "Anmeldebanner"
|
||||
if [[ -d /etc/update-motd.d ]]; then
|
||||
install_if_changed "$ROOT/deploy/tesm-motd" /usr/local/lib/tesm/tesm-motd 0755 >/dev/null \
|
||||
&& info "Bannerskript aktualisiert" || info "Bannerskript unveraendert"
|
||||
TMP_MOTD="$(mktemp)"
|
||||
{
|
||||
echo "#!/bin/sh"
|
||||
echo "# Von TESM erzeugt ($NAME). Aenderungen werden bei der naechsten"
|
||||
echo "# Installation ueberschrieben."
|
||||
echo "timeout 3 /usr/local/lib/tesm/tesm-motd --app $APP --root $ROOT 2>/dev/null || true"
|
||||
} > "$TMP_MOTD"
|
||||
install -o root -g root -m 0755 "$TMP_MOTD" "/etc/update-motd.d/50-$NAME"
|
||||
rm -f "$TMP_MOTD"
|
||||
info "Banner: /etc/update-motd.d/50-$NAME"
|
||||
# Ein Banner der Vorgaengerfassung zeigt Zahlen aus einer Datenbank, die
|
||||
# niemand mehr fuellt. Er wird nur entfernt, wenn er genau darauf zeigt --
|
||||
# nichts anderes wird angefasst.
|
||||
ALT_MOTD="/etc/update-motd.d/50-tesm"
|
||||
if [[ "$ALT_MOTD" != "/etc/update-motd.d/50-$NAME" && -f "$ALT_MOTD" ]] \
|
||||
&& grep -q "motd_banner.py" "$ALT_MOTD" 2>/dev/null; then
|
||||
rm -f "$ALT_MOTD"
|
||||
info "Banner der Vorgaengerfassung entfernt ($ALT_MOTD)"
|
||||
fi
|
||||
else
|
||||
info "Kein /etc/update-motd.d -- Banner uebersprungen"
|
||||
fi
|
||||
|
||||
# -- Rechte ------------------------------------------------------------------
|
||||
step "Dateirechte"
|
||||
chown -R "$SERVICE_USER:$SERVICE_USER" "$ROOT/data" "$LOG_DIR"
|
||||
chown -R root:root "$ROOT/packages" "$ROOT/apps" "$ROOT/deploy" 2>/dev/null || true
|
||||
chmod 0750 "$ROOT/data"
|
||||
chmod 0755 "$LOG_DIR"
|
||||
find "$ROOT/data" -maxdepth 1 -name '*.key*' -o -name 'data.keys' -o -name '*signing_key*' \
|
||||
| xargs -r chmod 0600
|
||||
info "Datenverzeichnis 0750, Schluesseldateien 0600"
|
||||
|
||||
# -- Umgebungsdatei ----------------------------------------------------------
|
||||
step "Umgebung"
|
||||
# Eine Quelle fuer beide Seiten: die systemd-Unit liest sie ueber
|
||||
# EnvironmentFile, die Kommandozeilenwerkzeuge ueber ihren Wrapper. Ohne das
|
||||
# wuesste "tesm-admin" nicht, wo die Instanz liegt, und wuerde sich eine leere
|
||||
# Datenbank im Arbeitsverzeichnis anlegen.
|
||||
install -d -o root -g root -m 0755 "$ETC_TESM"
|
||||
ENV_FILE="$ETC_TESM/$NAME.env"
|
||||
TMP_ENV="$(mktemp)"
|
||||
{
|
||||
echo "# Von install.sh erzeugt fuer $NAME -- nicht von Hand bearbeiten."
|
||||
echo "${ENV_PREFIX}BASE_DIR=$ROOT"
|
||||
echo "${ENV_PREFIX}DATA_DIR=$ROOT/data"
|
||||
echo "${ENV_PREFIX}LOG_DIR=$LOG_DIR"
|
||||
# Name dieser Installation auf dem Host. Alles ausserhalb von /srv/<name>
|
||||
# -- nginx-Site, netplan-Datei, logrotate-Regel, Zertifikatsverzeichnis,
|
||||
# Sitzungscookie -- laeuft darueber. Ohne das wuerde eine Testinstanz beim
|
||||
# Speichern der Webserver-Einstellungen die Site der Produktion
|
||||
# ueberschreiben und beide teilten sich dasselbe Cookie.
|
||||
echo "${ENV_PREFIX}SITE_KEY=$NAME"
|
||||
# Genau die Adresse, auf die gunicorn hoert. nginx nimmt sie von hier als
|
||||
# proxy_pass-Ziel -- so koennen Dienst und Reverse Proxy nicht auseinander
|
||||
# laufen. Vorher stand die 5000 fest im Code: eine Instanz auf 5100 erzeugte
|
||||
# damit eine Site, die auf die andere Installation zeigte.
|
||||
echo "${ENV_PREFIX}BIND=127.0.0.1:$PORT"
|
||||
echo "${ENV_PREFIX}COOKIE_SECURE=$COOKIE_SECURE"
|
||||
echo "${ENV_PREFIX}TRUSTED_PROXY_HOPS=1"
|
||||
echo "PYTHONUNBUFFERED=1"
|
||||
} > "$TMP_ENV"
|
||||
install_if_changed "$TMP_ENV" "$ENV_FILE" 0640 "root:$SERVICE_USER" >/dev/null && info "Umgebungsdatei $ENV_FILE geschrieben" || info "Umgebungsdatei unveraendert"
|
||||
rm -f "$TMP_ENV"
|
||||
if [[ "$COOKIE_SECURE" == "1" ]]; then
|
||||
info "COOKIE_SECURE=1 -- die Anmeldung funktioniert damit nur ueber HTTPS."
|
||||
else
|
||||
info "Hinweis: COOKIE_SECURE=0 bis HTTPS eingerichtet ist. Danach: install.sh --https"
|
||||
info " erneut aufrufen oder in $ENV_FILE auf 1 setzen und Dienst neu starten."
|
||||
fi
|
||||
|
||||
# Wrapper, damit die Werkzeuge dieselbe Umgebung sehen wie der Dienst.
|
||||
ADMIN_BIN="tesm-admin"
|
||||
[[ "$APP" == "tesm-license" ]] && ADMIN_BIN="tesm-license-admin"
|
||||
TMP_WRAPPER="$(mktemp)"
|
||||
cat > "$TMP_WRAPPER" <<WRAPPER
|
||||
#!/bin/sh
|
||||
# Von install.sh erzeugt: laedt die Instanzumgebung und ruft das Werkzeug auf.
|
||||
set -eu
|
||||
set -a
|
||||
. "$ENV_FILE"
|
||||
set +a
|
||||
exec "$ROOT/venv/bin/$ADMIN_BIN" "\$@"
|
||||
WRAPPER
|
||||
install_if_changed "$TMP_WRAPPER" "/usr/local/bin/$NAME-admin" 0755 >/dev/null && info "Werkzeug: $NAME-admin" || true
|
||||
rm -f "$TMP_WRAPPER"
|
||||
|
||||
# -- systemd -----------------------------------------------------------------
|
||||
step "systemd"
|
||||
UNITS_CHANGED=0
|
||||
render_unit() {
|
||||
local src="$1" dst="$2"
|
||||
local tmp; tmp="$(mktemp)"
|
||||
sed -e "s#/srv/$APP#$ROOT#g" \
|
||||
-e "s#/var/log/$APP#$LOG_DIR#g" \
|
||||
-e "s#127.0.0.1:5000#127.0.0.1:$PORT#" \
|
||||
-e "s#127.0.0.1:5001#127.0.0.1:$PORT#" \
|
||||
-e "/^WorkingDirectory=/a EnvironmentFile=$ENV_FILE" \
|
||||
"$src" > "$tmp"
|
||||
install_if_changed "$tmp" "$dst" 0644 && UNITS_CHANGED=1
|
||||
rm -f "$tmp"
|
||||
}
|
||||
render_unit "$ROOT/deploy/systemd/$APP.service" "/etc/systemd/system/$UNIT"
|
||||
if [[ "$APP" == "tesm" ]]; then
|
||||
render_unit "$ROOT/deploy/systemd/tesm-monitor.service" "/etc/systemd/system/$MONITOR_UNIT"
|
||||
fi
|
||||
# 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" \
|
||||
|| info "Units unveraendert"
|
||||
|
||||
# -- logrotate ---------------------------------------------------------------
|
||||
TMP_ROTATE="$(mktemp)"
|
||||
sed -e "s#/var/log/$APP/#$LOG_DIR/#" "$ROOT/deploy/logrotate/$APP" > "$TMP_ROTATE"
|
||||
install_if_changed "$TMP_ROTATE" "/etc/logrotate.d/$NAME" 0644 >/dev/null && info "logrotate aktualisiert"
|
||||
rm -f "$TMP_ROTATE"
|
||||
|
||||
# -- nginx -------------------------------------------------------------------
|
||||
step "nginx"
|
||||
TMP_SITE="$(mktemp)"
|
||||
sed -e "s#/srv/$APP/#$ROOT/#g" \
|
||||
-e "s#127.0.0.1:5000#127.0.0.1:$PORT#g" \
|
||||
-e "s#127.0.0.1:5001#127.0.0.1:$PORT#g" \
|
||||
-e "s#listen 80;#listen $HTTP_PORT;#" \
|
||||
-e "s#listen \[::\]:80;#listen [::]:$HTTP_PORT;#" \
|
||||
-e "s#/var/www/$APP-acme#/var/www/$NAME-acme#g" \
|
||||
"$ROOT/deploy/nginx/$APP.conf" > "$TMP_SITE"
|
||||
# Auf Port 80 kann nur eine Site die Vorgabe sein. Eine Instanz neben der
|
||||
# Produktion bekommt darum ueber --http-port einen eigenen Port -- sonst
|
||||
# verdeckt sie die laufende Installation (nginx meldet dann
|
||||
# "conflicting server name" und ignoriert eine der beiden).
|
||||
if [[ -n "$INSTANCE" && "$HTTP_PORT" == "80" ]]; then
|
||||
warn "Instanz $INSTANCE auf Port 80: sie verdeckt moeglicherweise die Hauptinstallation."
|
||||
fi
|
||||
|
||||
# Inhaltsvergleich statt reiner Existenzpruefung -- genau der Punkt, an dem der
|
||||
# Vorgaenger einen falschen alias-Pfad jahrelang mitschleppte. Die Anwendung
|
||||
# schreibt diese Datei spaeter ohnehin selbst neu (Verwaltung -> Webserver);
|
||||
# hier geht es nur um einen funktionierenden Erststart.
|
||||
if [[ -f "$NGINX_SITE" ]] && ! cmp -s "$TMP_SITE" "$NGINX_SITE"; then
|
||||
if grep -qE "alias\s+$ROOT/(static|static-core)/;" "$NGINX_SITE"; then
|
||||
info "Vorhandene Site-Konfiguration behalten (alias-Pfade stimmen)"
|
||||
else
|
||||
warn "Vorhandene Site-Konfiguration zeigt auf falsche Pfade -- wird erneuert"
|
||||
cp "$NGINX_SITE" "$NGINX_SITE.bak-$TIMESTAMP"
|
||||
install -m 0644 "$TMP_SITE" "$NGINX_SITE"
|
||||
fi
|
||||
elif [[ ! -f "$NGINX_SITE" ]]; then
|
||||
install -m 0644 "$TMP_SITE" "$NGINX_SITE"
|
||||
info "Site-Konfiguration angelegt"
|
||||
fi
|
||||
TMP_SITE_KEEP="$TMP_SITE"
|
||||
ln -sfn "$NGINX_SITE" "/etc/nginx/sites-enabled/$NAME"
|
||||
rm -f /etc/nginx/sites-enabled/default
|
||||
mkdir -p "/var/www/$NAME-acme"
|
||||
chown -R www-data:www-data "/var/www/$NAME-acme"
|
||||
|
||||
# "alias-Pfade stimmen" heisst noch nicht "nginx akzeptiert die Datei". Eine
|
||||
# von Hand nachgebesserte Site kann syntaktisch kaputt sein -- und dann
|
||||
# scheitert nicht nur diese Installation, sondern jeder Reload des Webservers,
|
||||
# auch fuer alle anderen Sites. Darum: pruefen, und im Zweifel die frisch
|
||||
# erzeugte Vorlage nehmen.
|
||||
if ! nginx -t >/dev/null 2>&1; then
|
||||
warn "nginx lehnt die vorhandene Site-Konfiguration ab -- sie wird durch die Vorlage ersetzt."
|
||||
[[ -f "$NGINX_SITE" ]] && cp "$NGINX_SITE" "$NGINX_SITE.bak-$TIMESTAMP"
|
||||
install -m 0644 "$TMP_SITE_KEEP" "$NGINX_SITE"
|
||||
nginx -t >/dev/null 2>&1 || fail "nginx lehnt auch die Vorlage ab -- bitte 'nginx -t' pruefen. Sicherung: $NGINX_SITE.bak-$TIMESTAMP"
|
||||
fi
|
||||
rm -f "$TMP_SITE_KEEP"
|
||||
systemctl reload nginx
|
||||
info "nginx neu geladen"
|
||||
|
||||
# -- Datenbank und Start -----------------------------------------------------
|
||||
step "Anwendung starten"
|
||||
# Das Schema entsteht beim ersten Start durch die Migrationen -- es gibt kein
|
||||
# separates Init-Skript mehr.
|
||||
systemctl enable --now "$UNIT" >/dev/null
|
||||
if [[ "$APP" == "tesm" ]]; then
|
||||
systemctl enable --now "$MONITOR_UNIT" >/dev/null
|
||||
fi
|
||||
# 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 ----------------------------------------------
|
||||
step "Gesundheitspruefung"
|
||||
HEALTHY=0
|
||||
for attempt in $(seq 1 20); do
|
||||
# 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
|
||||
info "Antwort nach ${attempt}x"
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
if [[ $HEALTHY -eq 0 ]]; then
|
||||
warn "Die Anwendung antwortet nicht."
|
||||
if [[ $IS_UPDATE -eq 1 ]]; then
|
||||
# Dieselbe Funktion wie die EXIT-Falle -- zwei Wege, die dasselbe tun
|
||||
# sollen, laufen sonst irgendwann auseinander.
|
||||
zurueckrollen_install
|
||||
fail "Aktualisierung fehlgeschlagen. Alter Stand wiederhergestellt.
|
||||
Fehlgeschlagene Version : $FAILED_DIR
|
||||
Sicherung : $BACKUP_DIR
|
||||
Protokoll : journalctl -u $UNIT -n 100"
|
||||
fi
|
||||
fail "Neuinstallation fehlgeschlagen. Protokoll: journalctl -u $UNIT -n 100"
|
||||
fi
|
||||
|
||||
URL_HINT="http://<host>:$HTTP_PORT/"
|
||||
[[ "$HTTP_PORT" == "80" ]] && URL_HINT="http://<host>/"
|
||||
# HTTPS war schon eingerichtet und wird durch dieses Update nicht angefasst.
|
||||
[[ $HTTPS -eq 0 && "$COOKIE_SECURE" == "1" ]] && URL_HINT="https://<host>/ (unveraendert)"
|
||||
|
||||
# -- HTTPS -------------------------------------------------------------------
|
||||
# Bewusst erst hier: die Anwendung muss laufen, denn sie erzeugt die
|
||||
# TLS-Konfiguration selbst (tesm-admin web-setup). Das Installationsskript
|
||||
# baut sich keine zweite, konkurrierende Vorlage -- genau daran ist der
|
||||
# Vorgaenger gescheitert.
|
||||
if [[ $HTTPS -eq 1 ]]; then
|
||||
step "HTTPS einrichten"
|
||||
WEB_ARGS=(web-setup --https --apply --self-signed
|
||||
--http-port "$HTTP_PORT" --https-port "$HTTPS_PORT")
|
||||
if [[ -n "$DOMAIN" ]]; then
|
||||
# HSTS nur mit einem Zertifikat, dem Browser auch trauen. Bei einem
|
||||
# selbstsignierten waere es eine Falle: der Browser merkt sich fuer Monate,
|
||||
# dass er die Seite nur ueber HTTPS aufrufen darf -- und zeigt bei jedem
|
||||
# Aufruf eine Warnung, die sich nicht mehr wegklicken laesst.
|
||||
WEB_ARGS+=(--domain "$DOMAIN")
|
||||
if [[ -n "$ACME_EMAIL" ]]; then
|
||||
# Let's Encrypt verlangt ohnehin eine Kontaktadresse. Sie ist damit das
|
||||
# eindeutige Signal "hol ein oeffentliches Zertifikat" -- ohne sie bleibt
|
||||
# es bei einem selbstsignierten, auch wenn eine Domain gesetzt ist
|
||||
# (interne Namen wie lizenz.firma.local kann keine CA bestaetigen).
|
||||
WEB_ARGS+=(--acme --acme-email "$ACME_EMAIL" --hsts)
|
||||
info "Versuche Lets Encrypt fuer $DOMAIN; bei Misserfolg selbstsigniert."
|
||||
else
|
||||
info "Domain $DOMAIN, aber keine --acme-email: selbstsigniert, ohne HSTS."
|
||||
fi
|
||||
else
|
||||
info "Ohne Domain: selbstsigniertes Zertifikat, ohne HSTS."
|
||||
fi
|
||||
if sudo -u "$SERVICE_USER" "/usr/local/bin/$NAME-admin" "${WEB_ARGS[@]}"; then
|
||||
info "HTTPS aktiv."
|
||||
URL_HINT="https://<host>/"
|
||||
[[ "$HTTPS_PORT" == "443" ]] || URL_HINT="https://<host>:$HTTPS_PORT/"
|
||||
else
|
||||
warn "HTTPS-Einrichtung fehlgeschlagen -- die Installation laeuft weiter ueber HTTP."
|
||||
warn "Nachtraeglich: sudo -u $SERVICE_USER $NAME-admin web-setup --https --self-signed --hsts --apply"
|
||||
# Ein Secure-Cookie ohne HTTPS macht die Anmeldung unmoeglich. Lieber
|
||||
# zurueckdrehen als eine Installation hinterlassen, an der niemand mehr
|
||||
# ankommt.
|
||||
if grep -q "^${ENV_PREFIX}COOKIE_SECURE=1" "$ENV_FILE"; then
|
||||
sed -i "s/^${ENV_PREFIX}COOKIE_SECURE=1/${ENV_PREFIX}COOKIE_SECURE=0/" "$ENV_FILE"
|
||||
systemctl restart "$UNIT"
|
||||
warn "COOKIE_SECURE auf 0 zurueckgesetzt, damit die Anmeldung weiter funktioniert."
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# -- Lizenzserver einrichten -------------------------------------------------
|
||||
# Bei einer Neuinstallation: Signaturschluessel anlegen (braucht jeder
|
||||
# Lizenzserver, um Kundenlizenzen zu signieren) und in der Master-Ausgabe
|
||||
# gleich die eigene Master-Lizenz. Bei einer Aktualisierung nichts davon --
|
||||
# ein bestehender Schluessel darf nie angetastet werden.
|
||||
if [[ "$APP" == "tesm-license" && $IS_UPDATE -eq 0 ]]; then
|
||||
step "Signaturschluessel"
|
||||
if sudo -u "$SERVICE_USER" "/usr/local/bin/$NAME-admin" init-key; then
|
||||
info "Bitte die Datei $DATEN_DIR/master_signing_key.json SOFORT sichern."
|
||||
else
|
||||
fail "Der Signaturschluessel liess sich nicht erzeugen. Ohne ihn kann dieser Server
|
||||
keine Lizenz ausstellen. Protokoll: journalctl -u $UNIT -n 50"
|
||||
fi
|
||||
|
||||
if [[ "$EDITION" == "master" ]]; then
|
||||
step "Eigene Master-Lizenz"
|
||||
if [[ -s "$DATEN_DIR/license.json" ]]; then
|
||||
info "Es liegt schon eine Lizenzdatei -- unangetastet."
|
||||
elif sudo -u "$SERVICE_USER" "/usr/local/bin/$NAME-admin" bootstrap; then
|
||||
systemctl restart "$UNIT"
|
||||
info "Dienst neu gestartet, damit die Lizenz gelesen wird."
|
||||
else
|
||||
fail "Die Master-Lizenz liess sich nicht ausstellen.
|
||||
Nachtraeglich: sudo -u $SERVICE_USER $NAME-admin bootstrap"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ $IS_UPDATE -eq 1 ]]; then
|
||||
rm -rf "$BACKUP_DIR"
|
||||
info "Sicherung entfernt (Aktualisierung erfolgreich)"
|
||||
fi
|
||||
|
||||
# -- Abschluss ---------------------------------------------------------------
|
||||
INSTALL_FERTIG=1
|
||||
step "Fertig"
|
||||
ADMIN_CMD="tesm-admin"
|
||||
[[ "$APP" == "tesm-license" ]] && ADMIN_CMD="tesm-license-admin"
|
||||
|
||||
cat <<SUMMARY
|
||||
|
||||
Anwendung : $APP${INSTANCE:+ (Instanz $INSTANCE)}
|
||||
Verzeichnis : $ROOT
|
||||
Protokolle : $LOG_DIR
|
||||
Dienst : systemctl status $UNIT
|
||||
Erreichbar : $URL_HINT
|
||||
|
||||
Naechste Schritte:
|
||||
SUMMARY
|
||||
|
||||
if [[ "$APP" == "tesm-license" && "$EDITION" == "master" ]]; then
|
||||
cat <<SUMMARY
|
||||
1. Administrator anlegen:
|
||||
sudo -u $SERVICE_USER $NAME-admin create-admin
|
||||
2. Signaturschluessel sichern -- ohne ihn ist keine ausgestellte Lizenz
|
||||
mehr pruefbar:
|
||||
$ROOT/data/master_signing_key.json
|
||||
SUMMARY
|
||||
elif [[ "$APP" == "tesm-license" ]]; then
|
||||
cat <<SUMMARY
|
||||
1. Administrator anlegen:
|
||||
sudo -u $SERVICE_USER $NAME-admin create-admin
|
||||
2. Beim Master-Lizenzserver eine Lizenz fuer diesen Server anfordern und
|
||||
unter "Eigene Lizenz" einspielen. Diese Ausgabe ist ein Sub-Lizenzserver
|
||||
und stellt keine eigene Master-Lizenz aus.
|
||||
3. Signaturschluessel sichern -- mit ihm signiert dieser Server seine
|
||||
Kundenlizenzen:
|
||||
$ROOT/data/master_signing_key.json
|
||||
SUMMARY
|
||||
else
|
||||
cat <<SUMMARY
|
||||
1. Administrator anlegen:
|
||||
sudo -u $SERVICE_USER $NAME-admin create-admin
|
||||
2. Lizenzdatei einspielen unter Verwaltung -> Lizenz
|
||||
SUMMARY
|
||||
fi
|
||||
|
||||
cat <<'SUMMARY'
|
||||
Danach in der Anwendung unter "Verwaltung -> Webserver und TLS" die Domain
|
||||
eintragen, ein Zertifikat anfordern und die Konfiguration uebernehmen --
|
||||
damit laeuft der Zugriff ueber HTTPS auf Port 443 mit Umleitung von 80.
|
||||
|
||||
SUMMARY
|
||||
@@ -0,0 +1,14 @@
|
||||
# Von TESM installiert.
|
||||
# copytruncate, weil keiner der schreibenden Prozesse (Web, Ueberwachung, Kea)
|
||||
# ein Reopen-Signal verarbeitet.
|
||||
/var/log/tesm/*.log {
|
||||
weekly
|
||||
rotate 4
|
||||
compress
|
||||
delaycompress
|
||||
missingok
|
||||
notifempty
|
||||
copytruncate
|
||||
su tesm tesm
|
||||
create 0640 tesm tesm
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
# Von TESM installiert.
|
||||
# copytruncate, weil keiner der schreibenden Prozesse (Web, Ueberwachung, Kea)
|
||||
# ein Reopen-Signal verarbeitet.
|
||||
/var/log/tesm-license/*.log {
|
||||
weekly
|
||||
rotate 4
|
||||
compress
|
||||
delaycompress
|
||||
missingok
|
||||
notifempty
|
||||
copytruncate
|
||||
su tesm-license tesm-license
|
||||
create 0640 tesm-license tesm-license
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
# Erststart-Konfiguration. Ab der ersten Uebernahme in der Anwendung
|
||||
# (Verwaltung -> Webserver und TLS) wird diese Datei vollstaendig neu
|
||||
# geschrieben -- inklusive HTTPS auf 443, Umleitung von 80 und HSTS.
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name _;
|
||||
|
||||
location /.well-known/acme-challenge/ {
|
||||
alias /var/www/tesm-license-acme/.well-known/acme-challenge/;
|
||||
default_type "text/plain";
|
||||
}
|
||||
|
||||
client_max_body_size 160m;
|
||||
|
||||
location /assets/ {
|
||||
alias /srv/tesm-license/static/;
|
||||
expires 7d;
|
||||
access_log off;
|
||||
}
|
||||
|
||||
location /core-assets/ {
|
||||
alias /srv/tesm-license/static-core/;
|
||||
expires 7d;
|
||||
access_log off;
|
||||
}
|
||||
|
||||
|
||||
location / {
|
||||
# $http_host statt $host: $host verwirft den Port. Hinter einem Proxy auf
|
||||
# abweichendem Port wuerde die Origin-Pruefung der Anwendung sonst
|
||||
# fehlschlagen.
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Forwarded-Host $http_host;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_pass http://127.0.0.1:5001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_read_timeout 120s;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
# Erststart-Konfiguration. Ab der ersten Uebernahme in der Anwendung
|
||||
# (Verwaltung -> Webserver und TLS) wird diese Datei vollstaendig neu
|
||||
# geschrieben -- inklusive HTTPS auf 443, Umleitung von 80 und HSTS.
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name _;
|
||||
|
||||
location /.well-known/acme-challenge/ {
|
||||
alias /var/www/tesm-acme/.well-known/acme-challenge/;
|
||||
default_type "text/plain";
|
||||
}
|
||||
|
||||
client_max_body_size 160m;
|
||||
|
||||
location /assets/ {
|
||||
alias /srv/tesm/static/;
|
||||
expires 7d;
|
||||
access_log off;
|
||||
}
|
||||
|
||||
location /core-assets/ {
|
||||
alias /srv/tesm/static-core/;
|
||||
expires 7d;
|
||||
access_log off;
|
||||
}
|
||||
|
||||
location /ws/ {
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
# $http_host statt $host: $host verwirft den Port. Hinter einem Proxy auf
|
||||
# abweichendem Port wuerde die Origin-Pruefung der Anwendung sonst
|
||||
# fehlschlagen.
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Forwarded-Host $http_host;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_pass http://127.0.0.1:5000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
location / {
|
||||
# $http_host statt $host: $host verwirft den Port. Hinter einem Proxy auf
|
||||
# abweichendem Port wuerde die Origin-Pruefung der Anwendung sonst
|
||||
# fehlschlagen.
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Forwarded-Host $http_host;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_pass http://127.0.0.1:5000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_read_timeout 120s;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env bash
|
||||
# Paketiert ein Release aus einem vorhandenen Tag.
|
||||
#
|
||||
# Auf Windows-Buildmaschinen ist -c core.autocrlf=false zwingend -- sonst landen
|
||||
# CRLF-Zeilenenden im Tarball und die Shell-Skripte scheitern auf dem Ziel mit
|
||||
# "bad interpreter". Das ist real passiert.
|
||||
#
|
||||
# Der Name kommt aus dem Repository, nicht aus einer festen Zeichenkette: die
|
||||
# beiden Anwendungen liegen in getrennten Repositorien, und ein
|
||||
# Lizenzserver-Release, das "tesm-..." heisst, verwechselt man genau einmal.
|
||||
set -euo pipefail
|
||||
|
||||
TAG="${1:?Verwendung: release.sh vX.Y.Z [name]}"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
|
||||
# Vorgabe: der Name des Repositoriums. Genau den erwartet der Bootstrap von
|
||||
# update.sh als Anhangsnamen ("$repo-$TAG.tar.gz"). Die Anwendung heisst nicht
|
||||
# immer so: im Repositorium tesm-license-master liegt die Anwendung
|
||||
# "tesm-license", und ein Anhang unter deren Namen waere nie zu finden.
|
||||
if [[ -n "${2:-}" ]]; then
|
||||
NAME="$2"
|
||||
else
|
||||
HERKUNFT="$(git -C "$REPO_DIR" config --get remote.origin.url 2>/dev/null || true)"
|
||||
if [[ -n "$HERKUNFT" ]]; then
|
||||
NAME="$(basename "$HERKUNFT" .git)"
|
||||
else
|
||||
# Ohne Gegenseite: die einzige Anwendung im Baum, sonst das Verzeichnis.
|
||||
mapfile -t APPS < <(ls -1 "$REPO_DIR/apps" 2>/dev/null || true)
|
||||
if [[ ${#APPS[@]} -eq 1 ]]; then NAME="${APPS[0]}"; else NAME="$(basename "$REPO_DIR")"; fi
|
||||
fi
|
||||
fi
|
||||
|
||||
OUT="$NAME-$TAG.tar.gz"
|
||||
git -c core.autocrlf=false -C "$REPO_DIR" archive --format=tar.gz --prefix="$NAME/" -o "$OUT" "$TAG"
|
||||
echo "Erzeugt: $OUT"
|
||||
file "$OUT"
|
||||
echo "Pruefsumme:"; sha256sum "$OUT"
|
||||
@@ -0,0 +1,359 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# Benennt eine Installation um -- aus einer Instanz wird die Hauptinstallation
|
||||
# (oder umgekehrt).
|
||||
#
|
||||
# sudo bash deploy/rename.sh --app tesm --from tesm-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."
|
||||
@@ -0,0 +1,15 @@
|
||||
# Von TESM installiert. Der Dienstbenutzer darf genau ein Programm als root
|
||||
# aufrufen -- den Helfer. Dessen Argumentpruefung ist die eigentliche
|
||||
# Sicherheitsgrenze; sie liegt in /usr/local/lib/tesm/tesm-helper und gehoert
|
||||
# root (Modus 0755).
|
||||
#
|
||||
# Bewusst KEIN "ALL=(ALL) NOPASSWD: ALL". Der Vorgaenger loeste dasselbe
|
||||
# Problem, indem die gesamte Anwendung als root lief.
|
||||
#
|
||||
# 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
|
||||
@@ -0,0 +1,15 @@
|
||||
# Von TESM installiert. Der Dienstbenutzer darf genau ein Programm als root
|
||||
# aufrufen -- den Helfer. Dessen Argumentpruefung ist die eigentliche
|
||||
# Sicherheitsgrenze; sie liegt in /usr/local/lib/tesm/tesm-helper und gehoert
|
||||
# root (Modus 0755).
|
||||
#
|
||||
# Bewusst KEIN "ALL=(ALL) NOPASSWD: ALL". Der Vorgaenger loeste dasselbe
|
||||
# Problem, indem die gesamte Anwendung als root lief.
|
||||
#
|
||||
# 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
|
||||
@@ -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
|
||||
@@ -0,0 +1,78 @@
|
||||
[Unit]
|
||||
Description=TESM-Lizenzserver
|
||||
Documentation=file:/srv/tesm-license/docs/BETRIEB.md
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=notify-reload
|
||||
User=tesm-license
|
||||
Group=tesm-license
|
||||
WorkingDirectory=/srv/tesm-license
|
||||
Environment=TESM_LICENSE_BASE_DIR=/srv/tesm-license
|
||||
Environment=TESM_LICENSE_DATA_DIR=/srv/tesm-license/data
|
||||
Environment=TESM_LICENSE_LOG_DIR=/var/log/tesm-license
|
||||
Environment=TESM_LICENSE_WEB_PROCESS=1
|
||||
Environment=TESM_LICENSE_COOKIE_SECURE=1
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
ExecStart=/srv/tesm-license/venv/bin/gunicorn \
|
||||
--workers 1 --worker-class gthread --threads 8 \
|
||||
--bind 127.0.0.1:5001 \
|
||||
--access-logfile - --error-logfile - \
|
||||
tesm_license.wsgi:app
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
# -- Haertung ---------------------------------------------------------------
|
||||
# Der Dienst braucht kein Root, keine neuen Privilegien und keinen Zugriff auf
|
||||
# das System ausserhalb seiner eigenen Verzeichnisse. Was er trotzdem braucht
|
||||
# (systemctl, nginx, netplan), laeuft ueber den Helfer und sudo.
|
||||
# Bewusst **kein** NoNewPrivileges: der Dienst ruft den privilegierten Helfer
|
||||
# ueber sudo auf, und sudo ist setuid. Unter NoNewPrivileges verweigert der
|
||||
# Kernel die Rechteerhoehung, sudo bricht ab -- und damit jede Systemaktion aus
|
||||
# der Oberflaeche. Die Sicherheitsgrenze liegt nicht hier, sondern in der
|
||||
# sudoers-Regel (genau ein erlaubter Pfad) und im Helfer selbst (jedes Argument
|
||||
# gegen eine Positivliste). Siehe docs/SICHERHEIT.md.
|
||||
PrivateTmp=yes
|
||||
PrivateDevices=yes
|
||||
# ProtectSystem=full statt strict: "strict" sperrt den **ganzen Prozessbaum**
|
||||
# schreibgeschuetzt -- auch den Helfer, der als root gerade /etc/nginx,
|
||||
# /etc/netplan und /etc/kea schreiben soll. Gemessen: unter "strict" scheitert
|
||||
# schon "nginx -t". Mit "full" bleiben /usr und /boot schreibgeschuetzt, /etc
|
||||
# ist im Namensraum wieder erreichbar -- der unprivilegierte Dienst kommt
|
||||
# trotzdem nicht heran, denn die Dateien gehoeren root. Die Grenze ist damit
|
||||
# die gewoehnliche Dateiberechtigung plus die sudoers-Regel, nicht dieser
|
||||
# Schalter.
|
||||
ProtectSystem=full
|
||||
ProtectHome=yes
|
||||
ProtectKernelTunables=yes
|
||||
ProtectKernelModules=yes
|
||||
ProtectKernelLogs=yes
|
||||
ProtectControlGroups=yes
|
||||
ProtectClock=yes
|
||||
ProtectHostname=yes
|
||||
ProtectProc=invisible
|
||||
RestrictSUIDSGID=yes
|
||||
RestrictRealtime=yes
|
||||
RestrictNamespaces=yes
|
||||
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
|
||||
LockPersonality=yes
|
||||
MemoryDenyWriteExecute=no
|
||||
SystemCallArchitectures=native
|
||||
SystemCallFilter=@system-service
|
||||
SystemCallErrorNumber=EPERM
|
||||
# Bewusst **kein** einschraenkendes CapabilityBoundingSet: sudo braucht
|
||||
# CAP_SETUID/CAP_SETGID, und der Helfer dahinter braucht Root. Was hier fehlt,
|
||||
# kann auch der Helfer nicht mehr erlangen -- gemessen: jede Einschraenkung
|
||||
# fuehrt zu "sudo: unable to change to root gid". Die Grenze ist die
|
||||
# sudoers-Regel und die Argumentpruefung im Helfer, nicht dieser Schalter.
|
||||
UMask=0027
|
||||
# Genau die Pfade, die der privilegierte Helfer schreibt -- nicht mehr.
|
||||
# ProtectSystem=full sperrt /usr, /boot **und /etc**; ohne diese Freigaben
|
||||
# scheitert der Helfer mit "Read-only file system", obwohl er root ist
|
||||
# (gemessen). Der unprivilegierte Dienst kommt hier trotzdem nicht heran: die
|
||||
# Dateien gehoeren root, und sudo laesst nur den einen Helferpfad zu.
|
||||
ReadWritePaths=/srv/tesm-license/data /var/log/tesm-license /etc/nginx /etc/netplan /etc/logrotate.d /etc/tesm /run
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,62 @@
|
||||
[Unit]
|
||||
Description=TESM -- Ueberwachung der PoE-Endgeraete
|
||||
After=network-online.target tesm.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=tesm
|
||||
Group=tesm
|
||||
WorkingDirectory=/srv/tesm
|
||||
Environment=TESM_BASE_DIR=/srv/tesm
|
||||
Environment=TESM_DATA_DIR=/srv/tesm/data
|
||||
Environment=TESM_LOG_DIR=/var/log/tesm
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
ExecStart=/srv/tesm/venv/bin/tesm-monitor
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
|
||||
# Das Pruefintervall liest der Prozess bei jedem Durchlauf frisch aus der
|
||||
# Datenbank -- der naechtliche Neustart-Timer des Vorgaengers entfaellt damit.
|
||||
# Bewusst **kein** NoNewPrivileges: der Dienst ruft den privilegierten Helfer
|
||||
# ueber sudo auf, und sudo ist setuid. Unter NoNewPrivileges verweigert der
|
||||
# Kernel die Rechteerhoehung, sudo bricht ab -- und damit jede Systemaktion aus
|
||||
# der Oberflaeche. Die Sicherheitsgrenze liegt nicht hier, sondern in der
|
||||
# sudoers-Regel (genau ein erlaubter Pfad) und im Helfer selbst (jedes Argument
|
||||
# gegen eine Positivliste). Siehe docs/SICHERHEIT.md.
|
||||
PrivateTmp=yes
|
||||
# ProtectSystem=full statt strict: "strict" sperrt den **ganzen Prozessbaum**
|
||||
# schreibgeschuetzt -- auch den Helfer, der als root gerade /etc/nginx,
|
||||
# /etc/netplan und /etc/kea schreiben soll. Gemessen: unter "strict" scheitert
|
||||
# schon "nginx -t". Mit "full" bleiben /usr und /boot schreibgeschuetzt, /etc
|
||||
# ist im Namensraum wieder erreichbar -- der unprivilegierte Dienst kommt
|
||||
# trotzdem nicht heran, denn die Dateien gehoeren root. Die Grenze ist damit
|
||||
# die gewoehnliche Dateiberechtigung plus die sudoers-Regel, nicht dieser
|
||||
# Schalter.
|
||||
ProtectSystem=full
|
||||
ProtectHome=yes
|
||||
ProtectKernelTunables=yes
|
||||
ProtectKernelModules=yes
|
||||
ProtectControlGroups=yes
|
||||
RestrictSUIDSGID=yes
|
||||
RestrictNamespaces=yes
|
||||
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
|
||||
LockPersonality=yes
|
||||
SystemCallArchitectures=native
|
||||
UMask=0027
|
||||
# Genau die Pfade, die der privilegierte Helfer schreibt -- nicht mehr.
|
||||
# ProtectSystem=full sperrt /usr, /boot **und /etc**; ohne diese Freigaben
|
||||
# scheitert der Helfer mit "Read-only file system", obwohl er root ist
|
||||
# (gemessen). Der unprivilegierte Dienst kommt hier trotzdem nicht heran: die
|
||||
# Dateien gehoeren root, und sudo laesst nur den einen Helferpfad zu.
|
||||
ReadWritePaths=/srv/tesm/data /var/log/tesm /etc/tesm /run
|
||||
# ping braucht CAP_NET_RAW -- als einzige Ausnahme, und nur diese.
|
||||
AmbientCapabilities=CAP_NET_RAW
|
||||
# Bewusst **kein** einschraenkendes CapabilityBoundingSet: sudo braucht
|
||||
# CAP_SETUID/CAP_SETGID, und der Helfer dahinter braucht Root. Was hier fehlt,
|
||||
# kann auch der Helfer nicht mehr erlangen -- gemessen: jede Einschraenkung
|
||||
# fuehrt zu "sudo: unable to change to root gid". Die Grenze ist die
|
||||
# sudoers-Regel und die Argumentpruefung im Helfer, nicht dieser Schalter.
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,91 @@
|
||||
[Unit]
|
||||
Description=TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten
|
||||
Documentation=file:/srv/tesm/docs/BETRIEB.md
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=notify-reload
|
||||
User=tesm
|
||||
Group=tesm
|
||||
WorkingDirectory=/srv/tesm
|
||||
Environment=TESM_BASE_DIR=/srv/tesm
|
||||
Environment=TESM_DATA_DIR=/srv/tesm/data
|
||||
Environment=TESM_LOG_DIR=/var/log/tesm
|
||||
Environment=TESM_WEB_PROCESS=1
|
||||
Environment=TESM_COOKIE_SECURE=1
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
ExecStart=/srv/tesm/venv/bin/gunicorn \
|
||||
--workers 1 --worker-class gthread --threads 16 --timeout 0 \
|
||||
--bind 127.0.0.1:5000 \
|
||||
--access-logfile - --error-logfile - \
|
||||
tesm.wsgi:app
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
# -- Haertung ---------------------------------------------------------------
|
||||
# Der Dienst braucht kein Root, keine neuen Privilegien und keinen Zugriff auf
|
||||
# das System ausserhalb seiner eigenen Verzeichnisse. Was er trotzdem braucht
|
||||
# (systemctl, nginx, netplan), laeuft ueber den Helfer und sudo.
|
||||
# Bewusst **kein** NoNewPrivileges: der Dienst ruft den privilegierten Helfer
|
||||
# ueber sudo auf, und sudo ist setuid. Unter NoNewPrivileges verweigert der
|
||||
# Kernel die Rechteerhoehung, sudo bricht ab -- und damit jede Systemaktion aus
|
||||
# der Oberflaeche. Die Sicherheitsgrenze liegt nicht hier, sondern in der
|
||||
# sudoers-Regel (genau ein erlaubter Pfad) und im Helfer selbst (jedes Argument
|
||||
# gegen eine Positivliste). Siehe docs/SICHERHEIT.md.
|
||||
PrivateTmp=yes
|
||||
PrivateDevices=yes
|
||||
# ProtectSystem=full statt strict: "strict" sperrt den **ganzen Prozessbaum**
|
||||
# schreibgeschuetzt -- auch den Helfer, der als root gerade /etc/nginx,
|
||||
# /etc/netplan und /etc/kea schreiben soll. Gemessen: unter "strict" scheitert
|
||||
# schon "nginx -t". Mit "full" bleiben /usr und /boot schreibgeschuetzt, /etc
|
||||
# ist im Namensraum wieder erreichbar -- der unprivilegierte Dienst kommt
|
||||
# trotzdem nicht heran, denn die Dateien gehoeren root. Die Grenze ist damit
|
||||
# die gewoehnliche Dateiberechtigung plus die sudoers-Regel, nicht dieser
|
||||
# Schalter.
|
||||
ProtectSystem=full
|
||||
ProtectHome=yes
|
||||
ProtectKernelTunables=yes
|
||||
ProtectKernelModules=yes
|
||||
ProtectKernelLogs=yes
|
||||
ProtectControlGroups=yes
|
||||
ProtectClock=yes
|
||||
ProtectHostname=yes
|
||||
ProtectProc=invisible
|
||||
RestrictSUIDSGID=yes
|
||||
RestrictRealtime=yes
|
||||
RestrictNamespaces=yes
|
||||
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
|
||||
LockPersonality=yes
|
||||
MemoryDenyWriteExecute=no
|
||||
SystemCallArchitectures=native
|
||||
# "@mount" zusaetzlich zu @system-service: die Dateifreigaben werden ueber
|
||||
# mount.cifs eingebunden, und @system-service enthaelt mount/umount2 nicht.
|
||||
# Ohne diese Ergaenzung scheitert jede Einbindung mit "mount error(1):
|
||||
# Operation not permitted" -- gemessen, und zwar unabhaengig von Rechten,
|
||||
# Zugangsdaten und Mount-Optionen: aus einer normalen Root-Shell gelingt
|
||||
# derselbe Aufruf sofort. Der Umweg ueber nsenter in den Namensraum des Wirts
|
||||
# ist keine Alternative, weil "RestrictNamespaces=yes" setns verbietet.
|
||||
#
|
||||
# Eingebunden wird damit im Mount-Namensraum *dieses* Dienstes. Das ist genau
|
||||
# richtig: nur er liest die Dateien, andere Prozesse haben dort nichts zu
|
||||
# suchen, und mit dem Dienst verschwinden die Einbindungen wieder. Die
|
||||
# Aufraeumaufgabe fuer abgelaufene Einbindungen laeuft deshalb im Webprozess --
|
||||
# der Ueberwachungsdienst koennte sie nicht einmal sehen.
|
||||
SystemCallFilter=@system-service @mount
|
||||
SystemCallErrorNumber=EPERM
|
||||
# Bewusst **kein** einschraenkendes CapabilityBoundingSet: sudo braucht
|
||||
# CAP_SETUID/CAP_SETGID, und der Helfer dahinter braucht Root. Was hier fehlt,
|
||||
# kann auch der Helfer nicht mehr erlangen -- gemessen: jede Einschraenkung
|
||||
# fuehrt zu "sudo: unable to change to root gid". Die Grenze ist die
|
||||
# sudoers-Regel und die Argumentpruefung im Helfer, nicht dieser Schalter.
|
||||
UMask=0027
|
||||
# Genau die Pfade, die der privilegierte Helfer schreibt -- nicht mehr.
|
||||
# ProtectSystem=full sperrt /usr, /boot **und /etc**; ohne diese Freigaben
|
||||
# scheitert der Helfer mit "Read-only file system", obwohl er root ist
|
||||
# (gemessen). Der unprivilegierte Dienst kommt hier trotzdem nicht heran: die
|
||||
# Dateien gehoeren root, und sudo laesst nur den einen Helferpfad zu.
|
||||
ReadWritePaths=/srv/tesm/data /var/log/tesm /mnt/tesm-shares /etc/nginx /etc/netplan /etc/kea /etc/logrotate.d /etc/tesm /var/lib/kea /var/log/kea /run
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,382 @@
|
||||
#!/bin/bash
|
||||
# =============================================================================
|
||||
# tesm-helper -- die einzige Stelle, an der TESM Root-Rechte bekommt.
|
||||
#
|
||||
# Der Webprozess laeuft unprivilegiert. Alles, was Root braucht, geht durch
|
||||
# dieses Skript: es gehoert root, ist per sudoers mit NOPASSWD nur fuer genau
|
||||
# diesen Pfad freigegeben und prueft jedes Argument gegen eine Positivliste.
|
||||
#
|
||||
# Damit ist die Sicherheitsgrenze klein, an einer Stelle und pruefbar -- statt
|
||||
# ueber tausende Zeilen Anwendungscode verteilt. Der Vorgaenger liess die
|
||||
# komplette Flask-Anwendung als root laufen; eine einzige Luecke in irgendeinem
|
||||
# Pfad haette den Host bedeutet.
|
||||
#
|
||||
# Regeln fuer Aenderungen an dieser Datei:
|
||||
# * Jedes neue Verb braucht eine Argumentpruefung. Ohne Pruefung kein Verb.
|
||||
# * Nie "$@" ungeprueft an eine Shell weiterreichen.
|
||||
# * Nie einen Pfad akzeptieren, der nicht gegen eine feste Liste geprueft ist.
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
umask 022
|
||||
|
||||
APP_KEYS_ALLOWED=("tesm" "tesm-license")
|
||||
SERVICES_ALLOWED=(
|
||||
"tesm.service" "tesm-monitor.service"
|
||||
"tesm-license.service"
|
||||
"kea-dhcp4-server" "kea-dhcp4-server.service"
|
||||
"nginx" "nginx.service"
|
||||
)
|
||||
PACKAGES_ALLOWED=("kea-dhcp4-server" "kea-common" "cifs-utils" "certbot" "expect"
|
||||
"samba-common-bin")
|
||||
|
||||
fail() { echo "tesm-helper: $*" >&2; exit 2; }
|
||||
|
||||
require_root() {
|
||||
[[ "$(id -u)" -eq 0 ]] || fail "muss als root laufen (ueber sudo aufrufen)"
|
||||
}
|
||||
|
||||
# -- Argumentpruefungen ------------------------------------------------------
|
||||
|
||||
check_in_list() {
|
||||
local needle="$1"; shift
|
||||
local item
|
||||
for item in "$@"; do
|
||||
[[ "$needle" == "$item" ]] && return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# Name einer Installation auf diesem Host: entweder die Hauptinstallation
|
||||
# ("tesm") oder eine benannte Instanz daneben ("tesm-test"). Mehr als diese
|
||||
# beiden Formen gibt es nicht -- so kann keine erfundene Zeichenkette einen
|
||||
# Pfad ausserhalb des vorgesehenen Namensraums erzeugen.
|
||||
check_app_key() {
|
||||
local key="$1" app
|
||||
for app in "${APP_KEYS_ALLOWED[@]}"; do
|
||||
[[ "$key" == "$app" ]] && return 0
|
||||
[[ "$key" == "$app-"* && "${key#"$app"-}" =~ ^[a-z0-9][a-z0-9-]{0,15}$ ]] && return 0
|
||||
done
|
||||
fail "unbekannter App-Schluessel: $key"
|
||||
}
|
||||
|
||||
check_service() {
|
||||
check_in_list "$1" "${SERVICES_ALLOWED[@]}" || fail "Dienst nicht erlaubt: $1"
|
||||
}
|
||||
|
||||
check_hostname() {
|
||||
[[ "$1" =~ ^[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?$ ]] || fail "ungueltiger Hostname"
|
||||
}
|
||||
|
||||
check_timezone() {
|
||||
[[ "$1" =~ ^[A-Za-z]+(/[A-Za-z0-9_+-]+){0,2}$ ]] || fail "ungueltige Zeitzone"
|
||||
[[ -f "/usr/share/zoneinfo/$1" ]] || fail "unbekannte Zeitzone"
|
||||
}
|
||||
|
||||
check_domain() {
|
||||
[[ "$1" =~ ^[A-Za-z0-9]([A-Za-z0-9.-]{0,251}[A-Za-z0-9])?$ ]] || fail "ungueltige Domain"
|
||||
}
|
||||
|
||||
check_email() {
|
||||
[[ "$1" =~ ^[^@[:space:]]+@[^@[:space:]]+\.[^@[:space:]]+$ ]] || fail "ungueltige E-Mail"
|
||||
}
|
||||
|
||||
check_unc() {
|
||||
[[ "$1" =~ ^//[A-Za-z0-9._-]+/[^[:space:]]+$ || "$1" =~ ^\\\\[A-Za-z0-9._-]+\\[^[:space:]]+$ ]] \
|
||||
|| fail "ungueltiger Freigabepfad"
|
||||
}
|
||||
|
||||
check_mount_target() {
|
||||
# Nur unterhalb der bekannten Einbindungswurzeln, kein ".." im Pfad.
|
||||
[[ "$1" == /mnt/tesm-shares/* ]] || fail "Einbindungsziel liegt ausserhalb von /mnt/tesm-shares"
|
||||
[[ "$1" != *".."* ]] || fail "unzulaessiger Pfad"
|
||||
}
|
||||
|
||||
check_readable_path() {
|
||||
# Positivliste der Dateien, die die Anwendung lesen darf, obwohl sie ihr
|
||||
# nicht gehoeren. Ohne die ".."-Sperre koennte ein Muster wie
|
||||
# /etc/nginx/sites-available/../../../root/tesm die Liste unterlaufen: der
|
||||
# Basisname waere "tesm" und damit gueltig, gelesen wuerde aber /root/tesm.
|
||||
[[ "$1" != *".."* ]] || fail "unzulaessiger Pfad"
|
||||
case "$1" in
|
||||
/var/log/kea/kea-dhcp4.log) ;;
|
||||
/var/lib/kea/kea-leases4.csv) ;;
|
||||
/etc/netplan/90-*.yaml)
|
||||
check_app_key "$(basename "$1" .yaml | sed 's/^90-//')" ;;
|
||||
/etc/nginx/sites-available/*)
|
||||
check_app_key "$(basename "$1")" ;;
|
||||
*) fail "Datei nicht in der Leseliste: $1" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
read_stdin_limited() {
|
||||
# Maximal 1 MiB aus stdin -- verhindert, dass ein Fehler die Platte fuellt.
|
||||
head -c 1048576
|
||||
}
|
||||
|
||||
write_atomic() {
|
||||
local target="$1" mode="$2" owner="$3"
|
||||
local tmp
|
||||
tmp="$(mktemp "${target}.XXXXXX")"
|
||||
read_stdin_limited > "$tmp"
|
||||
chmod "$mode" "$tmp"
|
||||
[[ -n "$owner" ]] && chown "$owner" "$tmp"
|
||||
mv -f "$tmp" "$target"
|
||||
}
|
||||
|
||||
# -- Verben ------------------------------------------------------------------
|
||||
|
||||
verb="${1:-}"; shift || true
|
||||
require_root
|
||||
|
||||
case "$verb" in
|
||||
|
||||
service-status)
|
||||
check_service "${1:?Dienst fehlt}"
|
||||
systemctl is-active "$1" || true
|
||||
systemctl is-enabled "$1" || true
|
||||
;;
|
||||
|
||||
service-start|service-stop|service-restart|service-enable|service-disable)
|
||||
check_service "${1:?Dienst fehlt}"
|
||||
systemctl "${verb#service-}" "$1"
|
||||
;;
|
||||
|
||||
nginx-test)
|
||||
nginx -t
|
||||
;;
|
||||
|
||||
nginx-reload)
|
||||
nginx -t && systemctl reload nginx
|
||||
;;
|
||||
|
||||
nginx-write)
|
||||
check_app_key "${1:?App-Schluessel fehlt}"
|
||||
target="/etc/nginx/sites-available/$1"
|
||||
write_atomic "$target" 0644 "root:root"
|
||||
ln -sfn "$target" "/etc/nginx/sites-enabled/$1"
|
||||
rm -f /etc/nginx/sites-enabled/default
|
||||
;;
|
||||
|
||||
nginx-apply)
|
||||
# Schreiben, pruefen, neu laden -- als *eine* Operation, mit Ruecknahme.
|
||||
#
|
||||
# Getrennte Verben waren hier ein echter Fehler: "nginx-write" verlinkt die
|
||||
# Datei sofort nach sites-enabled. Schlug das anschliessende "nginx -t"
|
||||
# fehl, blieb eine kaputte Konfiguration aktiviert liegen. Der laufende
|
||||
# nginx merkte davon nichts -- aber der naechste Reload oder Neustart durch
|
||||
# irgendwen (auch fuer eine voellig andere Site) schlug fehl. Aus einem
|
||||
# abgelehnten Formular wurde so ein Ausfall des ganzen Webservers.
|
||||
check_app_key "${1:?App-Schluessel fehlt}"
|
||||
target="/etc/nginx/sites-available/$1"
|
||||
link="/etc/nginx/sites-enabled/$1"
|
||||
backup="$(mktemp /run/tesm-nginx.XXXXXX)"
|
||||
had_file=0; had_link=0
|
||||
[[ -f "$target" ]] && { cp -a "$target" "$backup"; had_file=1; }
|
||||
[[ -L "$link" || -e "$link" ]] && had_link=1
|
||||
|
||||
write_atomic "$target" 0644 "root:root"
|
||||
ln -sfn "$target" "$link"
|
||||
|
||||
if nginx -t 2>&1; then
|
||||
rm -f /etc/nginx/sites-enabled/default
|
||||
systemctl reload nginx
|
||||
rm -f "$backup"
|
||||
echo "uebernommen"
|
||||
else
|
||||
# Genau in den Zustand zurueck, der vorher galt.
|
||||
if [[ $had_file -eq 1 ]]; then
|
||||
cp -a "$backup" "$target"
|
||||
else
|
||||
rm -f "$target"
|
||||
fi
|
||||
[[ $had_link -eq 1 ]] || rm -f "$link"
|
||||
rm -f "$backup"
|
||||
# Nach der Ruecknahme muss die Konfiguration wieder sauber sein. Ist sie
|
||||
# es nicht, war sie schon vorher kaputt -- das gehoert ins Protokoll.
|
||||
nginx -t >/dev/null 2>&1 || echo "Warnung: nginx-Konfiguration war schon vorher fehlerhaft." >&2
|
||||
fail "nginx hat die Konfiguration abgelehnt -- alter Stand wiederhergestellt"
|
||||
fi
|
||||
;;
|
||||
|
||||
netplan-write)
|
||||
check_app_key "${1:?App-Schluessel fehlt}"
|
||||
# Eigene Datei je Anwendung: der Vorgaenger schrieb aus beiden Apps nach
|
||||
# /etc/netplan/90-tesm.yaml und ueberschrieb sich damit gegenseitig.
|
||||
write_atomic "/etc/netplan/90-$1.yaml" 0600 "root:root"
|
||||
;;
|
||||
|
||||
netplan-apply)
|
||||
netplan apply
|
||||
;;
|
||||
|
||||
hostname-set)
|
||||
check_hostname "${1:?Hostname fehlt}"
|
||||
hostnamectl set-hostname "$1"
|
||||
;;
|
||||
|
||||
timezone-set)
|
||||
check_timezone "${1:?Zeitzone fehlt}"
|
||||
timedatectl set-timezone "$1"
|
||||
;;
|
||||
|
||||
kea-write)
|
||||
install -d -o root -g root -m 0755 /etc/kea
|
||||
# Kea laeuft als eigener, unprivilegierter Benutzer und muss seine
|
||||
# Lease-Datei und sein Protokoll schreiben koennen. Vor dem Test anlegen,
|
||||
# sonst beanstandet Kea die fehlenden Verzeichnisse.
|
||||
install -d -o _kea -g _kea -m 0750 /var/lib/kea /var/log/kea 2>/dev/null || true
|
||||
|
||||
# Erst pruefen, dann uebernehmen. Vorher wurde jede Konfiguration
|
||||
# ungeprueft abgelegt -- eine fehlerhafte fiel erst beim naechsten Start
|
||||
# auf, und dann lag der Dienst, bis jemand ins Journal sah.
|
||||
tmp="$(mktemp /etc/kea/kea-dhcp4.conf.XXXXXX)"
|
||||
chmod 0640 "$tmp"; chown root:_kea "$tmp"
|
||||
read_stdin_limited > "$tmp"
|
||||
if command -v kea-dhcp4 >/dev/null 2>&1; then
|
||||
if ! pruefung="$(kea-dhcp4 -t "$tmp" 2>&1)"; then
|
||||
rm -f "$tmp"
|
||||
# Nur die aussagekraeftige Zeile zurueckgeben, nicht das ganze Protokoll.
|
||||
fail "Konfiguration abgelehnt: $(printf '%s' "$pruefung" | grep -m1 -i "error" || printf '%s' "$pruefung" | tail -1)"
|
||||
fi
|
||||
fi
|
||||
mv -f "$tmp" /etc/kea/kea-dhcp4.conf
|
||||
;;
|
||||
|
||||
logrotate-write)
|
||||
check_app_key "${1:?App-Schluessel fehlt}"
|
||||
write_atomic "/etc/logrotate.d/$1" 0644 "root:root"
|
||||
;;
|
||||
|
||||
mount-cifs)
|
||||
check_unc "${1:?Freigabe fehlt}"
|
||||
check_mount_target "${2:?Ziel fehlt}"
|
||||
share="${1//\\//}"
|
||||
target="$2"
|
||||
creds="$(mktemp /run/tesm-cifs.XXXXXX)"
|
||||
chmod 600 "$creds"
|
||||
# Zugangsdaten kommen ueber stdin in eine 0600-Datei -- nie ueber die
|
||||
# Kommandozeile (Prozessliste) und nie ueber die Umgebung
|
||||
# (/proc/<pid>/environ).
|
||||
read_stdin_limited > "$creds"
|
||||
# Eingebunden wird im Mount-Namensraum des aufrufenden Dienstes -- absichtlich:
|
||||
# nur dieser Dienst liest die Dateien, und mit ihm verschwinden die
|
||||
# Einbindungen wieder. Damit der Syscall durchkommt, braucht die Unit
|
||||
# "@mount" im SystemCallFilter. Ohne das scheitert es mit
|
||||
# "mount error(1): Operation not permitted"; gemessen war der Syscall-Filter
|
||||
# die Ursache, nicht der Namensraum. Ein Umweg ueber nsenter in den
|
||||
# Namensraum des Wirts ist keine Option -- "RestrictNamespaces=yes"
|
||||
# verbietet setns, und das Recht dafuer wieder aufzugeben waere teurer als
|
||||
# der Gewinn.
|
||||
mkdir -p "$target"
|
||||
if mount -t cifs "$share" "$target" \
|
||||
-o "credentials=$creds,uid=$(id -u tesm 2>/dev/null || echo 0),gid=$(id -g tesm 2>/dev/null || echo 0),file_mode=0640,dir_mode=0750,vers=3.1.1,seal"
|
||||
then
|
||||
shred -u "$creds" 2>/dev/null || rm -f "$creds"
|
||||
else
|
||||
shred -u "$creds" 2>/dev/null || rm -f "$creds"
|
||||
fail "Einbindung fehlgeschlagen"
|
||||
fi
|
||||
;;
|
||||
|
||||
umount)
|
||||
check_mount_target "${1:?Ziel fehlt}"
|
||||
# Gegenstueck zu mount-cifs, und deshalb im selben Namensraum: loesen kann
|
||||
# nur, wer die Einbindung auch sieht. Ein Aufruf aus einem anderen Dienst
|
||||
# greift ins Leere -- genau deshalb laeuft die Aufraeumaufgabe fuer
|
||||
# abgelaufene Einbindungen im Webprozess und nicht im Ueberwachungsdienst.
|
||||
umount -l "$1" || true
|
||||
rmdir "$1" 2>/dev/null || true
|
||||
;;
|
||||
|
||||
apt-install)
|
||||
[[ $# -gt 0 ]] || fail "kein Paket angegeben"
|
||||
for package in "$@"; do
|
||||
check_in_list "$package" "${PACKAGES_ALLOWED[@]}" || fail "Paket nicht erlaubt: $package"
|
||||
done
|
||||
# apt laeuft ausserhalb des Sandkastens des aufrufenden Dienstes. Der wird
|
||||
# sonst an dpkg und dessen Maintainer-Skripte weitervererbt, und dort
|
||||
# scheitert etwas voellig Beilaeufiges: mit "ProtectKernelLogs=yes" gibt
|
||||
# "dmesg" ein EPERM zurueck, ein Postinst-Skript bricht daran ab, und dpkg
|
||||
# endet mit Code 1 -- die Installation schlaegt fehl, obwohl mit dem Paket
|
||||
# alles in Ordnung ist. Gemessen und mit systemd-run reproduzierbar
|
||||
# behoben: PID 1 startet den Prozess, also ohne unsere Haertung.
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
if command -v systemd-run >/dev/null 2>&1 && [[ -S /run/dbus/system_bus_socket ]]; then
|
||||
# Zwei getrennte Aufrufe, damit keine Shell dazwischen steht: die
|
||||
# Paketnamen gehen als eigene Argumente an apt-get, nicht durch eine
|
||||
# Kommandozeile, die sie erst wieder zerlegen muesste.
|
||||
ausserhalb=(systemd-run --wait --pipe --collect --quiet
|
||||
--setenv=DEBIAN_FRONTEND=noninteractive)
|
||||
"${ausserhalb[@]}" apt-get update -qq
|
||||
"${ausserhalb[@]}" apt-get install -y -qq "$@"
|
||||
else
|
||||
# Kein systemd (Container, Entwicklungsrechner): direkt, wie bisher.
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq "$@"
|
||||
fi
|
||||
;;
|
||||
|
||||
certbot-issue)
|
||||
check_domain "${1:?Domain fehlt}"
|
||||
check_email "${2:?E-Mail fehlt}"
|
||||
webroot="${3:?Webroot fehlt}"
|
||||
[[ "$webroot" == /var/www/* && "$webroot" != *".."* ]] || fail "ungueltiger Webroot"
|
||||
mkdir -p "$webroot"
|
||||
# Bewusst --webroot statt des nginx-Plugins: das Plugin wuerde die von der
|
||||
# Anwendung erzeugte Site-Datei selbst umschreiben.
|
||||
certbot certonly --webroot -w "$webroot" -d "$1" \
|
||||
--email "$2" --agree-tos --non-interactive --keep-until-expiring
|
||||
;;
|
||||
|
||||
selfsigned-issue)
|
||||
# Fuer Installationen ohne oeffentliche Domain: ein selbstsigniertes
|
||||
# Zertifikat, damit Port 443 ueberhaupt bedient werden kann. Bewusst
|
||||
# getrennt vom certbot-Pfad -- hier wird nichts nach draussen gesprochen.
|
||||
check_app_key "${1:?App-Schluessel fehlt}"
|
||||
subject="${2:?Common Name fehlt}"
|
||||
check_domain "$subject"
|
||||
dir="/etc/tesm/certs/$1"
|
||||
install -d -m 0755 /etc/tesm/certs
|
||||
# 0755 auf dem Verzeichnis, nicht 0750: die Anwendung muss das Zertifikat
|
||||
# lesen koennen, um Aussteller und Ablauf anzuzeigen. Geheim ist nur der
|
||||
# private Schluessel -- der bleibt 0640 root:root und wird ausschliesslich
|
||||
# vom nginx-Masterprozess (root) gelesen. Dieselbe Aufteilung wie bei
|
||||
# Let's Encrypt: live/ ist begehbar, privkey.pem nicht lesbar.
|
||||
install -d -m 0755 "$dir"
|
||||
|
||||
# Alternative Namen: alles, was ab Argument 3 kommt -- jeweils geprueft und
|
||||
# nach DNS- bzw. IP-Eintrag sortiert. Kein Argument geht ungeprueft weiter.
|
||||
alt="DNS:$subject"
|
||||
shift 2
|
||||
for name in "$@"; do
|
||||
if [[ "$name" =~ ^[0-9]{1,3}(\.[0-9]{1,3}){3}$ ]]; then
|
||||
alt="$alt,IP:$name"
|
||||
elif [[ "$name" =~ ^[A-Za-z0-9]([A-Za-z0-9.-]{0,251}[A-Za-z0-9])?$ ]]; then
|
||||
[[ "$name" == "$subject" ]] || alt="$alt,DNS:$name"
|
||||
else
|
||||
fail "ungueltiger alternativer Name: $name"
|
||||
fi
|
||||
done
|
||||
|
||||
openssl req -x509 -newkey rsa:3072 -sha256 -days 825 -nodes -keyout "$dir/privkey.pem" -out "$dir/fullchain.pem" -subj "/CN=$subject" -addext "subjectAltName=$alt" -addext "basicConstraints=critical,CA:FALSE" -addext "keyUsage=critical,digitalSignature,keyEncipherment" -addext "extendedKeyUsage=serverAuth" >/dev/null 2>&1 || fail "openssl ist fehlgeschlagen"
|
||||
chown root:root "$dir/privkey.pem" "$dir/fullchain.pem"
|
||||
chmod 0640 "$dir/privkey.pem"
|
||||
# Das Zertifikat selbst ist oeffentlich -- es geht bei jedem
|
||||
# TLS-Handschlag an jeden Client.
|
||||
chmod 0644 "$dir/fullchain.pem"
|
||||
# nginx laeuft als root und liest die Datei selbst -- die Anwendung braucht
|
||||
# den privaten Schluessel nie. Nur der Pfad wird zurueckgegeben.
|
||||
echo "$dir/fullchain.pem"
|
||||
echo "$dir/privkey.pem"
|
||||
;;
|
||||
|
||||
file-read)
|
||||
check_readable_path "${1:?Pfad fehlt}"
|
||||
[[ -f "$1" ]] || exit 0
|
||||
cat "$1"
|
||||
;;
|
||||
|
||||
*)
|
||||
fail "unbekanntes Verb: ${verb:-<leer>}"
|
||||
;;
|
||||
esac
|
||||
@@ -0,0 +1,185 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Live-Banner fuer die SSH-Anmeldung (MOTD).
|
||||
|
||||
Eingebunden ueber ``/etc/update-motd.d/50-<name>``, das ``install.sh``
|
||||
erzeugt. Aufruf mit ``--app`` und ``--root``; alles andere steht in der
|
||||
Datenbank der Installation.
|
||||
|
||||
**Nur Standardbibliothek, kein Flask, kein venv.** Der Vorgaenger importierte
|
||||
dafuer die ganze Anwendung -- bei jeder SSH-Anmeldung. Das ist teuer, und es
|
||||
faellt aus, sobald das venv nicht mehr passt: nach dem Umstieg auf diese
|
||||
Fassung zeigte das alte Banner monatelang eingefrorene Zahlen aus einer
|
||||
Datenbank, die niemand mehr fuellte. Hier wird gelesen, was auf der Platte
|
||||
liegt, und zwar **nur lesend** (``mode=ro``).
|
||||
|
||||
Ein Banner darf eine Anmeldung niemals stoeren: jede Auskunft, die nicht zu
|
||||
holen ist, wird zu einem kurzen Hinweis, und am Ende faengt ``main`` alles ab.
|
||||
Der Aufrufer setzt zusaetzlich ein Zeitlimit.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sqlite3
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
AMBER = "\033[38;2;226;166;60m"
|
||||
DIM = "\033[2m"
|
||||
BOLD = "\033[1m"
|
||||
RESET = "\033[0m"
|
||||
|
||||
#: Kea schreibt seine Leases als CSV; die letzte Zeile je Adresse gilt.
|
||||
#: Dieselbe Datei liest die Anwendung (``services/kea.py``: ``LEASE_FILE``).
|
||||
LEASE_FILE = "/var/lib/kea/kea-leases4.csv"
|
||||
|
||||
#: Zeile unter der Wortmarke. Wortlaut wie in der jeweiligen Anwendung
|
||||
#: (``AUTH_TAGLINE`` in deren ``__init__.py``) -- ein Hygienetest haelt beides
|
||||
#: zusammen.
|
||||
TAGLINE = {
|
||||
"tesm": "ÜBERWACHEN · BOOTEN · ANBINDEN",
|
||||
"tesm-license": "LIZENZIEREN",
|
||||
}
|
||||
|
||||
TITEL = {"tesm": "TimEShepManager", "tesm-license": "Lizenzserver"}
|
||||
|
||||
#: Wortgleich mit ``services/monitor.py``: ``DASHBOARD_COUNT_SQL``. Die
|
||||
#: Statusuebersicht und dieses Banner sollen nicht zwei Wahrheiten haben; ein
|
||||
#: Hygienetest vergleicht die beiden Zeichenketten.
|
||||
DASHBOARD_COUNT_SQL = """
|
||||
SELECT
|
||||
COUNT(*) AS total,
|
||||
SUM(CASE WHEN d.is_active=0 THEN 1 ELSE 0 END) AS disabled,
|
||||
SUM(CASE WHEN d.is_active=1 AND COALESCE(st.state,'unknown')='online' THEN 1 ELSE 0 END) AS online,
|
||||
SUM(CASE WHEN d.is_active=1 AND COALESCE(st.state,'unknown')='offline' THEN 1 ELSE 0 END) AS offline,
|
||||
SUM(CASE WHEN d.is_active=1 AND COALESCE(st.state,'unknown')='unknown' THEN 1 ELSE 0 END) AS unknown,
|
||||
SUM(CASE WHEN d.is_active=1 AND st.state='restarting' THEN 1 ELSE 0 END) AS restarting
|
||||
FROM devices d LEFT JOIN device_status st ON st.device_id = d.id
|
||||
WHERE d.deleted_at IS NULL
|
||||
"""
|
||||
|
||||
|
||||
def _zahl(anzahl: int, singular: str, plural: str) -> str:
|
||||
"""Zahl mit passendem Wort. "1 Clients" liest man jeden Tag einmal zu oft."""
|
||||
return f"{BOLD}{anzahl}{RESET} {singular if anzahl == 1 else plural}"
|
||||
|
||||
|
||||
def _oeffnen(root: Path) -> sqlite3.Connection:
|
||||
pfad = root / "data" / "app.db"
|
||||
conn = sqlite3.connect(f"file:{pfad.as_posix()}?mode=ro", uri=True, timeout=1.0)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
def _flotte(conn: sqlite3.Connection) -> str:
|
||||
row = conn.execute(DASHBOARD_COUNT_SQL).fetchone()
|
||||
online = int(row["online"] or 0)
|
||||
offline = int(row["offline"] or 0)
|
||||
teile = [
|
||||
_zahl(online, "Client online", "Clients online"),
|
||||
f"{BOLD}{offline}{RESET} nicht erreichbar",
|
||||
]
|
||||
# Nur nennen, wenn es sie gibt: eine Null bei "startet neu" ist keine
|
||||
# Auskunft, sondern Fuellmaterial.
|
||||
restarting = int(row["restarting"] or 0)
|
||||
if restarting:
|
||||
teile.append(_zahl(restarting, "startet neu", "starten neu"))
|
||||
unbekannt = int(row["unknown"] or 0)
|
||||
if unbekannt:
|
||||
teile.append(f"{DIM}{unbekannt} ohne Pruefung{RESET}")
|
||||
return " · ".join(teile)
|
||||
|
||||
|
||||
def _dhcp() -> str:
|
||||
"""Aktive Leases aus der Kea-CSV. Laeuft als root, braucht keinen Helfer."""
|
||||
datei = Path(LEASE_FILE)
|
||||
if not datei.is_file():
|
||||
return "keine Lease-Datei"
|
||||
try:
|
||||
zeilen = datei.read_text(encoding="utf-8", errors="replace").splitlines()
|
||||
except OSError:
|
||||
return "Lease-Datei nicht lesbar"
|
||||
if len(zeilen) < 2:
|
||||
return "0 Leases aktiv"
|
||||
kopf = [name.strip().strip('"') for name in zeilen[0].split(",")]
|
||||
try:
|
||||
i_adresse = kopf.index("address")
|
||||
i_ablauf = kopf.index("expire")
|
||||
i_zustand = kopf.index("state")
|
||||
except ValueError:
|
||||
return "Lease-Datei mit unerwarteten Spalten"
|
||||
|
||||
jetzt = int(time.time())
|
||||
letzte: dict[str, tuple[int, str]] = {}
|
||||
for zeile in zeilen[1:]:
|
||||
felder = [feld.strip().strip('"') for feld in zeile.split(",")]
|
||||
if len(felder) < len(kopf):
|
||||
continue
|
||||
try:
|
||||
letzte[felder[i_adresse]] = (int(felder[i_ablauf] or 0), felder[i_zustand] or "0")
|
||||
except ValueError:
|
||||
continue
|
||||
aktiv = sum(1 for ablauf, zustand in letzte.values() if zustand == "0" and ablauf > jetzt)
|
||||
return f"{aktiv} {'Lease' if aktiv == 1 else 'Leases'} aktiv"
|
||||
|
||||
|
||||
def _lizenzen(conn: sqlite3.Connection) -> str:
|
||||
kunden = int(conn.execute("SELECT COUNT(*) FROM license_customers").fetchone()[0] or 0)
|
||||
lizenzen = int(
|
||||
conn.execute(
|
||||
"SELECT COUNT(*) FROM licenses WHERE status IN ('issued','active')"
|
||||
).fetchone()[0]
|
||||
or 0
|
||||
)
|
||||
return (
|
||||
_zahl(kunden, "Kunde", "Kunden")
|
||||
+ " · "
|
||||
+ _zahl(lizenzen, "Lizenz ausgestellt", "Lizenzen ausgestellt")
|
||||
)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(prog="tesm-motd", description="Banner fuer die SSH-Anmeldung")
|
||||
parser.add_argument("--app", required=True, choices=sorted(TITEL))
|
||||
parser.add_argument("--root", required=True, help="Verzeichnis der Installation")
|
||||
parser.add_argument("--no-color", action="store_true")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if args.no_color:
|
||||
globals().update(AMBER="", DIM="", BOLD="", RESET="")
|
||||
|
||||
root = Path(args.root)
|
||||
# Kein isatty()-Test: dieses Skript schreibt beim Login nicht auf ein
|
||||
# Terminal, sondern ueber run-parts in eine Datei. Der Betrachter ist
|
||||
# trotzdem ein echtes Terminal -- deshalb immer Farbe.
|
||||
print(f"{BOLD}{AMBER}TESM{RESET} · {TITEL[args.app]}")
|
||||
if TAGLINE[args.app]:
|
||||
print(f"{DIM}{TAGLINE[args.app]}{RESET}")
|
||||
print()
|
||||
try:
|
||||
conn = _oeffnen(root)
|
||||
except sqlite3.Error:
|
||||
print(f"{DIM}Datenbank nicht lesbar: {root}{RESET}")
|
||||
print()
|
||||
return 0
|
||||
try:
|
||||
if args.app == "tesm":
|
||||
print(f"{_flotte(conn)} · DHCP: {_dhcp()}")
|
||||
else:
|
||||
print(_lizenzen(conn))
|
||||
except sqlite3.Error as fehler:
|
||||
print(f"{DIM}Zustand nicht verfuegbar ({fehler}){RESET}")
|
||||
finally:
|
||||
conn.close()
|
||||
print()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except SystemExit:
|
||||
raise
|
||||
except Exception: # noqa: BLE001 - ein Banner darf keinen Login stoeren
|
||||
sys.exit(0)
|
||||
@@ -0,0 +1,227 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# Aktualisierung: laedt das aktuelle Release und ruft dessen install.sh auf.
|
||||
# Enthaelt selbst keinerlei Installationslogik.
|
||||
#
|
||||
# Eine einzelne Datei genuegt -- sie holt sich alles Weitere:
|
||||
#
|
||||
# curl -fsSLO https://gitea.int.eertmoed.net/alientim/tesm/raw/branch/main/deploy/update.sh
|
||||
# sudo bash update.sh tesm
|
||||
#
|
||||
# Auf einer bestehenden Installation liegt sie schon bereit:
|
||||
#
|
||||
# sudo bash /srv/tesm/deploy/update.sh tesm
|
||||
#
|
||||
# Laeuft hier die Master-Ausgabe des Lizenzservers, kommt das Release aus dem
|
||||
# privaten Repositorium tesm-license-master -- dafuer braucht es einen
|
||||
# Zugriffstoken in TESM_RELEASE_TOKEN oder in /etc/tesm-release.token.
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
APP="${1:-}"
|
||||
TAG="${2:-latest}"
|
||||
|
||||
# Alles nach dem Tag geht unveraendert an install.sh weiter. Ohne das liesse
|
||||
# sich eine benannte Instanz nicht aktualisieren: install.sh leitet den Namen
|
||||
# aus "--instance" ab, und ohne den Schalter entstuende neben "tesm-test" eine
|
||||
# zweite Installation namens "tesm" -- mit eigener Datenbank, eigener
|
||||
# nginx-Site und demselben Port.
|
||||
shift $(( $# > 2 ? 2 : $# ))
|
||||
EXTRA=("$@")
|
||||
|
||||
[[ "$APP" == "tesm" || "$APP" == "tesm-license" ]] || {
|
||||
echo "Verwendung: update.sh <tesm|tesm-license> [tag] [install.sh-Argumente ...]" >&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
|
||||
}
|
||||
|
||||
# Repositorium: die Master-Ausgabe des Lizenzservers liegt in einem eigenen,
|
||||
# privaten Repositorium, heisst als Installation aber weiter "tesm-license".
|
||||
# Erkannt wird sie an der Installation und nicht an einem Schalter -- wer
|
||||
# aktualisiert, soll nicht erst wissen muessen, welche Ausgabe hier laeuft.
|
||||
# Sonst holte der Aufruf die Ausgabe fuer Sub-Lizenzserver, und der Installer
|
||||
# muesste sie abweisen.
|
||||
REPO="$APP"
|
||||
if [[ "$APP" == "tesm-license" ]]; then
|
||||
NAME="$APP"
|
||||
for ((i = 0; i < ${#EXTRA[@]}; i++)); do
|
||||
[[ "${EXTRA[i]}" == "--instance" ]] && NAME="$APP-${EXTRA[i + 1]:-}"
|
||||
done
|
||||
# Die Wurzel ist ueberschreibbar, damit ein Test die Erkennung wirklich
|
||||
# ausfuehren kann und nicht nur den Quelltext liest.
|
||||
if [[ -d "${TESM_SRV_ROOT:-/srv}/$NAME/apps/tesm-license/src/tesm_license/master" ]]; then
|
||||
REPO="tesm-license-master"
|
||||
echo "==> Master-Ausgabe erkannt -- Release aus $REPO."
|
||||
fi
|
||||
fi
|
||||
|
||||
# --8<-- Bootstrap: Release holen --------------------------------------------
|
||||
# Wortgleich in update.sh und upgrade.sh; ein Hygienetest vergleicht die beiden
|
||||
# Bloecke. Zwei eigenstaendige Skripte koennen sich keine Datei teilen, und
|
||||
# beide sollen als **einzelne Datei** genuegen: auf das Zielsystem legen,
|
||||
# ausfuehren, fertig -- auf einer 1.2.8 liegt nichts von diesem Projekt.
|
||||
#
|
||||
# Erwartet: $APP und $TAG, wahlweise $REPO (Vorgabe: $APP). Setzt: $TAG
|
||||
# (aufgeloest) und $SRC (entpacktes Release). Raeumt sein Arbeitsverzeichnis
|
||||
# beim Beenden auf.
|
||||
#
|
||||
# $REPO gibt es, weil Installationsname und Repositorium auseinanderfallen
|
||||
# koennen: die Master-Ausgabe des Lizenzservers liegt in
|
||||
# "tesm-license-master", die Installation heisst weiter "tesm-license".
|
||||
#
|
||||
# Zwei Fallen des Vorgaengers sind hier vermieden:
|
||||
# * Der Tag "latest" wird ueber die API aufgeloest, nie direkt in die
|
||||
# Download-URL geschrieben. Gitea liefert sonst mit HTTP 200 ein
|
||||
# automatisch erzeugtes Quell-Snapshot statt des echten Release-Anhangs.
|
||||
# * Nach dem Entpacken wird geprueft, dass ein install.sh existiert.
|
||||
BASE="${TESM_RELEASE_BASE:-https://gitea.int.eertmoed.net}"
|
||||
OWNER="${TESM_RELEASE_OWNER:-alientim}"
|
||||
|
||||
# Ein privates Repositorium -- tesm-license-master -- gibt sein Release nur mit
|
||||
# Zugriffstoken heraus. Reihenfolge: Umgebungsvariable, dann eine Datei, die
|
||||
# der Betreiber selbst anlegt. Der Installer legt sie nie an: ob ein Token auf
|
||||
# dem Server liegt, ist keine Entscheidung eines Skripts.
|
||||
TOKEN="${TESM_RELEASE_TOKEN:-}"
|
||||
if [[ -z "$TOKEN" && -r /etc/tesm-release.token ]]; then
|
||||
TOKEN="$(tr -d '[:space:]' < /etc/tesm-release.token)"
|
||||
fi
|
||||
|
||||
# Ein Wagenruecklauf im Paket macht jedes Shell-Skript auf dem Ziel unbrauchbar
|
||||
# ("bad interpreter"). Die Pruefung darf dabei nicht selbst von einem
|
||||
# Zusatzpaket abhaengen: mit "file" -- das auf einem schlanken Server fehlt --
|
||||
# endete sie in "command not found", grep bekam nichts zu sehen, und das Skript
|
||||
# machte weiter, als waere alles in Ordnung.
|
||||
hat_crlf() {
|
||||
local cr
|
||||
cr="$(printf '\r')"
|
||||
# -U (binaer lesen) ist unter Linux ohne Wirkung, auf einer
|
||||
# Windows-Buildmaschine aber entscheidend: dort entfernt grep den
|
||||
# Wagenruecklauf beim Lesen und findet deshalb nie einen.
|
||||
LC_ALL=C grep -qU "$cr" "$1"
|
||||
}
|
||||
|
||||
BOOTSTRAP_WORK=""
|
||||
bootstrap_aufraeumen() {
|
||||
# Den Ausstiegscode zuerst festhalten: alles danach ueberschreibt ihn.
|
||||
local code=$?
|
||||
[[ -n "$BOOTSTRAP_WORK" ]] && rm -rf "$BOOTSTRAP_WORK"
|
||||
# Wer einen eigenen Abschluss braucht -- etwa einen Rueckweg nach einem
|
||||
# Fehlschlag --, definiert "beim_beenden". Ein zweiter "trap ... EXIT" waere
|
||||
# der falsche Weg: er wuerde diesen hier ersetzen, und das entpackte Release
|
||||
# bliebe unter /tmp liegen.
|
||||
declare -F beim_beenden >/dev/null 2>&1 && beim_beenden "$code"
|
||||
return "$code"
|
||||
}
|
||||
trap bootstrap_aufraeumen EXIT
|
||||
|
||||
release_holen() {
|
||||
local repo="${TESM_RELEASE_REPO:-${REPO:-$APP}}" asset
|
||||
# Leeres Array und "set -u" vertragen sich nur mit dieser Schreibweise.
|
||||
local -a kopf=()
|
||||
[[ -n "$TOKEN" ]] && kopf=(-H "Authorization: token $TOKEN")
|
||||
for werkzeug in curl tar python3 find; do
|
||||
command -v "$werkzeug" >/dev/null 2>&1 || {
|
||||
echo "Auf diesem System fehlt $werkzeug -- ohne das geht es nicht." >&2
|
||||
return 1
|
||||
}
|
||||
done
|
||||
BOOTSTRAP_WORK="$(mktemp -d /tmp/tesm-release-XXXXXX)"
|
||||
|
||||
if [[ "$TAG" == "latest" ]]; then
|
||||
echo "==> Ermittle aktuellen Release-Tag ..."
|
||||
TAG="$(curl -fsS ${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 ------------------------------------------------------
|
||||
|
||||
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
|
||||
|
||||
# --yes darf auch hier stehen, nicht nur bei install.sh und upgrade.sh. Es
|
||||
# bleibt in EXTRA: install.sh bekommt es ohnehin, doppelt schadet nicht.
|
||||
OHNE_RUECKFRAGE=0
|
||||
for ((i = 0; i < ${#EXTRA[@]}; i++)); do
|
||||
[[ "${EXTRA[i]}" == "--yes" || "${EXTRA[i]}" == "-y" ]] && OHNE_RUECKFRAGE=1
|
||||
done
|
||||
|
||||
if [[ $OHNE_RUECKFRAGE -eq 1 ]]; then
|
||||
echo "==> Version $TAG wird ohne Rueckfrage installiert."
|
||||
elif [[ -t 0 ]]; then
|
||||
read -r -p "Version $TAG fuer $APP${EXTRA[0]+ (${EXTRA[*]})} installieren? [j/N] " answer
|
||||
[[ "$answer" =~ ^[jJyY]$ ]] || exit 0
|
||||
else
|
||||
# Ohne Terminal las "read" hier EOF, die Antwort blieb leer, und das Skript
|
||||
# endete mit 0 -- wer es aus einem anderen Skript aufrief, hielt das Update
|
||||
# fuer erledigt. Lieber laut abbrechen.
|
||||
echo "Kein Terminal fuer die Rueckfrage, und --yes fehlt -- es wurde nichts" >&2
|
||||
echo "installiert. Aufruf mit --yes wiederholen." >&2
|
||||
exit 3
|
||||
fi
|
||||
|
||||
# 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
|
||||
+523
@@ -0,0 +1,523 @@
|
||||
# Betrieb
|
||||
|
||||
Dieses Dokument richtet sich an die Person, die die Anlage installiert,
|
||||
aktualisiert und im Fehlerfall wieder zum Laufen bringt. Es beschreibt, was auf
|
||||
dem Host tatsächlich passiert -- nicht, wie die Oberfläche bedient wird.
|
||||
|
||||
---
|
||||
|
||||
## 1. Was wo liegt
|
||||
|
||||
| Zweck | Pfad |
|
||||
|---|---|
|
||||
| Anwendung (Code, venv, statische Dateien) | `/srv/<name>/` |
|
||||
| Instanzdaten (Datenbank, Schlüssel, Lizenz) | `/srv/<name>/data/` |
|
||||
| Protokolle | `/var/log/<name>/` |
|
||||
| Umgebungsdatei | `/etc/tesm/<name>.env` |
|
||||
| Zertifikate (selbstsigniert) | `/etc/tesm/certs/<name>/` |
|
||||
| nginx-Site | `/etc/nginx/sites-available/<name>` |
|
||||
| systemd-Unit | `/etc/systemd/system/<name>.service` |
|
||||
| Privilegierter Helfer | `/usr/local/lib/tesm/tesm-helper` |
|
||||
| Verwaltungswerkzeug | `/usr/local/bin/<name>-admin` |
|
||||
|
||||
`<name>` ist `tesm` bzw. `tesm-license` -- oder, bei einer zusätzlichen
|
||||
Instanz, `tesm-<instanz>`. Dieser Name (der **SITE_KEY**) benennt alles, was
|
||||
ausserhalb von `/srv/<name>` liegt. Deshalb können zwei Installationen
|
||||
derselben Anwendung nebeneinander laufen, ohne sich gegenseitig die
|
||||
nginx-Site, die netplan-Datei oder das Sitzungscookie zu überschreiben.
|
||||
|
||||
Alles, was ein Mensch verlieren würde, liegt unter `data/`. Das Verzeichnis
|
||||
wird von `install.sh` nie angefasst.
|
||||
|
||||
---
|
||||
|
||||
## 2. Installation
|
||||
|
||||
```bash
|
||||
sudo bash deploy/install.sh --app tesm
|
||||
sudo bash deploy/install.sh --app tesm-license
|
||||
```
|
||||
|
||||
Wichtige Optionen:
|
||||
|
||||
| Option | Wirkung |
|
||||
|---|---|
|
||||
| `--https` | Port 443 mit TLS, Port 80 leitet dorthin um. Ohne `--domain` entsteht ein selbstsigniertes Zertifikat. |
|
||||
| `--domain <name>` | Öffentlicher Name. Setzt `server_name`; die Site ist dann **nicht** mehr die Vorgabe-Site. |
|
||||
| `--acme-email <a>` | Erst zusammen mit `--domain` wird ein Let's-Encrypt-Zertifikat angefordert (und HSTS gesetzt). Ohne die Adresse bleibt es selbstsigniert -- interne Namen wie `lizenz.firma.local` kann keine CA bestätigen. |
|
||||
| `--instance <name>` | Zusätzliche Instanz neben der Hauptinstallation. |
|
||||
| `--port` / `--http-port` / `--https-port` | Abweichende Ports (nur für Testinstanzen sinnvoll). |
|
||||
| `--skip-packages` | Keine Systempakete installieren. |
|
||||
|
||||
Danach:
|
||||
|
||||
```bash
|
||||
sudo -u tesm tesm-admin create-admin
|
||||
```
|
||||
|
||||
Beim Lizenzserver zusätzlich, **in dieser Reihenfolge**:
|
||||
|
||||
```bash
|
||||
sudo -u tesm-license tesm-license-admin init-key # Signaturschlüssel -- sofort sichern!
|
||||
sudo -u tesm-license tesm-license-admin bootstrap # eigene Master-Lizenz
|
||||
sudo -u tesm-license tesm-license-admin create-admin
|
||||
```
|
||||
|
||||
> **Der Signaturschlüssel ist nicht wiederherstellbar.** Geht
|
||||
> `data/master_signing_key.json` verloren, ist keine ausgestellte Lizenz mehr
|
||||
> prüfbar. Sichern Sie ihn getrennt vom Server.
|
||||
|
||||
### Zwei Anwendungen auf einem Host
|
||||
|
||||
Beide wollen Port 80 und 443. nginx unterscheidet sie am Namen:
|
||||
|
||||
```bash
|
||||
sudo bash deploy/install.sh --app tesm --https
|
||||
sudo bash deploy/install.sh --app tesm-license --https --domain lizenz.firma.local
|
||||
```
|
||||
|
||||
Die Installation **ohne** Domain wird zur Vorgabe-Site (`default_server`) und
|
||||
beantwortet alles, was zu keinem Namen passt. Zwei namenlose Sites auf
|
||||
demselben Port weist nginx ab -- richtig so, und beim Übernehmen sofort
|
||||
sichtbar.
|
||||
|
||||
---
|
||||
|
||||
## 3. Aktualisierung
|
||||
|
||||
```bash
|
||||
sudo bash deploy/install.sh --app tesm
|
||||
```
|
||||
|
||||
Derselbe Befehl. Das Skript erkennt an der vorhandenen Datenbank, dass es eine
|
||||
Aktualisierung ist, und tut dann zusätzlich:
|
||||
|
||||
1. **Vollständige Sicherung** nach `/srv/<name>-backup-<zeitstempel>` --
|
||||
unabhängig davon, ob sich das Schema geändert hat.
|
||||
2. Dateiabgleich; `data/` bleibt unangetastet.
|
||||
3. Schema-Migrationen beim Start (versioniert, mit Prüfsumme).
|
||||
4. **Gesundheitsprüfung.** Antwortet die Anwendung nicht, wird der alte Stand
|
||||
automatisch zurückgeholt; die fehlgeschlagene Version bleibt unter
|
||||
`/srv/<name>-failed-<zeitstempel>` zur Analyse liegen.
|
||||
|
||||
Eine bereits eingerichtete HTTPS-Konfiguration bleibt erhalten, auch ohne
|
||||
`--https`.
|
||||
|
||||
### Ohne Quellbaum: `update.sh`
|
||||
|
||||
`update.sh` genügt als **einzelne Datei**. Auf einem System, auf dem noch nichts
|
||||
von diesem Projekt liegt:
|
||||
|
||||
```bash
|
||||
curl -fsSLO https://gitea.int.eertmoed.net/alientim/tesm/raw/branch/main/deploy/update.sh
|
||||
sudo bash update.sh tesm
|
||||
```
|
||||
|
||||
Auf einer bestehenden Installation liegt sie schon bereit:
|
||||
|
||||
```bash
|
||||
sudo bash /srv/tesm/deploy/update.sh tesm
|
||||
sudo bash /srv/tesm-license/deploy/update.sh tesm-license
|
||||
```
|
||||
|
||||
**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,
|
||||
eine **benannte Instanz** zu aktualisieren:
|
||||
|
||||
```bash
|
||||
sudo bash /srv/tesm/deploy/update.sh tesm
|
||||
```
|
||||
|
||||
Ohne `--instance` entstünde daneben eine zweite Installation namens `tesm`, mit
|
||||
eigener Datenbank und eigener nginx-Site. Die Ports einer bestehenden
|
||||
Installation bleiben erhalten, auch wenn sie von den Vorgaben abweichen: eine
|
||||
Aktualisierung verschiebt nichts, was nicht ausdrücklich neu gesetzt wird.
|
||||
|
||||
Ohne Tag wird `latest` **über die API aufgelöst**, nicht in die URL geschrieben:
|
||||
Gitea liefert unter einem Tag-Namen sonst ein automatisch erzeugtes
|
||||
Quell-Archiv mit HTTP 200 statt des echten Anhangs -- und das enthält kein
|
||||
Release, sondern den Repositoriumsstand.
|
||||
|
||||
Die Bezugsquelle ist voreingestellt und lässt sich über die Umgebung umlenken,
|
||||
etwa auf eine Spiegelung:
|
||||
|
||||
| Variable | Vorgabe |
|
||||
|---|---|
|
||||
| `TESM_RELEASE_BASE` | `https://gitea.int.eertmoed.net` |
|
||||
| `TESM_RELEASE_OWNER` | `alientim` |
|
||||
| `TESM_RELEASE_REPO` | der Anwendungsname (`tesm` bzw. `tesm-license`) |
|
||||
|
||||
Der erwartete Anhang heisst `<repo>-<tag>.tar.gz`.
|
||||
|
||||
### Upgrade vom Stand 1.x (Branch `SONNET5`)
|
||||
|
||||
Ein eigenes Skript, weil dabei mehr passiert als ein Dateiabgleich: die alte
|
||||
Fassung hat ein anderes Schema, andere Rechteschlüssel und ein anderes
|
||||
Exportformat.
|
||||
|
||||
Auch `upgrade.sh` genügt als **einzelne Datei** -- und das ist hier der
|
||||
Regelfall: auf einer 1.2.8 liegt nichts von dieser Fassung.
|
||||
|
||||
```bash
|
||||
curl -fsSLO https://gitea.int.eertmoed.net/alientim/tesm/raw/branch/main/deploy/upgrade.sh
|
||||
sudo bash upgrade.sh --app tesm
|
||||
```
|
||||
|
||||
Sie lädt das aktuelle Release, entpackt es und benutzt dessen `install.sh`.
|
||||
Liegt das Skript in einem entpackten Release, wird dieses benutzt statt eines
|
||||
Downloads -- wer ein Paket ausgepackt hat, will nicht, dass ungefragt ein
|
||||
anderes geladen wird:
|
||||
|
||||
```bash
|
||||
sudo bash deploy/upgrade.sh --app tesm
|
||||
sudo bash deploy/upgrade.sh --app tesm-license
|
||||
```
|
||||
|
||||
Eine bestimmte Version statt `latest`: `--tag v2.0.8`.
|
||||
|
||||
**Wenn etwas schiefgeht.** Schlägt `install.sh` fehl, dreht `upgrade.sh` den
|
||||
Lauf selbst zurück: der neue Dienst wird gestoppt, das Verzeichnis aus der
|
||||
Sicherung wiederhergestellt und der alte Dienst wieder eingeschaltet. Das
|
||||
Verzeichnis allein genügt dafür nicht -- bei einem Upgrade in dasselbe
|
||||
Verzeichnis schreibt `install.sh` auch systemd-Unit, nginx-Site, logrotate und
|
||||
Banner neu und ersetzt das venv. Die Sicherung enthält deshalb alles davon,
|
||||
und ein Manifest hält fest, woher der Stand kam.
|
||||
|
||||
Später von Hand zurückdrehen, etwa nach einem Lauf, der durchlief aber nicht
|
||||
gefallen hat:
|
||||
|
||||
```bash
|
||||
sudo bash upgrade.sh --app tesm --rollback # neueste Sicherung
|
||||
sudo bash upgrade.sh --app tesm --rollback /srv/tesm-upgrade-20260908-225428
|
||||
```
|
||||
|
||||
Die Sicherung bleibt danach liegen; gelöscht wird nichts.
|
||||
|
||||
Ohne `--source` wird die alte Fassung an zwei Stellen gesucht: unter
|
||||
`/srv/<app>` und unter `/srv/<app>-sonnet5`. Der zweite Ort ist der, an den
|
||||
`rename.sh` sie verschiebt, sobald der Hauptname für die neue Installation
|
||||
gebraucht wird -- danach liegt unter `/srv/<app>` die **laufende** Installation.
|
||||
Erkannt wird eine 1.x an der `sqlite.db` im Wurzelverzeichnis; die neue Fassung
|
||||
legt ihre Datenbank unter `data/app.db` ab.
|
||||
|
||||
Der Ablauf ist die Vorgabe und braucht keine Schalter:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| 1. Sichern | Vollständige Kopie der alten Installation nach `/srv/<name>-upgrade-<zeitstempel>/alt` (ohne `venv`, Verzeichnis 0700) |
|
||||
| 2. Abschalten | Alter Dienst **und seine Timer** werden gestoppt und deaktiviert |
|
||||
| 3. Installieren | `install.sh` dieses Verzeichnisses |
|
||||
| 4. Exportieren | Aus der Kopie entsteht `export-alt.json` im **alten** Format |
|
||||
| 5. Übersetzen | Daraus `import-neu.json` im neuen Format |
|
||||
| 6. Einspielen | Diese Datei geht durch denselben Import wie eine Sicherung |
|
||||
|
||||
Warum die Sicherung **vor** dem Export steht: ohne `--instance` installiert
|
||||
`install.sh` in dasselbe Verzeichnis und ersetzt die alte Installation. Wer
|
||||
erst exportiert und dann sichert, sichert im schlechtesten Fall nichts mehr.
|
||||
Gelesen wird ausschliesslich aus der Kopie -- die alte Installation wird nie
|
||||
verändert.
|
||||
|
||||
Die beiden Zwischendateien bleiben liegen; ihre Passphrase steht daneben in
|
||||
`passphrase.txt` (0600, Verzeichnis 0700). `export-alt.json` ist ein echter
|
||||
Export des Vorgängers und taugt deshalb auch als Rückweg.
|
||||
|
||||
**Daneben statt darüber.** Mit `--instance` entsteht eine zweite Installation,
|
||||
und die alte bleibt vollständig liegen:
|
||||
|
||||
```bash
|
||||
sudo bash deploy/upgrade.sh --app tesm --instance test --keep-old-running --port 5100 --http-port 8080
|
||||
```
|
||||
|
||||
**Was nicht mitkommt** -- und warum:
|
||||
|
||||
* **Lokale Konten.** Der alte Hash ist bcrypt, hier gilt Argon2id. Ein Hash
|
||||
ist nicht umrechenbar; das ist sein Zweck. Erstes Konto mit
|
||||
`tesm-admin create-admin`.
|
||||
* **Gruppen und Rechte.** Die Rechteschlüssel wurden neu geschnitten.
|
||||
* **LDAP, Webserver, Zertifikat.** Von Hand nachziehen.
|
||||
|
||||
Beim **Lizenzserver** gibt es Schritt 4 und 5 nicht: die Übernahme liest die
|
||||
alte Datenbank unmittelbar, holt Endpunkt und Anbieterdaten aus ihren
|
||||
Einstellungen und **signiert jede Lizenz neu**. Der alte Signaturschlüssel wird
|
||||
dabei übernommen -- sonst müsste jeder Kunde einen Ausstellerwechsel
|
||||
bestätigen; `--new-key` erzwingt das Gegenteil. Der Bericht nennt namentlich,
|
||||
wer eine neue Lizenzdatei braucht, denn die Bindung an eine Maschine gilt nur
|
||||
für die alte Datei.
|
||||
|
||||
Nur die Anwendung, ohne Daten: `--no-data`. Nur zeigen, was geschähe:
|
||||
`--dry-run`.
|
||||
|
||||
---
|
||||
|
||||
## 4. HTTPS
|
||||
|
||||
Der Zielzustand ist immer: **Port 443 mit TLS, Port 80 leitet um, ausser dem
|
||||
ACME-Pfad.**
|
||||
|
||||
Drei Wege dorthin:
|
||||
|
||||
**a) Bei der Installation** -- siehe oben, `--https`.
|
||||
|
||||
**b) Nachträglich über die Kommandozeile:**
|
||||
|
||||
```bash
|
||||
sudo -u tesm tesm-admin web-setup --https --self-signed --apply
|
||||
sudo -u tesm tesm-admin web-setup --https --domain tesm.firma.de \
|
||||
--acme --acme-email it@firma.de --hsts --apply
|
||||
```
|
||||
|
||||
`--print-config` zeigt die Datei, ohne etwas zu schreiben.
|
||||
|
||||
**c) In der Oberfläche** unter *Einstellungen -> Webserver und TLS*.
|
||||
|
||||
Danach muss `COOKIE_SECURE` in `/etc/tesm/<name>.env` auf `1` stehen (alle drei
|
||||
Wege erledigen das selbst) und der Dienst neu gestartet werden.
|
||||
|
||||
### Die Site-Datei wird immer neu erzeugt
|
||||
|
||||
Sie ist **kein** Ort für Handarbeit. Jede Übernahme schreibt sie vollständig
|
||||
aus der Vorlage neu. Was früher von Hand hineingeschrieben wurde -- Domain,
|
||||
Ports, Zertifikatspfade, HSTS, Upload-Grenze -- ist heute Einstellung in der
|
||||
Anwendung.
|
||||
|
||||
Der Grund steht im Vorgängerprojekt: dort wurde die Datei nur angelegt, wenn
|
||||
sie fehlte. Ein einmal falscher `alias`-Pfad überlebte dadurch jahrelang jedes
|
||||
Update, und die Oberfläche kam die ganze Zeit ohne Design.
|
||||
|
||||
### Selbstsigniert oder Let's Encrypt?
|
||||
|
||||
* **Öffentlich erreichbar, echter DNS-Name:** Let's Encrypt. Nur dann ist HSTS
|
||||
sinnvoll.
|
||||
* **Geschlossenes Netz:** selbstsigniert. Browser zeigen eine Warnung, die
|
||||
Verbindung ist trotzdem verschlüsselt und das Sitzungscookie darf `Secure`
|
||||
tragen. **HSTS hier nicht einschalten** -- der Browser merkt sich die Vorgabe
|
||||
monatelang und die Warnung lässt sich dann nicht mehr wegklicken.
|
||||
|
||||
Die Erneuerung von Let's Encrypt läuft über `certbot certonly --webroot`
|
||||
(bewusst nicht über das nginx-Plugin, das die erzeugte Site umschreiben
|
||||
würde). Der ACME-Pfad bleibt auch bei aktiver Umleitung erreichbar.
|
||||
|
||||
---
|
||||
|
||||
## 4a. Anmeldebanner (MOTD)
|
||||
|
||||
`install.sh` legt `/etc/update-motd.d/50-<name>` an. Bei jeder SSH-Anmeldung
|
||||
steht dann der Zustand der Installation im Login-Banner:
|
||||
|
||||
```
|
||||
TESM · TimEShepManager
|
||||
UEBERWACHEN · BOOTEN · ANBINDEN (DHCP) · BETRIEBSSYSTEM GEBEN (PXE)
|
||||
|
||||
3 Clients online · 0 nicht erreichbar · DHCP: 2 Leases aktiv
|
||||
```
|
||||
|
||||
Das Skript liegt unter `/usr/local/lib/tesm/tesm-motd` und benutzt **nur die
|
||||
Standardbibliothek**: kein venv, kein Import der Anwendung, die Datenbank wird
|
||||
nur lesend geöffnet. Ein Lauf dauert Millisekunden. Der Vorgänger importierte
|
||||
dafür die ganze Flask-Anwendung -- bei jeder Anmeldung, und nach einem Wechsel
|
||||
des Quellverzeichnisses zeigte er stillschweigend die Zahlen einer Datenbank,
|
||||
die niemand mehr füllte.
|
||||
|
||||
Von Hand prüfen:
|
||||
|
||||
```bash
|
||||
sudo /usr/local/lib/tesm/tesm-motd --app tesm --root /srv/tesm-test
|
||||
```
|
||||
|
||||
Der Aufruf im Banner ist mit `timeout 3` und `|| true` abgesichert: ein
|
||||
hängender oder fehlschlagender Bannerlauf darf eine Anmeldung nie blockieren.
|
||||
Ein Banner der Vorgängerfassung (`50-tesm`, das `motd_banner.py` aufruft) wird
|
||||
entfernt -- nur dieses, nichts anderes in dem Verzeichnis.
|
||||
|
||||
---
|
||||
|
||||
## 4b. Installation umbenennen
|
||||
|
||||
Aus einer Instanz die Hauptinstallation machen (oder umgekehrt):
|
||||
|
||||
```bash
|
||||
sudo bash deploy/rename.sh --app tesm --from tesm-test --to tesm
|
||||
sudo bash deploy/rename.sh --app tesm-license --from tesm-license-test --to tesm-license
|
||||
```
|
||||
|
||||
Der Name steckt an mehr Stellen als im Verzeichnisnamen, und drei davon sind
|
||||
leicht zu übersehen:
|
||||
|
||||
* **`SITE_KEY`** in der Umgebungsdatei -- daraus entsteht der Name des
|
||||
Sitzungscookies. Bleibt er alt, heisst das Cookie weiter wie die alte
|
||||
Instanz.
|
||||
* **`web_cert_path`, `web_key_path`, `web_acme_webroot`** in der Datenbank. Sie
|
||||
enthalten den Namen; ein Zertifikat unter dem alten Pfad findet nginx nach
|
||||
dem Umzug nicht mehr.
|
||||
* **`server_name`** der nginx-Site. `install.sh` schreibt die Datei aus seiner
|
||||
Vorlage neu, sobald sich die `alias`-Pfade ändern -- und die ändern sich hier
|
||||
zwangsläufig. Ohne den Namen aus der Datenbank stünden danach zwei Sites mit
|
||||
`server_name _` auf Port 80, und die erste gewinnt.
|
||||
|
||||
Ausserdem wird das **venv verworfen und neu gebaut**: seine Konsolenskripte
|
||||
tragen den absoluten Pfad im Shebang, und nach dem Umzug zeigt
|
||||
`venv/bin/gunicorn` auf einen Interpreter, den es nicht mehr gibt. systemd
|
||||
meldet dann `No such file or directory` -- für eine Datei, die sichtbar da ist.
|
||||
|
||||
Belegt der Zielname noch eine Installation der Fassung 1.x, wandert **alles**,
|
||||
was ihn belegt, in ein Archiv `/srv/<ziel>-sonnet5`: das Verzeichnis, die
|
||||
Protokolle unter `/var/log/<ziel>`, der ACME-Pfad, die logrotate-Datei, und die
|
||||
systemd-Units samt nginx-Site unter `systemd-alt/`. Gelöscht wird nichts.
|
||||
|
||||
Ports und HTTPS bleiben, wie sie waren; ein vorhandenes Zertifikat zieht mit
|
||||
und wird weiterverwendet. **Alle angemeldeten Benutzer werden abgemeldet** --
|
||||
das Sitzungscookie heisst danach anders.
|
||||
|
||||
Danach ist auch das Aktualisieren einfacher, weil der Instanzname wegfällt:
|
||||
|
||||
```bash
|
||||
sudo bash /srv/tesm/deploy/update.sh tesm
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Dienste
|
||||
|
||||
```bash
|
||||
systemctl status tesm.service # Weboberfläche (gunicorn)
|
||||
systemctl status tesm-monitor.service # Überwachungsschleife (nur TESM)
|
||||
systemctl status tesm-license.service
|
||||
journalctl -u tesm -n 100 -f
|
||||
```
|
||||
|
||||
Die Units laufen unprivilegiert (`User=tesm`) mit
|
||||
`NoNewPrivileges`, `ProtectSystem=strict`, `PrivateTmp` und einer engen
|
||||
`RestrictAddressFamilies`-Liste. Schreibrechte gibt es nur auf `/srv/<name>/data`
|
||||
und `/var/log/<name>`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Root-Rechte
|
||||
|
||||
Die Anwendung hat keine. Alles, was Root braucht, geht durch **einen** Pfad:
|
||||
|
||||
```
|
||||
/usr/local/lib/tesm/tesm-helper <verb> [argumente]
|
||||
```
|
||||
|
||||
Der Helfer gehört `root`, ist per sudoers ausschliesslich für genau diesen
|
||||
Pfad mit `NOPASSWD` freigegeben und prüft jedes Argument gegen eine
|
||||
Positivliste. Er reicht nie etwas an eine Shell weiter (kein `eval`, kein
|
||||
`sh -c`).
|
||||
|
||||
Verben ansehen:
|
||||
|
||||
```bash
|
||||
sudo /usr/local/lib/tesm/tesm-helper
|
||||
grep -n ')$' /usr/local/lib/tesm/tesm-helper
|
||||
```
|
||||
|
||||
Regel für Änderungen: **Jedes neue Verb braucht eine Argumentprüfung.**
|
||||
`tests/test_repo_hygiene.py` erzwingt, dass jedes der Anwendung bekannte Verb
|
||||
im Helfer vorkommt und dass jede Pfadprüfung `..` abweist.
|
||||
|
||||
---
|
||||
|
||||
## 7. Sicherung und Wiederherstellung
|
||||
|
||||
In der Oberfläche unter *Sicherung* -- verschlüsselte Ausfuhr (Argon2id +
|
||||
AES-256-GCM) mit einem selbst gewählten Kennwort. Der Import zeigt zuerst eine
|
||||
Vorschau, bevor irgendetwas geschrieben wird.
|
||||
|
||||
Auf dem Host reicht:
|
||||
|
||||
```bash
|
||||
sudo systemctl stop tesm
|
||||
sudo tar -czf /root/tesm-data-$(date +%F).tar.gz -C /srv/tesm data
|
||||
sudo systemctl start tesm
|
||||
```
|
||||
|
||||
Unverzichtbar:
|
||||
|
||||
* `data/app.db` -- alle Daten
|
||||
* `data/secret.key` -- ohne sie sind alle Sitzungen ungültig
|
||||
* `data/data.keys` -- ohne sie sind **alle gespeicherten Geheimnisse verloren**
|
||||
* `data/license.json` und `data/license_key.json`
|
||||
* Lizenzserver zusätzlich: `data/master_signing_key.json`
|
||||
|
||||
---
|
||||
|
||||
## 8. Wenn etwas nicht geht
|
||||
|
||||
**Die Anwendung antwortet nicht.**
|
||||
```bash
|
||||
journalctl -u tesm -n 200 --no-pager
|
||||
curl -s http://127.0.0.1:5000/health
|
||||
```
|
||||
Antwortet gunicorn, aber der Browser nicht, liegt es an nginx.
|
||||
|
||||
**nginx lässt sich nicht neu laden.**
|
||||
```bash
|
||||
sudo nginx -t
|
||||
```
|
||||
Die Übernahme aus der Anwendung nimmt eine abgelehnte Konfiguration selbst
|
||||
zurück. Eine von Hand bearbeitete Datei tut das nicht -- in dem Fall hilft
|
||||
`install.sh`, das eine unbrauchbare Site durch die Vorlage ersetzt.
|
||||
|
||||
**Anmeldung schlägt ohne Fehlermeldung fehl.**
|
||||
Fast immer `COOKIE_SECURE=1` ohne HTTPS. Der Browser bekommt dann ein Cookie,
|
||||
das er über HTTP nie zurücksendet.
|
||||
```bash
|
||||
grep COOKIE_SECURE /etc/tesm/tesm.env
|
||||
```
|
||||
|
||||
**"Die Anfrage kam von einer fremden Herkunft (Origin/Referer)."**
|
||||
Der Reverse Proxy reicht den Host falsch durch. In der Site-Datei muss
|
||||
`proxy_set_header Host $http_host;` stehen -- **nicht** `$host`, das verwirft
|
||||
den Port.
|
||||
|
||||
**Statische Dateien fehlen, die Oberfläche ist unformatiert.**
|
||||
```bash
|
||||
grep alias /etc/nginx/sites-available/tesm
|
||||
ls /srv/tesm/static /srv/tesm/static-core
|
||||
```
|
||||
Beide `alias`-Pfade müssen in das Installationsverzeichnis zeigen.
|
||||
|
||||
**Zwei Instanzen stören sich.**
|
||||
`SITE_KEY` in beiden `/etc/tesm/*.env` vergleichen -- er muss sich
|
||||
unterscheiden. Sonst teilen sich beide nginx-Site und Sitzungscookie.
|
||||
|
||||
**Die Wartung meldet "Der SSH-Host-Schlüssel ist unbekannt".**
|
||||
Kein Fehler, sondern die Absicht: ein Hintergrundauftrag darf keinen fremden
|
||||
Schlüssel akzeptieren. Die Freigabe erfolgt von Hand, in zwei Schritten, auf
|
||||
der Detailseite des Geräts (*Clients -> Gerät -> SSH-Host-Schlüssel*):
|
||||
erst **Auslesen**, dann den Fingerprint mit einer unabhängigen Quelle
|
||||
vergleichen, dann **Freigeben**. Die Wartungsübersicht zeigt in einer eigenen
|
||||
Spalte, für welche Geräte das noch offen ist. Für Switche liegt dieselbe
|
||||
Freigabe auf der Switch-Detailseite.
|
||||
|
||||
Der Schlüssel wird nach **IP und Port** gespeichert, nicht nach Name -- TESM
|
||||
verbindet sich immer über die hinterlegte IP-Adresse. Ändert sich die IP
|
||||
eines Geräts, ist der Schlüssel erneut freizugeben.
|
||||
|
||||
**Die Prüfkette des Änderungsprotokolls ist gebrochen.**
|
||||
Unter *Diagnose* sichtbar. Das Protokoll ist verkettet gehasht; ein Bruch heisst,
|
||||
dass jemand direkt in die Datenbank geschrieben hat. Der Eintrag, ab dem es
|
||||
nicht mehr stimmt, wird genannt.
|
||||
|
||||
---
|
||||
|
||||
## 9. Zurück auf eine ältere Version
|
||||
|
||||
```bash
|
||||
sudo systemctl stop tesm
|
||||
sudo mv /srv/tesm /srv/tesm-kaputt
|
||||
sudo cp -a /srv/tesm-backup-<zeitstempel> /srv/tesm
|
||||
sudo systemctl start tesm
|
||||
```
|
||||
|
||||
Schema-Migrationen laufen nur vorwärts. Ein Rücksprung über eine
|
||||
Schema-Änderung hinweg braucht deshalb auch die Datenbank aus der Sicherung --
|
||||
genau das liegt im Backup-Verzeichnis.
|
||||
@@ -0,0 +1,188 @@
|
||||
# Sicherheit
|
||||
|
||||
Was geschützt wird, womit, und wo bewusst Grenzen gezogen sind.
|
||||
|
||||
---
|
||||
|
||||
## 1. Anmeldung
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Speicherung | Argon2id (argon2-cffi) |
|
||||
| Altbestand | bcrypt wird noch geprüft und bei der nächsten erfolgreichen Anmeldung stillschweigend auf Argon2id umgestellt |
|
||||
| Zweiter Faktor | TOTP, optional erzwingbar für Administratoren |
|
||||
| Rechte | Eine Zeile je Funktion; das Administratorkennzeichen gibt den Verwaltungsbereich, nicht alles |
|
||||
| Sperre je Konto | nach `LOGIN_MAX_ATTEMPTS` Versuchen |
|
||||
| Sperre je Adresse | zusätzlich `LOGIN_MAX_ATTEMPTS_PER_IP` -- sonst lässt sich die Kontosperre umgehen, indem man viele Konten je einmal probiert |
|
||||
| Verzeichnisdienst | LDAP nur über TLS, Zertifikat wird geprüft. Ohne gültiges Zertifikat keine Anmeldung -- kein Schalter, der das abschaltet |
|
||||
|
||||
Nach einer Anmeldung wird die Sitzungskennung neu vergeben (gegen Session
|
||||
Fixation) und das CSRF-Token gewechselt.
|
||||
|
||||
### Erneute Bestätigung ("sudo-Modus")
|
||||
|
||||
Alles, was Geheimnisse offenlegt oder Daten aus dem System herausträgt,
|
||||
verlangt eine frische Passworteingabe: Klartextanzeige von Zugangsdaten,
|
||||
Ausfuhr, Herunterladen eines Lizenzbundles, Schlüsselrotation,
|
||||
2FA-Änderungen, Deaktivierung einer Lizenz.
|
||||
|
||||
Ein übernommenes Sitzungscookie allein reicht dafür nicht.
|
||||
|
||||
---
|
||||
|
||||
## 2. Sitzungen
|
||||
|
||||
Serverseitig in SQLite, nicht im Cookie. Das Cookie trägt nur eine
|
||||
Zufallskennung.
|
||||
|
||||
* `HttpOnly`, `SameSite=Lax`, `Secure` sobald HTTPS eingerichtet ist
|
||||
* Leerlaufzeit **und** absolute Höchstdauer
|
||||
* Der Cookiename enthält den `SITE_KEY` -- zwei Installationen auf einem Host
|
||||
können sich die Sitzung nicht gegenseitig überschreiben (Cookies
|
||||
unterscheiden keine Ports)
|
||||
* Aktive Sitzungen sind einsehbar und einzeln beendbar
|
||||
|
||||
---
|
||||
|
||||
## 3. CSRF
|
||||
|
||||
Drei voneinander unabhängige Schichten:
|
||||
|
||||
1. `SameSite=Lax` am Sitzungscookie.
|
||||
2. Ein Token je Sitzung, in jedem Formular, in konstanter Zeit verglichen.
|
||||
3. Origin-/Referer-Prüfung gegen den Host der Anfrage.
|
||||
|
||||
Die dritte Schicht akzeptiert den Host mit und ohne Port sowie die
|
||||
Standardports -- **nicht** aber beliebige Ports: ein anderer Dienst auf
|
||||
demselben Rechner ist ein anderer Ursprung.
|
||||
|
||||
Der Vorgänger hatte gar keinen CSRF-Schutz. Eine präparierte Seite konnte im
|
||||
Namen eines angemeldeten Administrators Geräte löschen oder Benutzer anlegen.
|
||||
|
||||
Ausnahmen (`@csrf.exempt`) gibt es nur für Maschinenschnittstellen, die sich
|
||||
**nicht** über Cookies autorisieren. Eine cookie-autorisierte Route ohne
|
||||
CSRF-Schutz wäre eine Lücke.
|
||||
|
||||
---
|
||||
|
||||
## 4. Header
|
||||
|
||||
Bei jeder Antwort:
|
||||
|
||||
* `Content-Security-Policy` mit einem Nonce je Anfrage. **Kein**
|
||||
`unsafe-inline` für Skripte -- deshalb gibt es im ganzen Projekt kein
|
||||
Inline-Script ohne Nonce, und JavaScript hängt über
|
||||
`data-behavior`-Attribute am DOM. `style-src` erlaubt `unsafe-inline`, weil
|
||||
einzelne berechnete Breiten (Fortschrittsbalken) als Style-Attribut gesetzt
|
||||
werden; Style-Attribute führen keinen Code aus.
|
||||
* `X-Content-Type-Options: nosniff`
|
||||
* `X-Frame-Options: DENY`, `frame-ancestors 'none'`
|
||||
* `Referrer-Policy: same-origin`
|
||||
* `Permissions-Policy` -- Kamera, Mikrofon, Ort, Zahlung, USB abgeschaltet
|
||||
* `Cross-Origin-Opener-Policy`, `Cross-Origin-Resource-Policy`
|
||||
* `Strict-Transport-Security` nur, wenn TLS tatsächlich terminiert wird
|
||||
|
||||
> HSTS gehört **nicht** zu einem selbstsignierten Zertifikat. Der Browser
|
||||
> merkt sich die Vorgabe monatelang; die Warnung lässt sich danach nicht mehr
|
||||
> wegklicken. `install.sh` setzt HSTS deshalb nur zusammen mit Let's Encrypt.
|
||||
|
||||
---
|
||||
|
||||
## 5. Geheimnisse im Ruhezustand
|
||||
|
||||
Ein Schlüsselspeicher mit AES-256-GCM (`data/data.keys`, Modus 0600):
|
||||
|
||||
* Versionierte Schlüssel, rotierbar. Chiffrate tragen ihre Schlüsselkennung
|
||||
(`v1.<id>.<nonce>.<ct>`) und bleiben nach einer Rotation lesbar.
|
||||
* Der Verwendungszweck geht als AAD in die Verschlüsselung ein. Ein Chiffrat
|
||||
aus einem Kontext lässt sich in einem anderen nicht entschlüsseln.
|
||||
* Beim Start wird geprüft, ob die Rechte auf Schlüssel- und Lizenzdateien
|
||||
noch stimmen; Abweichungen erscheinen unter *Diagnose*.
|
||||
|
||||
Passwörter für Mounts gehen über `stdin` an den privilegierten Helfer, der
|
||||
sie in eine 0600-Datei schreibt -- nie als Kommandozeilenargument (Prozessliste)
|
||||
und nie über die Umgebung (`/proc/<pid>/environ`).
|
||||
|
||||
---
|
||||
|
||||
## 6. Rechtetrennung auf dem Host
|
||||
|
||||
Der Webprozess läuft als `tesm` bzw. `tesm-license`. Root-Aktionen gehen durch
|
||||
genau einen allowlisted Helfer (`/usr/local/lib/tesm/tesm-helper`), der per
|
||||
sudoers ausschliesslich für diesen Pfad freigegeben ist.
|
||||
|
||||
Regeln, die im Repository durch Tests erzwungen werden:
|
||||
|
||||
* Jedes Verb, das die Anwendung kennt, existiert im Helfer.
|
||||
* Kein `eval`, kein `bash -c`, kein `sh -c`.
|
||||
* Jede Pfadprüfung weist `..` ab -- sonst lässt sich eine Positivliste über
|
||||
den Basisnamen unterlaufen (`/etc/nginx/sites-available/../../../root/x` hat
|
||||
den zulässigen Basisnamen `x`).
|
||||
* Der Instanzname ist auf `tesm[-<instanz>]` begrenzt; er landet in Pfaden.
|
||||
* Keine sudoers-Regel ausser der einen. `NOPASSWD: ALL` gibt es nicht.
|
||||
|
||||
Die systemd-Units haben `NoNewPrivileges=yes`, `ProtectSystem=strict`,
|
||||
`PrivateTmp=yes` und eine enge `RestrictAddressFamilies`-Liste.
|
||||
|
||||
Der Vorgänger liess die komplette Flask-Anwendung als root laufen. Eine
|
||||
einzige Lücke in irgendeinem Pfad hätte den Host bedeutet.
|
||||
|
||||
---
|
||||
|
||||
## 7. Änderungsprotokoll
|
||||
|
||||
Verkettet gehasht: jeder Eintrag enthält den Hash seines Vorgängers. Ein
|
||||
nachträgliches Ändern oder Löschen fällt auf und wird unter *Diagnose*
|
||||
angezeigt, mitsamt dem Eintrag, ab dem die Kette bricht.
|
||||
|
||||
Protokolliert wird, wer was wann von welcher Adresse getan hat -- ausdrücklich
|
||||
auch Lesezugriffe auf Geheimnisse (`credential.secret_revealed`).
|
||||
|
||||
Ältere Tage wandern in JSONL-Archive und lassen sich als ZIP herunterladen.
|
||||
|
||||
---
|
||||
|
||||
## 8. Lizenzprotokoll
|
||||
|
||||
Siehe [ARCHITEKTUR.md](ARCHITEKTUR.md), Abschnitt Lizenzierung. Kurz:
|
||||
getrennte Signaturkontexte, kanonisches JSON, Nonce gegen Wiedereinspielung,
|
||||
Frischefenster, `seq` gegen Rückstufung, Antwort an die Anfrage gebunden,
|
||||
Aussteller-Pinning.
|
||||
|
||||
Das Auslieferungsbundle enthält den privaten Clientschlüssel und ist damit
|
||||
selbst ein Geheimnis: eigenes Recht (`licenses.export`), frische
|
||||
Passwortbestätigung, `Cache-Control: no-store`, Eintrag im Zustell- und im
|
||||
Änderungsprotokoll.
|
||||
|
||||
---
|
||||
|
||||
## 9. Ein- und Ausfuhr
|
||||
|
||||
Verschlüsselte Umschläge (Argon2id-Ableitung + AES-256-GCM) mit einem selbst
|
||||
gewählten Kennwort. Der Import zeigt eine Vorschau, bevor etwas geschrieben
|
||||
wird; die Vorschau liegt in der Datenbank, nicht im Prozessspeicher -- deshalb
|
||||
braucht der Dienst kein `--workers 1` mehr.
|
||||
|
||||
---
|
||||
|
||||
## 10. Bewusst nicht getan
|
||||
|
||||
* **Kein Schalter, der die LDAP-Zertifikatsprüfung abschaltet.** Wer ihn
|
||||
einmal setzt, setzt ihn dauerhaft.
|
||||
* **Kein CSRF-Freibrief für cookie-autorisierte Routen.**
|
||||
* **Kein `unsafe-inline` für Skripte**, auch nicht "vorübergehend".
|
||||
* **Keine Rechteausweitung**, auch nicht für Administratoren untereinander.
|
||||
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
|
||||
nicht brechen.
|
||||
* **Keine von Hand gepflegte nginx-Site.** Sie wird immer neu erzeugt.
|
||||
|
||||
---
|
||||
|
||||
## 11. Wenn Sie eine Schwachstelle finden
|
||||
|
||||
Bitte nicht öffentlich melden. Wenden Sie sich an die im Anbieterprofil des
|
||||
Lizenzservers hinterlegte Adresse.
|
||||
@@ -1,43 +0,0 @@
|
||||
# Für den WebSocket-Upgrade des SSH-Terminals (/ws/ssh_terminal) nötig —
|
||||
# ohne diesen Map-Trick würde nginx den "Connection: Upgrade"-Header nicht
|
||||
# durchreichen und Flask/Werkzeug lehnt den Handshake dann mit 400 ab
|
||||
# (live an einer echten Ubuntu-Installation reproduziert: direkt gegen
|
||||
# Flask:5000 funktionierte der Handshake, durch nginx auf Port 80 nicht).
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
# Eigener Block vor "location /", da WebSockets lang offene Verbindungen
|
||||
# sind — die kurzen Standard-Timeouts von nginx (60s) würden ein länger
|
||||
# inaktives SSH-Terminal sonst hart trennen.
|
||||
location /ws/ {
|
||||
proxy_pass http://127.0.0.1:5000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:5000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /static/ {
|
||||
alias /srv/tesm/static/;
|
||||
expires 7d;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
[Unit]
|
||||
Description=Restart TESM Check
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/bin/systemctl restart tesm-check.service
|
||||
@@ -1,10 +0,0 @@
|
||||
[Unit]
|
||||
Description=Restart TESM Check daily at 00:00
|
||||
|
||||
[Timer]
|
||||
OnCalendar=*-*-* 00:00:00
|
||||
Persistent=true
|
||||
Unit=tesm-check-restart.service
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -1,14 +0,0 @@
|
||||
[Unit]
|
||||
Description=TESM Ping Check
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
WorkingDirectory=/srv/tesm
|
||||
ExecStart=/srv/tesm/venv/bin/python3 /srv/tesm/poe_wrapper.py
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,23 +0,0 @@
|
||||
[Unit]
|
||||
Description=TESM Web App
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
WorkingDirectory=/srv/tesm
|
||||
# Produktiver WSGI-Server (gunicorn) statt Flasks eigenem app.run()-
|
||||
# Entwicklungsserver -- siehe requirements.txt für die ausführliche
|
||||
# Begründung von "--workers 1" (In-Memory-Zustand) und "--worker-class
|
||||
# gthread" (blockierende WebSocket-Threads für das Browser-SSH-Terminal).
|
||||
# "--timeout 0" deaktiviert Gunicorns Worker-Timeout: der greift sonst
|
||||
# nach 30s auch bei einer offenen, aber gerade inaktiven SSH-Terminal-
|
||||
# WebSocket-Verbindung und würde den Worker (und damit JEDE Verbindung,
|
||||
# da nur ein Prozess läuft) killen.
|
||||
ExecStart=/srv/tesm/venv/bin/gunicorn --workers 1 --worker-class gthread --threads 16 \
|
||||
--timeout 0 --bind 0.0.0.0:5000 app:app
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,10 +0,0 @@
|
||||
#!/bin/sh
|
||||
# TESM -- Live-Flotten-Status im SSH-Login-Banner (dynamisches MOTD, siehe
|
||||
# /etc/pam.d/sshd: "session optional pam_motd.so motd=/run/motd.dynamic").
|
||||
# Ruft motd_banner.py im TESM-Quellverzeichnis über dessen venv auf, da
|
||||
# app.py (die eigentliche Datenquelle) Flask u.a. importiert, die nur dort
|
||||
# installiert sind -- gleiches Muster wie generate_ips.py/poe.sh.
|
||||
# "timeout" verhindert, dass ein hängender DB-/Dateizugriff den Login
|
||||
# blockiert; "|| true" sorgt dafür, dass ein Fehler hier nie den SSH-Login
|
||||
# selbst stört -- im schlimmsten Fall bleibt der Banner einfach leer.
|
||||
timeout 3 /srv/tesm/venv/bin/python3 /srv/tesm/motd_banner.py 2>/dev/null || true
|
||||
+172
-101
@@ -1,110 +1,181 @@
|
||||
#!/bin/bash
|
||||
# ============================================================================
|
||||
# TESM — Installer
|
||||
# Richtet System-Pakete, die Flask-App (systemd) sowie den Erreichbarkeits-
|
||||
# Check-Dienst (systemd + Timer) ein. Auszuführen als root, nachdem dieses
|
||||
# Repo z.B. nach /root/tesm geklont wurde.
|
||||
# ============================================================================
|
||||
set -e
|
||||
#!/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
|
||||
|
||||
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
NC='\033[0m'
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
print_status() { echo -e "${GREEN}✔${NC} ${1} completed."; }
|
||||
step() { echo -e "${RED}→${NC} ${1}..." | tee -a /var/log/tesm-install.log; }
|
||||
# --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
|
||||
|
||||
# "clear" schlägt ohne TERM/Pseudo-Terminal fehl (z.B. bei Automatisierung
|
||||
# per SSH exec_command ohne pty, genau wie update.sh dieses Skript aufruft)
|
||||
# -- mit "set -e" bricht das sonst die GESAMTE Installation sofort und ohne
|
||||
# jede Fehlermeldung an dieser Stelle ab, noch bevor auch nur ein Paket
|
||||
# installiert wurde (live so reproduziert). Rein kosmetisch, daher bewusst
|
||||
# nie fatal.
|
||||
clear 2>/dev/null || true
|
||||
|
||||
# ---- Pakete ----
|
||||
step "Installing system packages"
|
||||
sudo apt-get update >>/var/log/tesm-install.log 2>&1 && print_status "apt update"
|
||||
sudo apt-get install -y python3 python3-venv python3-pip nginx sqlite3 expect openssh-client git rsync iputils-ping logrotate >>/var/log/tesm-install.log 2>&1 && print_status "Packages installed"
|
||||
|
||||
# ---- Log-Verzeichnis ----
|
||||
# Weltweit beschreibbar, da sowohl root (tesm.service/poe.sh) als auch
|
||||
# der i.d.R. unprivilegierte kea-dhcp4-server-Dienstbenutzer (_kea) hier
|
||||
# ihre jeweils eigene Log-Datei anlegen/schreiben müssen. Die eigentliche
|
||||
# logrotate-Konfiguration (/etc/logrotate.d/tesm) schreibt die App
|
||||
# selbst beim Start bzw. beim Speichern unter Systemeinstellungen → Logs.
|
||||
sudo mkdir -p /var/log/tesm
|
||||
sudo chmod 777 /var/log/tesm
|
||||
|
||||
# ---- App-Verzeichnis ----
|
||||
step "Deploying application to /srv/tesm"
|
||||
sudo mkdir -p /srv/tesm
|
||||
sudo rsync -a --delete --exclude 'venv' --exclude 'sqlite.db' --exclude 'fernet.key' --exclude 'secret.key' \
|
||||
"$REPO_DIR/srv/tesm/" /srv/tesm/ >>/var/log/tesm-install.log 2>&1
|
||||
print_status "Application files copied"
|
||||
|
||||
# ---- Python venv ----
|
||||
# "python3 -m pip" statt des "pip"-Skripts im venv selbst: dessen Shebang
|
||||
# enthält einen zur Erstellungszeit FEST einprogrammierten, absoluten Pfad
|
||||
# zum venv-Python. Wird das venv-Verzeichnis später umbenannt/verschoben
|
||||
# (wie hier historisch bei der Umbenennung "poe_manager" -> "tesm"
|
||||
# passiert), bleibt die alte, nicht mehr existierende Pfadangabe stehen --
|
||||
# "./venv/bin/pip install ..." schlägt dann mit "cannot execute: required
|
||||
# file not found" fehl (live auf POETEST reproduziert), obwohl das venv
|
||||
# selbst voll funktionsfähig ist. "python3 -m pip" braucht dagegen nur den
|
||||
# (korrekt referenzierten) venv-Python-Interpreter selbst.
|
||||
step "Setting up Python virtual environment"
|
||||
cd /srv/tesm
|
||||
sudo python3 -m venv venv
|
||||
sudo ./venv/bin/python3 -m pip install --upgrade pip >>/var/log/tesm-install.log 2>&1
|
||||
sudo ./venv/bin/python3 -m pip install -r requirements.txt >>/var/log/tesm-install.log 2>&1
|
||||
print_status "Virtual environment ready"
|
||||
|
||||
# ---- Datenbank ----
|
||||
if [ ! -f /srv/tesm/sqlite.db ]; then
|
||||
step "Initializing database"
|
||||
sudo ./venv/bin/python3 create_db.py >>/var/log/tesm-install.log 2>&1
|
||||
print_status "Database initialized"
|
||||
echo -e "${RED}→${NC} Kein Admin-Benutzer vorhanden. Bitte danach ausführen: sudo /srv/tesm/venv/bin/python3 /srv/tesm/create_admin.py"
|
||||
# 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
|
||||
|
||||
# ---- Custom-Skripte + systemd ----
|
||||
step "Copying custom scripts and systemd units"
|
||||
sudo mkdir -p /usr/local/bin/custom
|
||||
sudo cp "$REPO_DIR/usr/local/bin/custom/poe.sh" /usr/local/bin/custom/poe.sh
|
||||
sudo chmod +x /usr/local/bin/custom/poe.sh
|
||||
sudo cp "$REPO_DIR"/etc/systemd/system/*.service /etc/systemd/system/
|
||||
sudo cp "$REPO_DIR"/etc/systemd/system/*.timer /etc/systemd/system/
|
||||
print_status "Scripts and units copied"
|
||||
# --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}"
|
||||
|
||||
# ---- SSH-Login-Banner (MOTD) ----
|
||||
# Live-Flotten-Status (Online/Offline, DHCP) statt der Standard-Ubuntu-
|
||||
# Willkommensmeldung -- siehe srv/tesm/motd_banner.py für die Datenquellen.
|
||||
# Die Standard-Skripte bleiben erhalten, werden aber deaktiviert (chmod -x,
|
||||
# nicht gelöscht), damit ein Rückbau jederzeit per chmod +x möglich ist.
|
||||
step "Setting up TESM SSH login banner"
|
||||
sudo cp "$REPO_DIR/etc/update-motd.d/50-tesm" /etc/update-motd.d/50-tesm
|
||||
sudo chmod 755 /etc/update-motd.d/50-tesm
|
||||
sudo chmod -x /etc/update-motd.d/00-header /etc/update-motd.d/10-help-text \
|
||||
/etc/update-motd.d/50-motd-news /etc/update-motd.d/60-unminimize \
|
||||
/etc/update-motd.d/91-release-upgrade /etc/update-motd.d/92-unattended-upgrades \
|
||||
2>/dev/null || true
|
||||
print_status "SSH login banner configured"
|
||||
# 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
|
||||
|
||||
# ---- nginx ----
|
||||
step "Configuring nginx reverse proxy"
|
||||
sudo cp "$REPO_DIR/etc/nginx/sites-available/tesm" /etc/nginx/sites-available/tesm
|
||||
sudo ln -sf /etc/nginx/sites-available/tesm /etc/nginx/sites-enabled/tesm
|
||||
sudo rm -f /etc/nginx/sites-enabled/default
|
||||
sudo nginx -t >>/var/log/tesm-install.log 2>&1 && sudo systemctl reload nginx
|
||||
print_status "nginx configured"
|
||||
# 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"
|
||||
}
|
||||
|
||||
# ---- Dienste aktivieren ----
|
||||
step "Enabling services"
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now tesm.service
|
||||
sudo systemctl enable --now tesm-check.service tesm-check-restart.timer
|
||||
print_status "Services enabled and started"
|
||||
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
|
||||
|
||||
echo -e "${GREEN}✔${NC} Installation abgeschlossen. Web-App erreichbar auf Port 80 (nginx) bzw. 5000 (Flask direkt)."
|
||||
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[@]}"}
|
||||
@@ -0,0 +1,25 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "tesm-core"
|
||||
version = "2.8.9"
|
||||
description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"Flask>=3.1",
|
||||
"cryptography>=42",
|
||||
"argon2-cffi>=23",
|
||||
"tesm-licensing>=2.0.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
ldap = ["ldap3>=2.9"]
|
||||
legacy-hashes = ["bcrypt>=4"]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
tesm_core = ["templates/**/*.html", "static/**/*"]
|
||||
@@ -0,0 +1,51 @@
|
||||
"""tesm_core -- gemeinsames Anwendungsgeruest von TESM und TESM-Lizenzserver.
|
||||
|
||||
Enthaelt alles, was beide Anwendungen teilen: Konfiguration, Datenbankzugriff,
|
||||
Migrationen, Schluesselverwaltung, Sitzungen, CSRF, Rechtesystem, Navigation,
|
||||
Benutzer-/Gruppen-/Kontoverwaltung, Papierkorb, Diagnose und das
|
||||
Design-System.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
#: Version aus den Metadaten des installierten Pakets -- die einzige Quelle
|
||||
#: ist ``pyproject.toml``. Vorher stand hier eine feste Zahl, und die
|
||||
#: Fusszeile zeigte vier Releases lang 2.0.2.
|
||||
#:
|
||||
#: Im Quellbaum ohne ``pip install`` gibt es keine Metadaten; dann steht dort
|
||||
#: "unbekannt" statt einer erfundenen Nummer. In einem editierbaren Install
|
||||
#: zeigt sie den Stand des letzten ``pip install -e`` -- also die Version, die
|
||||
#: tatsaechlich installiert ist.
|
||||
try: # pragma: no cover - haengt an der Installationsart
|
||||
from importlib.metadata import PackageNotFoundError, version as _paketversion
|
||||
|
||||
__version__ = _paketversion("tesm-core")
|
||||
except (ImportError, PackageNotFoundError): # pragma: no cover
|
||||
__version__ = "unbekannt"
|
||||
|
||||
from .app_factory import create_base_app
|
||||
from .config import CoreConfig, SecurityConfig, load_core_config
|
||||
from .db import Database, NestedConnectionError
|
||||
from .extension import TesmCore, core, db, transaction
|
||||
from .migrations import Migration
|
||||
from .nav import NavItem, NavTree
|
||||
from .settings_store import SettingSpec, SettingsStore
|
||||
|
||||
__all__ = [
|
||||
"CoreConfig",
|
||||
"Database",
|
||||
"Migration",
|
||||
"NavItem",
|
||||
"NavTree",
|
||||
"NestedConnectionError",
|
||||
"SecurityConfig",
|
||||
"SettingSpec",
|
||||
"SettingsStore",
|
||||
"TesmCore",
|
||||
"core",
|
||||
"create_base_app",
|
||||
"db",
|
||||
"load_core_config",
|
||||
"transaction",
|
||||
"__version__",
|
||||
]
|
||||
@@ -0,0 +1,483 @@
|
||||
"""Anwendungsfabrik: verdrahtet alle Bausteine zu einer lauffaehigen Flask-App.
|
||||
|
||||
Jede der beiden Anwendungen ruft ``create_base_app()`` und ergaenzt danach nur
|
||||
noch ihre eigenen Blueprints. Alles, was beide teilen -- Datenbank,
|
||||
Migrationen, Sitzungen, CSRF, Header, Protokollierung, Rechtesystem,
|
||||
Navigation, Benutzer-/Gruppenverwaltung, Kontoseite, Diagnose -- entsteht hier
|
||||
genau einmal.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Sequence
|
||||
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from flask import Blueprint, Flask, g, redirect, render_template, request
|
||||
from werkzeug.exceptions import HTTPException
|
||||
|
||||
from . import audit, legacy_urls, logging_setup, ui
|
||||
from .config import CoreConfig
|
||||
from .core_schema import CORE_MIGRATIONS
|
||||
from .db import Database
|
||||
from .extension import TesmCore, close_db, core, db
|
||||
from .keystore import Keystore, load_or_create_secret_key
|
||||
from .licensing_client import LicenseManager
|
||||
from .migrations import Migration
|
||||
from .nav import NavTree
|
||||
from .rbac import service as rbac
|
||||
from .rbac.model import PermissionTree, RolePreset
|
||||
from .security import csrf
|
||||
from .security import sessions as session_store
|
||||
from .security.sessions import SqliteSessionInterface
|
||||
from .settings_store import SettingSpec, SettingsStore
|
||||
from . import uploads
|
||||
from .sysops import SysOps
|
||||
from .auth import directory
|
||||
from .auth import routes as auth_routes
|
||||
from .auth import service as auth_service
|
||||
from .auth.models import ANONYMOUS, current_user
|
||||
from .views import account as account_views
|
||||
from .views import admin as admin_views
|
||||
from .views import diagnostics as diagnostics_views
|
||||
from .views import hostsettings as hostsettings_views
|
||||
from .views import logs as logs_views
|
||||
from .views import trash as trash_views
|
||||
|
||||
#: Rechteinhaber, in dieser Reihenfolge. Steht an einer Stelle, weil die
|
||||
#: Fusszeile in zwei Vorlagen auftaucht (Anwendungsrahmen und Anmeldeseiten)
|
||||
#: und zwei Texte fuer dieselbe Aussage auseinanderlaufen.
|
||||
COPYRIGHT_NOTICE = "Tim Eertmoed · WiS IT-Solutions GmbH"
|
||||
|
||||
CORE_SETTINGS: tuple[SettingSpec, ...] = (
|
||||
SettingSpec("nav_order", [], "json", "Reihenfolge der Navigation", section="intern"),
|
||||
SettingSpec("nav_child_order", {}, "json", "Reihenfolge der Unterpunkte", section="intern"),
|
||||
SettingSpec(
|
||||
"trash_retention_days",
|
||||
30,
|
||||
"int",
|
||||
"Aufbewahrung im Papierkorb (Tage)",
|
||||
hint="Nach dieser Frist werden Einträge beim nächsten Aufruf endgültig entfernt.",
|
||||
section="system",
|
||||
minimum=1,
|
||||
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(
|
||||
"session_idle_minutes",
|
||||
30,
|
||||
"int",
|
||||
"Automatische Abmeldung nach Leerlauf (Minuten)",
|
||||
hint="Jede Anfrage setzt den Zähler zurück – gezählt wird nur Untätigkeit. "
|
||||
"Gilt auch für die eingebundenen Dateifreigaben: mit der Sitzung endet die "
|
||||
"Einbindung.",
|
||||
section="system",
|
||||
minimum=5,
|
||||
maximum=1440,
|
||||
),
|
||||
SettingSpec("timezone", "", "str", "Zeitzone des Hosts", section="system"),
|
||||
SettingSpec("hostname", "", "str", "Hostname", section="system"),
|
||||
SettingSpec(
|
||||
"log_rotate_interval",
|
||||
"weekly",
|
||||
"str",
|
||||
"Rotationsintervall der Protokolle",
|
||||
section="system",
|
||||
choices=(("daily", "taeglich"), ("weekly", "woechentlich"), ("monthly", "monatlich")),
|
||||
),
|
||||
SettingSpec(
|
||||
"log_rotate_keep", 4, "int", "Aufbewahrte Rotationen", section="system", minimum=1, maximum=52
|
||||
),
|
||||
# -- Webserver/TLS (Host-Ebene, in beiden Anwendungen gleich) ----------- #
|
||||
# Lizenzserver: eigenes Zertifikat statt abgeschalteter Pruefung.
|
||||
SettingSpec(
|
||||
"modules_disabled",
|
||||
[],
|
||||
"json",
|
||||
"Vor Ort abgeschaltete Module",
|
||||
hint="Die Lizenz sagt, was erlaubt ist; das hier sagt, was benutzt wird.",
|
||||
section="system",
|
||||
),
|
||||
SettingSpec(
|
||||
"license_server_ca",
|
||||
"",
|
||||
"str",
|
||||
"Zertifikat des Lizenzservers (PEM)",
|
||||
section="license",
|
||||
),
|
||||
SettingSpec("web_domain", "", "str", "Domain", section="web"),
|
||||
SettingSpec("web_https_enabled", False, "bool", "HTTPS aktiv", section="web"),
|
||||
SettingSpec("web_hsts_enabled", False, "bool", "HSTS senden", section="web"),
|
||||
SettingSpec("web_redirect_http", True, "bool", "HTTP auf HTTPS umleiten", section="web"),
|
||||
SettingSpec("web_cert_path", "", "str", "Zertifikat (Pfad)", section="web"),
|
||||
SettingSpec("web_key_path", "", "str", "Privater Schlüssel (Pfad)", 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"),
|
||||
# Abweichende Ports braucht nur eine Testinstanz neben der Produktion --
|
||||
# im Regelbetrieb bleibt es bei 80 und 443.
|
||||
SettingSpec(
|
||||
"web_http_port", 80, "int", "HTTP-Port", section="web", minimum=1, maximum=65535
|
||||
),
|
||||
SettingSpec(
|
||||
"web_https_port", 443, "int", "HTTPS-Port", section="web", minimum=1, maximum=65535
|
||||
),
|
||||
SettingSpec(
|
||||
"web_max_body_mb", 128, "int", "Maximale Anfragegröße (MB)", section="web",
|
||||
minimum=1, maximum=8192,
|
||||
),
|
||||
*directory.SETTINGS,
|
||||
)
|
||||
|
||||
|
||||
def create_base_app(
|
||||
*,
|
||||
config: CoreConfig,
|
||||
permissions: PermissionTree,
|
||||
nav: NavTree,
|
||||
role_presets: Sequence[RolePreset],
|
||||
app_migrations: dict[str, Sequence[Migration]] | None = None,
|
||||
settings_specs: Sequence[SettingSpec] = (),
|
||||
import_name: str = "tesm_core",
|
||||
app_short: str = "TESM",
|
||||
app_edition: str = "",
|
||||
#: Zeile unter der Wortmarke auf den Anmeldeseiten. Leer = nur die
|
||||
#: Wortmarke, ohne Zeile darunter; die Marke selbst zeigen beide
|
||||
#: Anwendungen.
|
||||
auth_tagline: str = "",
|
||||
#: 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, ...] = (),
|
||||
enable_license: bool = True,
|
||||
template_folder: str | None = None,
|
||||
static_folder: str | None = None,
|
||||
) -> Flask:
|
||||
app = Flask(
|
||||
import_name,
|
||||
template_folder=template_folder or "templates",
|
||||
static_folder=static_folder or "static",
|
||||
static_url_path="/assets",
|
||||
)
|
||||
# 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
|
||||
# werden ueber einen eigenen Blueprint bereitgestellt. Dadurch kann jede
|
||||
# Anwendung zusaetzlich ihre eigenen templates/static mitbringen, ohne dass
|
||||
# sich Pfade in die Quere kommen.
|
||||
core_bp = Blueprint(
|
||||
"tesm_core",
|
||||
"tesm_core",
|
||||
template_folder="templates",
|
||||
static_folder="static/tesm_core",
|
||||
static_url_path="/core-assets",
|
||||
)
|
||||
app.register_blueprint(core_bp)
|
||||
|
||||
config.data_dir.mkdir(parents=True, exist_ok=True)
|
||||
config.log_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Die Groessengrenze einer Anfrage kommt aus der Einstellung, nicht aus
|
||||
# der Konfiguration beim Start.
|
||||
app.request_class = uploads.UploadLimitRequest
|
||||
|
||||
app.config.update(
|
||||
SECRET_KEY=load_or_create_secret_key(config.secret_key_file),
|
||||
# site_key statt app_key: Cookies unterscheiden keine Ports. Zwei
|
||||
# Instanzen derselben Anwendung auf einem Host wuerden sich sonst
|
||||
# gegenseitig die Sitzung ueberschreiben.
|
||||
SESSION_COOKIE_NAME=f"{config.site_key.replace('-', '_')}_session",
|
||||
SESSION_COOKIE_HTTPONLY=True,
|
||||
SESSION_COOKIE_SECURE=config.security.cookie_secure,
|
||||
SESSION_COOKIE_SAMESITE=config.security.cookie_samesite,
|
||||
# Nur die aeussere Schranke aus der Umgebung. Die eigentliche Grenze
|
||||
# steht in der Einstellung und wird pro Anfrage gelesen (uploads.py) --
|
||||
# sonst braeuchte jede Aenderung einen Neustart aller Arbeiter.
|
||||
MAX_CONTENT_LENGTH=config.security.max_content_length,
|
||||
JSON_SORT_KEYS=False,
|
||||
TRAP_HTTP_EXCEPTIONS=False,
|
||||
PREFERRED_URL_SCHEME="https" if config.security.cookie_secure else "http",
|
||||
TEMPLATES_AUTO_RELOAD=config.debug,
|
||||
EXPLAIN_TEMPLATE_LOADING=False,
|
||||
)
|
||||
app.debug = config.debug
|
||||
|
||||
logging_setup.init_app(app, log_dir=config.log_dir, debug=config.debug)
|
||||
|
||||
database = Database(config.db_path)
|
||||
settings = SettingsStore([*CORE_SETTINGS, *settings_specs])
|
||||
keystore = Keystore(config.data_key_file)
|
||||
|
||||
extension = TesmCore(
|
||||
config=config,
|
||||
database=database,
|
||||
keystore=keystore,
|
||||
permissions=permissions,
|
||||
nav=nav,
|
||||
settings=settings,
|
||||
role_presets=tuple(role_presets),
|
||||
migrations={"core": CORE_MIGRATIONS, **(app_migrations or {})},
|
||||
sysops=SysOps(app_key=config.app_key, site_key=config.site_key),
|
||||
)
|
||||
if enable_license:
|
||||
extension.license = LicenseManager(
|
||||
database=database,
|
||||
settings=settings,
|
||||
license_path=config.license_path,
|
||||
key_path=config.license_key_path,
|
||||
app_key=config.app_key,
|
||||
built_in_trusted_keys=built_in_trusted_keys,
|
||||
)
|
||||
app.extensions["tesm"] = extension
|
||||
|
||||
def _leerlauf(conn: Any) -> int:
|
||||
"""Leerlaufgrenze aus den Einstellungen -- ueber die offene Verbindung.
|
||||
|
||||
Bewusst mit der Verbindung, die die Sitzungsverwaltung schon haelt: eine
|
||||
zweite zu oeffnen waere eine verschachtelte Datenbankverbindung, und die
|
||||
gilt hier als Fehler.
|
||||
"""
|
||||
try:
|
||||
wert = int(settings.get(conn, "session_idle_minutes") or 0)
|
||||
except (TypeError, ValueError):
|
||||
wert = 0
|
||||
return wert if wert > 0 else config.security.session_idle_minutes
|
||||
|
||||
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(
|
||||
database,
|
||||
cookie_name=app.config["SESSION_COOKIE_NAME"],
|
||||
idle_minutes=config.security.session_idle_minutes,
|
||||
absolute_hours=config.security.session_absolute_hours,
|
||||
idle_lookup=_leerlauf,
|
||||
absolute_lookup=_gesamtdauer,
|
||||
)
|
||||
|
||||
# -- Schema und Startzustand ------------------------------------------ #
|
||||
applied = extension.run_migrations()
|
||||
with database.session() as conn, database.transaction(conn):
|
||||
created = rbac.ensure_presets(conn, permissions, extension.role_presets)
|
||||
if applied or created:
|
||||
audit.log(
|
||||
conn,
|
||||
actor="system",
|
||||
action="system.startup_migration",
|
||||
detail="; ".join([*applied, *(f"Gruppe {name} angelegt" for name in created)]),
|
||||
severity="notice",
|
||||
)
|
||||
session_store.prune(conn)
|
||||
if extension.license is not None:
|
||||
with database.session() as conn:
|
||||
extension.license.reload(conn)
|
||||
|
||||
# -- Request-Lebenszyklus --------------------------------------------- #
|
||||
csrf.init_app(app)
|
||||
ui.init_app(app)
|
||||
app.teardown_appcontext(close_db)
|
||||
|
||||
@app.before_request
|
||||
def _prepare_request() -> None:
|
||||
g.csp_nonce = secrets.token_urlsafe(16)
|
||||
auth_service.load_request_user()
|
||||
|
||||
@app.after_request
|
||||
def _finish_request(response): # type: ignore[no-untyped-def]
|
||||
# Ein Aktualisierungsdurchlauf, der mit Sitzungscookie kommt und
|
||||
# unangemeldet endet, hat gerade seine Sitzung verloren. Bei einer
|
||||
# geschuetzten Seite sagt das schon der Status (401); bei einer, die
|
||||
# auch oeffentlich antwortet -- die Statusuebersicht etwa -- kommt eine
|
||||
# 200 mit den oeffentlichen Kacheln zurueck, und der offene Tab merkt
|
||||
# nichts: er zeigt weiter eine Oberflaeche, als sei alles in Ordnung.
|
||||
# Deshalb ein Kopf, den der Browser unabhaengig vom Status auswerten
|
||||
# kann.
|
||||
if session_store.is_automatic(request) and not current_user.is_authenticated:
|
||||
if request.cookies.get(app.config["SESSION_COOKIE_NAME"], ""):
|
||||
response.headers["X-Session-Expired"] = "1"
|
||||
hsts = ""
|
||||
if config.security.hsts_enabled:
|
||||
hsts = f"max-age={config.security.hsts_max_age}; includeSubDomains"
|
||||
return csrf.apply_security_headers(
|
||||
response, nonce=getattr(g, "csp_nonce", ""), hsts=hsts
|
||||
)
|
||||
|
||||
# -- Template-Kontext -------------------------------------------------- #
|
||||
from .rbac.model import ACTION_META
|
||||
|
||||
@app.context_processor
|
||||
def _inject_context() -> dict[str, Any]:
|
||||
user = getattr(g, "user", ANONYMOUS)
|
||||
conn = db()
|
||||
order = settings.get(conn, "nav_order") or nav.default_order()
|
||||
child_order = settings.get(conn, "nav_child_order") or {}
|
||||
pills: list[dict[str, str]] = []
|
||||
if extension.license is not None and user.is_authenticated:
|
||||
pill = extension.license.topbar_pill()
|
||||
if pill:
|
||||
pills.append(pill)
|
||||
pills.extend(extension.registry.get("status_pill_builder", lambda _u: [])(user))
|
||||
|
||||
nav_items = nav.resolve(user, order=order, child_order=child_order)
|
||||
palette = [
|
||||
{**entry, "url": _safe_url(entry["endpoint"])}
|
||||
for entry in nav.searchable(user)
|
||||
if entry["endpoint"]
|
||||
]
|
||||
return {
|
||||
"app_name": config.app_name,
|
||||
"app_short": app_short,
|
||||
"app_edition": app_edition,
|
||||
"auth_tagline": auth_tagline,
|
||||
"app_version": app_version,
|
||||
"current_year": datetime.now(timezone.utc).year,
|
||||
"copyright_notice": COPYRIGHT_NOTICE,
|
||||
"current_user": current_user,
|
||||
"nav_items": nav_items,
|
||||
"palette_entries": palette,
|
||||
"status_pills": pills,
|
||||
"csp_nonce": getattr(g, "csp_nonce", ""),
|
||||
"permission_tree": permissions,
|
||||
"action_meta": ACTION_META,
|
||||
"license_state": extension.license.state if extension.license else None,
|
||||
"license_active": extension.license.active() if extension.license else True,
|
||||
"module_active": (
|
||||
extension.license.module_active if extension.license else (lambda _n: True)
|
||||
),
|
||||
"feature_allowed": (
|
||||
extension.license.feature_allowed if extension.license else (lambda _n: True)
|
||||
),
|
||||
"migration_warning": bool(extension.registry.get("migration_warning")),
|
||||
}
|
||||
|
||||
# -- Fehlerseiten ------------------------------------------------------ #
|
||||
@app.errorhandler(HTTPException)
|
||||
def _http_error(error: HTTPException): # type: ignore[no-untyped-def]
|
||||
if 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():
|
||||
return {
|
||||
"error": error.name,
|
||||
"message": error.description,
|
||||
"request_id": getattr(g, "request_id", ""),
|
||||
}, error.code or 500
|
||||
return (
|
||||
render_template(
|
||||
"tesm_core/error.html",
|
||||
code=error.code or 500,
|
||||
name=error.name,
|
||||
description=error.description,
|
||||
request_id=getattr(g, "request_id", ""),
|
||||
back_url=request.referrer or "/",
|
||||
),
|
||||
error.code or 500,
|
||||
)
|
||||
|
||||
@app.errorhandler(Exception)
|
||||
def _unhandled(error: Exception): # type: ignore[no-untyped-def]
|
||||
app.logger.exception("Unbehandelter Fehler: %s", error)
|
||||
if _wants_json():
|
||||
return {
|
||||
"error": "Interner Fehler",
|
||||
"message": "Der Vorgang konnte nicht abgeschlossen werden.",
|
||||
"request_id": getattr(g, "request_id", ""),
|
||||
}, 500
|
||||
return (
|
||||
render_template(
|
||||
"tesm_core/error.html",
|
||||
code=500,
|
||||
name="Interner Fehler",
|
||||
description=(
|
||||
"Der Vorgang konnte nicht abgeschlossen werden. Der Fehler wurde "
|
||||
"protokolliert."
|
||||
),
|
||||
request_id=getattr(g, "request_id", ""),
|
||||
back_url=request.referrer or "/",
|
||||
),
|
||||
500,
|
||||
)
|
||||
|
||||
# -- Gemeinsame Blueprints -------------------------------------------- #
|
||||
app.register_blueprint(auth_routes.bp)
|
||||
app.register_blueprint(admin_views.bp)
|
||||
app.register_blueprint(account_views.bp)
|
||||
app.register_blueprint(diagnostics_views.bp)
|
||||
app.register_blueprint(trash_views.bp)
|
||||
app.register_blueprint(hostsettings_views.bp)
|
||||
app.register_blueprint(logs_views.bp)
|
||||
with app.app_context():
|
||||
trash_views.register_core_providers()
|
||||
|
||||
app.add_url_rule("/health", "health", _health, methods=["GET"])
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def _safe_url(endpoint: str) -> str:
|
||||
from flask import url_for
|
||||
|
||||
try:
|
||||
return url_for(endpoint)
|
||||
except Exception: # noqa: BLE001 - Endpunkt existiert in dieser App nicht
|
||||
return ""
|
||||
|
||||
|
||||
def _wants_json() -> bool:
|
||||
if request.path.startswith("/api/"):
|
||||
return True
|
||||
accept = request.accept_mimetypes
|
||||
return bool(accept.accept_json and not accept.accept_html)
|
||||
|
||||
|
||||
def _health() -> tuple[dict[str, Any], int]:
|
||||
"""Gesundheitsendpunkt fuer install.sh und Monitoring.
|
||||
|
||||
Bewusst ohne Anmeldung, aber ohne jede verwertbare Information: nur
|
||||
"laeuft die App und antwortet die Datenbank".
|
||||
"""
|
||||
extension = core()
|
||||
try:
|
||||
with extension.database.session() as conn:
|
||||
conn.execute("SELECT 1").fetchone()
|
||||
except Exception: # noqa: BLE001
|
||||
return {"status": "degraded"}, 503
|
||||
return {"status": "ok", "version": extension.config.app_key}, 200
|
||||
@@ -0,0 +1,326 @@
|
||||
"""Aenderungsprotokoll mit Hash-Kette.
|
||||
|
||||
Ein Audit-Log, das der Administrator selbst manipulieren kann, ist kein
|
||||
Audit-Log. Der Vorgaenger schrieb einfache Zeilen in eine Tabelle -- wer
|
||||
Schreibzugriff auf die SQLite-Datei hatte (auf dem Zielsystem: jeder root, und
|
||||
die App lief als root), konnte Eintraege spurlos entfernen.
|
||||
|
||||
Hier bindet jede Zeile den Hash ihrer Vorgaengerin ein:
|
||||
|
||||
hash_n = sha256(hash_(n-1) || kanonische Felder von Zeile n)
|
||||
|
||||
Eine geloeschte oder geaenderte Zeile bricht die Kette an genau dieser Stelle.
|
||||
``verify_chain()`` findet den Bruch; die Diagnoseseite zeigt ihn an. Das
|
||||
verhindert Manipulation nicht, macht sie aber nachweisbar -- und genau darum
|
||||
geht es bei einem Protokoll.
|
||||
|
||||
Archivierung: ab ``archive_threshold`` Zeilen werden die aeltesten vollstaendigen
|
||||
Kalendertage in Dateien ``audit-JJJJ-MM-TT.jsonl`` ausgelagert, inklusive ihrer
|
||||
Hashes, sodass die Kette ueber die Archivgrenze hinweg pruefbar bleibt.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import sqlite3
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Collection, Iterable, Mapping, Sequence
|
||||
|
||||
from .db import Database
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
GENESIS = "0" * 64
|
||||
ARCHIVE_THRESHOLD = 20000
|
||||
ARCHIVE_TARGET = 15000
|
||||
|
||||
SEVERITIES = ("info", "notice", "warning", "critical")
|
||||
|
||||
|
||||
def _canonical(row: dict[str, Any]) -> bytes:
|
||||
payload = {
|
||||
"ts": row.get("ts", ""),
|
||||
"actor": row.get("actor", ""),
|
||||
"actor_id": row.get("actor_id"),
|
||||
"action": row.get("action", ""),
|
||||
"target": row.get("target", ""),
|
||||
"detail": row.get("detail", ""),
|
||||
"ip": row.get("ip", ""),
|
||||
"request_id": row.get("request_id", ""),
|
||||
"severity": row.get("severity", "info"),
|
||||
}
|
||||
return json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||
|
||||
|
||||
def compute_hash(prev_hash: str, row: dict[str, Any]) -> str:
|
||||
return hashlib.sha256((prev_hash or GENESIS).encode("ascii") + _canonical(row)).hexdigest()
|
||||
|
||||
|
||||
def log(
|
||||
conn: sqlite3.Connection,
|
||||
*,
|
||||
actor: str,
|
||||
action: str,
|
||||
target: str = "",
|
||||
detail: str = "",
|
||||
actor_id: int | None = None,
|
||||
ip: str = "",
|
||||
request_id: str = "",
|
||||
severity: str = "info",
|
||||
ts: datetime | None = None,
|
||||
) -> int:
|
||||
"""Schreibt einen Protokolleintrag auf der uebergebenen Verbindung.
|
||||
|
||||
Wichtig: **immer** die bestehende Verbindung nutzen. Eine Protokollfunktion,
|
||||
die selbst eine Verbindung oeffnet, blockiert die laufende Transaktion --
|
||||
das war im Vorgaenger eine reale Deadlock-Ursache.
|
||||
"""
|
||||
if severity not in SEVERITIES:
|
||||
severity = "info"
|
||||
row = {
|
||||
"ts": (ts or datetime.now(timezone.utc)).isoformat(timespec="seconds"),
|
||||
"actor": actor or "system",
|
||||
"actor_id": actor_id,
|
||||
"action": action,
|
||||
"target": target,
|
||||
"detail": detail,
|
||||
"ip": ip,
|
||||
"request_id": request_id,
|
||||
"severity": severity,
|
||||
}
|
||||
prev_hash = (
|
||||
Database.value(conn, "SELECT hash FROM audit_log ORDER BY id DESC LIMIT 1") or GENESIS
|
||||
)
|
||||
row_hash = compute_hash(str(prev_hash), row)
|
||||
cursor = conn.execute(
|
||||
"INSERT INTO audit_log (ts, actor, actor_id, action, target, detail, ip, request_id, "
|
||||
"severity, prev_hash, hash) VALUES (?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(
|
||||
row["ts"],
|
||||
row["actor"],
|
||||
row["actor_id"],
|
||||
row["action"],
|
||||
row["target"],
|
||||
row["detail"],
|
||||
row["ip"],
|
||||
row["request_id"],
|
||||
row["severity"],
|
||||
prev_hash,
|
||||
row_hash,
|
||||
),
|
||||
)
|
||||
return int(cursor.lastrowid or 0)
|
||||
|
||||
|
||||
def 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]:
|
||||
"""Prueft die Hash-Kette. Rueckgabe: ``{"ok", "checked", "broken_at", "reason"}``."""
|
||||
sql = "SELECT * FROM audit_log ORDER BY id"
|
||||
params: tuple[Any, ...] = ()
|
||||
if limit:
|
||||
sql = "SELECT * FROM (SELECT * FROM audit_log ORDER BY id DESC LIMIT ?) ORDER BY id"
|
||||
params = (limit,)
|
||||
|
||||
checked = 0
|
||||
expected_prev: str | None = None
|
||||
for row in conn.execute(sql, params):
|
||||
data = {key: row[key] for key in row.keys()}
|
||||
if expected_prev is not None and data["prev_hash"] != expected_prev:
|
||||
return {
|
||||
"ok": False,
|
||||
"checked": checked,
|
||||
"broken_at": data["id"],
|
||||
"reason": "Vorgänger-Hash passt nicht – eine Zeile wurde entfernt oder eingefügt.",
|
||||
}
|
||||
if compute_hash(str(data["prev_hash"]), data) != data["hash"]:
|
||||
return {
|
||||
"ok": False,
|
||||
"checked": checked,
|
||||
"broken_at": data["id"],
|
||||
"reason": "Zeileninhalt passt nicht zum gespeicherten Hash | Eintrag geändert.",
|
||||
}
|
||||
expected_prev = str(data["hash"])
|
||||
checked += 1
|
||||
return {"ok": True, "checked": checked, "broken_at": None, "reason": ""}
|
||||
|
||||
|
||||
def search(
|
||||
conn: sqlite3.Connection,
|
||||
*,
|
||||
query: str = "",
|
||||
action: str = "",
|
||||
actor: str = "",
|
||||
severity: str = "",
|
||||
limit: int = 200,
|
||||
offset: int = 0,
|
||||
) -> list[dict[str, Any]]:
|
||||
clauses: list[str] = []
|
||||
params: list[Any] = []
|
||||
if query:
|
||||
clauses.append("(target LIKE ? OR detail LIKE ? OR action LIKE ? OR actor LIKE ?)")
|
||||
params.extend([f"%{query}%"] * 4)
|
||||
if action:
|
||||
clauses.append("action = ?")
|
||||
params.append(action)
|
||||
if actor:
|
||||
clauses.append("actor = ?")
|
||||
params.append(actor)
|
||||
if severity:
|
||||
clauses.append("severity = ?")
|
||||
params.append(severity)
|
||||
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
|
||||
params.extend([limit, offset])
|
||||
return Database.all(
|
||||
conn,
|
||||
f"SELECT * FROM audit_log {where} ORDER BY id DESC LIMIT ? OFFSET ?",
|
||||
tuple(params),
|
||||
)
|
||||
|
||||
|
||||
def count(conn: sqlite3.Connection) -> int:
|
||||
return int(Database.value(conn, "SELECT COUNT(*) FROM audit_log", default=0) or 0)
|
||||
|
||||
|
||||
def distinct_actions(conn: sqlite3.Connection) -> list[str]:
|
||||
return [
|
||||
str(row["action"])
|
||||
for row in Database.all(conn, "SELECT DISTINCT action FROM audit_log ORDER BY action")
|
||||
]
|
||||
|
||||
|
||||
def archive_old_rows(
|
||||
conn: sqlite3.Connection,
|
||||
archive_dir: Path,
|
||||
*,
|
||||
threshold: int = ARCHIVE_THRESHOLD,
|
||||
target: int = ARCHIVE_TARGET,
|
||||
) -> list[Path]:
|
||||
"""Laagert ganze Kalendertage aus, bis die Tabelle unter ``target`` liegt.
|
||||
|
||||
Nur *vollstaendige* Tage werden ausgelagert -- der laufende Tag bleibt immer
|
||||
in der Datenbank, damit die Auditseite nie halbe Tage zeigt.
|
||||
"""
|
||||
total = count(conn)
|
||||
if total <= threshold:
|
||||
return []
|
||||
|
||||
archive_dir.mkdir(parents=True, exist_ok=True)
|
||||
today = datetime.now(timezone.utc).date().isoformat()
|
||||
written: list[Path] = []
|
||||
|
||||
while total > target:
|
||||
# Nach Zeitstempel, nicht nach id: Eintraege koennen mit einem
|
||||
# nachgereichten ts geschrieben worden sein (Import, Migration).
|
||||
oldest_day = Database.value(conn, "SELECT MIN(substr(ts,1,10)) FROM audit_log")
|
||||
if not oldest_day or str(oldest_day) >= today:
|
||||
break
|
||||
rows = Database.all(
|
||||
conn,
|
||||
"SELECT * FROM audit_log WHERE substr(ts,1,10)=? ORDER BY id",
|
||||
(str(oldest_day),),
|
||||
)
|
||||
if not rows:
|
||||
break
|
||||
path = archive_dir / f"audit-{oldest_day}.jsonl"
|
||||
with path.open("a", encoding="utf-8", newline="\n") as stream:
|
||||
for row in rows:
|
||||
stream.write(json.dumps(row, ensure_ascii=False, sort_keys=True) + "\n")
|
||||
conn.execute("DELETE FROM audit_log WHERE substr(ts,1,10)=?", (str(oldest_day),))
|
||||
written.append(path)
|
||||
total -= len(rows)
|
||||
logger.info("Auditlog: %s Zeilen von %s nach %s ausgelagert", len(rows), oldest_day, path)
|
||||
|
||||
return written
|
||||
|
||||
|
||||
def archived_days(archive_dir: Path) -> list[dict[str, Any]]:
|
||||
"""Liste der Archivdateien fuer die Verlaufsseite."""
|
||||
if not archive_dir.exists():
|
||||
return []
|
||||
entries: list[dict[str, Any]] = []
|
||||
for path in sorted(archive_dir.glob("audit-*.jsonl"), reverse=True):
|
||||
try:
|
||||
stat = path.stat()
|
||||
except OSError:
|
||||
continue
|
||||
entries.append(
|
||||
{
|
||||
"day": path.stem.removeprefix("audit-"),
|
||||
"path": path,
|
||||
"size": stat.st_size,
|
||||
"modified": datetime.fromtimestamp(stat.st_mtime, timezone.utc).isoformat(
|
||||
timespec="seconds"
|
||||
),
|
||||
}
|
||||
)
|
||||
return entries
|
||||
|
||||
|
||||
def read_archived_day(archive_dir: Path, day: str) -> list[dict[str, Any]]:
|
||||
path = archive_dir / f"audit-{day}.jsonl"
|
||||
if not path.is_file():
|
||||
return []
|
||||
rows: list[dict[str, Any]] = []
|
||||
with path.open("r", encoding="utf-8") as stream:
|
||||
for line in stream:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
rows.append(json.loads(line))
|
||||
except ValueError:
|
||||
continue
|
||||
return rows
|
||||
|
||||
|
||||
def summarize_by_day(rows: Iterable[dict[str, Any]]) -> list[tuple[str, int]]:
|
||||
buckets: dict[str, int] = defaultdict(int)
|
||||
for row in rows:
|
||||
buckets[str(row.get("ts", ""))[:10]] += 1
|
||||
return sorted(buckets.items(), reverse=True)
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Authentisierung: Benutzerobjekt, Anmeldedienst, Verzeichnisanbindung."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .models import (
|
||||
ANONYMOUS,
|
||||
AnonymousUser,
|
||||
Group,
|
||||
User,
|
||||
admin_required,
|
||||
current_user,
|
||||
login_required,
|
||||
permission_required,
|
||||
reauth_required,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ANONYMOUS",
|
||||
"AnonymousUser",
|
||||
"Group",
|
||||
"User",
|
||||
"admin_required",
|
||||
"current_user",
|
||||
"login_required",
|
||||
"permission_required",
|
||||
"reauth_required",
|
||||
]
|
||||
@@ -0,0 +1,756 @@
|
||||
"""Anmeldung gegen Active Directory / LDAP (Same Sign-On).
|
||||
|
||||
"Same Sign-On", nicht "Single Sign-On": der Benutzer tippt sein
|
||||
Verzeichnispasswort weiterhin auf der normalen Anmeldeseite ein; es wird bei
|
||||
jeder Anmeldung live gegen das Verzeichnis geprueft, nie hier gespeichert.
|
||||
|
||||
Sicherheitsaenderungen gegenueber dem Vorgaenger:
|
||||
|
||||
* **Transportverschluesselung ist Pflicht** (LDAPS oder StartTLS). Der
|
||||
Vorgaenger erlaubte unverschluesseltes LDAP auf Port 389 ohne Warnung -- das
|
||||
Passwort ging im Klartext ueber das Netz. Wer es dennoch braucht, muss
|
||||
``ldap_allow_insecure`` ausdruecklich setzen; das wird bei jeder Anmeldung
|
||||
protokolliert.
|
||||
* **Serverzertifikat wird geprueft.** Der Vorgaenger validierte es nicht --
|
||||
LDAPS ohne Validierung schuetzt nur gegen passives Mitlesen, nicht gegen
|
||||
einen Man-in-the-Middle.
|
||||
* Das Bind-Passwort des Dienstkontos liegt AES-GCM-verschluesselt in
|
||||
``service_accounts`` (an den Zweck gebunden), nicht Fernet-verschluesselt
|
||||
ohne Kontext.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import socket
|
||||
import sqlite3
|
||||
import ssl
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Literal
|
||||
|
||||
from .. import audit
|
||||
from ..db import Database
|
||||
from ..extension import core
|
||||
from ..settings_store import SettingSpec
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SERVICE_PURPOSE = "ldap_bind"
|
||||
AAD_LDAP_BIND = "service_accounts.ldap_bind"
|
||||
|
||||
#: userAccountControl-Bit fuer "Konto deaktiviert".
|
||||
ACCOUNTDISABLE = 0x0002
|
||||
#: OID fuer rekursive Gruppenaufloesung in AD.
|
||||
MATCHING_RULE_IN_CHAIN = "1.2.840.113556.1.4.1941"
|
||||
|
||||
Status = Literal["ok", "invalid", "unavailable", "disabled"]
|
||||
|
||||
SETTINGS: tuple[SettingSpec, ...] = (
|
||||
SettingSpec("ldap_enabled", False, "bool", "Verzeichnisanmeldung aktiv", section="ldap"),
|
||||
SettingSpec("ldap_host", "", "str", "Server (Hostname)", section="ldap"),
|
||||
SettingSpec("ldap_port", 636, "int", "Port", section="ldap", minimum=1, maximum=65535),
|
||||
SettingSpec(
|
||||
"ldap_security",
|
||||
"ldaps",
|
||||
"str",
|
||||
"Transport",
|
||||
section="ldap",
|
||||
choices=(("ldaps", "LDAPS (Port 636)"), ("starttls", "StartTLS (Port 389)"), ("none", "Unverschluesselt")),
|
||||
),
|
||||
SettingSpec("ldap_verify_cert", True, "bool", "Serverzertifikat prüfen", section="ldap"),
|
||||
SettingSpec("ldap_ca_file", "", "str", "CA-Zertifikat (Pfad)", section="ldap"),
|
||||
SettingSpec(
|
||||
"ldap_allow_insecure",
|
||||
False,
|
||||
"bool",
|
||||
"Unverschlüsselte Verbindung ausdrücklich erlauben",
|
||||
hint="Nur für Testumgebungen. Das Passwort geht dabei im Klartext über das Netz.",
|
||||
section="ldap",
|
||||
),
|
||||
SettingSpec("ldap_base_dn", "", "str", "Basis-DN (leer = automatisch)", section="ldap"),
|
||||
SettingSpec("ldap_user_attribute", "sAMAccountName", "str", "Anmeldeattribut", section="ldap"),
|
||||
SettingSpec("ldap_email_attribute", "userPrincipalName", "str", "E-Mail-Attribut", section="ldap"),
|
||||
SettingSpec("ldap_group_sync", True, "bool", "Gruppen aus dem Verzeichnis übernehmen", section="ldap"),
|
||||
SettingSpec("ldap_timeout", 8, "int", "Zeitlimit (Sekunden)", section="ldap", minimum=1, maximum=60),
|
||||
)
|
||||
|
||||
_USERNAME_RE = re.compile(r"^[A-Za-z0-9._\-@ ]{1,128}$")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DirectoryProfile:
|
||||
username: str
|
||||
dn: str
|
||||
first_name: str = ""
|
||||
last_name: str = ""
|
||||
email: str = ""
|
||||
group_dns: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DirectoryResult:
|
||||
status: Status
|
||||
profile: DirectoryProfile | None = None
|
||||
message: str = ""
|
||||
warnings: tuple[str, ...] = field(default=())
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def is_enabled(conn: sqlite3.Connection) -> bool:
|
||||
settings = core().settings
|
||||
if not settings.get(conn, "ldap_enabled"):
|
||||
return False
|
||||
return bool(settings.get(conn, "ldap_host"))
|
||||
|
||||
|
||||
def bind_credentials(conn: sqlite3.Connection) -> tuple[str, str]:
|
||||
"""Benutzer und Passwort des Bind-Dienstkontos (entschluesselt)."""
|
||||
row = Database.one(
|
||||
conn,
|
||||
"SELECT username, password_encrypted FROM service_accounts WHERE purpose=?",
|
||||
(SERVICE_PURPOSE,),
|
||||
)
|
||||
if row is None:
|
||||
return "", ""
|
||||
encrypted = str(row.get("password_encrypted") or "")
|
||||
if not encrypted:
|
||||
return str(row.get("username") or ""), ""
|
||||
try:
|
||||
secret = core().keystore.decrypt(encrypted, aad=AAD_LDAP_BIND)
|
||||
except Exception: # noqa: BLE001 - Schluessel gewechselt/Datei kaputt
|
||||
logger.error("Bind-Passwort des Verzeichnisdienstes konnte nicht entschlüsselt werden")
|
||||
return str(row.get("username") or ""), ""
|
||||
return str(row.get("username") or ""), secret
|
||||
|
||||
|
||||
def store_bind_credentials(conn: sqlite3.Connection, username: str, password: str | None) -> None:
|
||||
"""Speichert das Dienstkonto. ``password=None`` laesst das bestehende unveraendert."""
|
||||
existing = Database.one(
|
||||
conn, "SELECT password_encrypted FROM service_accounts WHERE purpose=?", (SERVICE_PURPOSE,)
|
||||
)
|
||||
encrypted = str(existing.get("password_encrypted") or "") if existing else ""
|
||||
if password:
|
||||
encrypted = core().keystore.encrypt(password, aad=AAD_LDAP_BIND)
|
||||
conn.execute(
|
||||
"INSERT INTO service_accounts (purpose, username, password_encrypted, updated_at) "
|
||||
"VALUES (?,?,?,?) ON CONFLICT(purpose) DO UPDATE SET username=excluded.username, "
|
||||
"password_encrypted=excluded.password_encrypted, updated_at=excluded.updated_at",
|
||||
(SERVICE_PURPOSE, username.strip(), encrypted, _now()),
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Verbindung
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _tls_object(config: dict[str, Any]):
|
||||
from ldap3 import Tls
|
||||
|
||||
verify = bool(config["ldap_verify_cert"])
|
||||
ca_file = str(config["ldap_ca_file"] or "") or None
|
||||
return Tls(
|
||||
validate=ssl.CERT_REQUIRED if verify else ssl.CERT_NONE,
|
||||
version=ssl.PROTOCOL_TLS_CLIENT,
|
||||
ca_certs_file=ca_file,
|
||||
)
|
||||
|
||||
|
||||
def _server(config: dict[str, Any]):
|
||||
"""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
|
||||
|
||||
security = str(config["ldap_security"])
|
||||
braucht_basis = not str(config.get("ldap_base_dn") or "").strip()
|
||||
return Server(
|
||||
str(config["ldap_host"]),
|
||||
port=int(config["ldap_port"]),
|
||||
use_ssl=security == "ldaps",
|
||||
tls=_tls_object(config) if security in {"ldaps", "starttls"} else None,
|
||||
get_info="DSA" if braucht_basis else "NO_INFO",
|
||||
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):
|
||||
"""Baut eine Verbindung auf und bindet. Wirft bei Netz-/TLS-Problemen."""
|
||||
from ldap3 import Connection
|
||||
|
||||
security = str(config["ldap_security"])
|
||||
if security == "none" and not config["ldap_allow_insecure"]:
|
||||
raise PermissionError(
|
||||
"Unverschlüsselte Verzeichnisverbindungen sind deaktiviert. "
|
||||
"Nutzen Sie LDAPS oder StartTLS."
|
||||
)
|
||||
connection = Connection(
|
||||
_server(config),
|
||||
user=user or None,
|
||||
password=password or None,
|
||||
auto_bind=False,
|
||||
raise_exceptions=False,
|
||||
receive_timeout=int(config["ldap_timeout"]),
|
||||
# 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 not connection.open():
|
||||
raise ConnectionError("Verbindung zum Verzeichnisdienst fehlgeschlagen")
|
||||
if not connection.start_tls():
|
||||
raise ConnectionError(
|
||||
f"StartTLS fehlgeschlagen: {connection.result.get('description', 'unbekannt')}"
|
||||
)
|
||||
if not connection.bind():
|
||||
return connection, False
|
||||
return connection, True
|
||||
|
||||
|
||||
def _read_config(conn: sqlite3.Connection) -> dict[str, Any]:
|
||||
settings = core().settings
|
||||
return settings.get_many(conn, [spec.key for spec in SETTINGS])
|
||||
|
||||
|
||||
def _base_dn(connection, config: dict[str, Any]) -> str:
|
||||
configured = str(config["ldap_base_dn"] or "").strip()
|
||||
if configured:
|
||||
return configured
|
||||
info = getattr(connection.server, "info", None)
|
||||
naming = getattr(info, "other", {}).get("defaultNamingContext") if info else None
|
||||
if isinstance(naming, list) and naming:
|
||||
return str(naming[0])
|
||||
if isinstance(naming, str):
|
||||
return naming
|
||||
contexts = getattr(info, "naming_contexts", None) if info else None
|
||||
if contexts:
|
||||
return str(contexts[0])
|
||||
return ""
|
||||
|
||||
|
||||
def _escape(value: str) -> str:
|
||||
"""RFC-4515-Escaping fuer Filterwerte -- verhindert LDAP-Injection."""
|
||||
replacements = {"\\": r"\5c", "*": r"\2a", "(": r"\28", ")": r"\29", "\x00": r"\00", "/": r"\2f"}
|
||||
return "".join(replacements.get(char, char) for char in value)
|
||||
|
||||
|
||||
def authenticate(conn: sqlite3.Connection, username: str, password: str) -> DirectoryResult:
|
||||
"""Suchen-und-Binden gegen das Verzeichnis."""
|
||||
try:
|
||||
import ldap3 # noqa: F401
|
||||
except ImportError:
|
||||
return DirectoryResult("unavailable", message="Das Paket ldap3 ist nicht installiert.")
|
||||
|
||||
if not _USERNAME_RE.match(username or ""):
|
||||
return DirectoryResult("invalid")
|
||||
|
||||
config = _read_config(conn)
|
||||
if not config.get("ldap_enabled") or not config.get("ldap_host"):
|
||||
return DirectoryResult("disabled")
|
||||
|
||||
bind_user, bind_password = bind_credentials(conn)
|
||||
warnings: list[str] = []
|
||||
if str(config["ldap_security"]) == "none":
|
||||
warnings.append("Unverschlüsselte Verzeichnisverbindung in Benutzung")
|
||||
if not config["ldap_verify_cert"]:
|
||||
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:
|
||||
service_conn, bound = mit_wiederholung(
|
||||
lambda: _connect(config, bind_user, bind_password)
|
||||
)
|
||||
except (PermissionError, ConnectionError) as exc:
|
||||
logger.error("Verzeichnisdienst: %s", exc)
|
||||
return DirectoryResult("unavailable", message=str(exc), warnings=tuple(warnings))
|
||||
except (socket.error, ssl.SSLError, OSError) as exc:
|
||||
logger.error("Verzeichnisdienst nicht erreichbar: %s", exc)
|
||||
return DirectoryResult("unavailable", message=str(exc), warnings=tuple(warnings))
|
||||
|
||||
if not bound:
|
||||
detail = service_conn.result.get("description", "unbekannt") if service_conn.result else ""
|
||||
logger.error("Bind des Dienstkontos fehlgeschlagen: %s", detail)
|
||||
return DirectoryResult(
|
||||
"unavailable",
|
||||
message=f"Anmeldung des Dienstkontos fehlgeschlagen ({detail}).",
|
||||
warnings=tuple(warnings),
|
||||
)
|
||||
|
||||
marke = gemessen("Dienstbindung", begonnen)
|
||||
try:
|
||||
base = _base_dn(service_conn, config)
|
||||
marke = gemessen("Basis-DN", marke)
|
||||
if not base:
|
||||
return DirectoryResult(
|
||||
"unavailable", message="Basis-DN konnte nicht ermittelt werden.", warnings=tuple(warnings)
|
||||
)
|
||||
|
||||
attribute = str(config["ldap_user_attribute"] or "sAMAccountName")
|
||||
email_attribute = str(config["ldap_email_attribute"] or "userPrincipalName")
|
||||
wanted = ["distinguishedName", "givenName", "sn", "userAccountControl", email_attribute, attribute]
|
||||
|
||||
service_conn.search(
|
||||
search_base=base,
|
||||
search_filter=f"(&(objectClass=user)({attribute}={_escape(username)}))",
|
||||
attributes=list(dict.fromkeys(wanted)),
|
||||
size_limit=2,
|
||||
)
|
||||
marke = gemessen("Benutzersuche", marke)
|
||||
entries = list(service_conn.entries)
|
||||
if len(entries) != 1:
|
||||
logger.info("Verzeichnisanmeldung %s: %s", username, ", ".join(zeiten))
|
||||
return DirectoryResult("invalid", warnings=tuple(warnings))
|
||||
|
||||
entry = entries[0]
|
||||
dn = str(entry.entry_dn)
|
||||
control = _attr_int(entry, "userAccountControl")
|
||||
if control is not None and control & ACCOUNTDISABLE:
|
||||
return DirectoryResult("invalid", message="Konto im Verzeichnis deaktiviert.", warnings=tuple(warnings))
|
||||
|
||||
# Zweite, unabhaengige Verbindung mit den Benutzerdaten -- die eigentliche
|
||||
# Passwortpruefung. Niemals ueber die Dienstkonto-Verbindung rebinden.
|
||||
#
|
||||
# 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:
|
||||
if not user_bound:
|
||||
logger.info("Verzeichnisanmeldung %s: %s", username, ", ".join(zeiten))
|
||||
return DirectoryResult("invalid", warnings=tuple(warnings))
|
||||
finally:
|
||||
try:
|
||||
user_conn.unbind()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
group_dns: tuple[str, ...] = ()
|
||||
if config["ldap_group_sync"]:
|
||||
group_dns = _resolve_groups(service_conn, base, dn)
|
||||
marke = gemessen("Gruppen", marke)
|
||||
|
||||
profile = DirectoryProfile(
|
||||
username=_attr(entry, attribute) or username,
|
||||
dn=dn,
|
||||
first_name=_attr(entry, "givenName"),
|
||||
last_name=_attr(entry, "sn"),
|
||||
email=_attr(entry, email_attribute),
|
||||
group_dns=group_dns,
|
||||
)
|
||||
# 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))
|
||||
finally:
|
||||
try:
|
||||
service_conn.unbind()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
|
||||
def _attr(entry: Any, name: str) -> str:
|
||||
try:
|
||||
value = entry[name].value
|
||||
except Exception: # noqa: BLE001
|
||||
return ""
|
||||
if isinstance(value, (list, tuple)):
|
||||
return str(value[0]) if value else ""
|
||||
return "" if value is None else str(value)
|
||||
|
||||
|
||||
def _attr_int(entry: Any, name: str) -> int | None:
|
||||
raw = _attr(entry, name)
|
||||
try:
|
||||
return int(raw)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_groups(connection: Any, base: str, user_dn: str) -> tuple[str, ...]:
|
||||
"""Rekursive Gruppenaufloesung ueber LDAP_MATCHING_RULE_IN_CHAIN."""
|
||||
try:
|
||||
connection.search(
|
||||
search_base=base,
|
||||
search_filter=f"(member:{MATCHING_RULE_IN_CHAIN}:={_escape(user_dn)})",
|
||||
attributes=["distinguishedName", "cn"],
|
||||
size_limit=500,
|
||||
)
|
||||
return tuple(str(entry.entry_dn) for entry in connection.entries)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("Gruppenauflösung fehlgeschlagen: %s", exc)
|
||||
return ()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Schattenkonto
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def sync_account(conn: sqlite3.Connection, profile: DirectoryProfile) -> dict[str, Any]:
|
||||
"""Legt das lokale Schattenkonto an bzw. aktualisiert es und mappt Gruppen."""
|
||||
from ..rbac import service as rbac
|
||||
|
||||
row = rbac.find_user_by_username(conn, profile.username)
|
||||
with core().database.transaction(conn):
|
||||
if row is None:
|
||||
cursor = conn.execute(
|
||||
"INSERT INTO users (username, password_hash, is_admin, auth_source, first_name, "
|
||||
"last_name, email, created_at, updated_at) VALUES (?,?,0,'ldap',?,?,?,?,?)",
|
||||
(
|
||||
profile.username,
|
||||
"", # Kein lokales Passwort -- Anmeldung laeuft immer ueber das Verzeichnis.
|
||||
profile.first_name,
|
||||
profile.last_name,
|
||||
profile.email,
|
||||
_now(),
|
||||
_now(),
|
||||
),
|
||||
)
|
||||
user_id = int(cursor.lastrowid or 0)
|
||||
audit.log(
|
||||
conn,
|
||||
actor=profile.username,
|
||||
actor_id=user_id,
|
||||
action="auth.directory_account_created",
|
||||
target=profile.username,
|
||||
detail=profile.dn,
|
||||
severity="notice",
|
||||
)
|
||||
else:
|
||||
user_id = int(row["id"])
|
||||
conn.execute(
|
||||
"UPDATE users SET first_name=?, last_name=?, email=?, auth_source='ldap', "
|
||||
"updated_at=? WHERE id=?",
|
||||
(profile.first_name, profile.last_name, profile.email, _now(), user_id),
|
||||
)
|
||||
|
||||
_apply_group_mappings(conn, user_id, profile.group_dns)
|
||||
|
||||
result = rbac.get_user(conn, user_id)
|
||||
assert result is not None
|
||||
return result
|
||||
|
||||
|
||||
def _apply_group_mappings(
|
||||
conn: sqlite3.Connection, user_id: int, group_dns: tuple[str, ...]
|
||||
) -> None:
|
||||
"""Setzt die Gruppen additiv aus den Verzeichnisgruppen.
|
||||
|
||||
Bewusst *ersetzend*: wer im Verzeichnis aus einer Gruppe entfernt wurde,
|
||||
verliert das Recht bei der naechsten Anmeldung. Manuell in der Anwendung
|
||||
zugewiesene Gruppen bleiben erhalten, wenn sie kein Verzeichnis-Mapping
|
||||
haben -- sonst waere jede lokale Zuweisung nach dem naechsten Login weg.
|
||||
"""
|
||||
mappings = Database.all(
|
||||
conn, "SELECT ad_group_dn, app_group_id, grants_admin FROM ldap_group_mappings"
|
||||
)
|
||||
if not mappings:
|
||||
return
|
||||
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 = {
|
||||
int(row["app_group_id"])
|
||||
for row in gruppenzuordnungen
|
||||
if str(row["ad_group_dn"]).lower() in lowered
|
||||
}
|
||||
|
||||
current = {int(row["id"]) for row in _user_group_ids(conn, user_id)}
|
||||
# Alles, was ueber ein Mapping kommt, wird neu gesetzt; alles andere bleibt.
|
||||
keep = {gid for gid in current if gid not in mapped_group_ids}
|
||||
final = keep | wanted
|
||||
|
||||
if final == current:
|
||||
return
|
||||
conn.execute("DELETE FROM user_groups WHERE user_id=?", (user_id,))
|
||||
conn.executemany(
|
||||
"INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?,?)",
|
||||
[(user_id, gid) for gid in sorted(final)],
|
||||
)
|
||||
|
||||
|
||||
def _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]]:
|
||||
return Database.all(conn, "SELECT group_id AS id FROM user_groups WHERE user_id=?", (user_id,))
|
||||
|
||||
|
||||
def search_directory_users(
|
||||
conn: sqlite3.Connection, query: str, *, limit: int = 20
|
||||
) -> list[dict[str, str]]:
|
||||
"""Live-Suche fuer die Vorab-Zuweisung von Verzeichniskonten."""
|
||||
if len(query.strip()) < 2:
|
||||
return []
|
||||
try:
|
||||
import ldap3 # noqa: F401
|
||||
except ImportError:
|
||||
return []
|
||||
|
||||
config = _read_config(conn)
|
||||
if not config.get("ldap_enabled") or not config.get("ldap_host"):
|
||||
return []
|
||||
bind_user, bind_password = bind_credentials(conn)
|
||||
try:
|
||||
connection, bound = _connect(config, bind_user, bind_password)
|
||||
except Exception: # noqa: BLE001
|
||||
return []
|
||||
if not bound:
|
||||
return []
|
||||
try:
|
||||
base = _base_dn(connection, config)
|
||||
attribute = str(config["ldap_user_attribute"] or "sAMAccountName")
|
||||
email_attribute = str(config["ldap_email_attribute"] or "userPrincipalName")
|
||||
escaped = _escape(query.strip())
|
||||
connection.search(
|
||||
search_base=base,
|
||||
search_filter=(
|
||||
f"(&(objectClass=user)(|({attribute}={escaped}*)"
|
||||
f"(givenName={escaped}*)(sn={escaped}*)(displayName={escaped}*)))"
|
||||
),
|
||||
attributes=[attribute, "givenName", "sn", email_attribute],
|
||||
size_limit=limit,
|
||||
)
|
||||
return [
|
||||
{
|
||||
"username": _attr(entry, attribute),
|
||||
"first_name": _attr(entry, "givenName"),
|
||||
"last_name": _attr(entry, "sn"),
|
||||
"email": _attr(entry, email_attribute),
|
||||
"dn": str(entry.entry_dn),
|
||||
}
|
||||
for entry in connection.entries
|
||||
if _attr(entry, attribute)
|
||||
]
|
||||
except Exception: # noqa: BLE001
|
||||
return []
|
||||
finally:
|
||||
try:
|
||||
connection.unbind()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
|
||||
def search_directory_groups(conn: sqlite3.Connection, query: str, *, limit: int = 20) -> list[dict[str, str]]:
|
||||
"""Live-Suche nach Verzeichnisgruppen fuer die Mapping-Oberflaeche."""
|
||||
if len(query.strip()) < 2:
|
||||
return []
|
||||
try:
|
||||
import ldap3 # noqa: F401
|
||||
except ImportError:
|
||||
return []
|
||||
config = _read_config(conn)
|
||||
if not config.get("ldap_enabled") or not config.get("ldap_host"):
|
||||
return []
|
||||
bind_user, bind_password = bind_credentials(conn)
|
||||
try:
|
||||
connection, bound = _connect(config, bind_user, bind_password)
|
||||
except Exception: # noqa: BLE001
|
||||
return []
|
||||
if not bound:
|
||||
return []
|
||||
try:
|
||||
base = _base_dn(connection, config)
|
||||
escaped = _escape(query.strip())
|
||||
connection.search(
|
||||
search_base=base,
|
||||
search_filter=f"(&(objectClass=group)(cn={escaped}*))",
|
||||
attributes=["cn", "distinguishedName"],
|
||||
size_limit=limit,
|
||||
)
|
||||
return [
|
||||
{"name": _attr(entry, "cn"), "dn": str(entry.entry_dn)} for entry in connection.entries
|
||||
]
|
||||
except Exception: # noqa: BLE001
|
||||
return []
|
||||
finally:
|
||||
try:
|
||||
connection.unbind()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
|
||||
def test_connection(conn: sqlite3.Connection) -> dict[str, Any]:
|
||||
"""Verbindungstest fuer die Einstellungsseite -- ohne Anmeldung eines Benutzers."""
|
||||
try:
|
||||
import ldap3 # noqa: F401
|
||||
except ImportError:
|
||||
return {"ok": False, "message": "Das Paket ldap3 ist nicht installiert."}
|
||||
config = _read_config(conn)
|
||||
if not config.get("ldap_host"):
|
||||
return {"ok": False, "message": "Es ist kein Server konfiguriert."}
|
||||
bind_user, bind_password = bind_credentials(conn)
|
||||
try:
|
||||
connection, bound = _connect(config, bind_user, bind_password)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {"ok": False, "message": str(exc)}
|
||||
if not bound:
|
||||
detail = connection.result.get("description", "unbekannt") if connection.result else ""
|
||||
return {"ok": False, "message": f"Anmeldung des Dienstkontos fehlgeschlagen ({detail})."}
|
||||
try:
|
||||
base = _base_dn(connection, config)
|
||||
return {
|
||||
"ok": True,
|
||||
"message": f"Verbindung erfolgreich. Basis-DN: {base or 'nicht ermittelbar'}",
|
||||
"base_dn": base,
|
||||
}
|
||||
finally:
|
||||
try:
|
||||
connection.unbind()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
@@ -0,0 +1,365 @@
|
||||
"""Benutzerobjekt, Rechtepruefung und Zugriffsdekoratoren."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, Iterable, Sequence
|
||||
|
||||
from flask import abort, flash, g, redirect, request, session, url_for
|
||||
from werkzeug.local import LocalProxy
|
||||
|
||||
from ..rbac.model import Action, PermissionTree
|
||||
from ..security.sessions import is_automatic
|
||||
|
||||
AUTH_SOURCES = ("local", "ldap")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Group:
|
||||
id: int
|
||||
name: str
|
||||
is_system: bool = False
|
||||
is_default: bool = False
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class User:
|
||||
"""Angemeldeter Benutzer. Unveraenderlich fuer die Dauer eines Requests."""
|
||||
|
||||
id: int
|
||||
username: str
|
||||
is_admin: bool = False
|
||||
is_locked: bool = False
|
||||
auth_source: str = "local"
|
||||
first_name: str = ""
|
||||
last_name: str = ""
|
||||
email: str = ""
|
||||
avatar_filename: str = ""
|
||||
totp_enabled: bool = False
|
||||
must_change_password: bool = False
|
||||
groups: tuple[Group, ...] = ()
|
||||
granted: frozenset[str] = frozenset()
|
||||
_tree: PermissionTree | None = field(default=None, repr=False, compare=False)
|
||||
_effective: frozenset[str] | None = field(default=None, repr=False, compare=False)
|
||||
|
||||
# -- Anzeige ------------------------------------------------------- #
|
||||
|
||||
@property
|
||||
def is_authenticated(self) -> bool:
|
||||
return True
|
||||
|
||||
@property
|
||||
def display_name(self) -> str:
|
||||
full = f"{self.first_name} {self.last_name}".strip()
|
||||
return full or self.username
|
||||
|
||||
@property
|
||||
def initials(self) -> str:
|
||||
parts = [p for p in (self.first_name, self.last_name) if p]
|
||||
if parts:
|
||||
return "".join(p[0] for p in parts[:2]).upper()
|
||||
return (self.username[:2] or "?").upper()
|
||||
|
||||
@property
|
||||
def role_label(self) -> str:
|
||||
if self.is_admin:
|
||||
return "Administrator"
|
||||
if self.groups:
|
||||
return ", ".join(group.name for group in self.groups)
|
||||
return "Ohne Gruppe"
|
||||
|
||||
@property
|
||||
def is_directory_account(self) -> bool:
|
||||
return self.auth_source == "ldap"
|
||||
|
||||
# -- Rechte -------------------------------------------------------- #
|
||||
|
||||
@property
|
||||
def effective_permissions(self) -> frozenset[str]:
|
||||
"""Rechte nach Anwendung der Bereichs-Kill-Switches.
|
||||
|
||||
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:
|
||||
tree = self._tree
|
||||
if tree is None:
|
||||
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
|
||||
|
||||
def has(self, *permissions: str) -> bool:
|
||||
"""True, wenn **alle** angegebenen Rechte greifen.
|
||||
|
||||
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:
|
||||
return True
|
||||
return all(permission in self.effective_permissions for permission in permissions)
|
||||
|
||||
def has_any(self, permissions: Iterable[str]) -> bool:
|
||||
effective = self.effective_permissions
|
||||
return any(permission in effective for permission in permissions)
|
||||
|
||||
def can(self, resource: str, action: Action | str) -> bool:
|
||||
value = action.value if isinstance(action, Action) else str(action)
|
||||
return self.has(f"{resource}.{value}")
|
||||
|
||||
def can_any(self, resource: str, *actions: Action | str) -> bool:
|
||||
return self.has_any(
|
||||
f"{resource}.{a.value if isinstance(a, Action) else a}" for a in actions
|
||||
)
|
||||
|
||||
def as_audit_actor(self) -> str:
|
||||
return self.username
|
||||
|
||||
|
||||
class AnonymousUser:
|
||||
"""Nicht angemeldet. Hat keine Rechte -- auch nicht ``view``."""
|
||||
|
||||
id = 0
|
||||
username = ""
|
||||
is_admin = False
|
||||
is_locked = False
|
||||
auth_source = "anonymous"
|
||||
first_name = ""
|
||||
last_name = ""
|
||||
email = ""
|
||||
avatar_filename = ""
|
||||
totp_enabled = False
|
||||
must_change_password = False
|
||||
groups: tuple[Group, ...] = ()
|
||||
granted: frozenset[str] = frozenset()
|
||||
|
||||
@property
|
||||
def is_authenticated(self) -> bool:
|
||||
return False
|
||||
|
||||
@property
|
||||
def display_name(self) -> str:
|
||||
return "Gast"
|
||||
|
||||
@property
|
||||
def initials(self) -> str:
|
||||
return "?"
|
||||
|
||||
@property
|
||||
def role_label(self) -> str:
|
||||
return "Nicht angemeldet"
|
||||
|
||||
@property
|
||||
def is_directory_account(self) -> bool:
|
||||
return False
|
||||
|
||||
@property
|
||||
def effective_permissions(self) -> frozenset[str]:
|
||||
return frozenset()
|
||||
|
||||
def has(self, *permissions: str) -> bool:
|
||||
return False
|
||||
|
||||
def has_any(self, permissions: Iterable[str]) -> bool:
|
||||
return False
|
||||
|
||||
def can(self, resource: str, action: Action | str) -> bool:
|
||||
return False
|
||||
|
||||
def can_any(self, resource: str, *actions: Action | str) -> bool:
|
||||
return False
|
||||
|
||||
def as_audit_actor(self) -> str:
|
||||
return "anonym"
|
||||
|
||||
|
||||
ANONYMOUS = AnonymousUser()
|
||||
|
||||
|
||||
def _lookup_user() -> User | AnonymousUser:
|
||||
return getattr(g, "user", ANONYMOUS)
|
||||
|
||||
|
||||
#: Aktueller Benutzer -- in Templates und Views gleich benutzbar.
|
||||
current_user: Any = LocalProxy(_lookup_user)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Dekoratoren
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _deny(message: str) -> Any:
|
||||
"""Nicht angemeldet -> Login; angemeldet aber ohne Recht -> 403."""
|
||||
if not current_user.is_authenticated:
|
||||
if request.method == "GET" and not request.headers.get("X-Requested-With"):
|
||||
session["_after_login"] = request.full_path if request.query_string else request.path
|
||||
# Eine automatische Aktualisierung fragt einen Ausschnitt ab und
|
||||
# erwartet HTML. Eine Umleitung auf die Anmeldeseite wuerde sie
|
||||
# dankbar entgegennehmen und eine ganze Seite in eine Kachel setzen --
|
||||
# der Tab bliebe stumm stehen, obwohl die Sitzung abgelaufen ist. 401
|
||||
# sagt es deutlich; der Browser laedt daraufhin die Seite neu und der
|
||||
# Mensch sieht die Anmeldemaske.
|
||||
if is_automatic(request):
|
||||
abort(401, description="Sitzung abgelaufen.")
|
||||
if request.accept_mimetypes.accept_json and not request.accept_mimetypes.accept_html:
|
||||
abort(401, description="Anmeldung erforderlich.")
|
||||
# "Bitte melden Sie sich an." stand auf einer Seite, die aus einem
|
||||
# Anmeldefeld, einem Passwortfeld und einem Knopf "Anmelden" besteht --
|
||||
# eine Meldung, die nichts hinzufuegt. Wer dagegen gerade noch
|
||||
# angemeldet war, soll erfahren, warum er es nicht mehr ist.
|
||||
if getattr(g, "session_expired", False):
|
||||
flash("Ihre Sitzung ist abgelaufen. Bitte melden Sie sich erneut an.", "info")
|
||||
return redirect(url_for("auth.login"))
|
||||
abort(403, description=message)
|
||||
|
||||
|
||||
def login_required(view: Callable[..., Any]) -> Callable[..., Any]:
|
||||
@functools.wraps(view)
|
||||
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
if not current_user.is_authenticated:
|
||||
return _deny("Anmeldung erforderlich.")
|
||||
return view(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def _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]:
|
||||
"""Erzwingt Rechte serverseitig.
|
||||
|
||||
``mode="any"`` fuer Seiten, die mehrere unabhaengige Rechte akzeptieren --
|
||||
wichtig, weil im Vorgaenger eine Seite mit reinem Export-Recht nicht
|
||||
erreichbar war, obwohl das Recht vergeben war.
|
||||
"""
|
||||
|
||||
def decorator(view: Callable[..., Any]) -> Callable[..., Any]:
|
||||
@functools.wraps(view)
|
||||
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
ok = (
|
||||
current_user.has_any(permissions)
|
||||
if mode == "any"
|
||||
else current_user.has(*permissions)
|
||||
)
|
||||
if not ok:
|
||||
return _deny("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)
|
||||
|
||||
wrapper._required_permissions = permissions # type: ignore[attr-defined]
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def admin_required(view: Callable[..., Any]) -> Callable[..., Any]:
|
||||
"""Nur echte Administratoren -- nicht ueber Gruppenrechte delegierbar.
|
||||
|
||||
Fuer Grenzen, die einen lokalen Admin *nicht* ueberleben muessen, aber auch
|
||||
nicht an eine Gruppe vergeben werden sollen (Rechtevergabe selbst,
|
||||
Navigationsreihenfolge, Schluesselrotation).
|
||||
"""
|
||||
|
||||
@functools.wraps(view)
|
||||
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
if not current_user.is_authenticated or not current_user.is_admin:
|
||||
return _deny("Diese Aktion ist Administratoren vorbehalten.")
|
||||
return view(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def reauth_required(view: Callable[..., Any]) -> Callable[..., Any]:
|
||||
"""Verlangt eine frische Passworteingabe ("sudo-Modus").
|
||||
|
||||
Vorgeschaltet bei allem, was Geheimnisse offenlegt oder Daten aus dem System
|
||||
heraustraegt: Klartextpasswoerter, Export, Schluesselrotation, 2FA-Aenderung.
|
||||
Ein uebernommenes Sitzungscookie allein reicht dafuer nicht mehr aus.
|
||||
"""
|
||||
|
||||
@functools.wraps(view)
|
||||
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
from .service import reauth_is_fresh # zyklischen Import vermeiden
|
||||
|
||||
if not current_user.is_authenticated:
|
||||
return _deny("Anmeldung erforderlich.")
|
||||
if not reauth_is_fresh():
|
||||
session["_after_reauth"] = request.full_path if request.query_string else request.path
|
||||
flash("Bitte bestätigen Sie zur Sicherheit Ihr Passwort.", "info")
|
||||
return redirect(url_for("auth.reauth"))
|
||||
return view(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def build_user(
|
||||
row: dict[str, Any],
|
||||
*,
|
||||
groups: Sequence[Group],
|
||||
granted: Iterable[str],
|
||||
tree: PermissionTree,
|
||||
) -> User:
|
||||
return User(
|
||||
id=int(row["id"]),
|
||||
username=str(row["username"]),
|
||||
is_admin=bool(row.get("is_admin")),
|
||||
is_locked=bool(row.get("is_locked")),
|
||||
auth_source=str(row.get("auth_source") or "local"),
|
||||
first_name=str(row.get("first_name") or ""),
|
||||
last_name=str(row.get("last_name") or ""),
|
||||
email=str(row.get("email") or ""),
|
||||
avatar_filename=str(row.get("avatar_filename") or ""),
|
||||
totp_enabled=bool(row.get("totp_secret")),
|
||||
must_change_password=bool(row.get("must_change_password")),
|
||||
groups=tuple(groups),
|
||||
granted=frozenset(granted),
|
||||
_tree=tree,
|
||||
)
|
||||
Loaded 100 of 333 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user