Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
bc360a0b7f |
@@ -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.0.8"
|
||||
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,378 @@
|
||||
"""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 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,
|
||||
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)
|
||||
|
||||
# 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 fuer %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 .services import fileshare
|
||||
|
||||
extension = core()
|
||||
with extension.database.transaction(conn):
|
||||
released = fileshare.release_all_for_user(conn, user.id)
|
||||
if released:
|
||||
current_app.logger.info(
|
||||
"Dateifreigaben fuer %s geloest: %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.view", "monitoring.view")):
|
||||
intervall = int(extension.settings.get(verbindung, "monitor_interval_seconds") or 300)
|
||||
faellig = monitor.next_check_at(verbindung, intervall)
|
||||
if faellig is not None:
|
||||
eintrag = {
|
||||
"label": "Naechste Pruefung in",
|
||||
"countdown_to": faellig.isoformat(),
|
||||
"countdown_done": "jetzt",
|
||||
"css_class": "",
|
||||
"dot": "1",
|
||||
"dot_class": "",
|
||||
"title": f"Der Ueberwachungsdienst prueft alle {intervall} Sekunden.",
|
||||
}
|
||||
if user.has("settings_system.execute"):
|
||||
from flask import url_for
|
||||
|
||||
eintrag["action"] = url_for("settings.run_check_now")
|
||||
eintrag["action_title"] = "Alle Clients sofort pruefen"
|
||||
pills.append(eintrag)
|
||||
|
||||
counts = monitor.dashboard_counts(verbindung)
|
||||
if counts["offline"]:
|
||||
pills.append(
|
||||
{
|
||||
"label": f"{counts['offline']} offline",
|
||||
"css_class": "status-pill--danger",
|
||||
"dot": "1",
|
||||
"dot_class": "status-pill__dot--pulse",
|
||||
"title": "Geraete, die beim letzten Durchlauf nicht geantwortet haben.",
|
||||
}
|
||||
)
|
||||
|
||||
if user.has_any(("dhcp.view", "dhcp.edit")) 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 Geraet 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="Endgueltiges Loeschen 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 uebersprungen -- 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 Wartungsauftraege als unklar markiert", orphaned)
|
||||
if vergessen:
|
||||
logger.info(
|
||||
"%s Freigabe-Einbindungen eines vorherigen Prozesses vergessen "
|
||||
"-- sie bestehen mit dessen Namensraum nicht mehr",
|
||||
vergessen,
|
||||
)
|
||||
|
||||
if extension.license is not None:
|
||||
import socket
|
||||
|
||||
extension.license.start_heartbeat(hostname=socket.gethostname())
|
||||
|
||||
_start_fileshare_sweep(app)
|
||||
|
||||
|
||||
#: Wie oft nach abgelaufenen Einbindungen gesehen wird.
|
||||
FILESHARE_SWEEP_SECONDS = 300
|
||||
|
||||
|
||||
def _start_fileshare_sweep(app: Flask) -> None:
|
||||
"""Loest abgelaufene Freigabe-Einbindungen -- im Webprozess.
|
||||
|
||||
Muss hier laufen und nicht im Ueberwachungsdienst: eingebunden wird im
|
||||
Mount-Namensraum dieses Dienstes, und ein anderer Dienst sieht diese
|
||||
Einbindungen nicht einmal. Ein ``umount`` von dort waere ein Aufruf ins
|
||||
Leere -- die Datenbankzeile waere als geloest markiert, die Freigabe aber
|
||||
weiter eingebunden.
|
||||
"""
|
||||
import threading
|
||||
|
||||
from .services import fileshare
|
||||
|
||||
def schleife() -> None:
|
||||
while True:
|
||||
time.sleep(FILESHARE_SWEEP_SECONDS)
|
||||
try:
|
||||
with app.app_context():
|
||||
geloest = fileshare.sweep_expired(core(app).database)
|
||||
if geloest:
|
||||
logger.info("%s abgelaufene Freigabe-Einbindungen geloest", geloest)
|
||||
except Exception: # noqa: BLE001 - der Webprozess darf nie sterben
|
||||
logger.exception("Aufraeumen der Dateifreigaben fehlgeschlagen")
|
||||
|
||||
threading.Thread(target=schleife, name="fileshare-sweep", daemon=True).start()
|
||||
|
||||
|
||||
# Fuer ``gunicorn tesm:app``
|
||||
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="/sicherung")
|
||||
|
||||
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.secrets"):
|
||||
continue
|
||||
allowed.append(category)
|
||||
return allowed
|
||||
|
||||
|
||||
@bp.get("/")
|
||||
@permission_required("backup.view", "backup.export", "backup.execute", 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.secrets"),
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/export")
|
||||
@permission_required("backup.export")
|
||||
@reauth_required
|
||||
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 ausgewaehlt.", "warning")
|
||||
return redirect(url_for("backup.overview"))
|
||||
|
||||
include_secrets = (
|
||||
request.form.get("include_secrets") == "1" and current_user.has("backup.secrets")
|
||||
)
|
||||
passphrase = request.form.get("passphrase") or ""
|
||||
repeat = request.form.get("passphrase_repeat") or ""
|
||||
if passphrase != repeat:
|
||||
flash("Die beiden Passphrasen stimmen nicht ueberein.", "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/vorschau")
|
||||
@permission_required("backup.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 ausgewaehlt.", "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 gross.", "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 geoeffnet. Sie haben {transfer.PREVIEW_TTL_MINUTES} Minuten Zeit, die "
|
||||
"Uebernahme zu bestaetigen.",
|
||||
"success",
|
||||
)
|
||||
return redirect(url_for("backup.overview"))
|
||||
|
||||
|
||||
@bp.post("/import/uebernehmen")
|
||||
@permission_required("backup.execute")
|
||||
@reauth_required
|
||||
def import_apply():
|
||||
extension = core()
|
||||
conn = db()
|
||||
token = session.get(PREVIEW_SESSION_KEY, "")
|
||||
if not token:
|
||||
flash("Es liegt keine geoeffnete 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 ausgewaehlt.", "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']} uebersprungen"
|
||||
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']} uebersprungen" if counts["skipped"] else "")
|
||||
for key, counts in report.items()
|
||||
if key in transfer.CATEGORY_BY_KEY
|
||||
]
|
||||
flash("Uebernahme abgeschlossen. " + " | ".join(lines), "success")
|
||||
return redirect(url_for("backup.overview"))
|
||||
|
||||
|
||||
@bp.post("/import/verwerfen")
|
||||
@permission_required("backup.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, reauth_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="/zugangsdaten")
|
||||
|
||||
|
||||
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.view")
|
||||
def overview():
|
||||
conn = db()
|
||||
return render_template(
|
||||
"tesm/credentials.html",
|
||||
credentials=inventory.list_credentials(conn),
|
||||
categories=inventory.CREDENTIAL_CATEGORIES,
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/neu")
|
||||
@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.edit")
|
||||
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("Aenderungen gespeichert." if changes else "Keine Aenderungen.", "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>/loeschen")
|
||||
@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>/anzeigen")
|
||||
@permission_required("credentials.secrets")
|
||||
@reauth_required
|
||||
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,150 @@
|
||||
"""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
|
||||
|
||||
conn = db()
|
||||
counts = monitor.dashboard_counts(conn)
|
||||
rows = inventory.list_devices(conn, only_active=not authenticated)
|
||||
|
||||
detailed = authenticated and current_user.has("devices.view")
|
||||
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.
|
||||
"restart_methods": restart.available(
|
||||
row, maintenance_active=_maintenance_active()
|
||||
),
|
||||
}
|
||||
)
|
||||
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 laeuft",
|
||||
"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 geprueft",
|
||||
"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("/uebersicht")
|
||||
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("/uebersicht/kacheln")
|
||||
@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,438 @@
|
||||
"""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, 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
|
||||
|
||||
bp = Blueprint("devices", __name__, url_prefix="/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.view")
|
||||
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("Fuer 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 Geraets erfasst.",
|
||||
actor=current_user.username,
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/neu")
|
||||
@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("Geraet 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.edit")
|
||||
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("Aenderungen gespeichert." if changes else "Keine Aenderungen.", "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>/aktiv")
|
||||
@permission_required("devices.edit")
|
||||
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("Geraet aktiviert." if active else "Geraet deaktiviert.", "success")
|
||||
return redirect(request.referrer or url_for("devices.overview"))
|
||||
|
||||
|
||||
@bp.post("/<int:device_id>/loeschen")
|
||||
@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("Geraet in den Papierkorb verschoben.", "success")
|
||||
return redirect(url_for("devices.overview"))
|
||||
|
||||
|
||||
@bp.post("/<int:device_id>/neustart")
|
||||
@permission_required("devices.execute")
|
||||
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,
|
||||
)
|
||||
if method is None:
|
||||
options = restart.available(dict(device), maintenance_active=maintenance_active)
|
||||
if options:
|
||||
flash(
|
||||
"Der gewaehlte Weg steht fuer dieses Geraet nicht zur Verfuegung. "
|
||||
"Moeglich waere: " + ", ".join(entry.label for entry in options),
|
||||
"warning",
|
||||
)
|
||||
else:
|
||||
flash(
|
||||
f"Fuer {device['name']} ist kein Neustart moeglich: es fehlen Switch und Port "
|
||||
"fuer den PoE-Weg und passende Zugangsdaten fuer 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 fuer {device['name']} durchgefuehrt."
|
||||
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} fuer {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.view")
|
||||
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_enabled")),
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# SSH-Host-Schluessel
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@bp.post("/<int:device_id>/hostkey/pruefen")
|
||||
@permission_required("devices.edit")
|
||||
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>/hostkey/freigeben")
|
||||
@permission_required("devices.edit")
|
||||
@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 gueltiger Fingerprint uebergeben.", "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-Schluessel freigegeben.", "success")
|
||||
return redirect(url_for("devices.detail", device_id=device_id))
|
||||
|
||||
|
||||
@bp.post("/<int:device_id>/hostkey/verwerfen")
|
||||
@permission_required("devices.edit")
|
||||
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-Schluessel entfernt.", "info")
|
||||
return redirect(url_for("devices.detail", device_id=device_id))
|
||||
@@ -0,0 +1,519 @@
|
||||
"""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
|
||||
|
||||
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.view", "dhcp.edit", "dhcp.execute", 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("/einstellungen")
|
||||
@permission_required("dhcp.edit")
|
||||
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 Aenderungen.", "success" if changed else "info")
|
||||
return _write_and_redirect()
|
||||
|
||||
|
||||
@bp.post("/subnetz")
|
||||
@permission_required("dhcp.edit")
|
||||
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("/subnetz/<int:subnet_id>")
|
||||
@permission_required("dhcp.edit")
|
||||
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"))
|
||||
|
||||
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,
|
||||
),
|
||||
)
|
||||
_audit("dhcp.subnet_updated", str(subnet_id), severity="notice")
|
||||
flash("Subnetz gespeichert.", "success")
|
||||
return _write_and_redirect()
|
||||
|
||||
|
||||
@bp.post("/subnetz/<int:subnet_id>/loeschen")
|
||||
@permission_required("dhcp.edit")
|
||||
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/wert")
|
||||
@permission_required("dhcp.edit")
|
||||
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/geraet")
|
||||
@permission_required("dhcp.edit")
|
||||
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']} fuer {device['name']} "
|
||||
f"{'gespeichert' if value else 'entfernt'}.",
|
||||
"success",
|
||||
)
|
||||
return _write_and_redirect()
|
||||
|
||||
|
||||
@bp.post("/option/neu")
|
||||
@permission_required("dhcp.edit")
|
||||
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>/loeschen")
|
||||
@permission_required("dhcp.edit")
|
||||
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 koennen nicht geloescht werden -- nur ihr Wert lasst 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("/reservierung")
|
||||
@permission_required("dhcp.edit")
|
||||
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 ungueltig.", "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 geprueft 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 waere ohne Wirkung.",
|
||||
"danger",
|
||||
)
|
||||
return redirect(url_for("dhcp.overview"))
|
||||
|
||||
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(),
|
||||
),
|
||||
)
|
||||
_audit("dhcp.reservation_saved", f"{mac} -> {ip}", severity="notice")
|
||||
flash("Reservierung gespeichert.", "success")
|
||||
return _write_and_redirect()
|
||||
|
||||
|
||||
@bp.post("/reservierung/<int:reservation_id>/loeschen")
|
||||
@permission_required("dhcp.edit")
|
||||
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 verfuegbar."}
|
||||
|
||||
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 gueltiges 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("/schreiben")
|
||||
@permission_required("dhcp.edit")
|
||||
def write_config():
|
||||
return _write_and_redirect()
|
||||
|
||||
|
||||
@bp.post("/dienst/<action>")
|
||||
@permission_required("dhcp.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 verfuegbar.", "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,300 @@
|
||||
"""Dateifreigaben: Blaettern, Herunterladen, Hochladen, Verwalten."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
|
||||
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.extension import core, db, transaction
|
||||
|
||||
from ..services import fileshare as service
|
||||
|
||||
bp = Blueprint("fileshare", __name__, url_prefix="/dateifreigaben")
|
||||
|
||||
|
||||
@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 _mount_or_404(token: str) -> service.Mount:
|
||||
mounts = service.active_mounts(db(), current_user.id)
|
||||
for mount in mounts:
|
||||
if mount.token == token:
|
||||
return mount
|
||||
abort(404)
|
||||
|
||||
|
||||
@bp.get("/")
|
||||
@permission_required("fileshare.view")
|
||||
def browse():
|
||||
conn = db()
|
||||
mounts = service.active_mounts(conn, current_user.id)
|
||||
token = request.args.get("freigabe") or (mounts[0].token if mounts else "")
|
||||
relative = request.args.get("pfad") 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,
|
||||
max_upload_mb=int(core().settings.get(conn, "fileshare_upload_max_mb") or 512),
|
||||
)
|
||||
|
||||
|
||||
@bp.get("/<token>/herunterladen")
|
||||
@permission_required("fileshare.view")
|
||||
def download(token: str):
|
||||
mount = _mount_or_404(token)
|
||||
relative = request.args.get("pfad") 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,
|
||||
mimetype=service.download_mime(path.name),
|
||||
)
|
||||
|
||||
|
||||
@bp.get("/<token>/vorschau")
|
||||
@permission_required("fileshare.view")
|
||||
def preview(token: str):
|
||||
mount = _mount_or_404(token)
|
||||
relative = request.args.get("pfad") 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>/hochladen")
|
||||
@permission_required("fileshare.create")
|
||||
def upload(token: str):
|
||||
mount = _mount_or_404(token)
|
||||
relative = request.form.get("pfad") or ""
|
||||
files = request.files.getlist("files")
|
||||
if not files:
|
||||
flash("Keine Datei ausgewaehlt.", "danger")
|
||||
return redirect(url_for("fileshare.browse", freigabe=token, pfad=relative))
|
||||
|
||||
limit = int(core().settings.get(db(), "fileshare_upload_max_mb") or 512) * 1024 * 1024
|
||||
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 gross (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", freigabe=token, pfad=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", freigabe=token, pfad=relative))
|
||||
|
||||
|
||||
@bp.post("/<token>/ordner")
|
||||
@permission_required("fileshare.create")
|
||||
def make_directory(token: str):
|
||||
mount = _mount_or_404(token)
|
||||
relative = request.form.get("pfad") 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", freigabe=token, pfad=relative))
|
||||
_audit("fileshare.directory_created", f"{mount.label}:{relative}/{request.form.get('name')}")
|
||||
flash("Ordner angelegt.", "success")
|
||||
return redirect(url_for("fileshare.browse", freigabe=token, pfad=relative))
|
||||
|
||||
|
||||
@bp.post("/<token>/umbenennen")
|
||||
@permission_required("fileshare.edit")
|
||||
def rename(token: str):
|
||||
mount = _mount_or_404(token)
|
||||
relative = request.form.get("pfad") 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", freigabe=token, pfad=parent))
|
||||
_audit("fileshare.renamed", f"{mount.label}:{relative}")
|
||||
flash("Umbenannt.", "success")
|
||||
return redirect(url_for("fileshare.browse", freigabe=token, pfad=parent))
|
||||
|
||||
|
||||
@bp.post("/<token>/loeschen")
|
||||
@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("pfade"):
|
||||
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/Eintraege geloescht.", "success")
|
||||
return redirect(url_for("fileshare.browse", freigabe=token, pfad=parent))
|
||||
|
||||
|
||||
@bp.post("/<token>/freigeben")
|
||||
@permission_required("fileshare.view")
|
||||
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 geloest.", "success")
|
||||
return redirect(url_for("fileshare.browse"))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Verwaltung der Zuordnungen (Einstellungen)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@bp.get("/zuordnungen")
|
||||
@permission_required("fileshare.edit")
|
||||
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("/zuordnungen")
|
||||
@permission_required("fileshare.edit")
|
||||
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", ""), severity="notice")
|
||||
flash("Zuordnung gespeichert.", "success")
|
||||
except service.FileshareError as exc:
|
||||
flash(str(exc), "danger")
|
||||
return redirect(url_for("fileshare.mappings"))
|
||||
|
||||
|
||||
@bp.post("/zuordnungen/<int:mapping_id>")
|
||||
@permission_required("fileshare.edit")
|
||||
def mapping_update(mapping_id: int):
|
||||
try:
|
||||
with transaction() as conn:
|
||||
service.update_mapping(
|
||||
conn,
|
||||
mapping_id,
|
||||
ad_group_dn=request.form.get("ad_group_dn", ""),
|
||||
ad_group_name=request.form.get("ad_group_name", ""),
|
||||
label=request.form.get("label", ""),
|
||||
unc=request.form.get("unc", ""),
|
||||
)
|
||||
_audit("fileshare.mapping_updated", request.form.get("label", ""), severity="notice")
|
||||
flash("Zuordnung geaendert.", "success")
|
||||
except service.FileshareError as exc:
|
||||
flash(str(exc), "danger")
|
||||
return redirect(url_for("fileshare.mappings"))
|
||||
|
||||
|
||||
@bp.post("/zuordnungen/<int:mapping_id>/loeschen")
|
||||
@permission_required("fileshare.edit")
|
||||
def mapping_delete(mapping_id: int):
|
||||
with transaction() as conn:
|
||||
service.delete_mapping(conn, mapping_id)
|
||||
_audit("fileshare.mapping_deleted", str(mapping_id), severity="notice")
|
||||
flash("Zuordnung entfernt.", "success")
|
||||
return redirect(url_for("fileshare.mappings"))
|
||||
@@ -0,0 +1,249 @@
|
||||
"""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="/hostschluessel")
|
||||
|
||||
KIND_DEVICE = "client"
|
||||
KIND_SWITCH = "switch"
|
||||
|
||||
#: Welches Recht welche Art von Ziel freigibt.
|
||||
EDIT_RIGHT = {KIND_DEVICE: "devices.edit", KIND_SWITCH: "switches.edit"}
|
||||
|
||||
|
||||
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("devices.view", "switches.view", mode="any")
|
||||
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>/pruefen")
|
||||
@permission_required("devices.view", "switches.view", mode="any")
|
||||
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>/freigeben")
|
||||
@permission_required("devices.view", "switches.view", mode="any")
|
||||
@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 gueltiger Fingerprint uebergeben.", "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-Schluessel von {target['label']} freigegeben.", "success")
|
||||
return redirect(url_for("hostkeys.overview"))
|
||||
|
||||
|
||||
@bp.post("/<kind>/<int:target_id>/verwerfen")
|
||||
@permission_required("devices.view", "switches.view", mode="any")
|
||||
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 Schluessel von {target['label']} entfernt.", "info")
|
||||
return redirect(url_for("hostkeys.overview"))
|
||||
|
||||
|
||||
@bp.post("/verwaist/verwerfen")
|
||||
@permission_required("devices.edit", "switches.edit", mode="any")
|
||||
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("Ungueltiges 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 fuer {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="/lizenz")
|
||||
|
||||
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.view")
|
||||
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("/einspielen")
|
||||
@permission_required("settings_license.edit")
|
||||
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 ausgewaehlt.", "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 gross fuer 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 ungewoehnlich.",
|
||||
"warning",
|
||||
)
|
||||
return redirect(url_for("license.overview"))
|
||||
|
||||
|
||||
@bp.post("/aktivieren")
|
||||
@permission_required("settings_license.edit")
|
||||
def activate():
|
||||
return _handshake("activate")
|
||||
|
||||
|
||||
@bp.post("/deaktivieren")
|
||||
@permission_required("settings_license.edit")
|
||||
@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.edit")
|
||||
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-antwort")
|
||||
@permission_required("settings_license.edit")
|
||||
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("/serverzertifikat")
|
||||
@permission_required("settings_license.edit")
|
||||
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 naechste Versuch laeuft wieder ueber den Online-Weg."
|
||||
if pem
|
||||
else "Zertifikat entfernt -- es gelten wieder die Systemanker.",
|
||||
"success",
|
||||
)
|
||||
return redirect(url_for("license.overview"))
|
||||
|
||||
|
||||
@bp.post("/aussteller/<key_id>/entfernen")
|
||||
@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-Schluessel 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="/protokolle")
|
||||
|
||||
MAX_TAIL_LINES = 2000
|
||||
|
||||
|
||||
@bp.get("/kea")
|
||||
@permission_required("logs_dhcp.view")
|
||||
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 verfuegbar."
|
||||
return render_template("tesm/logs_kea.html", lines=lines, path=kea.LOG_PATH, error=error)
|
||||
|
||||
|
||||
@bp.get("/neustarts")
|
||||
@permission_required("devices.view")
|
||||
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("methode") 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,290 @@
|
||||
"""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 ..services import inventory, maintenance as service, ssh, windowsops, monitor
|
||||
|
||||
bp = Blueprint("maintenance", __name__, url_prefix="/wartung")
|
||||
|
||||
|
||||
@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("maintenance.view", "maintenance.execute", 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_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("/paket-nachinstallieren")
|
||||
@permission_required("maintenance.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 verfuegbar.", "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("maintenance.view", "maintenance.execute", 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("/auftrag/<job_id>")
|
||||
@permission_required("maintenance.view", "maintenance.execute", 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("/auftrag/<job_id>/ausgabe")
|
||||
@permission_required("maintenance.view", "maintenance.execute", mode="any")
|
||||
@fragment
|
||||
def job_output(job_id: str):
|
||||
conn = db()
|
||||
job = service.get_job(conn, job_id)
|
||||
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 Geraet ausgewaehlt.", "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 fuer Linux-Clients. "
|
||||
"Windows-Geraete 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/Auftraege gestartet.", "success")
|
||||
|
||||
|
||||
@bp.post("/aktualisieren")
|
||||
@permission_required("maintenance.execute")
|
||||
def start_update():
|
||||
_start(service.JOB_KIND_UPDATE)
|
||||
return redirect(url_for("maintenance.overview"))
|
||||
|
||||
|
||||
@bp.post("/neustarten")
|
||||
@permission_required("maintenance.execute")
|
||||
def start_reboot():
|
||||
_start(service.JOB_KIND_REBOOT)
|
||||
return redirect(url_for("maintenance.overview"))
|
||||
@@ -0,0 +1,235 @@
|
||||
"""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
|
||||
|
||||
bp = Blueprint("settings", __name__, url_prefix="/einstellungen")
|
||||
|
||||
|
||||
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 _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.view")
|
||||
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",
|
||||
"terminal_enabled",
|
||||
"terminal_idle_timeout",
|
||||
"trash_retention_days",
|
||||
"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(),
|
||||
nav_items=extension.nav.items,
|
||||
nav_order=extension.settings.get(conn, "nav_order") or extension.nav.default_order(),
|
||||
license_hint=_license_hint("monitor_interval"),
|
||||
sysops_available=bool(sysops and sysops.available),
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/system")
|
||||
@permission_required("settings_system.edit")
|
||||
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",
|
||||
"terminal_enabled",
|
||||
"terminal_idle_timeout",
|
||||
"trash_retention_days",
|
||||
"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 Pruefintervall greift beim naechsten Durchlauf."
|
||||
flash(message, "success")
|
||||
else:
|
||||
flash("Keine Aenderungen.", "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 ungueltig.", "danger")
|
||||
return redirect(url_for("settings.system"))
|
||||
if sysops is None or not sysops.available:
|
||||
flash("Systemaktionen sind in dieser Umgebung nicht verfuegbar.", "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/zeitzone")
|
||||
@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 verfuegbar.", "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/pruefung-jetzt")
|
||||
@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
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
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="/switche")
|
||||
|
||||
|
||||
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.view")
|
||||
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_enabled")),
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/neu")
|
||||
@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-Schluessel 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.edit")
|
||||
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("Aenderungen gespeichert." if changes else "Keine Aenderungen.", "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>/loeschen")
|
||||
@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.view")
|
||||
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_enabled")),
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Host-Schluessel
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@bp.post("/<int:switch_id>/hostkey/pruefen")
|
||||
@permission_required("switches.edit")
|
||||
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>/hostkey/freigeben")
|
||||
@permission_required("switches.edit")
|
||||
@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 gueltiger Fingerprint uebergeben.", "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-Schluessel freigegeben.", "success")
|
||||
return redirect(url_for("switches.detail", switch_id=switch_id))
|
||||
|
||||
|
||||
@bp.post("/<int:switch_id>/hostkey/verwerfen")
|
||||
@permission_required("switches.edit")
|
||||
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-Schluessel entfernt.", "info")
|
||||
return redirect(url_for("switches.detail", switch_id=switch_id))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Kommandos
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@bp.post("/<int:switch_id>/kommando")
|
||||
@permission_required("switches.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("Fuer 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,397 @@
|
||||
"""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** -- ``maintenance.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 ueber die Einstellung ``terminal_enabled``.
|
||||
* 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", "Verfuegbare 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 _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 extension.settings.get(conn, "terminal_enabled"):
|
||||
abort(404)
|
||||
if not target.credential_id:
|
||||
abort(409, description="Fuer 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("/switche/<int:switch_id>/konsole")
|
||||
@permission_required("switches.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("/clients/<int:device_id>/konsole")
|
||||
@permission_required("maintenance.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]
|
||||
ws.send(json.dumps({"type": kind, "data": text}))
|
||||
|
||||
|
||||
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]
|
||||
extension = core()
|
||||
conn = db()
|
||||
|
||||
# -- Berechtigungen: dieselben Pruefungen wie in der HTTP-Route. Ein
|
||||
# WebSocket umgeht Dekoratoren, also stehen sie hier ausdruecklich.
|
||||
required = "switches.execute" if kind == KIND_SWITCH else "maintenance.execute"
|
||||
if not current_user.is_authenticated or not current_user.has(required):
|
||||
_send(ws, "error", "Keine Berechtigung fuer diese Konsole.")
|
||||
return
|
||||
if not reauth_is_fresh():
|
||||
_send(ws, "error", "Die Passwortbestaetigung ist abgelaufen. Bitte Seite neu laden.")
|
||||
return
|
||||
if not extension.settings.get(conn, "terminal_enabled"):
|
||||
_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 gehoert 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"
|
||||
)
|
||||
|
||||
try:
|
||||
username, password = inventory.credential_secret(
|
||||
conn, extension.keystore, target.credential_id
|
||||
)
|
||||
except inventory.ValidationError as exc:
|
||||
_send(ws, "error", str(exc))
|
||||
return
|
||||
|
||||
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 Schluessel -- bitte auf der "
|
||||
f"{page} ausdruecklich 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 Untaetigkeit 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
|
||||
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
|
||||
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"Rueckgabewert {result['status']} nach {result['duration_ms']} ms.",
|
||||
)
|
||||
return commands
|
||||
@@ -0,0 +1,468 @@
|
||||
"""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 Passwoerter stimmen nicht ueberein.", 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"
|
||||
audit.log(
|
||||
conn,
|
||||
actor="cli",
|
||||
actor_id=user_id,
|
||||
action="user.admin_created",
|
||||
target=username,
|
||||
detail=f"ueber die Kommandozeile {action}",
|
||||
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']} geprueft)"
|
||||
)
|
||||
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_check_now(_args: argparse.Namespace) -> int:
|
||||
app, extension = _app_and_core()
|
||||
from .services import inventory, monitor
|
||||
|
||||
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,
|
||||
)
|
||||
print(
|
||||
f"{report.checked} geprueft, {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_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 ueberschreiben.")
|
||||
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 fuer 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 ueberschreiben.")
|
||||
return 2
|
||||
|
||||
passphrase = args.passphrase or getpass("Passphrase fuer die Exportdatei: ")
|
||||
try:
|
||||
nutzlast, bericht = legacy.read_installation(quelle)
|
||||
inhalt = legacy.seal_legacy(nutzlast, passphrase)
|
||||
except legacy.LegacyTransferError as exc:
|
||||
print(f"Fehlgeschlagen: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print(bericht.as_text("Gelesen"))
|
||||
if args.dry_run:
|
||||
print()
|
||||
print("--dry-run: keine Datei geschrieben.")
|
||||
return 0
|
||||
try:
|
||||
ziel.write_text(inhalt, encoding="utf-8", newline="\n")
|
||||
except OSError as exc:
|
||||
print(f"Schreiben fehlgeschlagen: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
print()
|
||||
print(f"Geschrieben: {ziel}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_legacy_import(args: argparse.Namespace) -> int:
|
||||
"""Liest eine Datei im neuen Format ein -- derselbe Weg wie in der Oberflaeche.
|
||||
|
||||
Gebraucht wird das fuer das Upgrade: dort laeuft niemand durch einen
|
||||
Browser. Geschrieben wird ueber ``transfer.apply_import`` -- dieselbe
|
||||
Funktion, die die Seite "Sicherung" benutzt. Ein zweiter Schreibpfad fuer
|
||||
Fremddaten waere die Stelle, die niemand pflegt.
|
||||
"""
|
||||
from getpass import getpass
|
||||
from pathlib import Path
|
||||
|
||||
from .services import transfer
|
||||
|
||||
quelle = Path(args.datei)
|
||||
if not quelle.is_file():
|
||||
print(f"Datei nicht gefunden: {quelle}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
app, extension = _app_and_core()
|
||||
passphrase = args.passphrase or getpass("Passphrase der Datei: ")
|
||||
try:
|
||||
nutzlast, kopf = transfer.unseal(quelle.read_bytes(), passphrase)
|
||||
except transfer.TransferError as exc:
|
||||
print(f"Fehlgeschlagen: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
vorhanden = [eintrag["key"] for eintrag in transfer.summarize(nutzlast)]
|
||||
gewaehlt = (
|
||||
[key for key in args.categories.split(",") if key.strip()]
|
||||
if args.categories
|
||||
else vorhanden
|
||||
)
|
||||
unbekannt = sorted(set(gewaehlt) - set(transfer.CATEGORY_BY_KEY))
|
||||
if unbekannt:
|
||||
print(f"Unbekannte Kategorie(n): {', '.join(unbekannt)}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
print(f"Datei vom {kopf.get('created_at') or 'unbekannt'}:")
|
||||
for eintrag in transfer.summarize(nutzlast):
|
||||
marke = "x" if eintrag["key"] in gewaehlt else " "
|
||||
print(f" [{marke}] {eintrag['label']}: {eintrag['count']}")
|
||||
if args.dry_run:
|
||||
print()
|
||||
print("--dry-run: nichts geschrieben.")
|
||||
return 0
|
||||
|
||||
with app.app_context(), extension.database.session() as conn:
|
||||
with extension.database.transaction(conn):
|
||||
bericht = transfer.apply_import(
|
||||
conn,
|
||||
extension.keystore,
|
||||
nutzlast,
|
||||
categories=gewaehlt,
|
||||
set_setting=lambda key, value: extension.settings.set(
|
||||
conn, key, value, actor=args.actor
|
||||
),
|
||||
)
|
||||
audit.log(
|
||||
conn,
|
||||
actor=args.actor,
|
||||
action="backup.imported",
|
||||
detail="; ".join(
|
||||
f"{key}: {zahlen['created']} neu, {zahlen['updated']} aktualisiert, "
|
||||
f"{zahlen['skipped']} uebersprungen"
|
||||
for key, zahlen in bericht.items()
|
||||
)
|
||||
+ " (Kommandozeile)",
|
||||
severity="warning",
|
||||
)
|
||||
print()
|
||||
for key, zahlen in sorted(bericht.items()):
|
||||
print(
|
||||
f" {key}: {zahlen['created']} neu, {zahlen['updated']} aktualisiert, "
|
||||
f"{zahlen['skipped']} uebersprungen"
|
||||
)
|
||||
return 0
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="tesm-admin", description="Verwaltungswerkzeuge fuer TESM"
|
||||
)
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
admin = sub.add_parser("create-admin", help="Administratorkonto anlegen oder zuruecksetzen")
|
||||
admin.add_argument("--username")
|
||||
admin.add_argument("--password", help="Nur fuer 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 aufraeumen")
|
||||
house.set_defaults(func=cmd_housekeeping)
|
||||
|
||||
check = sub.add_parser("check-now", help="Einen Ueberwachungsdurchlauf sofort ausfuehren")
|
||||
check.set_defaults(func=cmd_check_now)
|
||||
|
||||
legacy = sub.add_parser(
|
||||
"convert-legacy",
|
||||
help="Export des alten TESM in das neue Format uebersetzen",
|
||||
description=(
|
||||
"Liest eine Exportdatei des Vorgaengers und schreibt eine Datei im "
|
||||
"Format dieser Anwendung. Uebersetzt 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 ueberschreiben")
|
||||
legacy.set_defaults(func=cmd_convert_legacy)
|
||||
|
||||
|
||||
export_alt = sub.add_parser(
|
||||
"legacy-export",
|
||||
help="Daten einer alten Installation als Exportdatei sichern",
|
||||
description=(
|
||||
"Liest die Datenbank einer Installation der Vorgaengerfassung und schreibt "
|
||||
"Clients, Switche, Zugangsdaten und DHCP in eine Datei im alten Format. "
|
||||
"Die alte Installation wird nur gelesen. Ohne Browser und auch dann noch "
|
||||
"moeglich, wenn die alte Anwendung nicht mehr startet."
|
||||
),
|
||||
)
|
||||
export_alt.add_argument(
|
||||
"--source", default="/srv/tesm", help="Verzeichnis der alten Installation"
|
||||
)
|
||||
export_alt.add_argument("--out", required=True, help="Zieldatei")
|
||||
export_alt.add_argument("--passphrase", help="Passphrase der Zieldatei (sonst wird gefragt)")
|
||||
export_alt.add_argument("--dry-run", action="store_true", help="Nur den Bericht zeigen")
|
||||
export_alt.add_argument("--force", action="store_true", help="Zieldatei ueberschreiben")
|
||||
export_alt.set_defaults(func=cmd_legacy_export)
|
||||
|
||||
import_neu = sub.add_parser(
|
||||
"legacy-import",
|
||||
help="Datei im Format dieser Anwendung einlesen",
|
||||
description=(
|
||||
"Liest eine Sicherungs- oder uebersetzte Datei ein -- derselbe Weg wie unter "
|
||||
"Verwaltung -> Sicherung, nur ohne Browser. Ohne --categories werden alle "
|
||||
"Kategorien uebernommen, die in der Datei stehen."
|
||||
),
|
||||
)
|
||||
import_neu.add_argument("datei", help="Datei im Format dieser Anwendung")
|
||||
import_neu.add_argument("--passphrase", help="Passphrase der Datei (sonst wird gefragt)")
|
||||
import_neu.add_argument(
|
||||
"--categories", help="Kommaliste, z.B. devices,switches,credentials,dhcp"
|
||||
)
|
||||
import_neu.add_argument(
|
||||
"--actor", default="upgrade", help="Name, der im Auditlog erscheint"
|
||||
)
|
||||
import_neu.add_argument("--dry-run", action="store_true", help="Nur zeigen, was drin steht")
|
||||
import_neu.set_defaults(func=cmd_legacy_import)
|
||||
|
||||
add_web_setup(sub, cmd_web_setup)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
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,245 @@
|
||||
"""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
|
||||
|
||||
|
||||
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.edit"):
|
||||
return True
|
||||
from .services import fileshare
|
||||
|
||||
return fileshare.has_mounts(user.id)
|
||||
|
||||
|
||||
NAVIGATION = NavTree(
|
||||
[
|
||||
NavItem(key="index", label="Statusuebersicht", icon="grid", endpoint="dashboard.index"),
|
||||
NavItem(
|
||||
key="monitoring",
|
||||
label="Ueberwachung",
|
||||
icon="cpu",
|
||||
children=(
|
||||
NavItem(
|
||||
key="devices",
|
||||
label="Clients",
|
||||
icon="cpu",
|
||||
endpoint="devices.overview",
|
||||
permissions=("devices.view",),
|
||||
),
|
||||
NavItem(
|
||||
key="switches",
|
||||
label="Switche",
|
||||
icon="switch",
|
||||
endpoint="switches.overview",
|
||||
permissions=("switches.view",),
|
||||
),
|
||||
NavItem(
|
||||
key="credentials",
|
||||
label="Zugangsdaten",
|
||||
icon="key",
|
||||
endpoint="credentials.overview",
|
||||
permissions=("credentials.view",),
|
||||
),
|
||||
NavItem(
|
||||
key="hostkeys",
|
||||
label="Host-Schluessel",
|
||||
icon="shield",
|
||||
endpoint="hostkeys.overview",
|
||||
permissions=("devices.view", "switches.view"),
|
||||
),
|
||||
NavItem(
|
||||
key="maintenance",
|
||||
label="Wartung",
|
||||
icon="wrench",
|
||||
endpoint="maintenance.overview",
|
||||
permissions=("maintenance.view", "maintenance.execute"),
|
||||
condition=_module("maintenance"),
|
||||
),
|
||||
),
|
||||
),
|
||||
NavItem(
|
||||
key="dhcp",
|
||||
label="DHCP-Server",
|
||||
icon="network",
|
||||
endpoint="dhcp.overview",
|
||||
permissions=("dhcp.view", "dhcp.edit", "dhcp.execute"),
|
||||
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.view",),
|
||||
),
|
||||
NavItem(
|
||||
key="fileshare_mappings",
|
||||
label="Zuordnungen",
|
||||
icon="link",
|
||||
endpoint="fileshare.mappings",
|
||||
permissions=("fileshare.edit",),
|
||||
),
|
||||
),
|
||||
),
|
||||
NavItem(
|
||||
key="observability",
|
||||
label="Protokolle",
|
||||
icon="terminal",
|
||||
children=(
|
||||
NavItem(
|
||||
key="logs_live",
|
||||
label="Laufend",
|
||||
icon="terminal",
|
||||
endpoint="logs.live",
|
||||
permissions=("logs_live.view",),
|
||||
),
|
||||
NavItem(
|
||||
key="logs_history",
|
||||
label="Verlauf",
|
||||
icon="clock",
|
||||
endpoint="logs.history",
|
||||
permissions=("logs_history.view", "logs_history.export"),
|
||||
),
|
||||
NavItem(
|
||||
key="logs_audit",
|
||||
label="Audit",
|
||||
icon="history",
|
||||
endpoint="logs.audit_view",
|
||||
permissions=("logs_audit.view", "logs_audit.export"),
|
||||
),
|
||||
NavItem(
|
||||
key="logs_dhcp",
|
||||
label="Kea-DHCP",
|
||||
icon="network",
|
||||
endpoint="tesmlogs.dhcp",
|
||||
permissions=("logs_dhcp.view",),
|
||||
condition=_module("dhcp"),
|
||||
),
|
||||
NavItem(
|
||||
key="restarts",
|
||||
label="Neustarts",
|
||||
icon="power",
|
||||
endpoint="tesmlogs.restarts",
|
||||
permissions=("devices.view",),
|
||||
),
|
||||
),
|
||||
),
|
||||
NavItem(
|
||||
key="administration",
|
||||
label="Verwaltung",
|
||||
icon="sliders",
|
||||
children=(
|
||||
NavItem(
|
||||
key="users",
|
||||
label="Benutzer",
|
||||
icon="users",
|
||||
endpoint="admin.users",
|
||||
permissions=("users.view",),
|
||||
),
|
||||
NavItem(
|
||||
key="groups",
|
||||
label="Gruppen und Rechte",
|
||||
icon="shield-users",
|
||||
endpoint="admin.groups",
|
||||
permissions=("groups.view",),
|
||||
),
|
||||
NavItem(
|
||||
key="settings_system",
|
||||
label="System",
|
||||
icon="sliders",
|
||||
endpoint="settings.system",
|
||||
permissions=("settings_system.view",),
|
||||
),
|
||||
NavItem(
|
||||
key="settings_network",
|
||||
label="Netzwerk",
|
||||
icon="network",
|
||||
endpoint="hostsettings.network",
|
||||
permissions=("settings_network.view",),
|
||||
),
|
||||
NavItem(
|
||||
key="settings_web",
|
||||
label="Webserver und TLS",
|
||||
icon="server",
|
||||
endpoint="hostsettings.web",
|
||||
permissions=("settings_web.view",),
|
||||
),
|
||||
NavItem(
|
||||
key="settings_directory",
|
||||
label="Verzeichnisdienst",
|
||||
icon="users",
|
||||
endpoint="hostsettings.directory_settings",
|
||||
permissions=("settings_directory.view",),
|
||||
),
|
||||
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.view",),
|
||||
),
|
||||
NavItem(
|
||||
key="backup",
|
||||
label="Sicherung",
|
||||
icon="transfer",
|
||||
endpoint="backup.overview",
|
||||
permissions=("backup.view", "backup.export", "backup.execute"),
|
||||
),
|
||||
NavItem(
|
||||
key="trash",
|
||||
label="Papierkorb",
|
||||
icon="trash",
|
||||
endpoint="trash.overview",
|
||||
permissions=("trash.view",),
|
||||
),
|
||||
NavItem(
|
||||
key="diagnostics",
|
||||
label="Diagnose",
|
||||
icon="info",
|
||||
endpoint="diagnostics.overview",
|
||||
permissions=("diagnostics.view",),
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
)
|
||||
@@ -0,0 +1,222 @@
|
||||
"""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.VIEW, A.CREATE, A.EDIT, A.DELETE, A.EXECUTE),
|
||||
hint="Ueberwachte PoE-Endgeraete",
|
||||
action_hints={
|
||||
A.EXECUTE: "PoE-Neustart eines Geraets manuell ausloesen. Der automatische "
|
||||
"Neustart bei Ausfall ist davon unabhaengig und laeuft immer.",
|
||||
A.DELETE: "Geraet in den Papierkorb legen.",
|
||||
},
|
||||
)
|
||||
|
||||
SWITCHES = Resource(
|
||||
key="switches",
|
||||
label="Switche",
|
||||
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE, A.EXECUTE),
|
||||
hint="Aruba-Switche und ihre Erreichbarkeit",
|
||||
action_hints={
|
||||
A.EXECUTE: "Browser-Terminal oeffnen und Kommandos auf dem Switch ausfuehren. "
|
||||
"Verlangt zusaetzlich eine frische Passwortbestaetigung; jede Sitzung wird "
|
||||
"vollstaendig protokolliert.",
|
||||
},
|
||||
)
|
||||
|
||||
CREDENTIALS = Resource(
|
||||
key="credentials",
|
||||
label="Zugangsdaten",
|
||||
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE, A.SECRETS),
|
||||
hint="Wiederverwendbare SSH-Logins",
|
||||
action_hints={
|
||||
A.VIEW: "Namen und Benutzer sehen -- nicht das Passwort.",
|
||||
A.SECRETS: "Passwort im Klartext anzeigen. Verlangt eine frische "
|
||||
"Passwortbestaetigung und wird protokolliert.",
|
||||
},
|
||||
)
|
||||
|
||||
MAINTENANCE = Resource(
|
||||
key="maintenance",
|
||||
label="Wartung",
|
||||
actions=(A.VIEW, A.EXECUTE),
|
||||
hint="Paketaktualisierung und Neustart per SSH (lizenzpflichtiges Modul)",
|
||||
action_hints={A.EXECUTE: "Aktualisierung oder Neustart auf ausgewaehlten Geraeten starten."},
|
||||
)
|
||||
|
||||
DHCP = Resource(
|
||||
key="dhcp",
|
||||
label="DHCP-Server",
|
||||
actions=(A.VIEW, A.EDIT, A.EXECUTE),
|
||||
hint="Kea-DHCP: Subnetze, Optionen, Reservierungen (lizenzpflichtiges Modul)",
|
||||
action_hints={
|
||||
A.EDIT: "Konfiguration aendern und schreiben.",
|
||||
A.EXECUTE: "Dienst installieren, starten oder stoppen.",
|
||||
},
|
||||
)
|
||||
|
||||
FILESHARE = Resource(
|
||||
key="fileshare",
|
||||
label="Dateifreigaben",
|
||||
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE),
|
||||
hint="SMB-Freigaben aus dem Verzeichnisdienst (lizenzpflichtiges Modul)",
|
||||
action_hints={
|
||||
A.CREATE: "Ordner anlegen und Dateien hochladen.",
|
||||
A.EDIT: "Umbenennen und verschieben.",
|
||||
},
|
||||
)
|
||||
|
||||
LOGS_DHCP = Resource(
|
||||
key="logs_dhcp",
|
||||
label="Kea-DHCP-Protokoll",
|
||||
actions=(A.VIEW,),
|
||||
)
|
||||
|
||||
MONITORING_AREA = Area(
|
||||
key="monitoring",
|
||||
label="Ueberwachung",
|
||||
icon="cpu",
|
||||
hint="Geraete, Switche, Zugangsdaten und Wartung. Ohne diesen Zugang bleibt der "
|
||||
"gesamte Bereich verborgen.",
|
||||
resources=(DEVICES, SWITCHES, CREDENTIALS, MAINTENANCE),
|
||||
)
|
||||
|
||||
NETWORK_AREA = Area(
|
||||
key="network",
|
||||
label="Netzwerkdienste",
|
||||
icon="network",
|
||||
resources=(DHCP,),
|
||||
)
|
||||
|
||||
FILES_AREA = Area(
|
||||
key="files",
|
||||
label="Dateifreigaben",
|
||||
icon="folder",
|
||||
resources=(FILESHARE,),
|
||||
)
|
||||
|
||||
PERMISSIONS = PermissionTree(
|
||||
[
|
||||
MONITORING_AREA,
|
||||
NETWORK_AREA,
|
||||
FILES_AREA,
|
||||
observability_area(LOGS_DHCP),
|
||||
administration_area(),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
ROLE_PRESETS: tuple[RolePreset, ...] = (
|
||||
RolePreset(
|
||||
key="viewer",
|
||||
name="Benutzer",
|
||||
description="Standardgruppe: Geraete und Switche ansehen, laufendes Protokoll lesen.",
|
||||
grants=(
|
||||
("devices", (A.VIEW,)),
|
||||
("switches", (A.VIEW,)),
|
||||
("logs_live", (A.VIEW,)),
|
||||
),
|
||||
is_default=True,
|
||||
),
|
||||
RolePreset(
|
||||
key="operations",
|
||||
name="Betrieb",
|
||||
description="Geraete und Switche pflegen, Neustarts ausloesen, Wartung fahren.",
|
||||
grants=(
|
||||
("devices", "*"),
|
||||
("switches", (A.VIEW, A.CREATE, A.EDIT, A.EXECUTE)),
|
||||
("credentials", (A.VIEW,)),
|
||||
("maintenance", "*"),
|
||||
("logs_live", (A.VIEW,)),
|
||||
("logs_history", (A.VIEW,)),
|
||||
("trash", (A.VIEW, A.EDIT)),
|
||||
),
|
||||
),
|
||||
RolePreset(
|
||||
key="support",
|
||||
name="Support",
|
||||
description="Erste Hilfe am Telefon: Status sehen, PoE-Neustart ausloesen.",
|
||||
grants=(
|
||||
("devices", (A.VIEW, A.EXECUTE)),
|
||||
("switches", (A.VIEW,)),
|
||||
("logs_live", (A.VIEW,)),
|
||||
),
|
||||
),
|
||||
RolePreset(
|
||||
key="network",
|
||||
name="Netzwerk",
|
||||
description="DHCP und Host-Netzwerkeinstellungen.",
|
||||
grants=(
|
||||
("dhcp", "*"),
|
||||
("settings_network", "*"),
|
||||
("logs_dhcp", (A.VIEW,)),
|
||||
("devices", (A.VIEW,)),
|
||||
("switches", (A.VIEW,)),
|
||||
),
|
||||
),
|
||||
RolePreset(
|
||||
key="auditor",
|
||||
name="Revision",
|
||||
description="Nur lesen -- inklusive Aenderungsprotokoll und Diagnose.",
|
||||
grants=(
|
||||
("devices", (A.VIEW,)),
|
||||
("switches", (A.VIEW,)),
|
||||
("credentials", (A.VIEW,)),
|
||||
("dhcp", (A.VIEW,)),
|
||||
("users", (A.VIEW,)),
|
||||
("groups", (A.VIEW,)),
|
||||
("logs_live", (A.VIEW,)),
|
||||
("logs_history", (A.VIEW, A.EXPORT)),
|
||||
("logs_audit", (A.VIEW, A.EXPORT)),
|
||||
("diagnostics", (A.VIEW,)),
|
||||
("trash", (A.VIEW,)),
|
||||
),
|
||||
),
|
||||
RolePreset(
|
||||
key="administration",
|
||||
name="Administration",
|
||||
description="Benutzer, Gruppen und Einstellungen -- ohne Admin-Kennzeichen.",
|
||||
grants=(
|
||||
("users", "*"),
|
||||
("groups", "*"),
|
||||
("settings_system", "*"),
|
||||
("settings_license", "*"),
|
||||
("settings_directory", (A.VIEW, A.EDIT, A.EXECUTE)),
|
||||
("settings_web", "*"),
|
||||
("settings_network", "*"),
|
||||
("backup", (A.VIEW, A.EXECUTE, A.EXPORT)),
|
||||
("trash", "*"),
|
||||
("diagnostics", (A.VIEW,)),
|
||||
("logs_audit", (A.VIEW,)),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
__all__ = ["PERMISSIONS", "ROLE_PRESETS", "LOGS_DHCP"]
|
||||
@@ -0,0 +1,257 @@
|
||||
"""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 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);
|
||||
"""
|
||||
|
||||
|
||||
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),
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
"""Fachdienste von TESM -- Netzwerk, SSH, DHCP, Dateifreigaben, Wartung, Sicherung."""
|
||||
@@ -0,0 +1,597 @@
|
||||
"""Dateifreigaben: SMB-Einbindungen und sicheres Blaettern im Dateibaum.
|
||||
|
||||
Einbindungen entstehen ausschliesslich nach einer erfolgreichen Anmeldung gegen
|
||||
den Verzeichnisdienst -- lokale Konten haben hier grundsaetzlich nichts zu
|
||||
sehen. *Welche* Freigaben sichtbar sind, entscheidet allein die Gruppen-
|
||||
mitgliedschaft im Verzeichnis; das Recht ``fileshare.view`` entscheidet, ob
|
||||
ueberhaupt eingebunden wird.
|
||||
|
||||
Sicherheitsrelevante Aenderungen gegenueber dem Vorgaenger:
|
||||
|
||||
* **Das Passwort geht nie ueber die Umgebung.** Der Vorgaenger uebergab es per
|
||||
``PASSWD``-Variable an ``mount.cifs`` -- sichtbar in ``/proc/<pid>/environ``
|
||||
fuer denselben Benutzer und fuer root. Hier schreibt der privilegierte Helfer
|
||||
es in eine Datei mit Modus 0600, die unmittelbar danach geloescht wird.
|
||||
* **Pfadpruefung mit ``Path.resolve()``.** Jeder Zugriff wird gegen das
|
||||
Wurzelverzeichnis der Sitzung geprueft; Symlinks koennen nicht herausfuehren.
|
||||
* **Einbindungen stehen in der Datenbank.** Der Aufraeumprozess liest sie dort,
|
||||
statt das Dateisystem zu durchsuchen -- und ein Neustart des Dienstes verliert
|
||||
nicht mehr die Information, was noch eingebunden ist.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import mimetypes
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import shutil
|
||||
import socket
|
||||
import sqlite3
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Any, Iterable
|
||||
|
||||
from tesm_core.db import Database
|
||||
from tesm_core.extension import core
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_UNC_HINT = (
|
||||
"Der Freigabepfad muss die Form \\\\server\\freigabe haben. Schraegstriche "
|
||||
"gehen auch: //server/freigabe."
|
||||
)
|
||||
|
||||
UNC_RE = re.compile(r"^\\\\[A-Za-z0-9._-]+\\[^\\/:*?\"<>|]+(\\[^\\/:*?\"<>|]+)*$")
|
||||
|
||||
#: Endungen, die inline angezeigt werden duerfen -- alles andere nur als Download.
|
||||
#: ``.doc`` fehlt bewusst: alte Word-Dateien koennen Makros tragen und werden von
|
||||
#: Browsern uneinheitlich behandelt.
|
||||
PREVIEW_MIME: dict[str, str] = {
|
||||
".pdf": "application/pdf",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
".bmp": "image/bmp",
|
||||
".txt": "text/plain; charset=utf-8",
|
||||
".csv": "text/plain; charset=utf-8",
|
||||
".log": "text/plain; charset=utf-8",
|
||||
".md": "text/plain; charset=utf-8",
|
||||
".json": "text/plain; charset=utf-8",
|
||||
".xml": "text/plain; charset=utf-8",
|
||||
}
|
||||
#: SVG ist bewusst *nicht* in der Vorschau: es kann Skript enthalten und wuerde
|
||||
#: im Ursprung der Anwendung ausgefuehrt. Download bleibt moeglich.
|
||||
|
||||
|
||||
class FileshareError(Exception):
|
||||
"""Fehler mit Klartextmeldung fuer die Oberflaeche."""
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _iso(value: datetime) -> str:
|
||||
return value.isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def validate_unc(value: str) -> str:
|
||||
"""Prueft und vereinheitlicht einen Freigabepfad.
|
||||
|
||||
Beide Schreibweisen sind erlaubt und bedeuten dasselbe::
|
||||
|
||||
\\\\dc2022\\daten
|
||||
//dc2022/daten
|
||||
|
||||
Gespeichert wird immer die Backslash-Form; der Helfer dreht sie fuer
|
||||
``mount`` wieder um. Ueberzaehlige Trenner und ein Trenner am Ende werden
|
||||
entfernt, statt den Pfad abzulehnen: genau so sieht ein Pfad aus, den
|
||||
jemand aus dem Explorer oder aus einem Browser kopiert hat, und eine
|
||||
Ablehnung dafuer waere Schikane ohne Gewinn.
|
||||
"""
|
||||
text = (value or "").strip().replace("/", "\\")
|
||||
# Der doppelte Trenner am Anfang macht den Pfad zur Freigabe. Ein einfacher
|
||||
# waere ein absoluter Pfad auf diesem Rechner -- etwas voellig anderes.
|
||||
if not text.startswith("\\\\"):
|
||||
raise FileshareError(_UNC_HINT)
|
||||
|
||||
teile = [teil for teil in text.split("\\") if teil]
|
||||
if len(teile) < 2:
|
||||
raise FileshareError(_UNC_HINT)
|
||||
|
||||
text = "\\\\" + "\\".join(teile)
|
||||
if not UNC_RE.match(text):
|
||||
raise FileshareError(_UNC_HINT)
|
||||
return text
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Zuordnungen
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _resolvable(host: str) -> bool:
|
||||
"""Loest dieser Name auf diesem Server auf?"""
|
||||
try:
|
||||
socket.getaddrinfo(host, None)
|
||||
except (socket.gaierror, UnicodeError, OSError):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def search_suffixes(conn: sqlite3.Connection) -> list[str]:
|
||||
"""Domaenen, mit denen ein kurzer Hostname vervollstaendigt werden kann.
|
||||
|
||||
Ein Windows-Client loest "S2025" ueber seine Suchdomaene auf. Dieser Server
|
||||
hat oft keine (``search .`` in resolv.conf), und dann scheitert die
|
||||
Einbindung mit "could not resolve address for S2025" -- obwohl der Name im
|
||||
Netz voellig richtig ist. Die AD-Domaene ist aber bekannt, also wird sie
|
||||
hier zum Vervollstaendigen benutzt.
|
||||
"""
|
||||
kandidaten: list[str] = []
|
||||
einstellungen = core().settings
|
||||
basis = str(einstellungen.get(conn, "ldap_base_dn") or "").strip()
|
||||
if basis:
|
||||
# DC=ad,DC=eertmoed,DC=net -> ad.eertmoed.net
|
||||
teile = [
|
||||
stueck.split("=", 1)[1]
|
||||
for stueck in basis.split(",")
|
||||
if stueck.strip().lower().startswith("dc=")
|
||||
]
|
||||
if teile:
|
||||
kandidaten.append(".".join(teile))
|
||||
rechner = str(einstellungen.get(conn, "ldap_host") or "").strip()
|
||||
if rechner and "." in rechner:
|
||||
kandidaten.append(rechner)
|
||||
# Auch die Elterndomaene: ist ldap_host ein DC-Name, liegt die Domaene
|
||||
# eine Ebene darueber.
|
||||
kandidaten.append(rechner.split(".", 1)[1])
|
||||
# Reihenfolge erhalten, Doppelte entfernen.
|
||||
gesehen: set[str] = set()
|
||||
return [k for k in kandidaten if k and not (k in gesehen or gesehen.add(k))]
|
||||
|
||||
|
||||
def resolve_unc_host(unc: str, suffixes: Iterable[str]) -> tuple[str, str]:
|
||||
"""Ersetzt den Hostnamen einer UNC durch einen aufloesbaren.
|
||||
|
||||
Rueckgabe: ``(unc, hinweis)``. Der Hinweis ist leer, wenn nichts geaendert
|
||||
wurde; laesst sich der Name gar nicht aufloesen, bleibt die UNC unveraendert
|
||||
und der Hinweis nennt das Problem.
|
||||
"""
|
||||
teile = unc.lstrip(chr(92)).split(chr(92), 1)
|
||||
if not teile or not teile[0]:
|
||||
return unc, ""
|
||||
host = teile[0]
|
||||
rest = teile[1] if len(teile) > 1 else ""
|
||||
if _resolvable(host):
|
||||
return unc, ""
|
||||
for suffix in suffixes:
|
||||
kandidat = f"{host}.{suffix.strip('.')}"
|
||||
if _resolvable(kandidat):
|
||||
neu = chr(92) * 2 + kandidat + (chr(92) + rest if rest else "")
|
||||
return neu, f"Name {host} ueber {kandidat} aufgeloest"
|
||||
return unc, (
|
||||
f"Der Name {host} ist auf diesem Server nicht aufloesbar. Bitte den "
|
||||
"vollqualifizierten Namen oder die IP-Adresse eintragen -- oder unter "
|
||||
"Netzwerk eine Suchdomaene setzen."
|
||||
)
|
||||
|
||||
|
||||
def list_mappings(conn: sqlite3.Connection) -> list[dict[str, Any]]:
|
||||
return Database.all(
|
||||
conn, "SELECT * FROM fileshare_mappings ORDER BY label COLLATE NOCASE"
|
||||
)
|
||||
|
||||
|
||||
def add_mapping(
|
||||
conn: sqlite3.Connection, *, ad_group_dn: str, ad_group_name: str, label: str, unc: str
|
||||
) -> int:
|
||||
clean_unc = validate_unc(unc)
|
||||
clean_label = (label or "").strip()
|
||||
if not clean_label:
|
||||
raise FileshareError("Der Anzeigename darf nicht leer sein.")
|
||||
if not (ad_group_dn or "").strip():
|
||||
raise FileshareError("Es ist keine Verzeichnisgruppe ausgewaehlt.")
|
||||
cursor = conn.execute(
|
||||
"INSERT INTO fileshare_mappings (ad_group_dn, ad_group_name, label, unc, created_at) "
|
||||
"VALUES (?,?,?,?,?) ON CONFLICT(ad_group_dn, unc) DO NOTHING",
|
||||
(ad_group_dn.strip(), (ad_group_name or "").strip(), clean_label, clean_unc, _iso(_now())),
|
||||
)
|
||||
if not cursor.rowcount:
|
||||
raise FileshareError("Diese Zuordnung existiert bereits.")
|
||||
return int(cursor.lastrowid or 0)
|
||||
|
||||
|
||||
def update_mapping(
|
||||
conn: sqlite3.Connection,
|
||||
mapping_id: int,
|
||||
*,
|
||||
ad_group_dn: str,
|
||||
ad_group_name: str,
|
||||
label: str,
|
||||
unc: str,
|
||||
) -> None:
|
||||
"""Aendert eine bestehende Zuordnung.
|
||||
|
||||
Dieselben Pruefungen wie beim Anlegen -- eine Zuordnung, die man nur neu
|
||||
anlegen statt korrigieren kann, verleitet dazu, den Tippfehler stehen zu
|
||||
lassen und daneben einen zweiten Eintrag zu machen.
|
||||
"""
|
||||
clean_unc = validate_unc(unc)
|
||||
clean_label = (label or "").strip()
|
||||
clean_dn = (ad_group_dn or "").strip()
|
||||
if not clean_label:
|
||||
raise FileshareError("Der Anzeigename darf nicht leer sein.")
|
||||
if not clean_dn:
|
||||
raise FileshareError("Es ist keine Verzeichnisgruppe ausgewaehlt.")
|
||||
|
||||
vorhanden = Database.one(
|
||||
conn, "SELECT id FROM fileshare_mappings WHERE id=?", (mapping_id,)
|
||||
)
|
||||
if vorhanden is None:
|
||||
raise FileshareError("Diese Zuordnung existiert nicht mehr.")
|
||||
|
||||
# Die Eindeutigkeit (ad_group_dn, unc) gilt weiterhin -- nur nicht gegen
|
||||
# sich selbst.
|
||||
kollision = Database.one(
|
||||
conn,
|
||||
"SELECT id FROM fileshare_mappings WHERE ad_group_dn=? AND unc=? AND id<>?",
|
||||
(clean_dn, clean_unc, mapping_id),
|
||||
)
|
||||
if kollision is not None:
|
||||
raise FileshareError("Diese Zuordnung existiert bereits.")
|
||||
|
||||
conn.execute(
|
||||
"UPDATE fileshare_mappings SET ad_group_dn=?, ad_group_name=?, label=?, unc=? "
|
||||
"WHERE id=?",
|
||||
(clean_dn, (ad_group_name or "").strip(), clean_label, clean_unc, mapping_id),
|
||||
)
|
||||
|
||||
|
||||
def delete_mapping(conn: sqlite3.Connection, mapping_id: int) -> None:
|
||||
conn.execute("DELETE FROM fileshare_mappings WHERE id=?", (mapping_id,))
|
||||
|
||||
|
||||
def shares_for_groups(
|
||||
conn: sqlite3.Connection, group_dns: Iterable[str]
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Freigaben, auf die diese Verzeichnisgruppen Anspruch geben.
|
||||
|
||||
Es gibt bewusst keinen zweiten Weg: eine Freigabe wird mit dem Konto des
|
||||
angemeldeten Verzeichnisbenutzers eingebunden. Ein lokales TESM-Konto hat
|
||||
kein Passwort, das ein Fileserver akzeptieren wuerde -- eine Zuordnung an
|
||||
eine lokale Gruppe waere deshalb eine Zuordnung, die nie zu einer
|
||||
Einbindung fuehrt.
|
||||
"""
|
||||
lowered = {dn.strip().lower() for dn in group_dns if dn and dn.strip()}
|
||||
if not lowered:
|
||||
return []
|
||||
return [
|
||||
row
|
||||
for row in list_mappings(conn)
|
||||
if str(row["ad_group_dn"] or "").lower() in lowered
|
||||
]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Einbindungen
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Mount:
|
||||
id: int
|
||||
token: str
|
||||
label: str
|
||||
unc: str
|
||||
path: Path
|
||||
expires_at: str
|
||||
|
||||
|
||||
def mount_root() -> Path:
|
||||
from tesm_core.extension import db
|
||||
|
||||
return Path(str(core().settings.get(db(), "fileshare_mount_root") or "/mnt/tesm-shares"))
|
||||
|
||||
|
||||
def active_mounts(conn: sqlite3.Connection, user_id: int) -> list[Mount]:
|
||||
rows = Database.all(
|
||||
conn,
|
||||
"SELECT * FROM fileshare_mounts WHERE user_id=? AND released_at IS NULL "
|
||||
"AND expires_at > ? ORDER BY label COLLATE NOCASE",
|
||||
(user_id, _iso(_now())),
|
||||
)
|
||||
return [
|
||||
Mount(
|
||||
id=int(row["id"]),
|
||||
token=str(row["token"]),
|
||||
label=str(row["label"]),
|
||||
unc=str(row["unc"]),
|
||||
path=Path(str(row["mount_path"])),
|
||||
expires_at=str(row["expires_at"]),
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
def has_mounts(user_id: int) -> bool:
|
||||
from tesm_core.extension import db
|
||||
|
||||
return bool(active_mounts(db(), user_id))
|
||||
|
||||
|
||||
def mount_for_login(
|
||||
conn: sqlite3.Connection,
|
||||
*,
|
||||
user_id: int,
|
||||
session_id: str,
|
||||
username: str,
|
||||
password: str,
|
||||
group_dns: Iterable[str] = (),
|
||||
domain: str = "",
|
||||
) -> list[str]:
|
||||
"""Bindet alle Freigaben ein, auf die die Verzeichnisgruppen Anspruch geben.
|
||||
|
||||
Rueckgabe: Liste der Meldungen fuer das Protokoll. Fehler einzelner
|
||||
Freigaben brechen die Anmeldung **nicht** ab -- der Benutzer soll sich
|
||||
anmelden koennen, auch wenn ein Fileserver gerade nicht erreichbar ist.
|
||||
"""
|
||||
extension = core()
|
||||
sysops = extension.sysops
|
||||
if sysops is None or not sysops.available:
|
||||
return ["Systemhelfer nicht verfuegbar -- keine Einbindungen."]
|
||||
|
||||
dns = [dn for dn in group_dns if dn and dn.strip()]
|
||||
shares = shares_for_groups(conn, dns)
|
||||
if not shares:
|
||||
# Stillschweigen war hier der eigentliche Fehler: ob keine Gruppe ankam
|
||||
# oder keine passte, liess sich von aussen nicht unterscheiden -- und
|
||||
# ein Merkmal, das lautlos nichts tut, ist nicht zu diagnostizieren.
|
||||
bekannt = len(list_mappings(conn))
|
||||
if bekannt:
|
||||
logger.info(
|
||||
"Keine Freigabe passend: %d Verzeichnisgruppe(n) der Anmeldung gegen "
|
||||
"%d Zuordnung(en). Gruppen: %s",
|
||||
len(dns),
|
||||
bekannt,
|
||||
", ".join(dns) or "keine",
|
||||
)
|
||||
return []
|
||||
|
||||
# Ohne Passwort kaeme eine Gast-Einbindung zustande, die still danebengreift:
|
||||
# eingebunden, aber leer. Das betrifft die Anmeldung mit zweitem Faktor, bei
|
||||
# der das Passwort im zweiten Schritt nicht mehr vorliegt.
|
||||
if not password:
|
||||
return ["Kein Passwort fuer den Fileserver verfuegbar -- keine Einbindungen."]
|
||||
|
||||
hours = int(extension.settings.get(conn, "fileshare_max_age_hours") or 12)
|
||||
root = Path(str(extension.settings.get(conn, "fileshare_mount_root") or "/mnt/tesm-shares"))
|
||||
messages: list[str] = []
|
||||
|
||||
suffixes = search_suffixes(conn)
|
||||
for share in shares:
|
||||
token = secrets.token_urlsafe(16)
|
||||
target = root / token
|
||||
# Kurze Hostnamen vervollstaendigen, bevor mount daran scheitert.
|
||||
unc, hinweis = resolve_unc_host(str(share["unc"]), suffixes)
|
||||
if unc == str(share["unc"]) and hinweis:
|
||||
messages.append(f"{share['label']}: {hinweis}")
|
||||
continue
|
||||
if hinweis:
|
||||
logger.info("%s: %s", share["label"], hinweis)
|
||||
result = sysops.mount_cifs(unc, str(target), username, password, domain=domain)
|
||||
if not result.ok:
|
||||
messages.append(f"{share['label']}: Einbindung fehlgeschlagen ({result.message})")
|
||||
continue
|
||||
conn.execute(
|
||||
"INSERT INTO fileshare_mounts (token, user_id, session_id, label, unc, mount_path, "
|
||||
"created_at, expires_at) VALUES (?,?,?,?,?,?,?,?)",
|
||||
(
|
||||
token,
|
||||
user_id,
|
||||
session_id,
|
||||
str(share["label"]),
|
||||
str(share["unc"]),
|
||||
str(target),
|
||||
_iso(_now()),
|
||||
_iso(_now() + timedelta(hours=hours)),
|
||||
),
|
||||
)
|
||||
messages.append(f"{share['label']}: eingebunden")
|
||||
return messages
|
||||
|
||||
|
||||
def release_mount(conn: sqlite3.Connection, mount_id: int) -> None:
|
||||
row = Database.one(conn, "SELECT * FROM fileshare_mounts WHERE id=?", (mount_id,))
|
||||
if row is None:
|
||||
return
|
||||
sysops = core().sysops
|
||||
if sysops is not None and sysops.available:
|
||||
sysops.umount(str(row["mount_path"]))
|
||||
conn.execute(
|
||||
"UPDATE fileshare_mounts SET released_at=? WHERE id=?", (_iso(_now()), mount_id)
|
||||
)
|
||||
|
||||
|
||||
def forget_all_mounts(conn: sqlite3.Connection) -> int:
|
||||
"""Vergisst alle Einbindungen -- beim Start des Webprozesses.
|
||||
|
||||
Eingebunden wird im Mount-Namensraum dieses Dienstes. Mit dem Dienst
|
||||
verschwindet der Namensraum und mit ihm jede Einbindung; die Zeilen in der
|
||||
Datenbank ueberleben aber. Ohne dieses Aufraeumen zeigte die Oberflaeche
|
||||
nach jedem Neustart Freigaben an, die es nicht mehr gibt -- und ein Klick
|
||||
darauf fuehrte in ein leeres Verzeichnis.
|
||||
|
||||
Bewusst **kein** ``umount``: es gibt nichts zu loesen. Der Aufruf ginge in
|
||||
einen Namensraum, der nicht mehr existiert.
|
||||
"""
|
||||
cursor = conn.execute(
|
||||
"UPDATE fileshare_mounts SET released_at=? WHERE released_at IS NULL", (_iso(_now()),)
|
||||
)
|
||||
return int(cursor.rowcount or 0)
|
||||
|
||||
|
||||
def release_all_for_user(conn: sqlite3.Connection, user_id: int) -> int:
|
||||
count = 0
|
||||
for mount in active_mounts(conn, user_id):
|
||||
release_mount(conn, mount.id)
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def sweep_expired(database: Any) -> int:
|
||||
"""Loest abgelaufene Einbindungen -- laeuft im Hintergrundprozess.
|
||||
|
||||
Bewusst datenbankgetrieben: der Vorgaenger durchsuchte beim Start das
|
||||
Dateisystem und raeumte dabei auch Einbindungen *anderer, aktiver* Sitzungen
|
||||
ab, sobald ein Hilfsskript das Modul importierte.
|
||||
"""
|
||||
released = 0
|
||||
with database.session() as conn:
|
||||
# Eine Einbindung lebt genau so lange wie die Sitzung, aus der sie
|
||||
# entstanden ist. Vorher hing sie an einer eigenen Frist von zwoelf
|
||||
# Stunden: nach dem Abmelden -- oder nach der automatischen Abmeldung
|
||||
# bei Leerlauf -- blieb die Freigabe mit den Zugangsdaten des Benutzers
|
||||
# weiter eingebunden. Die eigene Frist bleibt als Obergrenze bestehen;
|
||||
# eine Sitzung kann durch Aktivitaet beliebig lange leben, eine
|
||||
# Einbindung soll trotzdem irgendwann neu entstehen muessen.
|
||||
rows = Database.all(
|
||||
conn,
|
||||
"""
|
||||
SELECT m.id FROM fileshare_mounts m
|
||||
LEFT JOIN user_sessions s ON s.id = m.session_id
|
||||
WHERE m.released_at IS NULL
|
||||
AND (m.expires_at <= ?
|
||||
OR s.id IS NULL
|
||||
OR s.revoked_at IS NOT NULL
|
||||
OR s.idle_expires_at <= ?
|
||||
OR s.absolute_expires_at <= ?)
|
||||
""",
|
||||
(_iso(_now()), _iso(_now()), _iso(_now())),
|
||||
)
|
||||
if not rows:
|
||||
return 0
|
||||
with database.transaction(conn):
|
||||
for row in rows:
|
||||
release_mount(conn, int(row["id"]))
|
||||
released += 1
|
||||
return released
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Dateibaum
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def resolve_path(mount: Mount, relative: str) -> Path:
|
||||
"""Loest einen relativen Pfad innerhalb einer Einbindung auf.
|
||||
|
||||
Wirft, sobald das Ergebnis das Wurzelverzeichnis verlaesst -- auch ueber
|
||||
Symlinks, weil ``resolve()`` diesen folgt.
|
||||
"""
|
||||
candidate = PurePosixPath((relative or "").strip().lstrip("/"))
|
||||
if any(part == ".." for part in candidate.parts):
|
||||
raise FileshareError("Ungueltiger Pfad.")
|
||||
root = mount.path.resolve()
|
||||
target = (root / str(candidate)).resolve()
|
||||
if target != root and root not in target.parents:
|
||||
raise FileshareError("Der Pfad liegt ausserhalb der Freigabe.")
|
||||
return target
|
||||
|
||||
|
||||
def list_directory(mount: Mount, relative: str = "") -> dict[str, Any]:
|
||||
path = resolve_path(mount, relative)
|
||||
if not path.is_dir():
|
||||
raise FileshareError("Dieses Verzeichnis existiert nicht.")
|
||||
directories: list[dict[str, Any]] = []
|
||||
files: list[dict[str, Any]] = []
|
||||
try:
|
||||
for entry in sorted(path.iterdir(), key=lambda item: item.name.lower()):
|
||||
try:
|
||||
stat = entry.stat()
|
||||
except OSError:
|
||||
continue
|
||||
record = {
|
||||
"name": entry.name,
|
||||
"relative": str(PurePosixPath(relative or "") / entry.name).lstrip("/"),
|
||||
"size": stat.st_size,
|
||||
"modified": datetime.fromtimestamp(stat.st_mtime, timezone.utc).isoformat(
|
||||
timespec="seconds"
|
||||
),
|
||||
}
|
||||
if entry.is_dir():
|
||||
directories.append(record)
|
||||
else:
|
||||
record["previewable"] = entry.suffix.lower() in PREVIEW_MIME
|
||||
files.append(record)
|
||||
except OSError as exc:
|
||||
raise FileshareError(f"Verzeichnis nicht lesbar ({exc}).") from exc
|
||||
|
||||
parts = [part for part in PurePosixPath(relative or "").parts]
|
||||
breadcrumb = []
|
||||
walked = PurePosixPath("")
|
||||
for part in parts:
|
||||
walked = walked / part
|
||||
breadcrumb.append({"name": part, "relative": str(walked)})
|
||||
return {
|
||||
"directories": directories,
|
||||
"files": files,
|
||||
"breadcrumb": breadcrumb,
|
||||
"relative": relative or "",
|
||||
}
|
||||
|
||||
|
||||
def preview_mime(name: str) -> str:
|
||||
suffix = Path(name).suffix.lower()
|
||||
if suffix in PREVIEW_MIME:
|
||||
return PREVIEW_MIME[suffix]
|
||||
raise FileshareError("Fuer diesen Dateityp gibt es keine Vorschau -- bitte herunterladen.")
|
||||
|
||||
|
||||
def download_mime(name: str) -> str:
|
||||
guessed, _ = mimetypes.guess_type(name)
|
||||
return guessed or "application/octet-stream"
|
||||
|
||||
|
||||
def safe_filename(name: str) -> str:
|
||||
"""Verhindert Pfadanteile und versteckte Dateien in Uploads/Neuanlagen."""
|
||||
cleaned = os.path.basename((name or "").strip().replace("\\", "/"))
|
||||
if not cleaned or cleaned in {".", ".."} or cleaned.startswith("."):
|
||||
raise FileshareError("Ungueltiger Dateiname.")
|
||||
if len(cleaned) > 200:
|
||||
raise FileshareError("Der Dateiname ist zu lang.")
|
||||
return cleaned
|
||||
|
||||
|
||||
def delete_entry(mount: Mount, relative: str) -> str:
|
||||
path = resolve_path(mount, relative)
|
||||
if path == mount.path.resolve():
|
||||
raise FileshareError("Das Wurzelverzeichnis der Freigabe kann nicht geloescht werden.")
|
||||
if path.is_dir():
|
||||
shutil.rmtree(path)
|
||||
return "Verzeichnis"
|
||||
path.unlink()
|
||||
return "Datei"
|
||||
|
||||
|
||||
def make_directory(mount: Mount, relative: str, name: str) -> Path:
|
||||
parent = resolve_path(mount, relative)
|
||||
target = parent / safe_filename(name)
|
||||
if target.exists():
|
||||
raise FileshareError("Ein Eintrag mit diesem Namen existiert bereits.")
|
||||
target.mkdir()
|
||||
return target
|
||||
|
||||
|
||||
def rename_entry(mount: Mount, relative: str, new_name: str) -> Path:
|
||||
path = resolve_path(mount, relative)
|
||||
target = path.parent / safe_filename(new_name)
|
||||
if target.exists():
|
||||
raise FileshareError("Ein Eintrag mit diesem Namen existiert bereits.")
|
||||
path.rename(target)
|
||||
return target
|
||||
@@ -0,0 +1,764 @@
|
||||
"""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 ueber SSH: Paketaktualisierung (apt) und Neustart. Konsole.",
|
||||
"windows": (
|
||||
"Neustart ueber RPC (wie 'shutdown /m'). Kein SSH, kein Host-Schluessel. Das "
|
||||
"Geraet 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 loest 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 zwoelf 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 gueltige 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 enthaelt 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 enthaelt 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 loeschen Sie sie endgueltig."
|
||||
)
|
||||
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 geaendert")
|
||||
if category != str(row["category"]):
|
||||
changes.append(f"Kategorie: {row['category']} -> {category}")
|
||||
if (description or "").strip() != str(row["description"] or ""):
|
||||
changes.append("Beschreibung geaendert")
|
||||
|
||||
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 geaendert")
|
||||
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 entschluesselt werden. Wurde der "
|
||||
"Datenschluessel 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')} Geraet(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} Geraet(e) zugeordnet. Bitte zuerst umhaengen."
|
||||
)
|
||||
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
|
||||
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 Geraet mit der MAC {clean_mac} liegt im Papierkorb.")
|
||||
raise ValidationError(f"Ein Geraet mit der MAC {clean_mac} existiert bereits.")
|
||||
|
||||
if switch_id and not clean_port:
|
||||
raise ValidationError(
|
||||
"Zu einem zugeordneten Switch gehoert eine Portbezeichnung -- sonst ist kein "
|
||||
"PoE-Neustart moeglich."
|
||||
)
|
||||
|
||||
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 Geraet 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 Geraet mit der MAC {clean_mac} existiert bereits.")
|
||||
if switch_id and not clean_port:
|
||||
raise ValidationError("Zu einem zugeordneten Switch gehoert 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))),
|
||||
):
|
||||
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-Groesse (Bloecke)"),
|
||||
(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-Reassemblierungsgroesse"),
|
||||
(23, "default-ip-ttl", "uint8", "Standard-TTL fuer 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 fuer 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 ausgewaehlt."
|
||||
try:
|
||||
start = ipaddress.ip_address(range_start)
|
||||
end = ipaddress.ip_address(range_end)
|
||||
except ValueError:
|
||||
return "Start- und Endadresse muessen gueltige 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 uebersprungen."
|
||||
)
|
||||
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 gueltiges Subnetz konfiguriert -- Kea wuerde 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 Geraet benutzt diese Adresse gerade."),
|
||||
"abgelaufen": ("", "Lease auf dieser Adresse ist abgelaufen -- Geraet derzeit nicht da."),
|
||||
"andere_adresse": (
|
||||
"warning",
|
||||
"Das Geraet haelt noch eine Lease auf einer anderen Adresse. Es uebernimmt die "
|
||||
"Reservierung erst nach Ablauf oder Neuanforderung.",
|
||||
),
|
||||
"unbenutzt": ("", "Fuer dieses Geraet 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,686 @@
|
||||
"""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 und die DHCP-Konfiguration.
|
||||
Was in der Datei sonst noch steht, wird nicht stillschweigend verworfen,
|
||||
sondern im Bericht benannt -- damit sichtbar bleibt, was von Hand nachzuziehen
|
||||
ist.
|
||||
"""
|
||||
|
||||
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")
|
||||
|
||||
#: 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 laesst sich nicht umrechnen -- das ist sein "
|
||||
"Zweck. Konten neu anlegen."
|
||||
),
|
||||
"groups": "Gruppen und Rechte: die Rechteschluessel wurden neu geschnitten.",
|
||||
"ldap": (
|
||||
"Verzeichnisdienst: Einstellungen, Gruppen- und Freigabe-Zuordnungen von "
|
||||
"Hand uebertragen."
|
||||
),
|
||||
"nginx": "Webserver: Domain, Ports und Zertifikat von Hand uebertragen.",
|
||||
"logs": "Auditlog: bleibt bewusst beim alten System.",
|
||||
"known_hosts": (
|
||||
"SSH-Host-Schluessel: werden hier je Geraet mit Fingerprint gefuehrt und "
|
||||
"ueber die Seite 'Host-Schluessel' 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 enthaelt kein Objekt.")
|
||||
if str(envelope.get("app") or "") != LEGACY_APP_TAG:
|
||||
raise LegacyTransferError(
|
||||
f"Diese Datei gehoert 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(
|
||||
"Entschluesselung fehlgeschlagen -- falsche Passphrase oder beschaedigte Datei."
|
||||
) from exc
|
||||
|
||||
try:
|
||||
payload = json.loads(klartext.decode("utf-8"))
|
||||
except ValueError as exc:
|
||||
raise LegacyTransferError("Die entschluesselte Nutzlast ist kein JSON.") from exc
|
||||
if not isinstance(payload, dict):
|
||||
raise LegacyTransferError("Die Nutzlast enthaelt 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 uebersprungen.")
|
||||
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 uebernommen",
|
||||
}
|
||||
# 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 muessen 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 uebersprungen.")
|
||||
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 uebersprungen.")
|
||||
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 Vorgaenger kennt kein solches Feld. Bitte pruefen."
|
||||
)
|
||||
report.took("devices", len(ergebnis))
|
||||
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 uebersprungen.")
|
||||
continue
|
||||
reservations.append(
|
||||
{
|
||||
"mac": mac,
|
||||
"ip": str(entry.get("ip") or ""),
|
||||
"hostname": str(entry.get("name") or ""),
|
||||
"comment": "aus altem TESM uebernommen",
|
||||
}
|
||||
)
|
||||
|
||||
# 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 uebersprungen.")
|
||||
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 uebersprungen -- 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
|
||||
|
||||
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' mitwaehlen."
|
||||
)
|
||||
|
||||
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 uebersprungen: {', '.join(unbekannt)}.")
|
||||
|
||||
if not payload:
|
||||
raise LegacyTransferError(
|
||||
"Die Datei enthaelt keine Clients, Switche, Zugangsdaten oder DHCP-Daten. "
|
||||
"Beim Export im alten TESM diese Kategorien mitwaehlen."
|
||||
)
|
||||
return payload, report
|
||||
|
||||
|
||||
def _eine(conn: Any, sql: str, args: tuple[Any, ...] = ()) -> Any:
|
||||
"""Eine Zeile oder ``None`` -- die alte Datenbank kennt kein Database."""
|
||||
return conn.execute(sql, args).fetchone()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Alte Installation lesen
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
#: Dateinamen der Vorgaengerfassung, relativ zu ihrem Verzeichnis.
|
||||
LEGACY_DB_NAME = "sqlite.db"
|
||||
LEGACY_FERNET_NAME = "fernet.key"
|
||||
|
||||
#: Genau die Abfragen des alten Exports. Sie stehen hier als Text und nicht als
|
||||
#: Nachbau: was der Vorgaenger exportiert hat, ist das, was uebersetzbar ist.
|
||||
_ABFRAGEN: dict[str, str] = {
|
||||
"devices": """
|
||||
SELECT devices.mac, devices.ip, devices.port, devices.name,
|
||||
devices.switch_hostname, devices.is_active, devices.ssh_port,
|
||||
credentials.name AS credential_name
|
||||
FROM devices LEFT JOIN credentials ON credentials.id = devices.credential_id
|
||||
WHERE devices.deleted_at IS NULL
|
||||
""",
|
||||
"switches": """
|
||||
SELECT switches.hostname, switches.ip, switches.ssh_port,
|
||||
credentials.name AS credential_name
|
||||
FROM switches LEFT JOIN credentials ON credentials.id = switches.credential_id
|
||||
WHERE switches.deleted_at IS NULL
|
||||
""",
|
||||
}
|
||||
|
||||
|
||||
def _oeffne_alt(pfad: Path) -> Any:
|
||||
"""Oeffnet die alte Datenbank **nur lesend**.
|
||||
|
||||
Der alte Dienst laeuft womoeglich noch. Ein Schreibzugriff waere hier in
|
||||
jedem Fall falsch, und ``mode=ro`` macht daraus einen Fehler statt einer
|
||||
Moeglichkeit.
|
||||
"""
|
||||
import sqlite3
|
||||
|
||||
if not pfad.is_file():
|
||||
raise LegacyTransferError(f"Keine Datenbank gefunden: {pfad}")
|
||||
conn = sqlite3.connect(f"file:{pfad.as_posix()}?mode=ro", uri=True)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
def _tabellen(conn: Any) -> set[str]:
|
||||
return {
|
||||
str(row[0])
|
||||
for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()
|
||||
}
|
||||
|
||||
|
||||
def _entschluessler(root: Path, report: Report) -> Any:
|
||||
"""Gibt eine Funktion zurueck, die ein altes Passwort entschluesselt.
|
||||
|
||||
Fehlt der Schluessel, bleibt der Datensatz erhalten und das Passwort leer.
|
||||
Ein Zugangsdatensatz ohne Passwort ist nachtragbar; ein abgebrochenes
|
||||
Upgrade ist es nicht.
|
||||
"""
|
||||
schluesseldatei = root / LEGACY_FERNET_NAME
|
||||
if not schluesseldatei.is_file():
|
||||
report.note(
|
||||
f"{LEGACY_FERNET_NAME} nicht gefunden -- die Passwoerter der Zugangsdaten "
|
||||
"bleiben leer und sind nachzutragen."
|
||||
)
|
||||
return lambda _token: ""
|
||||
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
|
||||
try:
|
||||
fernet = Fernet(schluesseldatei.read_bytes().strip())
|
||||
except (ValueError, TypeError):
|
||||
report.note(
|
||||
f"{LEGACY_FERNET_NAME} ist unlesbar -- die Passwoerter der Zugangsdaten "
|
||||
"bleiben leer und sind nachzutragen."
|
||||
)
|
||||
return lambda _token: ""
|
||||
|
||||
gescheitert: list[int] = []
|
||||
|
||||
def entschluesseln(token: Any) -> str:
|
||||
if not token:
|
||||
return ""
|
||||
try:
|
||||
return fernet.decrypt(str(token).encode("utf-8")).decode("utf-8")
|
||||
except (InvalidToken, ValueError):
|
||||
gescheitert.append(1)
|
||||
if len(gescheitert) == 1:
|
||||
report.note(
|
||||
"Mindestens ein Passwort liess sich mit dem gefundenen Schluessel "
|
||||
"nicht entschluesseln -- es bleibt leer."
|
||||
)
|
||||
return ""
|
||||
|
||||
return entschluesseln
|
||||
|
||||
|
||||
def _alt_dhcp(conn: Any, vorhanden: set[str]) -> dict[str, Any] | None:
|
||||
"""Baut den DHCP-Abschnitt so, wie der alte Export ihn geschrieben hat."""
|
||||
if "dhcp_subnets" not in vorhanden:
|
||||
return None
|
||||
|
||||
einstellungen: dict[str, Any] = {}
|
||||
if "settings" in vorhanden:
|
||||
# Nur die Schluessel holen, die der Uebersetzer kennt -- der alte
|
||||
# Einstellungsbestand ist gross und heisst groesstenteils anders.
|
||||
for key in DHCP_SETTING_KEYS:
|
||||
row = _eine(conn, "SELECT value FROM settings WHERE key=?", (key,))
|
||||
if row and str(row["value"] or ""):
|
||||
einstellungen[key] = row["value"]
|
||||
|
||||
subnets = [
|
||||
dict(row)
|
||||
for row in conn.execute(
|
||||
"SELECT interface, range_start, range_end, gateway, dns, sort_order, enabled "
|
||||
"FROM dhcp_subnets"
|
||||
).fetchall()
|
||||
]
|
||||
reservations = (
|
||||
[
|
||||
dict(row)
|
||||
for row in conn.execute(
|
||||
"SELECT mac, ip, name FROM dhcp_manual_reservations"
|
||||
).fetchall()
|
||||
]
|
||||
if "dhcp_manual_reservations" in vorhanden
|
||||
else []
|
||||
)
|
||||
|
||||
options: list[dict[str, Any]] = []
|
||||
if "dhcp_option_defs" in vorhanden:
|
||||
for definition in conn.execute(
|
||||
"SELECT id, code, name, type, description, is_standard FROM dhcp_option_defs"
|
||||
).fetchall():
|
||||
werte = (
|
||||
conn.execute(
|
||||
"SELECT device_mac, value FROM dhcp_option_values WHERE option_def_id=?",
|
||||
(definition["id"],),
|
||||
).fetchall()
|
||||
if "dhcp_option_values" in vorhanden
|
||||
else []
|
||||
)
|
||||
if not werte and definition["is_standard"]:
|
||||
continue
|
||||
options.append(
|
||||
{
|
||||
"code": definition["code"],
|
||||
"name": definition["name"],
|
||||
"type": definition["type"],
|
||||
"description": definition["description"],
|
||||
"is_standard": definition["is_standard"],
|
||||
"values": [
|
||||
{"device_mac": w["device_mac"], "value": w["value"]} for w in werte
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
if not (einstellungen or subnets or reservations or options):
|
||||
return None
|
||||
return {
|
||||
"settings": einstellungen,
|
||||
"subnets": subnets,
|
||||
"reservations": reservations,
|
||||
"options": options,
|
||||
}
|
||||
|
||||
|
||||
def read_installation(root: Path) -> tuple[dict[str, Any], Report]:
|
||||
"""Liest eine alte Installation und gibt deren Nutzlast zurueck.
|
||||
|
||||
Ergebnis ist dieselbe Struktur, die ``open_legacy`` aus einer Exportdatei
|
||||
liefert -- der Uebersetzer dahinter bleibt also derselbe. Das ist der
|
||||
Unterschied zum Weg ueber die Oberflaeche: fuer ein Upgrade darf niemand
|
||||
sich anmelden, eine Kategorie anhaken und eine Datei herunterladen muessen.
|
||||
|
||||
Gelesen wird ausschliesslich; die alte Installation bleibt unberuehrt.
|
||||
"""
|
||||
root = Path(root)
|
||||
report = Report()
|
||||
conn = _oeffne_alt(root / LEGACY_DB_NAME)
|
||||
try:
|
||||
vorhanden = _tabellen(conn)
|
||||
payload: dict[str, Any] = {}
|
||||
|
||||
for abschnitt, sql in _ABFRAGEN.items():
|
||||
tabelle = abschnitt
|
||||
if tabelle not in vorhanden:
|
||||
report.note(f"Tabelle {tabelle!r} fehlt in der alten Datenbank.")
|
||||
continue
|
||||
payload[abschnitt] = [dict(row) for row in conn.execute(sql).fetchall()]
|
||||
|
||||
if "credentials" in vorhanden:
|
||||
entschluesseln = _entschluessler(root, report)
|
||||
payload["credentials"] = [
|
||||
{
|
||||
"name": row["name"],
|
||||
"username": row["username"],
|
||||
"password": entschluesseln(row["password"]),
|
||||
"category": row["category"],
|
||||
}
|
||||
for row in conn.execute(
|
||||
"SELECT name, username, password, category FROM credentials "
|
||||
"WHERE deleted_at IS NULL"
|
||||
).fetchall()
|
||||
]
|
||||
else:
|
||||
report.note("Tabelle 'credentials' fehlt in der alten Datenbank.")
|
||||
|
||||
dhcp = _alt_dhcp(conn, vorhanden)
|
||||
if dhcp:
|
||||
payload["dhcp"] = dhcp
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
payload = {key: value for key, value in payload.items() if value}
|
||||
if not payload:
|
||||
raise LegacyTransferError(
|
||||
f"In {root} stehen keine uebernehmbaren Daten (Clients, Switche, "
|
||||
"Zugangsdaten, DHCP)."
|
||||
)
|
||||
for abschnitt, inhalt in payload.items():
|
||||
report.took(abschnitt, len(inhalt) if isinstance(inhalt, list) else 1)
|
||||
return payload, report
|
||||
|
||||
|
||||
def seal_legacy(payload: dict[str, Any], passphrase: str) -> str:
|
||||
"""Schreibt eine Nutzlast im **alten** Umschlag.
|
||||
|
||||
Damit ist die Datei genau das, was der Vorgaenger selbst exportiert haette
|
||||
-- lesbar fuer ``open_legacy`` und fuer den Import des alten TESM. Das ist
|
||||
beim Upgrade der Unterschied zwischen einem Zwischenschritt und einem
|
||||
Rueckweg: geht danach etwas schief, laesst sich diese Datei in die alte
|
||||
Installation zurueckspielen.
|
||||
"""
|
||||
import secrets
|
||||
from datetime import datetime
|
||||
|
||||
from cryptography.fernet import Fernet
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
||||
|
||||
if len(passphrase) < 8:
|
||||
raise LegacyTransferError("Die Passphrase braucht mindestens 8 Zeichen.")
|
||||
|
||||
salt = secrets.token_bytes(16)
|
||||
kdf = PBKDF2HMAC(
|
||||
algorithm=hashes.SHA256(), length=32, salt=salt, iterations=LEGACY_KDF_ITERATIONS
|
||||
)
|
||||
key = base64.urlsafe_b64encode(kdf.derive(passphrase.encode("utf-8")))
|
||||
token = Fernet(key).encrypt(json.dumps(payload).encode("utf-8"))
|
||||
umschlag = {
|
||||
"app": LEGACY_APP_TAG,
|
||||
"version": 2,
|
||||
"exported_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"salt": base64.urlsafe_b64encode(salt).decode("utf-8"),
|
||||
"payload": token.decode("utf-8"),
|
||||
}
|
||||
return json.dumps(umschlag, indent=2, ensure_ascii=False) + "\n"
|
||||
@@ -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. Fuer die "
|
||||
"SSH-Anmeldung hat es gereicht -- 'sudo' auf diesem Geraet 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 Geraet kein 'sudo' ausfuehren. Auf dem "
|
||||
"Zielgeraet eintragen, z. B. in /etc/sudoers.d/tesm-wartung: "
|
||||
"'<benutzer> ALL=(root) /usr/bin/apt-get, /sbin/reboot'. Mit vorangestelltem "
|
||||
"NOPASSWD: entfaellt die Passwortabfrage ganz.",
|
||||
),
|
||||
(
|
||||
"a password is required",
|
||||
"'sudo' verlangt ein Passwort, und in den Zugangsdaten dieses Geraets ist keines "
|
||||
"hinterlegt. Tragen Sie dort das Passwort des Wartungskontos ein -- TESM reicht "
|
||||
"es ueber die bereits verschluesselte SSH-Verbindung an 'sudo' weiter.",
|
||||
),
|
||||
(
|
||||
"no tty present",
|
||||
"Das Wartungskonto verlangt fuer 'sudo' ein Terminal. In der sudoers-Datei des "
|
||||
"Zielgeraets 'Defaults requiretty' entfernen oder fuer dieses Konto abschalten.",
|
||||
),
|
||||
(
|
||||
"command not found",
|
||||
"Das Kommando gibt es auf dem Zielgeraet nicht. Die Wartung erwartet ein "
|
||||
"Debian- oder Ubuntu-System mit apt-get.",
|
||||
),
|
||||
(
|
||||
"Access is denied",
|
||||
"Das Konto darf auf diesem Windows-Geraet keinen Neustart ausloesen. Es muss "
|
||||
"Mitglied der lokalen Administratoren sein.",
|
||||
),
|
||||
(
|
||||
"is not recognized as an internal or external command",
|
||||
"Das Kommando gibt es auf diesem Geraet nicht. Ist bei den Zugangsdaten die "
|
||||
"richtige Kategorie gewaehlt (Linux-Client bzw. Windows-Client)?",
|
||||
),
|
||||
(
|
||||
"Could not get lock",
|
||||
"Ein anderer Paketvorgang laeuft bereits auf dem Zielgeraet (etwa "
|
||||
"unattended-upgrades). Spaeter 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 waehrend des Laufs neu gestartet. Was auf dem Zielgeraet "
|
||||
"passiert ist, laesst 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 uebergeben 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 ueberschritten -- 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 moeglich.")
|
||||
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 uebergeben.")
|
||||
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 Rueckgabewert {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-Geraet -- Neustart ueber 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 ausgeloest ({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 Rueckkehr des Geraets")
|
||||
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"Geraet 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 ueber SSH, Geraet ist zurueckgekehrt.",
|
||||
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"Geraet nicht zurueckgekehrt ({host}:{ssh_port} antwortet nicht).",
|
||||
finished=True,
|
||||
)
|
||||
_log_restart(
|
||||
database,
|
||||
device,
|
||||
result="error",
|
||||
actor=actor,
|
||||
duration_ms=elapsed(),
|
||||
detail=(
|
||||
f"Neustart ausgeloest, 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 (Geraet %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,663 @@
|
||||
"""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 ueberschritten")
|
||||
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 ueber SSH",
|
||||
METHOD_RPC: "Neustart ueber 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 moeglich."
|
||||
)
|
||||
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"Fuer 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 durchgefuehrt.",
|
||||
"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 run_sweep(
|
||||
*,
|
||||
database: Any,
|
||||
live: LiveLog,
|
||||
keystore: Any,
|
||||
license_allows_auto_restart: bool,
|
||||
failures_before_restart: int = DEFAULT_FAILURES_BEFORE_RESTART,
|
||||
) -> 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.
|
||||
"""
|
||||
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
|
||||
|
||||
with ThreadPoolExecutor(max_workers=min(MAX_PARALLEL_CHECKS, len(devices))) as pool:
|
||||
results = list(pool.map(lambda device: (device, ping(str(device["ip"]))), devices))
|
||||
|
||||
restart_candidates: list[dict[str, Any]] = []
|
||||
with database.session() as conn:
|
||||
with database.transaction(conn):
|
||||
for device, result in results:
|
||||
report.checked += 1
|
||||
previous, current = record_status(conn, int(device["id"]), result)
|
||||
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 uebersprungen -- keine gueltige 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,136 @@
|
||||
"""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
|
||||
|
||||
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 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 Geraet, 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 Geraet haengt.",
|
||||
),
|
||||
METHOD_SSH: Method(
|
||||
key=METHOD_SSH,
|
||||
label="Neustart ueber SSH",
|
||||
hint="Faehrt das Betriebssystem sauber herunter. Setzt ein antwortendes Geraet "
|
||||
"und einen freigegebenen Host-Schluessel voraus.",
|
||||
licensed=True,
|
||||
),
|
||||
METHOD_RPC: Method(
|
||||
key=METHOD_RPC,
|
||||
label="Neustart ueber RPC",
|
||||
hint="Windows: wie 'shutdown /m'. Setzt offene Datei- und Druckerfreigabe voraus.",
|
||||
licensed=True,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def available(device: Any, *, maintenance_active: bool = True) -> 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.
|
||||
"""
|
||||
|
||||
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":
|
||||
return [DEFINITIONS[POWER_ON]] if hat_poe else []
|
||||
|
||||
category = str(field("credential_category") or "")
|
||||
has_login = bool(field("credential_id"))
|
||||
if has_login and maintenance_active:
|
||||
if category == "windows":
|
||||
methods.append(DEFINITIONS[METHOD_RPC])
|
||||
elif category == "linux":
|
||||
methods.append(DEFINITIONS[METHOD_SSH])
|
||||
|
||||
return sorted(methods, key=lambda entry: PREFERENCE.index(entry.key))
|
||||
|
||||
|
||||
def preferred(device: Any, *, maintenance_active: bool = True) -> Method | None:
|
||||
"""Der Weg, der vorausgewaehlt wird -- oder ``None``, wenn es keinen gibt."""
|
||||
options = available(device, maintenance_active=maintenance_active)
|
||||
return options[0] if options else None
|
||||
|
||||
|
||||
def resolve(device: Any, requested: str, *, maintenance_active: bool = True) -> 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)}
|
||||
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 ueberschritten."
|
||||
)
|
||||
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-Schluessel von {host}:{port} ist unbekannt "
|
||||
f"({key_type}, {fingerprint}). Bitte pruefen und ausdruecklich 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-Schluessel von {host}:{port} hat sich geaendert "
|
||||
f"(erwartet {expected}, erhalten {received}). Der Aufbau wurde abgebrochen. "
|
||||
"Das kann ein Geraetetausch 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-Schluessel gelesen: {record.key_type} {record.fingerprint}. "
|
||||
"Bitte gegen eine unabhaengige Quelle pruefen und dann freigeben.",
|
||||
)
|
||||
if known.fingerprint != record.fingerprint:
|
||||
return (
|
||||
"danger",
|
||||
f"Achtung: der Schluessel weicht vom gespeicherten ab. Gespeichert "
|
||||
f"{known.fingerprint}, gelesen {record.fingerprint}. Das kann ein "
|
||||
"Geraetetausch sein -- oder ein Angriff.",
|
||||
)
|
||||
return ("success", "Der Host-Schluessel stimmt mit dem gespeicherten ueberein.")
|
||||
|
||||
|
||||
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 Geraet inzwischen einen anderen Schluesseltyp an, muss der "
|
||||
"Schluessel 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,255 @@
|
||||
"""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__)
|
||||
|
||||
#: Erkennt Operator- (``>``) und privilegierten Modus (``#``) sowie den
|
||||
#: Konfigurationsmodus (``switch(config)#``).
|
||||
PROMPT_RE = re.compile(rb"[\r\n][^\r\n]{0,80}?[>#]\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
|
||||
)
|
||||
|
||||
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:
|
||||
for closer in (self._channel, 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:
|
||||
assert self._channel is not None
|
||||
deadline = time.monotonic() + timeout
|
||||
collected = b""
|
||||
while time.monotonic() < deadline:
|
||||
if self._channel.recv_ready():
|
||||
chunk = self._channel.recv(READ_CHUNK)
|
||||
if not chunk:
|
||||
break
|
||||
collected += chunk
|
||||
if PAGER_RE.search(collected[-200:]):
|
||||
self._channel.send(" ")
|
||||
collected = PAGER_RE.sub(b"", collected)
|
||||
continue
|
||||
if PROMPT_RE.search(collected[-200:]):
|
||||
break
|
||||
# Der Login-Banner mancher Geraete verlangt eine Bestaetigung.
|
||||
if initial and collected.rstrip().endswith(b"continue"):
|
||||
self._channel.send("\r\n")
|
||||
else:
|
||||
time.sleep(0.05)
|
||||
text = collected.decode("utf-8", errors="replace")
|
||||
self.transcript.append(text)
|
||||
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 geoeffnet.")
|
||||
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
|
||||
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
|
||||
|
||||
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-Uebersicht aller Ports"),
|
||||
("show interfaces brief", "Portstatus"),
|
||||
("show vlans", "VLAN-Uebersicht"),
|
||||
("show mac-address", "MAC-Adresstabelle"),
|
||||
("show lldp info remote-device", "Nachbargeraete (LLDP)"),
|
||||
("show version", "Firmware-Version"),
|
||||
("show logging", "Switch-Protokoll"),
|
||||
)
|
||||
@@ -0,0 +1,637 @@
|
||||
"""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.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", "Geraetestammdaten inklusive Switch- und Portzuordnung"),
|
||||
Category("switches", "Switche", "Switch-Inventar"),
|
||||
Category(
|
||||
"credentials",
|
||||
"Zugangsdaten",
|
||||
"SSH-Logins. Die Passwoerter liegen im Export im Klartext -- geschuetzt "
|
||||
"ausschliesslich durch die Passphrase.",
|
||||
sensitive=True,
|
||||
),
|
||||
Category("dhcp", "DHCP-Konfiguration", "Subnetze, Optionen und manuelle Reservierungen"),
|
||||
Category("fileshare", "Freigabe-Zuordnungen", "Verzeichnisgruppe zu UNC-Pfad"),
|
||||
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_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 entschluesselbar"
|
||||
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 "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_")
|
||||
}
|
||||
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
|
||||
|
||||
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", []):
|
||||
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",
|
||||
(
|
||||
str(entry.get("ad_group_dn") or ""),
|
||||
str(entry.get("ad_group_name") or ""),
|
||||
str(entry.get("label") or ""),
|
||||
str(entry.get("unc") or ""),
|
||||
stamp,
|
||||
),
|
||||
)
|
||||
bump("fileshare", "updated")
|
||||
|
||||
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,))
|
||||
conn.executemany(
|
||||
"INSERT OR IGNORE INTO group_permissions (group_id, permission) VALUES (?,?)",
|
||||
[(group_id, str(permission)) for permission in entry.get("permissions", [])],
|
||||
)
|
||||
|
||||
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 "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")
|
||||
|
||||
return report
|
||||
@@ -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-Geraet abgewiesen. Bei einem "
|
||||
"Domaenenkonto die Form DOMAENE\\benutzer verwenden -- mit *einem* "
|
||||
"Backslash.",
|
||||
),
|
||||
(
|
||||
"NT_STATUS_ACCESS_DENIED",
|
||||
"Das Konto darf auf diesem Geraet keinen Neustart ausloesen. Es braucht das Recht "
|
||||
"'Herunterfahren von einem Remotesystem aus' -- Mitglied der lokalen "
|
||||
"Administratoren genuegt dafuer in der Regel.",
|
||||
),
|
||||
(
|
||||
"NT_STATUS_CONNECTION_REFUSED",
|
||||
"Das Geraet 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 Geraet erreichbar und die Firewall offen?",
|
||||
),
|
||||
(
|
||||
"NT_STATUS_HOST_UNREACHABLE",
|
||||
"Das Geraet ist nicht erreichbar.",
|
||||
),
|
||||
# Diese Meldung liest sich wie ein technisches Detail, hat aber fast immer
|
||||
# eine einzige Ursache: das Konto ist auf dem Zielgeraet kein Administrator.
|
||||
# Nachgemessen an einem echten Fall -- die Anmeldung gelang, aber schon
|
||||
# "net rpc info" und der Dienstmanager antworteten mit ACCESS_DENIED, also
|
||||
# bekommt ein Standardkonto die winreg-Pipe gar nicht zu sehen.
|
||||
(
|
||||
"pipe winreg",
|
||||
"Das Konto hat auf diesem Geraet keine Administratorrechte. Ein Neustart ueber "
|
||||
"RPC braucht sie: die Pipe, ueber die er laeuft, ist fuer Standardkonten nicht "
|
||||
"sichtbar. Ein Konto der lokalen Administratoren hinterlegen -- pruefen laesst "
|
||||
"sich das mit 'net rpc service status RemoteRegistry'.",
|
||||
),
|
||||
(
|
||||
"NT_STATUS_OBJECT_NAME_NOT_FOUND",
|
||||
"Der benoetigte RPC-Dienst antwortet auf dem Geraet nicht. Meist fehlen dem "
|
||||
"Konto die Administratorrechte; sonst ist der Dienst 'Remoteregistrierung' "
|
||||
"gestoppt.",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
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' laesst sich "
|
||||
"ein Windows-Geraet 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 ueberschritten.")
|
||||
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 ueberschrieben 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 Geraet keine "
|
||||
"Administratorrechte. Fuer einen Neustart ueber RPC braucht es sie.",
|
||||
)
|
||||
return RemoteResult(False, output, hint_for(output) or "Pruefung fehlgeschlagen.")
|
||||
|
||||
|
||||
def reboot(
|
||||
host: str,
|
||||
username: str,
|
||||
password: str,
|
||||
*,
|
||||
domain: str = "",
|
||||
comment: str = "Neustart durch TESM-Wartung",
|
||||
delay: int = 5,
|
||||
timeout: int = DEFAULT_TIMEOUT,
|
||||
) -> RemoteResult:
|
||||
"""Loest einen Neustart auf einem Windows-Geraet aus."""
|
||||
ergebnis = _net_with_credentials(
|
||||
[
|
||||
"rpc",
|
||||
"shutdown",
|
||||
"-r", # neu starten statt herunterfahren
|
||||
"-f", # laufende Anwendungen nicht blockieren lassen
|
||||
"-t",
|
||||
str(max(0, int(delay))),
|
||||
"-C",
|
||||
comment,
|
||||
"-I",
|
||||
host,
|
||||
],
|
||||
username=username,
|
||||
password=password,
|
||||
domain=domain,
|
||||
timeout=timeout,
|
||||
)
|
||||
if isinstance(ergebnis, RemoteResult):
|
||||
return ergebnis
|
||||
code, output = ergebnis
|
||||
if code == 0:
|
||||
return RemoteResult(True, output or "Neustart ausgeloest.")
|
||||
return RemoteResult(False, output, hint_for(output))
|
||||
@@ -0,0 +1,129 @@
|
||||
"""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",
|
||||
"Pruefintervall (Sekunden)",
|
||||
hint="Abstand zwischen zwei Erreichbarkeitspruefungen. 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 Fehlschlaegen wird geschaltet. "
|
||||
"Der Vorgaenger 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",
|
||||
"Statusuebersicht ohne Anmeldung erreichbar",
|
||||
hint="Zeigt ausschliesslich Name und Erreichbarkeit aktiver Geraete -- keine "
|
||||
"IP-Adressen, keine Switch-Zuordnung, keine Verwaltungsfunktionen.",
|
||||
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 loesen nach (Stunden)",
|
||||
section="fileshare",
|
||||
minimum=1,
|
||||
maximum=168,
|
||||
),
|
||||
SettingSpec(
|
||||
"fileshare_upload_max_mb",
|
||||
512,
|
||||
"int",
|
||||
"Maximale Dateigroesse beim Hochladen (MB)",
|
||||
section="fileshare",
|
||||
minimum=1,
|
||||
maximum=8192,
|
||||
),
|
||||
# -- Wartung ------------------------------------------------------------ #
|
||||
SettingSpec(
|
||||
"maintenance_command_timeout",
|
||||
900,
|
||||
"int",
|
||||
"Zeitlimit eines Wartungslaufs (Sekunden)",
|
||||
section="maintenance",
|
||||
minimum=60,
|
||||
maximum=7200,
|
||||
),
|
||||
SettingSpec(
|
||||
"maintenance_reboot_wait",
|
||||
300,
|
||||
"int",
|
||||
"Wartezeit auf die Rueckkehr nach einem Neustart (Sekunden)",
|
||||
section="maintenance",
|
||||
minimum=30,
|
||||
maximum=1800,
|
||||
),
|
||||
# -- Terminal ----------------------------------------------------------- #
|
||||
SettingSpec(
|
||||
"terminal_enabled",
|
||||
True,
|
||||
"bool",
|
||||
"Browser-Terminal fuer Switche aktiv",
|
||||
hint="Auch wenn aktiv: erfordert das Recht 'Ausfuehren' auf Switche, eine frische "
|
||||
"Passwortbestaetigung und wird vollstaendig protokolliert.",
|
||||
section="monitoring",
|
||||
),
|
||||
SettingSpec(
|
||||
"terminal_idle_timeout",
|
||||
300,
|
||||
"int",
|
||||
"Terminal bei Untaetigkeit trennen nach (Sekunden)",
|
||||
section="monitoring",
|
||||
minimum=60,
|
||||
maximum=3600,
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,125 @@
|
||||
{# 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.execute') and 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 %}
|
||||
{% if current_user.has('devices.edit') and tile.state == 'disabled' %}
|
||||
<form method="post" action="{{ url_for('devices.toggle_active', device_id=tile.id) }}"
|
||||
data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="active" value="1">
|
||||
<button class="btn btn--sm" type="submit">Aktivieren</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% 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.edit') %}
|
||||
<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.edit') %}
|
||||
{% 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,248 @@
|
||||
{% 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('maintenance.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 current_user.has('devices.execute') and 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.edit') %}
|
||||
<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.edit') %}
|
||||
<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.edit') %}
|
||||
<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.edit') %}
|
||||
<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>
|
||||
<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.edit') %}
|
||||
<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 current_user.has('devices.execute') %}
|
||||
{{ restart_control(device, restart_methods.get(device.id, []), compact=True) }}
|
||||
{% endif %}
|
||||
{% if current_user.has('devices.edit') %}
|
||||
<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.edit') %}
|
||||
{% 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 + manüll</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat__label">Leases</span>
|
||||
<span class="stat__value">{{ leases | rejectattr('is_expired') | list | length }}</span>
|
||||
<span class="stat__hint">
|
||||
gültig{% if leases | selectattr('is_expired') | list %},
|
||||
{{ leases | selectattr('is_expired') | list | length }} abgelaufen{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if current_user.has('dhcp.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.edit') %}
|
||||
<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.edit') %}
|
||||
<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.edit') %}
|
||||
<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.edit') %}
|
||||
<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.edit') 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.edit') %}
|
||||
<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 == 'manüll' and current_user.has('dhcp.edit') %}
|
||||
<form method="post" data-no-guard
|
||||
action="{{ url_for('dhcp.delete_reservation', reservation_id=reservation.reservation_id) }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<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.edit') %}
|
||||
<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.edit') %}
|
||||
<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,236 @@
|
||||
{% 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.edit') %}
|
||||
<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', freigabe=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', freigabe=token) }}">{{ current.label }}</a></span>
|
||||
{% for crumb in listing.breadcrumb %}
|
||||
<span><a href="{{ url_for('fileshare.browse', freigabe=token, pfad=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', freigabe=token,
|
||||
pfad=(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="pfade" value="{{ entry.relative }}"><span class="sr-only">auswählen</span></label></td>
|
||||
{% endif %}
|
||||
<td>
|
||||
{{ icon('folder', size=15) }}
|
||||
<a href="{{ url_for('fileshare.browse', freigabe=token, pfad=entry.relative) }}">{{ entry.name }}</a>
|
||||
</td>
|
||||
<td class="numeric faint">–</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="pfade" 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, pfad=entry.relative) }}"
|
||||
data-fill-Name="{{ entry.name }}"
|
||||
data-fill-Download="{{ url_for('fileshare.download', token=token, pfad=entry.relative) }}">
|
||||
{{ icon('file-text', size=15) }} {{ entry.name }}
|
||||
</button>
|
||||
{% else %}
|
||||
<a class="link-icon"
|
||||
href="{{ url_for('fileshare.download', token=token, pfad=entry.relative) }}">
|
||||
{{ icon('file-text', size=15) }} {{ entry.name }}
|
||||
</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="numeric fs-sm">{{ entry.size | bytes }}</td>
|
||||
<td class="fs-sm">{{ entry.modified | dt }}</td>
|
||||
<td class="actions">
|
||||
{% if entry.previewable %}
|
||||
<button class="btn btn--sm btn--ghost" type="button"
|
||||
data-dialog-open="file-preview"
|
||||
data-fill-Src="{{ url_for('fileshare.preview', token=token, pfad=entry.relative) }}"
|
||||
data-fill-Name="{{ entry.name }}"
|
||||
data-fill-Download="{{ url_for('fileshare.download', token=token, pfad=entry.relative) }}"
|
||||
aria-label="Vorschau">{{ icon('eye', size=15) }}</button>
|
||||
{% endif %}
|
||||
{% if current_user.has('fileshare.edit') %}
|
||||
<button class="btn btn--sm btn--ghost" type="button"
|
||||
data-dialog-open="file-rename"
|
||||
data-fill-Pfad="{{ entry.relative }}"
|
||||
data-fill-Name="{{ entry.name }}"
|
||||
aria-label="Umbenennen">{{ icon('pencil', size=15) }}</button>
|
||||
{% endif %}
|
||||
<a class="btn btn--sm btn--ghost"
|
||||
href="{{ url_for('fileshare.download', token=token, pfad=entry.relative) }}"
|
||||
aria-label="Herunterladen">{{ icon('download', size=15) }}</a>
|
||||
</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="pfad" value="{{ listing.relative }}">
|
||||
<div class="card__body row">
|
||||
<input class="input" type="file" name="files" multiple required style="flex:1">
|
||||
<button class="btn btn--primary" type="submit" data-busy-label="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="pfad" 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>
|
||||
<div class="modal__body modal__body--flush">
|
||||
<iframe class="file-preview" title="Vorschau" data-fill-target="Src" data-fill-as="src"
|
||||
src="about:blank" referrerpolicy="same-origin"></iframe>
|
||||
</div>
|
||||
<div class="modal__footer">
|
||||
<a class="btn btn--sm" data-fill-target="Download" data-fill-as="href" href="#" download>
|
||||
{{ icon('download', size=15) }} Herunterladen
|
||||
</a>
|
||||
<button class="btn btn--primary" type="button" data-dialog-close>Schliessen</button>
|
||||
</div>
|
||||
{% endcall %}
|
||||
{% endif %}
|
||||
|
||||
{% if listing and current_user.has('fileshare.edit') %}
|
||||
{% call modal('file-rename', 'Umbenennen', '', 'narrow') %}
|
||||
<form method="post" action="{{ url_for('fileshare.rename', token=token) }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="parent" value="{{ listing.relative }}">
|
||||
<input type="hidden" name="pfad" data-fill-target="Pfad" value="">
|
||||
<div class="modal__body">
|
||||
{{ field('name', 'Neuer Name', required=True, attrs='data-fill-target="Name"') }}
|
||||
<p class="fs-xs faint">
|
||||
Umbenannt wird auf der Freigabe selbst -- es gibt dort keinen Papierkorb.
|
||||
</p>
|
||||
</div>
|
||||
<div class="modal__footer">
|
||||
<button class="btn" type="button" data-dialog-close>Abbrechen</button>
|
||||
<button class="btn btn--primary" type="submit">Umbenennen</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endcall %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -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.edit') %}
|
||||
<button class="btn btn--sm btn--ghost" type="button"
|
||||
data-dialog-open="mapping-edit-{{ entry.id }}"
|
||||
aria-label="Bearbeiten">{{ icon('pencil', size=15) }}</button>
|
||||
{% endif %}
|
||||
<form method="post" action="{{ url_for('fileshare.mapping_delete', mapping_id=entry.id) }}" data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<button class="btn btn--sm btn--ghost danger-text" type="submit"
|
||||
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.edit') %}
|
||||
{% for entry in mappings %}
|
||||
{% call modal('mapping-edit-' ~ entry.id, entry.label, 'Zuordnung ändern') %}
|
||||
<form method="post" action="{{ url_for('fileshare.mapping_update', mapping_id=entry.id) }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<div class="modal__body">
|
||||
<div class="form-grid">
|
||||
{{ field('label', 'Anzeigename', value=entry.label, required=True) }}
|
||||
{{ field('unc', 'UNC-Pfad', value=entry.unc, required=True,
|
||||
hint_text='Schrägstriche gehen auch: //dc2022/daten') }}
|
||||
{{ group_picker('ad_group_name', 'ad_group_dn', 'edit-' ~ entry.id,
|
||||
name_value=entry.ad_group_name, dn_value=entry.ad_group_dn,
|
||||
required=True,
|
||||
label='Verzeichnisgruppe, die diese Freigabe bekommt') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal__footer">
|
||||
<button class="btn" type="button" data-dialog-close>Abbrechen</button>
|
||||
<button class="btn btn--primary" type="submit">Speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endcall %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -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.edit') or current_user.has('switches.edit') %}
|
||||
<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,316 @@
|
||||
{% 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 %}{{ pill(module_labels[module], 'online') }}
|
||||
{% else %}{{ pill(module_labels[module], '') }}{% endif %}
|
||||
{% endfor %}
|
||||
{% 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.edit') %}
|
||||
<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.edit') %}
|
||||
<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.edit') %}
|
||||
<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 %}Kea-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 manüll, über den PoE-Port des Switches ebenso wie per Neustart-Befehl aus der Wartung.
|
||||
Der Vorgänger protokollierte Neustarts ausdrücklich nicht.', 'info') }}
|
||||
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<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', methode=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('maintenance.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('maintenance.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('maintenance.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,172 @@
|
||||
{% extends "tesm_core/base.html" %}
|
||||
{% from "tesm_core/macros.html" import field, select_field, checkbox, alert, pill %}
|
||||
|
||||
{% 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 manülle Knopf im Dashboard
|
||||
funktioniert unabhängig davon.') }}
|
||||
{{ checkbox('dashboard_public', 'Statusübersicht ohne Anmeldung erreichbar',
|
||||
checked=values.dashboard_public,
|
||||
hint_text=specs['dashboard_public'].hint) }}
|
||||
</div>
|
||||
<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('terminal', size=17) }} Switch-Konsole</div>
|
||||
<div class="card__sub">
|
||||
Auch bei aktiver Konsole gilt: eigenes Recht, frische Passwortbestätigung,
|
||||
vollständige Mitschrift
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" action="{{ url_for('settings.system_save') }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<div class="card__body stack">
|
||||
{{ checkbox('terminal_enabled', 'Browser-Konsole für Switche zulassen',
|
||||
checked=values.terminal_enabled) }}
|
||||
{{ 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('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('session_idle_minutes', 'Automatische Abmeldung (Minuten)',
|
||||
value=values.session_idle_minutes, type='number',
|
||||
hint_text=specs['session_idle_minutes'].hint) }}
|
||||
{{ field('trash_retention_days', 'Papierkorb-Aufbewahrung (Tage)',
|
||||
value=values.trash_retention_days, type='number',
|
||||
hint_text=specs['trash_retention_days'].hint) }}
|
||||
</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 %}
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('sliders', size=17) }} Navigationsreihenfolge</div>
|
||||
<div class="card__sub">Gilt für alle Benutzer; jeder sieht weiter nur, was er darf</div>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" action="{{ url_for('admin.nav_order') }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<div class="card__body">
|
||||
<ol class="stack stack--tight">
|
||||
{% for key in nav_order %}
|
||||
{% set item = nav_items | selectattr('key', 'equalto', key) | list | first %}
|
||||
{% if item %}
|
||||
<li class="row row--tight">
|
||||
<input class="input" type="number" name="position_{{ item.key }}" value="{{ loop.index }}"
|
||||
min="1" style="width:70px" disabled>
|
||||
<input type="hidden" name="order" value="{{ item.key }}">
|
||||
<span class="flex-1">{{ icon(item.icon, size=15) }} {{ item.label }}</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</ol>
|
||||
<p class="fs-xs faint mt-3">
|
||||
Die Reihenfolge folgt der Liste. Zum Umsortieren die Einträge in der gewünschten
|
||||
Reihenfolge speichern (Drag-and-drop folgt in einer späteren Version).
|
||||
</p>
|
||||
</div>
|
||||
<div class="card__footer">
|
||||
<button class="btn btn--sm" type="submit">Reihenfolge speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
{% endif %}
|
||||
</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('switches.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.edit') %}
|
||||
<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.edit') %}
|
||||
<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.edit') %}
|
||||
<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('switches.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('switches.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.edit') %}
|
||||
<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.edit') %}
|
||||
{% 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,132 @@
|
||||
"""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
|
||||
|
||||
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", "Ueberwachungsprozess gestartet")
|
||||
logger.info("Ueberwachung laeuft, 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(),
|
||||
)
|
||||
logger.info(
|
||||
"Durchlauf: %s geprueft, %s online, %s offline, %s Neustarts (%s erfolgreich), "
|
||||
"%s ohne Lizenz uebersprungen, %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", "Ueberwachungsprozess 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,614 @@
|
||||
#!/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=""
|
||||
PORT=""
|
||||
HTTP_PORT=""
|
||||
SKIP_PACKAGES=0
|
||||
ASSUME_YES=0
|
||||
HTTPS=0
|
||||
HTTPS_PORT=""
|
||||
DOMAIN=""
|
||||
ACME_EMAIL=""
|
||||
|
||||
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: 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
|
||||
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 ;;
|
||||
-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"
|
||||
NGINX_SITE="/etc/nginx/sites-available/$NAME"
|
||||
ENV_PREFIX="TESM_"
|
||||
[[ "$APP" == "tesm-license" ]] && ENV_PREFIX="TESM_LICENSE_"
|
||||
# Bei einer Aktualisierung gilt zuerst, was schon eingerichtet ist. Sonst
|
||||
# verschiebt ein Update ohne Portangaben die Anwendung: eine benannte Instanz
|
||||
# faellt auf 8080/8443 zurueck, auch wenn sie seit der Installation auf Port 80
|
||||
# lief -- und ein Reverse Proxy davor zeigt dann ins Leere. Dieselbe Regel wie
|
||||
# beim Secure-Flag weiter unten: eine Aktualisierung nimmt nichts weg, was
|
||||
# nicht ausdruecklich neu gesetzt wird.
|
||||
ENV_FILE="/etc/tesm/$NAME.env"
|
||||
if [[ -z "$PORT" && -f "$ENV_FILE" ]]; then
|
||||
PORT="$(sed -n "s/^${ENV_PREFIX}BIND=127\.0\.0\.1:\([0-9]\{1,5\}\)$/\1/p" "$ENV_FILE" | head -1)"
|
||||
fi
|
||||
if [[ -f "$NGINX_SITE" ]]; then
|
||||
[[ -z "$HTTP_PORT" ]] && HTTP_PORT="$(sed -n 's/^[[:space:]]*listen[[:space:]]\([0-9]\{1,5\}\);.*/\1/p' "$NGINX_SITE" | head -1)"
|
||||
[[ -z "$HTTPS_PORT" ]] && HTTPS_PORT="$(sed -n 's/^[[:space:]]*listen[[:space:]]\([0-9]\{1,5\}\)[[:space:]]\+ssl.*/\1/p' "$NGINX_SITE" | head -1)"
|
||||
fi
|
||||
|
||||
if [[ -z "$PORT" ]]; then
|
||||
PORT=5000
|
||||
[[ "$APP" == "tesm-license" ]] && PORT=5001
|
||||
[[ -n "$INSTANCE" ]] && PORT=$((PORT + 100))
|
||||
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
|
||||
|
||||
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)"
|
||||
|
||||
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.
|
||||
|
||||
# -- 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"
|
||||
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
|
||||
[[ $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
|
||||
|
||||
# -- Health-Check und Rollback ----------------------------------------------
|
||||
step "Gesundheitspruefung"
|
||||
HEALTHY=0
|
||||
for attempt in $(seq 1 20); do
|
||||
if curl -fsS --max-time 3 "http://127.0.0.1:$PORT/gesundheit" >/dev/null 2>&1; then
|
||||
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
|
||||
warn "Rollback wird ausgefuehrt."
|
||||
systemctl stop "$UNIT" 2>/dev/null || true
|
||||
[[ "$APP" == "tesm" ]] && systemctl stop "$MONITOR_UNIT" 2>/dev/null || true
|
||||
mv "$ROOT" "$FAILED_DIR"
|
||||
cp -a "$BACKUP_DIR" "$ROOT"
|
||||
systemctl start "$UNIT" || true
|
||||
[[ "$APP" == "tesm" ]] && systemctl start "$MONITOR_UNIT" || true
|
||||
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
|
||||
|
||||
if [[ $IS_UPDATE -eq 1 ]]; then
|
||||
rm -rf "$BACKUP_DIR"
|
||||
info "Sicherung entfernt (Aktualisierung erfolgreich)"
|
||||
fi
|
||||
|
||||
# -- Abschluss ---------------------------------------------------------------
|
||||
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" ]]; then
|
||||
cat <<SUMMARY
|
||||
1. Signaturschluessel erzeugen und sofort sichern:
|
||||
sudo -u $SERVICE_USER $NAME-admin init-key
|
||||
2. Eigene Master-Lizenz ausstellen:
|
||||
sudo -u $SERVICE_USER $NAME-admin bootstrap
|
||||
3. Administrator anlegen:
|
||||
sudo -u $SERVICE_USER $NAME-admin create-admin
|
||||
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,29 @@
|
||||
#!/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 der einzigen Anwendung im Baum, sonst der Verzeichnisname.
|
||||
if [[ -n "${2:-}" ]]; then
|
||||
NAME="$2"
|
||||
else
|
||||
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
|
||||
|
||||
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-opus --to tesm
|
||||
# sudo bash deploy/rename.sh --app tesm-license --from tesm-license-opus \
|
||||
# --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-opus (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,9 @@
|
||||
# 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.
|
||||
Defaults:tesm !requiretty
|
||||
tesm ALL=(root) NOPASSWD: /usr/local/lib/tesm/tesm-helper
|
||||
@@ -0,0 +1,9 @@
|
||||
# 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.
|
||||
Defaults:tesm-license !requiretty
|
||||
tesm-license ALL=(root) NOPASSWD: /usr/local/lib/tesm/tesm-helper
|
||||
@@ -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-opus"). 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,89 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# Bootstrapper: laedt ein Release herunter und ruft dessen install.sh auf.
|
||||
# Enthaelt selbst keinerlei Installationslogik.
|
||||
#
|
||||
# Zwei Fallen des Vorgaengers sind hier bewusst vermieden:
|
||||
# * Der Tag "latest" wird ueber die API aufgeloest, nie direkt in die
|
||||
# Download-URL geschrieben. Gitea liefert sonst mit HTTP 200 ein
|
||||
# automatisch erzeugtes Quell-Snapshot statt des echten Release-Assets.
|
||||
# * Nach dem Entpacken wird geprueft, dass ein install.sh existiert -- faengt
|
||||
# denselben Fehler ein zweites Mal ab.
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
# Ein Wagenruecklauf im Paket macht jedes Shell-Skript auf dem Ziel
|
||||
# unbrauchbar ("bad interpreter"). Die Pruefung darf dabei nicht selbst
|
||||
# von einem Zusatzpaket abhaengen: mit "file" -- das auf einem schlanken
|
||||
# Server fehlt -- endete sie in "command not found", grep bekam nichts zu
|
||||
# sehen, und das Skript machte weiter, als waere alles in Ordnung.
|
||||
hat_crlf() {
|
||||
local cr
|
||||
cr="$(printf '\r')"
|
||||
# -U (binaer lesen) ist unter Linux ohne Wirkung, auf einer Windows-Buildmaschine
|
||||
# aber entscheidend: dort entfernt grep den Wagenruecklauf beim Lesen und
|
||||
# findet deshalb nie einen.
|
||||
LC_ALL=C grep -qU "$cr" "$1"
|
||||
}
|
||||
|
||||
APP="${1:-}"
|
||||
TAG="${2:-latest}"
|
||||
|
||||
# Alles nach dem Tag geht unveraendert an install.sh weiter. Ohne das liesse
|
||||
# sich eine benannte Instanz nicht aktualisieren: install.sh leitet den Namen
|
||||
# aus "--instance" ab, und ohne den Schalter entstuende neben "tesm-opus" eine
|
||||
# zweite Installation namens "tesm" -- mit eigener Datenbank, eigener
|
||||
# nginx-Site und demselben Port.
|
||||
shift $(( $# > 2 ? 2 : $# ))
|
||||
EXTRA=("$@")
|
||||
# Bezugsquelle. Die Vorgaben zeigen dorthin, wo die Releases tatsaechlich
|
||||
# liegen -- ein Platzhalter wie "gitea.example.invalid" macht das Skript auf
|
||||
# jedem Host unbrauchbar, und genau das war hier der Fall.
|
||||
#
|
||||
# Das Repository heisst wie die Anwendung; getrennte Repositorien, getrennte
|
||||
# Releases. Der Anhang heisst "<repo>-<tag>.tar.gz", siehe unten.
|
||||
BASE="${TESM_RELEASE_BASE:-https://gitea.int.eertmoed.net}"
|
||||
OWNER="${TESM_RELEASE_OWNER:-alientim}"
|
||||
|
||||
[[ "$APP" == "tesm" || "$APP" == "tesm-license" ]] || {
|
||||
echo "Verwendung: update.sh <tesm|tesm-license> [tag] [install.sh-Argumente ...]" >&2
|
||||
echo "Beispiel : update.sh tesm latest --instance opus" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
REPO="${TESM_RELEASE_REPO:-$APP}"
|
||||
|
||||
WORK="$(mktemp -d /tmp/tesm-update-XXXXXX)"
|
||||
trap 'rm -rf "$WORK"' EXIT # anders als im Vorgaenger wird aufgeraeumt
|
||||
|
||||
if [[ "$TAG" == "latest" ]]; then
|
||||
echo "==> Ermittle aktuellen Release-Tag ..."
|
||||
TAG="$(curl -fsS "$BASE/api/v1/repos/$OWNER/$REPO/releases/latest" \
|
||||
| python3 -c 'import json,sys; print(json.load(sys.stdin)["tag_name"])')"
|
||||
echo " $TAG"
|
||||
fi
|
||||
|
||||
ASSET="$REPO-$TAG.tar.gz"
|
||||
echo "==> Lade $ASSET ..."
|
||||
curl -fsSL -o "$WORK/$ASSET" "$BASE/$OWNER/$REPO/releases/download/$TAG/$ASSET"
|
||||
|
||||
echo "==> Entpacke ..."
|
||||
tar -xzf "$WORK/$ASSET" -C "$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 "$WORK" -maxdepth 3 -name install.sh -path '*/deploy/*' -printf '%h/..\n' | head -1)"
|
||||
[[ -n "$SRC" ]] || { echo "Im Paket fehlt deploy/install.sh -- Download unbrauchbar." >&2; exit 1; }
|
||||
SRC="$(cd "$SRC" && pwd)"
|
||||
|
||||
echo "==> Pruefe auf CRLF (Windows-Buildmaschinen) ..."
|
||||
if hat_crlf "$SRC/deploy/install.sh"; then
|
||||
echo "Das Paket enthaelt CRLF-Zeilenenden. Beim Paketieren fehlte" >&2
|
||||
echo "'git -c core.autocrlf=false archive'." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
read -r -p "Version $TAG fuer $APP${EXTRA[0]+ (${EXTRA[*]})} installieren? [j/N] " answer
|
||||
[[ "$answer" =~ ^[jJyY]$ ]] || exit 0
|
||||
|
||||
exec sudo bash "$SRC/deploy/install.sh" --app "$APP" --yes ${EXTRA[0]+"${EXTRA[@]}"}
|
||||
@@ -0,0 +1,316 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# Upgrade vom Stand SONNET5 (Version 1.x) auf diese Fassung -- mit Datenuebernahme.
|
||||
#
|
||||
# Aufruf (aus dem entpackten Release-Verzeichnis):
|
||||
# sudo bash deploy/upgrade.sh --app tesm
|
||||
# sudo bash deploy/upgrade.sh --app tesm-license
|
||||
# sudo bash deploy/upgrade.sh --app tesm --instance opus --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
|
||||
#: Namenszusatz, unter dem ``rename.sh`` eine verdraengte Installation der
|
||||
#: Fassung 1.x ablegt. Beide Skripte muessen denselben Wert benutzen; ein
|
||||
#: Hygienetest vergleicht sie.
|
||||
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.
|
||||
--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 opus --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
|
||||
--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"
|
||||
[[ -f "$SCRIPT_DIR/install.sh" ]] || fail "install.sh fehlt neben diesem Skript"
|
||||
|
||||
# -- 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"
|
||||
warn " /srv/$APP-$ARCHIV_SUFFIX (dorthin verschiebt rename.sh sie)"
|
||||
[[ -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"
|
||||
|
||||
NAME="$APP"
|
||||
[[ -n "$INSTANCE" ]] && NAME="$APP-$INSTANCE"
|
||||
ADMIN="$NAME-admin"
|
||||
NEW_ROOT="/srv/$NAME"
|
||||
IN_PLACE=0
|
||||
[[ "$(cd "$SOURCE" && pwd)" == "$NEW_ROOT" ]] && IN_PLACE=1
|
||||
|
||||
STAMP="$(date +%Y%m%d-%H%M%S)"
|
||||
BACKUP="/srv/$NAME-upgrade-$STAMP"
|
||||
ALT_KOPIE="$BACKUP/alt"
|
||||
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.
|
||||
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
|
||||
|
||||
step "Vorhaben"
|
||||
info "Anwendung : $APP${INSTANCE:+ (Instanz $INSTANCE)}"
|
||||
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"
|
||||
chmod 700 "$BACKUP"
|
||||
# -a: Rechte und Zeiten bleiben, damit die Kopie auch als Rueckweg taugt.
|
||||
# Das venv der alten Installation bleibt aussen vor -- es ist nachbaubar,
|
||||
# gross, und seine Shebangs zeigen ohnehin auf den alten Pfad.
|
||||
tar -C "$SOURCE" --exclude=venv --exclude=__pycache__ -cf - . | tar -C "$ALT_KOPIE" -xf -
|
||||
info "$(du -sh "$ALT_KOPIE" | cut -f1) kopiert (ohne venv)"
|
||||
[[ -f "$ALT_KOPIE/sqlite.db" ]] || fail "In der Kopie fehlt die Datenbank -- Abbruch"
|
||||
|
||||
# -- 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")
|
||||
bash "$SCRIPT_DIR/install.sh" "${INSTALL_ARGS[@]}" ${EXTRA[0]+"${EXTRA[@]}"}
|
||||
|
||||
if [[ $WITH_DATA -eq 0 ]]; then
|
||||
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 --------------------------------------------------------------- #
|
||||
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
|
||||
+475
@@ -0,0 +1,475 @@
|
||||
# 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`
|
||||
|
||||
Liegt kein entpacktes Release vor, holt `update.sh` es selbst -- es lädt das
|
||||
Release-Asset, entpackt es und ruft dessen `install.sh` auf:
|
||||
|
||||
```bash
|
||||
sudo bash /opt/tesm/deploy/update.sh tesm
|
||||
sudo bash /opt/tesm-license/deploy/update.sh tesm-license
|
||||
```
|
||||
|
||||
Alles nach dem Tag geht unverändert an `install.sh` weiter -- das ist der Weg,
|
||||
eine **benannte Instanz** zu aktualisieren:
|
||||
|
||||
```bash
|
||||
sudo bash /opt/tesm-opus/deploy/update.sh tesm latest --instance opus
|
||||
```
|
||||
|
||||
Ohne `--instance` entstünde daneben eine zweite Installation namens `tesm`, mit
|
||||
eigener Datenbank und eigener nginx-Site. Die Ports einer bestehenden
|
||||
Installation bleiben erhalten, auch wenn sie von den Vorgaben abweichen: eine
|
||||
Aktualisierung verschiebt nichts, was nicht ausdrücklich neu gesetzt wird.
|
||||
|
||||
Ohne Tag wird `latest` **über die API aufgelöst**, nicht in die URL geschrieben:
|
||||
Gitea liefert unter einem Tag-Namen sonst ein automatisch erzeugtes
|
||||
Quell-Archiv mit HTTP 200 statt des echten Anhangs -- und das enthält kein
|
||||
Release, sondern den Repositoriumsstand.
|
||||
|
||||
Die Bezugsquelle ist voreingestellt und lässt sich über die Umgebung umlenken,
|
||||
etwa auf eine Spiegelung:
|
||||
|
||||
| Variable | Vorgabe |
|
||||
|---|---|
|
||||
| `TESM_RELEASE_BASE` | `https://gitea.int.eertmoed.net` |
|
||||
| `TESM_RELEASE_OWNER` | `alientim` |
|
||||
| `TESM_RELEASE_REPO` | der Anwendungsname (`tesm` bzw. `tesm-license`) |
|
||||
|
||||
Der erwartete Anhang heisst `<repo>-<tag>.tar.gz`.
|
||||
|
||||
### 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.
|
||||
|
||||
```bash
|
||||
sudo bash deploy/upgrade.sh --app tesm
|
||||
sudo bash deploy/upgrade.sh --app tesm-license
|
||||
```
|
||||
|
||||
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 opus --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-opus
|
||||
```
|
||||
|
||||
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-opus --to tesm
|
||||
sudo bash deploy/rename.sh --app tesm-license --from tesm-license-opus --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/gesundheit
|
||||
```
|
||||
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,183 @@
|
||||
# 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 |
|
||||
| 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.
|
||||
* **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,15 +0,0 @@
|
||||
[Unit]
|
||||
Description=TESM Web App
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
WorkingDirectory=/srv/tesm
|
||||
# Nutze die virtuelle Umgebung
|
||||
ExecStart=/srv/tesm/venv/bin/python3 /srv/tesm/app.py
|
||||
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
|
||||
-101
@@ -1,101 +0,0 @@
|
||||
#!/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
|
||||
|
||||
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
NC='\033[0m'
|
||||
|
||||
print_status() { echo -e "${GREEN}✔${NC} ${1} completed."; }
|
||||
step() { echo -e "${RED}→${NC} ${1}..." | tee -a /var/log/tesm-install.log; }
|
||||
|
||||
# "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 ----
|
||||
step "Setting up Python virtual environment"
|
||||
cd /srv/tesm
|
||||
sudo python3 -m venv venv
|
||||
sudo ./venv/bin/pip install --upgrade pip >>/var/log/tesm-install.log 2>&1
|
||||
sudo ./venv/bin/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"
|
||||
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"
|
||||
|
||||
# ---- 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"
|
||||
|
||||
# ---- 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"
|
||||
|
||||
# ---- 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"
|
||||
|
||||
echo -e "${GREEN}✔${NC} Installation abgeschlossen. Web-App erreichbar auf Port 80 (nginx) bzw. 5000 (Flask direkt)."
|
||||
@@ -0,0 +1,25 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "tesm-core"
|
||||
version = "2.0.8"
|
||||
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,417 @@
|
||||
"""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 flask import Blueprint, Flask, g, render_template, request
|
||||
from werkzeug.exceptions import HTTPException
|
||||
|
||||
from . import audit, 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 .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 Eintraege beim naechsten Aufruf endgueltig entfernt.",
|
||||
section="system",
|
||||
minimum=1,
|
||||
maximum=3650,
|
||||
),
|
||||
SettingSpec(
|
||||
"session_idle_minutes",
|
||||
30,
|
||||
"int",
|
||||
"Automatische Abmeldung nach Leerlauf (Minuten)",
|
||||
hint="Jede Anfrage setzt den Zaehler zurueck -- gezaehlt wird nur Untaetigkeit. "
|
||||
"Gilt auch fuer die eingebundenen Dateifreigaben: mit der Sitzung endet die "
|
||||
"Einbindung.",
|
||||
section="system",
|
||||
minimum=5,
|
||||
maximum=1440,
|
||||
),
|
||||
SettingSpec("timezone", "", "str", "Zeitzone des Hosts", section="system"),
|
||||
SettingSpec("hostname", "", "str", "Hostname", section="system"),
|
||||
SettingSpec(
|
||||
"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(
|
||||
"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 Schluessel (Pfad)", section="web"),
|
||||
SettingSpec("web_acme_email", "", "str", "E-Mail fuer 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 Anfragegroesse (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 = "",
|
||||
app_version: str = "2.0.8",
|
||||
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",
|
||||
)
|
||||
|
||||
# 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)
|
||||
|
||||
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,
|
||||
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
|
||||
|
||||
app.session_interface = SqliteSessionInterface(
|
||||
database,
|
||||
cookie_name=app.config["SESSION_COOKIE_NAME"],
|
||||
idle_minutes=config.security.session_idle_minutes,
|
||||
absolute_hours=config.security.session_absolute_hours,
|
||||
idle_lookup=_leerlauf,
|
||||
)
|
||||
|
||||
# -- Schema und Startzustand ------------------------------------------ #
|
||||
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 _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("/gesundheit", "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,283 @@
|
||||
"""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, Iterable
|
||||
|
||||
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 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": "Vorgaenger-Hash passt nicht -- eine Zeile wurde entfernt oder eingefuegt.",
|
||||
}
|
||||
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 geaendert.",
|
||||
}
|
||||
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,581 @@
|
||||
"""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
|
||||
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 pruefen", section="ldap"),
|
||||
SettingSpec("ldap_ca_file", "", "str", "CA-Zertifikat (Pfad)", section="ldap"),
|
||||
SettingSpec(
|
||||
"ldap_allow_insecure",
|
||||
False,
|
||||
"bool",
|
||||
"Unverschluesselte Verbindung ausdruecklich erlauben",
|
||||
hint="Nur fuer Testumgebungen. Das Passwort geht dabei im Klartext ueber 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 uebernehmen", 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 entschluesselt 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]):
|
||||
from ldap3 import Server
|
||||
|
||||
security = str(config["ldap_security"])
|
||||
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="ALL",
|
||||
connect_timeout=int(config["ldap_timeout"]),
|
||||
)
|
||||
|
||||
|
||||
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(
|
||||
"Unverschluesselte 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"]),
|
||||
)
|
||||
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("Unverschluesselte Verzeichnisverbindung in Benutzung")
|
||||
if not config["ldap_verify_cert"]:
|
||||
warnings.append("Serverzertifikat des Verzeichnisdienstes wird nicht geprueft")
|
||||
|
||||
try:
|
||||
service_conn, bound = _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),
|
||||
)
|
||||
|
||||
try:
|
||||
base = _base_dn(service_conn, config)
|
||||
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,
|
||||
)
|
||||
entries = list(service_conn.entries)
|
||||
if len(entries) != 1:
|
||||
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.
|
||||
user_conn, user_bound = _connect(config, dn, password)
|
||||
try:
|
||||
if not user_bound:
|
||||
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)
|
||||
|
||||
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,
|
||||
)
|
||||
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("Gruppenaufloesung 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.
|
||||
"""
|
||||
if not group_dns:
|
||||
return
|
||||
mappings = Database.all(
|
||||
conn, "SELECT ad_group_dn, app_group_id FROM ldap_group_mappings"
|
||||
)
|
||||
if not mappings:
|
||||
return
|
||||
mapped_group_ids = {int(row["app_group_id"]) for row in mappings}
|
||||
lowered = {dn.lower() for dn in group_dns}
|
||||
wanted = {
|
||||
int(row["app_group_id"])
|
||||
for row in mappings
|
||||
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 _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,311 @@
|
||||
"""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."""
|
||||
if self._effective is None:
|
||||
tree = self._tree
|
||||
self._effective = (
|
||||
frozenset(tree.effective(self.granted)) if tree is not None else frozenset()
|
||||
)
|
||||
return self._effective
|
||||
|
||||
def has(self, *permissions: str) -> bool:
|
||||
"""True, wenn **alle** angegebenen Rechte greifen. Admins duerfen immer alles."""
|
||||
if self.is_admin:
|
||||
return True
|
||||
if not permissions:
|
||||
return True
|
||||
return all(permission in self.effective_permissions for permission in permissions)
|
||||
|
||||
def has_any(self, permissions: Iterable[str]) -> bool:
|
||||
if self.is_admin:
|
||||
return True
|
||||
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 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("Fuer diese Seite fehlt Ihnen die Berechtigung.")
|
||||
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 bestaetigen 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,
|
||||
)
|
||||
@@ -0,0 +1,135 @@
|
||||
"""Anmelde-Routen: Login, zweiter Faktor, Abmeldung, erneute Bestaetigung."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from flask import Blueprint, flash, redirect, render_template, request, session, url_for
|
||||
|
||||
from ..extension import core, db
|
||||
from . import directory, service
|
||||
from .models import current_user, login_required
|
||||
|
||||
bp = Blueprint("auth", __name__)
|
||||
|
||||
|
||||
def _safe_next(candidate: str | None, fallback_endpoint: str = "index") -> str:
|
||||
"""Verhindert Open Redirects: nur pfad-relative Ziele derselben Anwendung."""
|
||||
default = url_for(fallback_endpoint)
|
||||
if not candidate:
|
||||
return default
|
||||
parsed = urlparse(candidate)
|
||||
if parsed.scheme or parsed.netloc or not candidate.startswith("/"):
|
||||
return default
|
||||
return candidate
|
||||
|
||||
|
||||
@bp.route("/login", methods=["GET", "POST"])
|
||||
def login():
|
||||
if current_user.is_authenticated:
|
||||
return redirect(_safe_next(session.pop("_after_login", None)))
|
||||
|
||||
conn = db()
|
||||
username = ""
|
||||
if request.method == "POST":
|
||||
username = (request.form.get("username") or "").strip()
|
||||
password = request.form.get("password") or ""
|
||||
outcome = service.authenticate(
|
||||
conn, username, password, ip=service.client_ip()
|
||||
)
|
||||
|
||||
if outcome.status == "needs_totp" and outcome.user_row:
|
||||
service.start_totp_challenge(outcome.user_row)
|
||||
return redirect(url_for("auth.second_factor"))
|
||||
|
||||
if outcome.ok and outcome.user_row:
|
||||
user = service.complete_login(
|
||||
conn,
|
||||
outcome.user_row,
|
||||
ip=service.client_ip(),
|
||||
secret=password,
|
||||
group_dns=outcome.group_dns,
|
||||
)
|
||||
flash(f"Willkommen, {user.display_name}.", "success")
|
||||
if user.must_change_password:
|
||||
flash("Bitte legen Sie zuerst ein neues Passwort fest.", "warning")
|
||||
return redirect(url_for("account.security"))
|
||||
return redirect(_safe_next(session.pop("_after_login", None)))
|
||||
|
||||
flash(outcome.message or service.GENERIC_FAILURE, "danger")
|
||||
|
||||
return render_template(
|
||||
"tesm_core/login.html",
|
||||
username=username,
|
||||
directory_enabled=directory.is_enabled(conn),
|
||||
public_dashboard_url=core().registry.get("public_dashboard_url", ""),
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/login/zweiter-faktor", methods=["GET", "POST"])
|
||||
def second_factor():
|
||||
conn = db()
|
||||
user_row = service.pending_totp_user(conn)
|
||||
if user_row is None:
|
||||
flash("Die Anmeldung ist abgelaufen. Bitte erneut versuchen.", "info")
|
||||
return redirect(url_for("auth.login"))
|
||||
|
||||
if request.method == "POST":
|
||||
outcome = service.verify_second_factor(
|
||||
conn, user_row, request.form.get("code") or "", ip=service.client_ip()
|
||||
)
|
||||
if outcome.ok:
|
||||
# Kein ``secret``: das Passwort liegt seit dem ersten Schritt nicht
|
||||
# mehr vor, und es fuer den zweiten Schritt in der Sitzung
|
||||
# zwischenzulagern waere ein schlechter Tausch. Freigaben, die die
|
||||
# Anmeldung des Benutzers weiterreichen, binden sich bei aktivem
|
||||
# zweitem Faktor deshalb nicht ein -- Freigaben mit hinterlegtem
|
||||
# Konto schon.
|
||||
user = service.complete_login(conn, user_row, ip=service.client_ip())
|
||||
flash(f"Willkommen, {user.display_name}.", "success")
|
||||
return redirect(_safe_next(session.pop("_after_login", None)))
|
||||
flash(outcome.message, "danger")
|
||||
|
||||
return render_template("tesm_core/second_factor.html")
|
||||
|
||||
|
||||
@bp.post("/logout")
|
||||
@login_required
|
||||
def logout():
|
||||
service.logout(db())
|
||||
flash("Sie wurden abgemeldet.", "info")
|
||||
return redirect(url_for("auth.login"))
|
||||
|
||||
|
||||
@bp.get("/logout")
|
||||
def logout_get():
|
||||
"""Abmeldung ueber einen Link -- fuer den Abbruch des 2FA-Schritts.
|
||||
|
||||
Aendert nur die eigene Sitzung und ist damit kein CSRF-relevanter Vorgang
|
||||
mit Datenwirkung; die eigentliche Abmeldung im angemeldeten Bereich laeuft
|
||||
trotzdem ueber POST mit Token.
|
||||
"""
|
||||
if current_user.is_authenticated:
|
||||
service.logout(db())
|
||||
else:
|
||||
session.clear()
|
||||
return redirect(url_for("auth.login"))
|
||||
|
||||
|
||||
@bp.route("/bestaetigen", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def reauth():
|
||||
target = _safe_next(session.get("_after_reauth"))
|
||||
if request.method == "POST":
|
||||
password = request.form.get("password") or ""
|
||||
if service.confirm_password(db(), current_user, password):
|
||||
service.mark_reauth()
|
||||
session.pop("_after_reauth", None)
|
||||
return redirect(target)
|
||||
flash("Das Passwort ist nicht korrekt.", "danger")
|
||||
|
||||
return render_template(
|
||||
"tesm_core/reauth.html",
|
||||
cancel_url=url_for("index"),
|
||||
reauth_minutes=core().config.security.reauth_valid_minutes,
|
||||
)
|
||||
@@ -0,0 +1,499 @@
|
||||
"""Anmeldung, Sitzungsaufbau, erneute Passwortbestaetigung.
|
||||
|
||||
Ablauf einer Anmeldung (bewusst in dieser Reihenfolge):
|
||||
|
||||
1. Ratenbegrenzung pro IP **und** pro Benutzername pruefen -- bevor irgendein
|
||||
Hash berechnet wird.
|
||||
2. Lokales Konto suchen. Existiert es mit ``auth_source='local'``, wird das
|
||||
Passwort gegen den Argon2-Hash geprueft; existiert es nicht oder als
|
||||
Verzeichniskonto, greift (falls konfiguriert) die LDAP-Pruefung.
|
||||
3. Bei Erfolg: gesperrt? -> abweisen. 2FA aktiv? -> Zwischenschritt.
|
||||
4. Sitzung rotieren (neue ID), Rechte laden, Erfolg protokollieren.
|
||||
|
||||
Bei jedem Fehlschlag wird dieselbe, nichts verratende Meldung ausgegeben und
|
||||
dieselbe Rechenzeit verbraucht (``passwords.dummy_verify``) -- die Antwort
|
||||
erlaubt keinen Rueckschluss darauf, ob ein Konto existiert.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sqlite3
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Literal
|
||||
|
||||
from flask import current_app, g, request, session
|
||||
|
||||
from .. import audit
|
||||
from ..db import Database
|
||||
from ..extension import core, db
|
||||
from ..rbac import service as rbac
|
||||
from ..security import passwords, ratelimit
|
||||
from ..security import sessions as session_store
|
||||
from .models import ANONYMOUS, Group, User, build_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SESSION_USER_KEY = "user_id"
|
||||
SESSION_PENDING_KEY = "_pending_user_id"
|
||||
SESSION_REAUTH_KEY = "_reauth_at"
|
||||
|
||||
BUCKET_LOGIN_USER = "login:user"
|
||||
BUCKET_LOGIN_IP = "login:ip"
|
||||
BUCKET_TOTP = "totp:user"
|
||||
|
||||
GENERIC_FAILURE = "Benutzername oder Passwort ist falsch."
|
||||
|
||||
Status = Literal["ok", "invalid", "locked", "rate_limited", "needs_totp", "directory_unavailable"]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AuthOutcome:
|
||||
status: Status
|
||||
message: str = ""
|
||||
user_row: dict[str, Any] | None = None
|
||||
retry_after: int = 0
|
||||
#: Verzeichnisgruppen der laufenden Anmeldung. Sie stehen nur hier zur
|
||||
#: Verfuegung -- gespeichert werden sie bewusst nicht, denn beim naechsten
|
||||
#: Mal koennen es andere sein.
|
||||
group_dns: tuple[str, ...] = ()
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
return self.status == "ok"
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _iso(value: datetime) -> str:
|
||||
return value.isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def client_ip() -> str:
|
||||
"""Client-IP unter Beruecksichtigung vertrauenswuerdiger Proxy-Hops."""
|
||||
hops = core().config.security.trusted_proxy_hops
|
||||
if hops > 0:
|
||||
forwarded = request.headers.get("X-Forwarded-For", "")
|
||||
if forwarded:
|
||||
parts = [part.strip() for part in forwarded.split(",") if part.strip()]
|
||||
if parts:
|
||||
# Der letzte vertrauenswuerdige Hop steht von rechts gezaehlt.
|
||||
index = max(0, len(parts) - hops)
|
||||
return parts[index]
|
||||
return request.remote_addr or ""
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Benutzer laden
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def load_user(conn: sqlite3.Connection, user_id: int) -> User | None:
|
||||
row = Database.one(
|
||||
conn, "SELECT * FROM users WHERE id=? AND deleted_at IS NULL", (user_id,)
|
||||
)
|
||||
if row is None or row.get("is_locked"):
|
||||
return None
|
||||
groups = [
|
||||
Group(
|
||||
id=int(entry["id"]),
|
||||
name=str(entry["name"]),
|
||||
is_system=bool(entry["is_system"]),
|
||||
is_default=bool(entry["is_default"]),
|
||||
)
|
||||
for entry in rbac.user_groups(conn, user_id)
|
||||
]
|
||||
return build_user(
|
||||
row,
|
||||
groups=groups,
|
||||
granted=rbac.granted_permissions(conn, user_id),
|
||||
tree=core().permissions,
|
||||
)
|
||||
|
||||
|
||||
def load_request_user() -> None:
|
||||
"""``before_request``-Hook: setzt ``g.user``."""
|
||||
g.user = ANONYMOUS
|
||||
user_id = session.get(SESSION_USER_KEY)
|
||||
if not user_id:
|
||||
return
|
||||
user = load_user(db(), int(user_id))
|
||||
if user is None:
|
||||
# Konto geloescht, gesperrt oder Rechte entzogen -> Sitzung beenden.
|
||||
session.clear()
|
||||
return
|
||||
g.user = user
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Anmeldung
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def authenticate(
|
||||
conn: sqlite3.Connection, username: str, password: str, *, ip: str
|
||||
) -> AuthOutcome:
|
||||
security = core().config.security
|
||||
name = (username or "").strip()
|
||||
if not name or not password:
|
||||
return AuthOutcome("invalid", GENERIC_FAILURE)
|
||||
|
||||
ip_verdict = ratelimit.check(
|
||||
conn,
|
||||
BUCKET_LOGIN_IP,
|
||||
ip or "unbekannt",
|
||||
limit=security.login_max_attempts_per_ip,
|
||||
window_minutes=security.login_lockout_minutes,
|
||||
)
|
||||
if ip_verdict.blocked:
|
||||
return AuthOutcome(
|
||||
"rate_limited",
|
||||
"Zu viele Anmeldeversuche von dieser Adresse. Bitte spaeter erneut versuchen.",
|
||||
retry_after=ip_verdict.retry_after_seconds,
|
||||
)
|
||||
|
||||
user_verdict = ratelimit.check(
|
||||
conn,
|
||||
BUCKET_LOGIN_USER,
|
||||
name.lower(),
|
||||
limit=security.login_max_attempts,
|
||||
window_minutes=security.login_lockout_minutes,
|
||||
)
|
||||
if user_verdict.blocked:
|
||||
return AuthOutcome(
|
||||
"rate_limited",
|
||||
"Dieses Konto ist nach mehreren Fehlversuchen voruebergehend gesperrt.",
|
||||
retry_after=user_verdict.retry_after_seconds,
|
||||
)
|
||||
|
||||
row = rbac.find_user_by_username(conn, name)
|
||||
outcome = _verify_credentials(conn, row, name, password)
|
||||
|
||||
if not outcome.ok:
|
||||
with core().database.transaction(conn):
|
||||
ratelimit.record(conn, BUCKET_LOGIN_IP, ip or "unbekannt")
|
||||
ratelimit.record(conn, BUCKET_LOGIN_USER, name.lower())
|
||||
audit.log(
|
||||
conn,
|
||||
actor=name,
|
||||
action="auth.login_failed",
|
||||
target=name,
|
||||
detail=outcome.status,
|
||||
ip=ip,
|
||||
severity="warning",
|
||||
request_id=getattr(g, "request_id", ""),
|
||||
)
|
||||
return outcome
|
||||
|
||||
user_row = outcome.user_row or {}
|
||||
if bool(user_row.get("is_locked")):
|
||||
with core().database.transaction(conn):
|
||||
audit.log(
|
||||
conn,
|
||||
actor=str(user_row.get("username") or name),
|
||||
action="auth.login_locked",
|
||||
target=str(user_row.get("username") or name),
|
||||
ip=ip,
|
||||
severity="warning",
|
||||
)
|
||||
return AuthOutcome("locked", "Dieses Konto ist gesperrt. Bitte wenden Sie sich an die IT.")
|
||||
|
||||
with core().database.transaction(conn):
|
||||
ratelimit.clear(conn, BUCKET_LOGIN_USER, name.lower())
|
||||
|
||||
if str(user_row.get("totp_secret") or ""):
|
||||
return AuthOutcome("needs_totp", "", user_row=user_row, group_dns=outcome.group_dns)
|
||||
|
||||
if core().config.security.require_totp_for_admins and bool(user_row.get("is_admin")):
|
||||
# Kein harter Block: der Admin kommt herein, muss aber sofort 2FA
|
||||
# einrichten (die Kontoseite fuehrt ihn dorthin).
|
||||
logger.warning(
|
||||
"Administrator %s hat keine 2FA, obwohl sie verlangt wird", user_row.get("username")
|
||||
)
|
||||
|
||||
# ``outcome`` mitfuehren, nicht neu bauen: die Verzeichnisgruppen der
|
||||
# laufenden Anmeldung stehen nur dort. Wer hier ein frisches AuthOutcome
|
||||
# erzeugt, verliert sie lautlos -- und mit ihnen jede Freigabe, die daran
|
||||
# haengt.
|
||||
return AuthOutcome("ok", user_row=user_row, group_dns=outcome.group_dns)
|
||||
|
||||
|
||||
def _verify_credentials(
|
||||
conn: sqlite3.Connection, row: dict[str, Any] | None, name: str, password: str
|
||||
) -> AuthOutcome:
|
||||
from . import directory # lokaler Import: ldap3 ist optional
|
||||
|
||||
directory_enabled = directory.is_enabled(conn)
|
||||
|
||||
if row is not None and str(row.get("auth_source")) == "local":
|
||||
if not passwords.verify_password(str(row.get("password_hash") or ""), password):
|
||||
return AuthOutcome("invalid", GENERIC_FAILURE)
|
||||
if passwords.needs_rehash(str(row.get("password_hash") or "")):
|
||||
with core().database.transaction(conn):
|
||||
conn.execute(
|
||||
"UPDATE users SET password_hash=?, updated_at=? WHERE id=?",
|
||||
(passwords.hash_password(password), _iso(_now()), row["id"]),
|
||||
)
|
||||
return AuthOutcome("ok", user_row=row)
|
||||
|
||||
if not directory_enabled:
|
||||
passwords.dummy_verify()
|
||||
return AuthOutcome("invalid", GENERIC_FAILURE)
|
||||
|
||||
result = directory.authenticate(conn, name, password)
|
||||
if result.status == "unavailable":
|
||||
return AuthOutcome(
|
||||
"directory_unavailable",
|
||||
"Der Verzeichnisdienst ist derzeit nicht erreichbar. "
|
||||
"Bitte versuchen Sie es spaeter erneut.",
|
||||
)
|
||||
if result.status != "ok" or result.profile is None:
|
||||
return AuthOutcome("invalid", GENERIC_FAILURE)
|
||||
|
||||
user_row = directory.sync_account(conn, result.profile)
|
||||
return AuthOutcome("ok", user_row=user_row, group_dns=result.profile.group_dns)
|
||||
|
||||
|
||||
def complete_login(
|
||||
conn: sqlite3.Connection,
|
||||
user_row: dict[str, Any],
|
||||
*,
|
||||
ip: str,
|
||||
secret: str = "",
|
||||
group_dns: tuple[str, ...] = (),
|
||||
) -> User:
|
||||
"""Baut die Sitzung auf. Rotiert die Sitzungs-ID (Session-Fixation).
|
||||
|
||||
``secret`` ist das Passwort dieser Anmeldung. Es wird **nicht** gespeichert
|
||||
und nicht in die Sitzung geschrieben; es wird ausschliesslich an die
|
||||
angemeldeten Haken weitergereicht und ist danach weg. Gebraucht wird es
|
||||
genau von einem: der Einbindung von Dateifreigaben, die sich mit der
|
||||
Anmeldung des Benutzers am Fileserver anmeldet.
|
||||
"""
|
||||
from ..security import csrf
|
||||
|
||||
with core().database.transaction(conn):
|
||||
session_store.rotate(conn, session, reason="login") # type: ignore[arg-type]
|
||||
conn.execute(
|
||||
"UPDATE users SET last_login_at=? WHERE id=?", (_iso(_now()), user_row["id"])
|
||||
)
|
||||
audit.log(
|
||||
conn,
|
||||
actor=str(user_row["username"]),
|
||||
actor_id=int(user_row["id"]),
|
||||
action="auth.login",
|
||||
target=str(user_row["username"]),
|
||||
detail=f"Quelle: {user_row.get('auth_source') or 'local'}",
|
||||
ip=ip,
|
||||
request_id=getattr(g, "request_id", ""),
|
||||
)
|
||||
|
||||
session.clear()
|
||||
session[SESSION_USER_KEY] = int(user_row["id"])
|
||||
session[SESSION_REAUTH_KEY] = _iso(_now())
|
||||
session["_ip"] = ip
|
||||
session["_ua"] = (request.user_agent.string or "")[:200]
|
||||
csrf.rotate_token()
|
||||
|
||||
user = load_user(conn, int(user_row["id"]))
|
||||
if user is None: # pragma: no cover - direkt nach Pruefung praktisch unmoeglich
|
||||
raise RuntimeError("Benutzer konnte nach der Anmeldung nicht geladen werden")
|
||||
g.user = user
|
||||
_run_login_hooks(conn, user, secret=secret, group_dns=group_dns)
|
||||
return user
|
||||
|
||||
|
||||
def _run_login_hooks(
|
||||
conn: sqlite3.Connection, user: User, *, secret: str, group_dns: tuple[str, ...]
|
||||
) -> None:
|
||||
"""Ruft die angemeldeten Haken auf -- ohne die Anmeldung zu gefaehrden.
|
||||
|
||||
Ein Haken, der scheitert, darf niemanden aussperren: was hier passiert, ist
|
||||
Zusatzarbeit (Freigaben einbinden), nicht die Anmeldung selbst. Deshalb
|
||||
wird jeder Fehler protokolliert und geschluckt.
|
||||
"""
|
||||
for hook in core().login_hooks:
|
||||
try:
|
||||
hook(conn, user, secret, group_dns)
|
||||
except Exception: # pragma: no cover - haengt am Fileserver
|
||||
current_app.logger.exception(
|
||||
"Anmeldehaken %r fehlgeschlagen", getattr(hook, "__name__", hook)
|
||||
)
|
||||
|
||||
|
||||
def logout(conn: sqlite3.Connection) -> None:
|
||||
user = getattr(g, "user", ANONYMOUS)
|
||||
if user.is_authenticated:
|
||||
# Vor dem Protokolleintrag, solange die Sitzung noch steht: die Haken
|
||||
# raeumen auf, was zu ihr gehoert (z. B. eingebundene Freigaben).
|
||||
for hook in core().logout_hooks:
|
||||
try:
|
||||
hook(conn, user)
|
||||
except Exception: # pragma: no cover - haengt am Fileserver
|
||||
current_app.logger.exception(
|
||||
"Abmeldehaken %r fehlgeschlagen", getattr(hook, "__name__", hook)
|
||||
)
|
||||
with core().database.transaction(conn):
|
||||
audit.log(
|
||||
conn,
|
||||
actor=user.username,
|
||||
actor_id=user.id,
|
||||
action="auth.logout",
|
||||
target=user.username,
|
||||
ip=client_ip(),
|
||||
)
|
||||
session.destroy() if hasattr(session, "destroy") else session.clear() # type: ignore[attr-defined]
|
||||
g.user = ANONYMOUS
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Zweiter Faktor
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def start_totp_challenge(user_row: dict[str, Any]) -> None:
|
||||
session[SESSION_PENDING_KEY] = int(user_row["id"])
|
||||
session["_pending_since"] = _iso(_now())
|
||||
|
||||
|
||||
def pending_totp_user(conn: sqlite3.Connection) -> dict[str, Any] | None:
|
||||
user_id = session.get(SESSION_PENDING_KEY)
|
||||
if not user_id:
|
||||
return None
|
||||
started = session.get("_pending_since")
|
||||
if started:
|
||||
try:
|
||||
if _now() - datetime.fromisoformat(str(started)) > timedelta(minutes=5):
|
||||
session.pop(SESSION_PENDING_KEY, None)
|
||||
return None
|
||||
except ValueError:
|
||||
pass
|
||||
return Database.one(
|
||||
conn, "SELECT * FROM users WHERE id=? AND deleted_at IS NULL", (int(user_id),)
|
||||
)
|
||||
|
||||
|
||||
def verify_second_factor(
|
||||
conn: sqlite3.Connection, user_row: dict[str, Any], code: str, *, ip: str
|
||||
) -> AuthOutcome:
|
||||
from ..security import totp
|
||||
|
||||
security = core().config.security
|
||||
subject = str(user_row["id"])
|
||||
verdict = ratelimit.check(
|
||||
conn, BUCKET_TOTP, subject, limit=8, window_minutes=security.login_lockout_minutes
|
||||
)
|
||||
if verdict.blocked:
|
||||
return AuthOutcome(
|
||||
"rate_limited",
|
||||
"Zu viele Fehlversuche. Bitte warten Sie einen Moment.",
|
||||
retry_after=verdict.retry_after_seconds,
|
||||
)
|
||||
|
||||
cleaned = (code or "").strip()
|
||||
if totp.verify_code(str(user_row.get("totp_secret") or ""), cleaned):
|
||||
with core().database.transaction(conn):
|
||||
ratelimit.clear(conn, BUCKET_TOTP, subject)
|
||||
return AuthOutcome("ok", user_row=user_row)
|
||||
|
||||
if _consume_recovery_code(conn, int(user_row["id"]), cleaned, ip=ip):
|
||||
return AuthOutcome("ok", user_row=user_row)
|
||||
|
||||
with core().database.transaction(conn):
|
||||
ratelimit.record(conn, BUCKET_TOTP, subject)
|
||||
audit.log(
|
||||
conn,
|
||||
actor=str(user_row["username"]),
|
||||
actor_id=int(user_row["id"]),
|
||||
action="auth.totp_failed",
|
||||
target=str(user_row["username"]),
|
||||
ip=ip,
|
||||
severity="warning",
|
||||
)
|
||||
return AuthOutcome("invalid", "Der Code ist nicht korrekt.")
|
||||
|
||||
|
||||
def _consume_recovery_code(
|
||||
conn: sqlite3.Connection, user_id: int, code: str, *, ip: str
|
||||
) -> bool:
|
||||
candidates = Database.all(
|
||||
conn,
|
||||
"SELECT id, code_hash FROM user_recovery_codes WHERE user_id=? AND used_at IS NULL",
|
||||
(user_id,),
|
||||
)
|
||||
normalized = code.strip().lower()
|
||||
for entry in candidates:
|
||||
if passwords.verify_password(str(entry["code_hash"]), normalized):
|
||||
with core().database.transaction(conn):
|
||||
conn.execute(
|
||||
"UPDATE user_recovery_codes SET used_at=? WHERE id=?",
|
||||
(_iso(_now()), entry["id"]),
|
||||
)
|
||||
remaining = int(
|
||||
Database.value(
|
||||
conn,
|
||||
"SELECT COUNT(*) FROM user_recovery_codes "
|
||||
"WHERE user_id=? AND used_at IS NULL",
|
||||
(user_id,),
|
||||
default=0,
|
||||
)
|
||||
or 0
|
||||
)
|
||||
audit.log(
|
||||
conn,
|
||||
actor=str(
|
||||
Database.value(conn, "SELECT username FROM users WHERE id=?", (user_id,))
|
||||
or ""
|
||||
),
|
||||
actor_id=user_id,
|
||||
action="auth.recovery_code_used",
|
||||
detail=f"Verbleibende Codes: {remaining}",
|
||||
ip=ip,
|
||||
severity="notice",
|
||||
)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Erneute Bestaetigung ("sudo-Modus")
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def reauth_is_fresh() -> bool:
|
||||
stamp = session.get(SESSION_REAUTH_KEY)
|
||||
if not stamp:
|
||||
return False
|
||||
try:
|
||||
moment = datetime.fromisoformat(str(stamp))
|
||||
except ValueError:
|
||||
return False
|
||||
if moment.tzinfo is None:
|
||||
moment = moment.replace(tzinfo=timezone.utc)
|
||||
window = timedelta(minutes=core().config.security.reauth_valid_minutes)
|
||||
return _now() - moment <= window
|
||||
|
||||
|
||||
def mark_reauth() -> None:
|
||||
session[SESSION_REAUTH_KEY] = _iso(_now())
|
||||
|
||||
|
||||
def confirm_password(conn: sqlite3.Connection, user: User, password: str) -> bool:
|
||||
"""Prueft das Passwort des angemeldeten Benutzers erneut.
|
||||
|
||||
Fuer Verzeichniskonten laeuft die Pruefung gegen das Verzeichnis -- sonst
|
||||
koennten LDAP-Nutzer den sudo-Modus nie erreichen.
|
||||
"""
|
||||
from . import directory
|
||||
|
||||
row = rbac.get_user(conn, user.id)
|
||||
if row is None:
|
||||
return False
|
||||
if str(row.get("auth_source")) == "local":
|
||||
return passwords.verify_password(str(row.get("password_hash") or ""), password)
|
||||
if not directory.is_enabled(conn):
|
||||
return False
|
||||
return directory.authenticate(conn, user.username, password).status == "ok"
|
||||
@@ -0,0 +1,230 @@
|
||||
"""Der Befehl ``web-setup`` -- HTTPS einrichten, ohne nginx von Hand anzufassen.
|
||||
|
||||
Beide Anwendungen registrieren ihn; die Logik steht darum genau einmal hier.
|
||||
|
||||
Warum es diesen Befehl ueberhaupt gibt: die nginx-Site wird ausschliesslich aus
|
||||
der Vorlage in :mod:`tesm_core.system.webserver` erzeugt -- das ist die
|
||||
Konsequenz aus dem alias-Fehler des Vorgaengers, der jahrelang ueberlebte, weil
|
||||
irgendwann jemand die Datei von Hand angelegt hatte. Ein Installationsskript,
|
||||
das sich seine eigene TLS-Konfiguration zusammensetzt, waere derselbe Fehler in
|
||||
neu. Stattdessen setzt dieser Befehl die Einstellungen und laesst die Anwendung
|
||||
schreiben -- genau wie die Oberflaeche es tut.
|
||||
|
||||
Aufruf aus ``install.sh``:
|
||||
|
||||
tesm-admin web-setup --https --self-signed --hsts --apply
|
||||
tesm-admin web-setup --https --domain tesm.example.org --acme-email a@b.c --apply
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
from .system import webserver
|
||||
|
||||
|
||||
def _config_from_settings(extension: Any, conn: Any) -> webserver.WebConfig:
|
||||
values = extension.settings.get_many(
|
||||
conn,
|
||||
[
|
||||
"web_domain",
|
||||
"web_https_enabled",
|
||||
"web_hsts_enabled",
|
||||
"web_redirect_http",
|
||||
"web_cert_path",
|
||||
"web_key_path",
|
||||
"web_acme_webroot",
|
||||
"web_max_body_mb",
|
||||
"web_http_port",
|
||||
"web_https_port",
|
||||
],
|
||||
)
|
||||
return webserver.WebConfig(
|
||||
app_key=extension.config.site_key,
|
||||
upstream=str(extension.registry.get("upstream", "127.0.0.1:5000")),
|
||||
static_root=str(
|
||||
extension.registry.get("static_root", extension.config.base_dir / "static")
|
||||
),
|
||||
domain=str(values["web_domain"] or ""),
|
||||
https_enabled=bool(values["web_https_enabled"]),
|
||||
hsts_enabled=bool(values["web_hsts_enabled"]),
|
||||
redirect_http=bool(values["web_redirect_http"]),
|
||||
cert_path=str(values["web_cert_path"] or ""),
|
||||
key_path=str(values["web_key_path"] or ""),
|
||||
acme_webroot=str(
|
||||
values["web_acme_webroot"] or f"/var/www/{extension.config.site_key}-acme"
|
||||
),
|
||||
websocket_path=str(extension.registry.get("websocket_path", "")),
|
||||
max_body_mb=int(values["web_max_body_mb"] or 128),
|
||||
http_port=int(values["web_http_port"] or 80),
|
||||
https_port=int(values["web_https_port"] or 443),
|
||||
)
|
||||
|
||||
|
||||
def run_web_setup(app: Any, extension: Any, args: argparse.Namespace) -> int:
|
||||
"""Setzt die Webserver-Einstellungen und schreibt optional die Site-Datei."""
|
||||
sysops = extension.sysops
|
||||
with app.app_context(), extension.database.session() as conn:
|
||||
values: dict[str, Any] = {}
|
||||
|
||||
if args.domain is not None:
|
||||
try:
|
||||
values["web_domain"] = webserver.validate_domain(args.domain)
|
||||
except ValueError as exc:
|
||||
print(str(exc), file=sys.stderr)
|
||||
return 2
|
||||
if args.acme_email:
|
||||
values["web_acme_email"] = args.acme_email.strip()
|
||||
if args.max_body_mb:
|
||||
values["web_max_body_mb"] = int(args.max_body_mb)
|
||||
for flag, key in (("http_port", "web_http_port"), ("https_port", "web_https_port")):
|
||||
port = getattr(args, flag, None)
|
||||
if port:
|
||||
if not 1 <= int(port) <= 65535:
|
||||
print(f"Ungueltiger Port: {port}", file=sys.stderr)
|
||||
return 2
|
||||
values[key] = int(port)
|
||||
|
||||
with extension.database.transaction(conn):
|
||||
if values:
|
||||
extension.settings.set_many(conn, values, actor="install")
|
||||
|
||||
# -- Zertifikat --------------------------------------------------- #
|
||||
config = _config_from_settings(extension, conn)
|
||||
cert: dict[str, Any] = {"ok": True, "message": "Zertifikat unveraendert."}
|
||||
|
||||
if args.acme:
|
||||
if sysops is None or not sysops.available:
|
||||
print("Der Systemhelfer ist nicht verfuegbar.", file=sys.stderr)
|
||||
return 2
|
||||
cert = webserver.request_certificate(
|
||||
sysops,
|
||||
domain=config.domain,
|
||||
email=str(extension.settings.get(conn, "web_acme_email") or ""),
|
||||
webroot=config.acme_webroot,
|
||||
)
|
||||
if not cert["ok"] and args.self_signed:
|
||||
# Kein oeffentliches Zertifikat bekommen -- lieber
|
||||
# selbstsigniertes HTTPS als gar keins. Der Aufrufer erfaehrt
|
||||
# beides.
|
||||
print(f"Let's Encrypt fehlgeschlagen: {cert.get('detail', cert['message'])}")
|
||||
print("Weiche auf ein selbstsigniertes Zertifikat aus.")
|
||||
args.acme = False
|
||||
elif not cert["ok"]:
|
||||
print(cert.get("detail") or cert["message"], file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if args.self_signed and not args.acme:
|
||||
if sysops is None or not sysops.available:
|
||||
print("Der Systemhelfer ist nicht verfuegbar.", file=sys.stderr)
|
||||
return 2
|
||||
suggested = webserver.local_names()
|
||||
cert = webserver.create_self_signed(
|
||||
sysops,
|
||||
common_name=config.domain or (suggested[0] if suggested else "localhost"),
|
||||
alt_names=[*suggested, "localhost"],
|
||||
)
|
||||
if not cert["ok"]:
|
||||
print(cert.get("detail") or cert["message"], file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if cert.get("cert_path"):
|
||||
with extension.database.transaction(conn):
|
||||
extension.settings.set_many(
|
||||
conn,
|
||||
{"web_cert_path": cert["cert_path"], "web_key_path": cert["key_path"]},
|
||||
actor="install",
|
||||
)
|
||||
print(cert["message"])
|
||||
|
||||
# -- HTTPS-Schalter ------------------------------------------------ #
|
||||
config = _config_from_settings(extension, conn)
|
||||
if args.https:
|
||||
if not (config.cert_path and config.key_path):
|
||||
print(
|
||||
"HTTPS verlangt Zertifikat und Schluessel -- bitte --self-signed "
|
||||
"oder --acme angeben.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
with extension.database.transaction(conn):
|
||||
extension.settings.set_many(
|
||||
conn,
|
||||
{
|
||||
"web_https_enabled": True,
|
||||
"web_hsts_enabled": bool(args.hsts),
|
||||
"web_redirect_http": not args.no_redirect,
|
||||
},
|
||||
actor="install",
|
||||
)
|
||||
|
||||
config = _config_from_settings(extension, conn)
|
||||
rendered = webserver.render(config)
|
||||
|
||||
if args.print_config:
|
||||
print(rendered)
|
||||
return 0
|
||||
|
||||
if not args.apply:
|
||||
print("Einstellungen gespeichert. Mit --apply wird nginx neu geschrieben.")
|
||||
return 0
|
||||
|
||||
if sysops is None or not sysops.available:
|
||||
print("Der Systemhelfer ist nicht verfuegbar.", file=sys.stderr)
|
||||
return 2
|
||||
result = webserver.apply_config(sysops, rendered)
|
||||
print(result["message"])
|
||||
if not result["ok"]:
|
||||
print(result.get("detail", ""), file=sys.stderr)
|
||||
return 1
|
||||
if config.https_enabled:
|
||||
detail = f"HTTPS auf {config.https_port}, {config.http_port} leitet um"
|
||||
else:
|
||||
detail = f"HTTP auf {config.http_port}"
|
||||
print(f"nginx: {config.server_name} -> {config.upstream} -- {detail}")
|
||||
return 0
|
||||
|
||||
|
||||
def add_web_setup(sub: Any, runner: Any) -> None:
|
||||
"""Haengt ``web-setup`` an einen ``argparse``-Subparser."""
|
||||
parser = sub.add_parser(
|
||||
"web-setup",
|
||||
help="nginx und TLS einrichten (Port 80/443)",
|
||||
description=(
|
||||
"Setzt die Webserver-Einstellungen und erzeugt daraus die nginx-Site. "
|
||||
"Die Datei wird immer vollstaendig neu geschrieben -- eine von Hand "
|
||||
"bearbeitete Site-Datei gibt es in diesem Projekt bewusst nicht."
|
||||
),
|
||||
)
|
||||
parser.add_argument("--https", action="store_true", help="HTTPS auf Port 443 aktivieren")
|
||||
parser.add_argument(
|
||||
"--self-signed",
|
||||
action="store_true",
|
||||
help="Selbstsigniertes Zertifikat erzeugen (geschlossene Netze ohne Domain)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--acme",
|
||||
action="store_true",
|
||||
help="Zertifikat per Let's Encrypt anfordern (Domain muss oeffentlich zeigen)",
|
||||
)
|
||||
parser.add_argument("--domain", help="Oeffentlicher Name dieser Installation")
|
||||
parser.add_argument("--acme-email", help="Kontaktadresse fuer Let's Encrypt")
|
||||
parser.add_argument("--hsts", action="store_true", help="HSTS-Header setzen")
|
||||
parser.add_argument(
|
||||
"--no-redirect",
|
||||
action="store_true",
|
||||
help="Port 80 nicht auf HTTPS umleiten (Sonderfall)",
|
||||
)
|
||||
parser.add_argument("--max-body-mb", type=int, help="Obergrenze fuer Uploads")
|
||||
parser.add_argument("--http-port", type=int, help="Abweichender HTTP-Port (Standard 80)")
|
||||
parser.add_argument("--https-port", type=int, help="Abweichender HTTPS-Port (Standard 443)")
|
||||
parser.add_argument("--apply", action="store_true", help="nginx schreiben, pruefen, neu laden")
|
||||
parser.add_argument(
|
||||
"--print-config", action="store_true", help="Nur die erzeugte Datei ausgeben"
|
||||
)
|
||||
parser.set_defaults(func=runner)
|
||||
|
||||
|
||||
__all__ = ["add_web_setup", "run_web_setup"]
|
||||
@@ -0,0 +1,205 @@
|
||||
"""Konfiguration aus Umgebungsvariablen -- typisiert, an einer Stelle.
|
||||
|
||||
Im Vorgaengerprojekt waren Pfade und Konstanten quer durch eine mehrere
|
||||
tausend Zeilen lange ``app.py`` verstreut. Hier gibt es genau ein Objekt, das
|
||||
beim Start gebaut, validiert und danach nicht mehr veraendert wird.
|
||||
|
||||
Namensraum: jede Anwendung bekommt ein eigenes Env-Praefix (``TESM_`` bzw.
|
||||
``TESM_LICENSE_``), damit beide Dienste auf demselben Host ohne Kollisionen
|
||||
laufen koennen -- inklusive der Pfade, die im Vorgaenger kollidierten
|
||||
(netplan-Override!).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass, field, replace
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _env(prefix: str, name: str, default: str = "") -> str:
|
||||
return os.environ.get(f"{prefix}{name}", default).strip()
|
||||
|
||||
|
||||
def _env_bool(prefix: str, name: str, default: bool) -> bool:
|
||||
raw = _env(prefix, name)
|
||||
if not raw:
|
||||
return default
|
||||
return raw.lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def _env_int(prefix: str, name: str, default: int) -> int:
|
||||
raw = _env(prefix, name)
|
||||
try:
|
||||
return int(raw) if raw else default
|
||||
except ValueError:
|
||||
return default
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SecurityConfig:
|
||||
"""Alles, was das Sicherheitsverhalten steuert. Defaults sind die sichere Wahl."""
|
||||
|
||||
#: Cookie nur ueber HTTPS ausliefern. In Produktion immer True (nginx terminiert TLS).
|
||||
cookie_secure: bool = True
|
||||
cookie_samesite: str = "Lax"
|
||||
#: Leerlauf-Zeitfenster einer Sitzung.
|
||||
session_idle_minutes: int = 30
|
||||
#: Harte Obergrenze unabhaengig von Aktivitaet.
|
||||
session_absolute_hours: int = 12
|
||||
#: Wie lange eine erneute Passwortabfrage ("sudo-Modus") gilt.
|
||||
reauth_valid_minutes: int = 15
|
||||
#: Fehlversuche bis zur temporaeren Sperre eines Kontos.
|
||||
login_max_attempts: int = 8
|
||||
login_lockout_minutes: int = 15
|
||||
#: Fehlversuche pro IP im gleichen Zeitfenster (unabhaengig vom Konto).
|
||||
login_max_attempts_per_ip: int = 30
|
||||
#: Mindestlaenge lokaler Passwoerter.
|
||||
password_min_length: int = 12
|
||||
#: 2FA fuer Administratoren erzwingen.
|
||||
require_totp_for_admins: bool = False
|
||||
#: HSTS-Header setzen (nur sinnvoll, wenn TLS tatsaechlich terminiert wird).
|
||||
hsts_enabled: bool = False
|
||||
hsts_max_age: int = 15552000
|
||||
#: Vertrauenswuerdige Proxy-Hops fuer X-Forwarded-For (0 = Header ignorieren).
|
||||
trusted_proxy_hops: int = 1
|
||||
#: Maximale Groesse eines Uploads in Bytes (None = unbegrenzt).
|
||||
max_content_length: int | None = 64 * 1024 * 1024
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CoreConfig:
|
||||
"""Basiskonfiguration, die beide Anwendungen teilen."""
|
||||
|
||||
app_key: str
|
||||
"""Technischer Kurzname, z. B. ``tesm`` -- Namensraum fuer Pfade und Units."""
|
||||
|
||||
site_key: str
|
||||
"""Name *dieser Installation* auf dem Host, z. B. ``tesm-opus``.
|
||||
|
||||
Weicht vom ``app_key`` ab, sobald neben der Hauptinstallation eine zweite
|
||||
Instanz laeuft. Alles, was ausserhalb von ``/srv/<name>`` liegt und damit
|
||||
zwischen Instanzen kollidieren koennte -- nginx-Site, netplan-Datei,
|
||||
logrotate-Regel, Zertifikatsverzeichnis, Name des Sitzungscookies --, wird
|
||||
ueber diesen Schluessel benannt und **nicht** ueber den ``app_key``.
|
||||
|
||||
Ohne diese Trennung wuerde eine Testinstanz beim Speichern der
|
||||
Webserver-Einstellungen die Site-Datei der Produktion ueberschreiben, und
|
||||
beide Instanzen wuerden sich auf demselben Host dasselbe Sitzungscookie
|
||||
teilen (Cookies unterscheiden keine Ports).
|
||||
"""
|
||||
|
||||
app_name: str
|
||||
"""Anzeigename in der Oberflaeche."""
|
||||
|
||||
env_prefix: str
|
||||
base_dir: Path
|
||||
data_dir: Path
|
||||
log_dir: Path
|
||||
db_path: Path
|
||||
secret_key_file: Path
|
||||
data_key_file: Path
|
||||
license_path: Path
|
||||
license_key_path: Path
|
||||
#: Adresse, auf der der Anwendungsserver lauscht -- exakt das, was nginx
|
||||
#: als ``proxy_pass``-Ziel braucht. Kommt aus derselben Umgebungsdatei wie
|
||||
#: der ``--bind``-Parameter von gunicorn, damit beide nie auseinanderlaufen.
|
||||
#: Vorher stand hier ein fest verdrahtetes ``127.0.0.1:5000``; eine zweite
|
||||
#: Instanz auf Port 5100 erzeugte damit eine nginx-Site, die auf die
|
||||
#: *andere* Installation zeigte.
|
||||
bind: str = "127.0.0.1:5000"
|
||||
debug: bool = False
|
||||
#: Instanz laeuft als echter Webprozess (gesetzt durch die systemd-Unit).
|
||||
web_process: bool = False
|
||||
security: SecurityConfig = field(default_factory=SecurityConfig)
|
||||
#: Zusaetzliche, app-spezifische Werte.
|
||||
extra: dict[str, str] = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def is_production(self) -> bool:
|
||||
return not self.debug
|
||||
|
||||
def path_in_data(self, name: str) -> Path:
|
||||
return self.data_dir / name
|
||||
|
||||
|
||||
def load_core_config(
|
||||
*,
|
||||
app_key: str,
|
||||
app_name: str,
|
||||
env_prefix: str,
|
||||
default_base: Path | None = None,
|
||||
default_bind: str = "127.0.0.1:5000",
|
||||
) -> CoreConfig:
|
||||
"""Liest die Konfiguration aus der Umgebung.
|
||||
|
||||
Ohne gesetzte Env-Vars laeuft alles in einem Unterverzeichnis
|
||||
``instance/<app_key>`` relativ zum aktuellen Arbeitsverzeichnis -- damit ist
|
||||
die Anwendung ohne jede Vorbereitung lokal startbar, ohne je nach
|
||||
``/srv``/``/var/log`` zu greifen.
|
||||
"""
|
||||
base = Path(_env(env_prefix, "BASE_DIR") or (default_base or Path.cwd() / "instance" / app_key))
|
||||
base = base.expanduser().resolve()
|
||||
data_dir = Path(_env(env_prefix, "DATA_DIR") or base / "data").expanduser()
|
||||
log_dir = Path(_env(env_prefix, "LOG_DIR") or base / "logs").expanduser()
|
||||
|
||||
security = SecurityConfig(
|
||||
cookie_secure=_env_bool(env_prefix, "COOKIE_SECURE", True),
|
||||
cookie_samesite=_env(env_prefix, "COOKIE_SAMESITE") or "Lax",
|
||||
session_idle_minutes=_env_int(env_prefix, "SESSION_IDLE_MINUTES", 30),
|
||||
session_absolute_hours=_env_int(env_prefix, "SESSION_ABSOLUTE_HOURS", 12),
|
||||
reauth_valid_minutes=_env_int(env_prefix, "REAUTH_VALID_MINUTES", 15),
|
||||
login_max_attempts=_env_int(env_prefix, "LOGIN_MAX_ATTEMPTS", 8),
|
||||
login_lockout_minutes=_env_int(env_prefix, "LOGIN_LOCKOUT_MINUTES", 15),
|
||||
login_max_attempts_per_ip=_env_int(env_prefix, "LOGIN_MAX_ATTEMPTS_PER_IP", 30),
|
||||
password_min_length=_env_int(env_prefix, "PASSWORD_MIN_LENGTH", 12),
|
||||
require_totp_for_admins=_env_bool(env_prefix, "REQUIRE_TOTP_FOR_ADMINS", False),
|
||||
hsts_enabled=_env_bool(env_prefix, "HSTS_ENABLED", False),
|
||||
hsts_max_age=_env_int(env_prefix, "HSTS_MAX_AGE", 15552000),
|
||||
trusted_proxy_hops=_env_int(env_prefix, "TRUSTED_PROXY_HOPS", 1),
|
||||
max_content_length=_env_int(env_prefix, "MAX_CONTENT_LENGTH", 64 * 1024 * 1024) or None,
|
||||
)
|
||||
|
||||
debug = _env_bool(env_prefix, "DEBUG", False)
|
||||
if debug:
|
||||
# Lokale Entwicklung laeuft ueber http://127.0.0.1 -- ein Secure-Cookie
|
||||
# wuerde dort nie gesetzt und die Anmeldung schlicht nicht funktionieren.
|
||||
# dataclasses.replace, nicht __dict__: SecurityConfig hat slots und
|
||||
# damit gar kein __dict__ -- der alte Ausdruck warf beim Start einen
|
||||
# AttributeError, und zwar genau im Entwicklungsmodus, den das README
|
||||
# empfiehlt.
|
||||
security = replace(
|
||||
security, cookie_secure=_env_bool(env_prefix, "COOKIE_SECURE", False)
|
||||
)
|
||||
|
||||
bind = (_env(env_prefix, "BIND") or default_bind).strip()
|
||||
if not re.fullmatch(r"[0-9A-Za-z_.\[\]:-]{1,80}:\d{1,5}", bind):
|
||||
raise ValueError(f"Ungueltige Bind-Adresse: {bind!r}")
|
||||
|
||||
site_key = (_env(env_prefix, "SITE_KEY") or app_key).strip().lower()
|
||||
if not re.fullmatch(r"[a-z0-9][a-z0-9-]{0,39}", site_key):
|
||||
raise ValueError(f"Ungueltiger SITE_KEY: {site_key!r}")
|
||||
|
||||
return CoreConfig(
|
||||
app_key=app_key,
|
||||
site_key=site_key,
|
||||
bind=bind,
|
||||
app_name=_env(env_prefix, "APP_NAME") or app_name,
|
||||
env_prefix=env_prefix,
|
||||
base_dir=base,
|
||||
data_dir=data_dir,
|
||||
log_dir=log_dir,
|
||||
db_path=Path(_env(env_prefix, "DB_PATH") or data_dir / "app.db").expanduser(),
|
||||
secret_key_file=Path(
|
||||
_env(env_prefix, "SECRET_KEY_FILE") or data_dir / "secret.key"
|
||||
).expanduser(),
|
||||
data_key_file=Path(_env(env_prefix, "DATA_KEY_FILE") or data_dir / "data.keys").expanduser(),
|
||||
license_path=Path(_env(env_prefix, "LICENSE_PATH") or data_dir / "license.json").expanduser(),
|
||||
license_key_path=Path(
|
||||
_env(env_prefix, "LICENSE_KEY_PATH") or data_dir / "license_key.json"
|
||||
).expanduser(),
|
||||
debug=debug,
|
||||
web_process=_env(env_prefix, "WEB_PROCESS") == "1",
|
||||
security=security,
|
||||
)
|
||||
@@ -0,0 +1,193 @@
|
||||
"""Migrationen der Komponente ``core``: Benutzer, Gruppen, Sitzungen, Audit, Settings.
|
||||
|
||||
Diese Tabellen sind in beiden Anwendungen identisch. Neue Aenderungen kommen
|
||||
als **neue** Migration mit neuer Versionsnummer dazu -- bestehende Migrationen
|
||||
werden nie editiert (die Pruefsummenwarnung des Runners weist darauf hin).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .migrations import Migration
|
||||
|
||||
V1_BASELINE = """
|
||||
-- Benutzerkonten. 'local' = eigenes Passwort, 'ldap' = Schattenkonto aus dem
|
||||
-- Verzeichnisdienst (Passwort wird dort geprueft, nie hier gespeichert).
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL COLLATE NOCASE,
|
||||
password_hash TEXT NOT NULL DEFAULT '',
|
||||
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||
is_locked INTEGER NOT NULL DEFAULT 0,
|
||||
auth_source TEXT NOT NULL DEFAULT 'local',
|
||||
first_name TEXT NOT NULL DEFAULT '',
|
||||
last_name TEXT NOT NULL DEFAULT '',
|
||||
email TEXT NOT NULL DEFAULT '',
|
||||
avatar_filename TEXT NOT NULL DEFAULT '',
|
||||
totp_secret TEXT NOT NULL DEFAULT '',
|
||||
totp_confirmed_at TEXT,
|
||||
must_change_password INTEGER NOT NULL DEFAULT 0,
|
||||
password_changed_at TEXT,
|
||||
last_login_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
deleted_at TEXT
|
||||
);
|
||||
-- Eindeutigkeit nur unter den *nicht* geloeschten Konten: ein Name im
|
||||
-- Papierkorb blockiert die Neuanlage nicht dauerhaft, solange die Anwendung
|
||||
-- den Konflikt bewusst behandelt.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_username_live
|
||||
ON users(username) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_users_deleted ON users(deleted_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS groups (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL COLLATE NOCASE,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
is_system INTEGER NOT NULL DEFAULT 0,
|
||||
is_default INTEGER NOT NULL DEFAULT 0,
|
||||
preset_key TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
deleted_at TEXT
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_groups_name_live
|
||||
ON groups(name) WHERE deleted_at IS NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS group_permissions (
|
||||
group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||
permission TEXT NOT NULL,
|
||||
PRIMARY KEY (group_id, permission)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_groups (
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (user_id, group_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_groups_group ON user_groups(group_id);
|
||||
|
||||
-- Einmal-Codes fuer den Verlust des Authenticators. Gespeichert wird nur ein
|
||||
-- Argon2-Hash -- ein Datenbankleck gibt die Codes nicht her.
|
||||
CREATE TABLE IF NOT EXISTS user_recovery_codes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
code_hash TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
used_at TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_recovery_user ON user_recovery_codes(user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at TEXT NOT NULL,
|
||||
last_seen_at TEXT NOT NULL,
|
||||
idle_expires_at TEXT NOT NULL,
|
||||
absolute_expires_at TEXT NOT NULL,
|
||||
reauth_at TEXT,
|
||||
ip TEXT NOT NULL DEFAULT '',
|
||||
user_agent TEXT NOT NULL DEFAULT '',
|
||||
data TEXT NOT NULL DEFAULT '{}',
|
||||
revoked_at TEXT,
|
||||
revoked_reason TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_user ON user_sessions(user_id, revoked_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_idle ON user_sessions(idle_expires_at);
|
||||
|
||||
-- Sliding-Window-Log der Ratenbegrenzung (Anmeldeversuche, Portal-Anfragen, API).
|
||||
CREATE TABLE IF NOT EXISTS rate_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
bucket TEXT NOT NULL,
|
||||
subject TEXT NOT NULL,
|
||||
ts TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_rate_lookup ON rate_events(bucket, subject, ts);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
updated_by TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
-- Aenderungsprotokoll mit Hash-Kette: jede Zeile bindet den Hash ihrer
|
||||
-- Vorgaengerin ein. Eine nachtraeglich geloeschte oder veraenderte Zeile bricht
|
||||
-- die Kette und wird von der Pruefroutine gefunden.
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ts TEXT NOT NULL,
|
||||
actor TEXT NOT NULL,
|
||||
actor_id INTEGER,
|
||||
action TEXT NOT NULL,
|
||||
target TEXT NOT NULL DEFAULT '',
|
||||
detail TEXT NOT NULL DEFAULT '',
|
||||
ip TEXT NOT NULL DEFAULT '',
|
||||
request_id TEXT NOT NULL DEFAULT '',
|
||||
severity TEXT NOT NULL DEFAULT 'info',
|
||||
prev_hash TEXT NOT NULL DEFAULT '',
|
||||
hash TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_ts ON audit_log(ts);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_actor ON audit_log(actor);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_log(action);
|
||||
|
||||
-- Dienstkonten (z. B. LDAP-Bind). Das Passwort liegt AES-GCM-verschluesselt vor,
|
||||
-- gebunden an den Verwendungszweck als AAD.
|
||||
CREATE TABLE IF NOT EXISTS service_accounts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
purpose TEXT NOT NULL UNIQUE,
|
||||
username TEXT NOT NULL DEFAULT '',
|
||||
password_encrypted TEXT NOT NULL DEFAULT '',
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ldap_group_mappings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ad_group_dn TEXT NOT NULL UNIQUE,
|
||||
ad_group_name TEXT NOT NULL,
|
||||
app_group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
"""
|
||||
|
||||
V2_TRUSTED_KEYS = """
|
||||
-- Gepinnte Aussteller-Schluessel der Lizenzpruefung (Trust on First Use bzw.
|
||||
-- im Build mitgeliefert). Der Vorgaenger vertraute allein dem in der
|
||||
-- Lizenzdatei eingebetteten Schluessel.
|
||||
CREATE TABLE IF NOT EXISTS trusted_issuer_keys (
|
||||
key_id TEXT PRIMARY KEY,
|
||||
public_key TEXT NOT NULL,
|
||||
label TEXT NOT NULL DEFAULT '',
|
||||
source TEXT NOT NULL DEFAULT 'tofu',
|
||||
added_at TEXT NOT NULL,
|
||||
added_by TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
"""
|
||||
|
||||
V3_JOBS = """
|
||||
-- Persistente Auftragsverfolgung fuer Hintergrundlaeufe (Wartung, Import,
|
||||
-- Berichte). Im Vorgaenger lebte dieser Zustand ausschliesslich im
|
||||
-- Prozessspeicher und war nach einem Dienst-Neustart weg -- inklusive der
|
||||
-- Information, ob ein Lauf ueberhaupt stattgefunden hat.
|
||||
CREATE TABLE IF NOT EXISTS jobs (
|
||||
id TEXT PRIMARY KEY,
|
||||
kind TEXT NOT NULL,
|
||||
subject TEXT NOT NULL DEFAULT '',
|
||||
state TEXT NOT NULL DEFAULT 'pending',
|
||||
progress INTEGER NOT NULL DEFAULT 0,
|
||||
message TEXT NOT NULL DEFAULT '',
|
||||
output TEXT NOT NULL DEFAULT '',
|
||||
started_by TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
finished_at TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_jobs_kind_state ON jobs(kind, state);
|
||||
CREATE INDEX IF NOT EXISTS idx_jobs_created ON jobs(created_at);
|
||||
"""
|
||||
|
||||
CORE_MIGRATIONS: tuple[Migration, ...] = (
|
||||
Migration(version=1, name="baseline", sql=V1_BASELINE),
|
||||
Migration(version=2, name="trusted-issuer-keys", sql=V2_TRUSTED_KEYS),
|
||||
Migration(version=3, name="jobs", sql=V3_JOBS),
|
||||
)
|
||||
@@ -0,0 +1,213 @@
|
||||
"""Datenbankzugriff auf SQLite -- eine Verbindung pro Request, Schreibsperren strukturell.
|
||||
|
||||
Die schmerzhafteste Fehlerklasse des Vorgaengerprojekts war
|
||||
``sqlite3.OperationalError: database is locked``: eine Hilfsfunktion oeffnete
|
||||
still eine *zweite* Verbindung, waehrend die aeussere Funktion noch eine offene
|
||||
Schreibtransaktion hielt. Das ist hier nicht mehr durch Disziplin, sondern
|
||||
strukturell verhindert:
|
||||
|
||||
* Pro Request existiert genau **eine** Verbindung (in ``flask.g``).
|
||||
* ``Database.transaction()`` zaehlt die Verschachtelungstiefe. Wer waehrend
|
||||
einer offenen Schreibtransaktion ``Database.connect()`` aufruft, bekommt
|
||||
sofort einen ``NestedConnectionError`` mit Klartext-Erklaerung -- nicht erst
|
||||
nach 15 Sekunden Busy-Timeout einen Deadlock.
|
||||
* Verschachtelte ``transaction()``-Aufrufe auf derselben Verbindung sind
|
||||
erlaubt und werden zu einem einzigen Commit zusammengefasst (Savepoints),
|
||||
damit Service-Funktionen sich gefahrlos gegenseitig aufrufen koennen.
|
||||
|
||||
Zusaetzlich: WAL-Modus (paralleles Lesen waehrend eines Schreibers),
|
||||
``foreign_keys=ON`` (im Vorgaenger nie aktiviert -- Fremdschluessel waren
|
||||
Dekoration) und ``busy_timeout=15000``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
import threading
|
||||
from collections.abc import Iterator, Mapping, Sequence
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
class NestedConnectionError(RuntimeError):
|
||||
"""Zweite Verbindung waehrend einer offenen Schreibtransaktion."""
|
||||
|
||||
|
||||
class Row(sqlite3.Row):
|
||||
"""``sqlite3.Row`` mit ``dict``-artigem ``get`` und Mapping-Konvertierung."""
|
||||
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
try:
|
||||
return self[key]
|
||||
except (IndexError, KeyError):
|
||||
return default
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {key: self[key] for key in self.keys()}
|
||||
|
||||
|
||||
def row_to_dict(row: sqlite3.Row | None) -> dict[str, Any] | None:
|
||||
if row is None:
|
||||
return None
|
||||
return {key: row[key] for key in row.keys()}
|
||||
|
||||
|
||||
def rows_to_dicts(rows: Sequence[sqlite3.Row]) -> list[dict[str, Any]]:
|
||||
return [{key: row[key] for key in row.keys()} for row in rows]
|
||||
|
||||
|
||||
class Database:
|
||||
"""Zugriffsobjekt fuer eine SQLite-Datei."""
|
||||
|
||||
def __init__(self, path: Path | str, *, timeout: float = 15.0) -> None:
|
||||
self.path = Path(path)
|
||||
self.timeout = timeout
|
||||
self._state = threading.local()
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Verbindungen
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def _depth(self) -> int:
|
||||
return int(getattr(self._state, "depth", 0))
|
||||
|
||||
def _set_depth(self, value: int) -> None:
|
||||
self._state.depth = value
|
||||
|
||||
def connect(self) -> sqlite3.Connection:
|
||||
"""Oeffnet eine neue Verbindung. Nicht aufrufen, solange eine Transaktion laeuft."""
|
||||
if self._depth() > 0:
|
||||
raise NestedConnectionError(
|
||||
"Es laeuft bereits eine Schreibtransaktion auf dieser Datenbank. "
|
||||
"Reiche die bestehende Verbindung durch, statt eine neue zu oeffnen "
|
||||
"(sonst blockiert SQLite sich selbst)."
|
||||
)
|
||||
return self._raw_connect()
|
||||
|
||||
def _raw_connect(self) -> sqlite3.Connection:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fresh = not self.path.exists()
|
||||
conn = sqlite3.connect(
|
||||
self.path,
|
||||
timeout=self.timeout,
|
||||
isolation_level=None, # Transaktionen steuern wir selbst, explizit.
|
||||
check_same_thread=False,
|
||||
)
|
||||
conn.row_factory = Row
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA synchronous=NORMAL")
|
||||
conn.execute("PRAGMA foreign_keys=ON")
|
||||
conn.execute("PRAGMA busy_timeout=15000")
|
||||
conn.execute("PRAGMA secure_delete=ON")
|
||||
if fresh:
|
||||
# Die Datei enthaelt Passwort-Hashes, verschluesselte Zugangsdaten und
|
||||
# Sitzungen -- sie gehoert niemandem ausser dem Dienstbenutzer.
|
||||
try:
|
||||
os.chmod(self.path, 0o600)
|
||||
except OSError: # pragma: no cover - z. B. Windows/NTFS
|
||||
pass
|
||||
return conn
|
||||
|
||||
@contextmanager
|
||||
def session(self) -> Iterator[sqlite3.Connection]:
|
||||
"""Kurzlebige Verbindung fuer Skripte und Hintergrund-Threads."""
|
||||
conn = self.connect()
|
||||
try:
|
||||
yield conn
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Transaktionen
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
@contextmanager
|
||||
def transaction(self, conn: sqlite3.Connection | None = None) -> Iterator[sqlite3.Connection]:
|
||||
"""Schreibtransaktion mit ``BEGIN IMMEDIATE``.
|
||||
|
||||
``BEGIN IMMEDIATE`` nimmt die Schreibsperre sofort statt erst beim ersten
|
||||
Schreibzugriff -- damit scheitert ein konkurrierender Schreiber sauber am
|
||||
Busy-Timeout, statt mitten in der Transaktion in einen
|
||||
``SQLITE_BUSY_SNAPSHOT`` zu laufen.
|
||||
"""
|
||||
own_connection = conn is None
|
||||
if own_connection:
|
||||
conn = self._raw_connect()
|
||||
assert conn is not None
|
||||
|
||||
depth = self._depth()
|
||||
savepoint = f"sp_{depth}"
|
||||
self._set_depth(depth + 1)
|
||||
try:
|
||||
if depth == 0:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
else:
|
||||
conn.execute(f"SAVEPOINT {savepoint}")
|
||||
yield conn
|
||||
except BaseException:
|
||||
try:
|
||||
if depth == 0:
|
||||
conn.execute("ROLLBACK")
|
||||
else:
|
||||
conn.execute(f"ROLLBACK TO {savepoint}")
|
||||
except sqlite3.Error: # pragma: no cover - Verbindung schon tot
|
||||
pass
|
||||
raise
|
||||
else:
|
||||
if depth == 0:
|
||||
conn.execute("COMMIT")
|
||||
else:
|
||||
conn.execute(f"RELEASE {savepoint}")
|
||||
finally:
|
||||
self._set_depth(depth)
|
||||
if own_connection:
|
||||
conn.close()
|
||||
|
||||
@property
|
||||
def in_transaction(self) -> bool:
|
||||
return self._depth() > 0
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Bequeme Abfragehelfer (immer parametrisiert -- kein String-Basteln)
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
@staticmethod
|
||||
def one(
|
||||
conn: sqlite3.Connection, sql: str, params: Sequence[Any] | Mapping[str, Any] = ()
|
||||
) -> dict[str, Any] | None:
|
||||
return row_to_dict(conn.execute(sql, params).fetchone())
|
||||
|
||||
@staticmethod
|
||||
def all(
|
||||
conn: sqlite3.Connection, sql: str, params: Sequence[Any] | Mapping[str, Any] = ()
|
||||
) -> list[dict[str, Any]]:
|
||||
return rows_to_dicts(conn.execute(sql, params).fetchall())
|
||||
|
||||
@staticmethod
|
||||
def value(
|
||||
conn: sqlite3.Connection,
|
||||
sql: str,
|
||||
params: Sequence[Any] | Mapping[str, Any] = (),
|
||||
default: Any = None,
|
||||
) -> Any:
|
||||
row = conn.execute(sql, params).fetchone()
|
||||
if row is None:
|
||||
return default
|
||||
return row[0]
|
||||
|
||||
@staticmethod
|
||||
def column_exists(conn: sqlite3.Connection, table: str, column: str) -> bool:
|
||||
rows = conn.execute(f"PRAGMA table_info({table})").fetchall()
|
||||
return any(row["name"] == column for row in rows)
|
||||
|
||||
@staticmethod
|
||||
def table_exists(conn: sqlite3.Connection, table: str) -> bool:
|
||||
return (
|
||||
conn.execute(
|
||||
"SELECT 1 FROM sqlite_master WHERE type IN ('table','view') AND name=?",
|
||||
(table,),
|
||||
).fetchone()
|
||||
is not None
|
||||
)
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Die zentrale Erweiterung: haelt alles zusammen, was eine App braucht.
|
||||
|
||||
Statt globaler Modulvariablen (der Vorgaenger hatte ein Dutzend davon, u. a.
|
||||
``_license_state`` und die DB-Pfade) haengt der gesamte Anwendungszustand an
|
||||
einem Objekt in ``app.extensions["tesm"]``. Zugriff ueber ``core()``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Callable, Sequence
|
||||
|
||||
from flask import Flask, current_app, g
|
||||
|
||||
from .config import CoreConfig
|
||||
from .db import Database
|
||||
from .keystore import Keystore
|
||||
from .migrations import Migration, MigrationRunner
|
||||
from .nav import NavTree
|
||||
from .rbac.model import PermissionTree, RolePreset
|
||||
from .security.passwords import PasswordPolicy
|
||||
from .settings_store import SettingsStore
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from .licensing_client import LicenseManager
|
||||
from .sysops import SysOps
|
||||
|
||||
|
||||
@dataclass
|
||||
class TesmCore:
|
||||
"""Anwendungsweiter Zustand. Nach ``init_app`` unveraendert."""
|
||||
|
||||
config: CoreConfig
|
||||
database: Database
|
||||
keystore: Keystore
|
||||
permissions: PermissionTree
|
||||
nav: NavTree
|
||||
settings: SettingsStore
|
||||
role_presets: tuple[RolePreset, ...] = ()
|
||||
migrations: dict[str, Sequence[Migration]] = field(default_factory=dict)
|
||||
license: "LicenseManager | None" = None
|
||||
sysops: "SysOps | None" = None
|
||||
#: App-spezifische Erweiterungen (z. B. Papierkorb-Register, Job-Registry).
|
||||
registry: dict[str, Any] = field(default_factory=dict)
|
||||
#: Wird nach einer erfolgreichen Anmeldung aufgerufen:
|
||||
#: ``(conn, user, secret, group_dns)``. ``secret`` ist das Passwort dieser
|
||||
#: Anmeldung -- es wird nirgends gespeichert und ist nach dem Aufruf weg.
|
||||
#: Ein Haken, der scheitert, sperrt niemanden aus (siehe
|
||||
#: ``auth.service._run_login_hooks``).
|
||||
login_hooks: list[Callable[..., None]] = field(default_factory=list)
|
||||
#: Wird vor dem Verwerfen der Sitzung aufgerufen: ``(conn, user)``. Was die
|
||||
#: Anmeldung aufgebaut hat, wird hier wieder abgebaut -- sonst bliebe es
|
||||
#: bestehen, bis eine Zeitgrenze greift.
|
||||
logout_hooks: list[Callable[..., None]] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def password_policy(self) -> PasswordPolicy:
|
||||
return PasswordPolicy(min_length=self.config.security.password_min_length)
|
||||
|
||||
@property
|
||||
def audit_archive_dir(self) -> Path:
|
||||
return self.config.log_dir / "audit"
|
||||
|
||||
def run_migrations(self) -> list[str]:
|
||||
runner = MigrationRunner(self.database)
|
||||
applied: list[str] = []
|
||||
for component, migrations in self.migrations.items():
|
||||
applied.extend(runner.run(component, migrations))
|
||||
return applied
|
||||
|
||||
def migration_status(self) -> list[dict[str, object]]:
|
||||
return MigrationRunner(self.database).status(list(self.migrations))
|
||||
|
||||
|
||||
def core(app: Flask | None = None) -> TesmCore:
|
||||
target = app or current_app
|
||||
try:
|
||||
return target.extensions["tesm"] # type: ignore[return-value]
|
||||
except KeyError as exc: # pragma: no cover - Programmierfehler
|
||||
raise RuntimeError("tesm_core ist fuer diese Anwendung nicht initialisiert") from exc
|
||||
|
||||
|
||||
def db() -> sqlite3.Connection:
|
||||
"""Die Verbindung dieses Requests. Genau eine pro Request, nie eine zweite.
|
||||
|
||||
Diese Funktion ist der einzige zugelassene Weg, im Request-Kontext an eine
|
||||
Verbindung zu kommen. Wer stattdessen selbst ``Database.connect()`` aufruft,
|
||||
riskiert genau den Deadlock, an dem der Vorgaenger mehrfach hing.
|
||||
"""
|
||||
connection = getattr(g, "_tesm_conn", None)
|
||||
if connection is None:
|
||||
connection = core().database.connect()
|
||||
g._tesm_conn = connection
|
||||
return connection
|
||||
|
||||
|
||||
def close_db(_exception: BaseException | None = None) -> None:
|
||||
connection = getattr(g, "_tesm_conn", None)
|
||||
if connection is not None:
|
||||
g._tesm_conn = None
|
||||
connection.close()
|
||||
|
||||
|
||||
def transaction() -> Any:
|
||||
"""Kurzform fuer ``core().database.transaction(db())``."""
|
||||
return core().database.transaction(db())
|
||||
|
||||
|
||||
def with_transaction(fn: Callable[..., Any]) -> Callable[..., Any]:
|
||||
"""Dekorator: fuehrt eine Funktion innerhalb einer Schreibtransaktion aus."""
|
||||
|
||||
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
with transaction() as conn:
|
||||
return fn(conn, *args, **kwargs)
|
||||
|
||||
wrapper.__name__ = fn.__name__
|
||||
wrapper.__doc__ = fn.__doc__
|
||||
return wrapper
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user