Compare commits

...
26 Commits
Author SHA1 Message Date
alientim 5cc34f2a1f Version 2.8.47: Verzeichnis der eigenen Optionen 2026-09-17 16:22:19 +02:00
alientim e284d70855 Version 2.8.46: DHCP-Optionen im Klartext auf dem Client 2026-09-17 11:37:17 +02:00
alientim b4e81959e7 Version 2.8.45: Auslesezeile fuer den Client 2026-09-17 11:28:06 +02:00
alientim 29b62714cb Version 2.8.44 / Lizenzserver 2.8.22: Client-Konfiguration am richtigen Ort, Fusszeile bleibt stehen 2026-09-17 11:24:17 +02:00
alientim f59ba42e55 Version 2.8.43: ein Netz, ein Eintrag -- und die Seite sagt, was wirklich laeuft 2026-09-17 11:10:41 +02:00
alientim b1275b2c71 Version 2.8.42: sichtbare Pfeile an allem Aufklappbaren 2026-09-17 10:41:58 +02:00
alientim 1d8f92fd2d Version 2.8.41: Kea-Fassung ohne Herkunft 2026-09-17 10:27:20 +02:00
alientim 5324174f90 Version 2.8.40: Client-Konfiguration, Kea-Fassung, DHCP-Seite aufgeraeumt 2026-09-17 10:26:12 +02:00
alientim d69d177995 Version 2.8.39: gescheitertes pip bricht ab, keine ==-Bindung mehr 2026-09-17 09:39:01 +02:00
alientim f481a91773 Version 2.8.38: eine Rechnung fuer die Resttage, Module benannt 2026-09-17 09:26:31 +02:00
alientim 970f45cdf7 Version 2.8.37: eigene Sprachumgebung fuer die Skripte 2026-09-17 09:01:04 +02:00
alientim c9ceb871b6 Version 2.8.36: Lizenzabfrage fuer ein RMM auf der Maschine 2026-09-17 08:49:24 +02:00
alientim 37c508297f Version 2.8.35: der reparierte venv bleibt fuer den Dienstbenutzer lesbar 2026-09-16 21:32:20 +02:00
alientim 13bf07fecf Version 2.8.34: Skripte aus dem Baum ueber bash aufrufen 2026-09-16 21:23:51 +02:00
alientim 849b821501 Version 2.8.33: Selbstreparatur der Python-Umgebung nach einem Release-Upgrade 2026-09-16 21:17:06 +02:00
alientim 00a27018d9 Version 2.8.32: Rueckbau einer Installation (deploy/uninstall.sh) 2026-09-16 19:55:03 +02:00
alientim 95238b781f Version 2.8.31: Protokollverlauf verlangt keine Bestaetigung mehr 2026-09-16 18:07:29 +02:00
alientim 3ea7937b5b Version 2.8.30: Begruessungstext schweigt ueber entfernte Installationen 2026-09-16 17:56:53 +02:00
alientim 80d9e30bd4 Version 2.8.29 / Lizenzserver 2.8.5: Release-Upgrade von Ubuntu bricht den venv nicht mehr 2026-09-16 17:44:32 +02:00
alientim bde2df2b44 Version 2.8.28: dynamische Lease als Client uebernehmen 2026-09-16 12:28:02 +02:00
alientim 76a73017db Version 2.8.27: Live-Protokoll mit Stufen, Auftraege fallen nicht mehr heraus 2026-09-16 11:32:00 +02:00
alientim db06ddc054 Version 2.8.26: die Ordnerauswahl zeigt wieder Ordner 2026-09-15 07:11:53 +02:00
alientim e0a048fbcc Version 2.8.25: Wartungsseite ohne Neuladen, Ausweich auf die Adresse 2026-09-13 22:23:17 +02:00
alientim 9d736bb91f Version 2.8.24: Massenbefehl, Mitlesen, F5 2026-09-13 15:11:22 +02:00
alientim 914609b758 Version 2.8.23: nicht das teure Verfahren zuerst 2026-09-13 14:52:11 +02:00
alientim af81c92695 Version 2.8.22: Alt+F4, Sitzung im Fenster, bewaehrtes Verfahren zuerst 2026-09-13 14:30:37 +02:00
58 changed files with 3593 additions and 378 deletions

No files matched your search

+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "tesm"
version = "2.8.21"
version = "2.8.47"
description = "TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten und Aruba-Switchen"
requires-python = ">=3.11"
dependencies = [
+16 -1
View File
@@ -14,7 +14,7 @@ from __future__ import annotations
from typing import Any
from flask import Blueprint, abort, render_template, request
from flask import Blueprint, abort, render_template, request, url_for
from tesm_core.security.sessions import fragment
from tesm_core.extension import core, db
@@ -49,6 +49,13 @@ def _tiles(*, authenticated: bool) -> dict[str, Any]:
# Einmal je Seitenaufbau, nicht je Kachel: der Port ist fuer alle derselbe,
# und ohne das Recht wird gar nicht erst nachgesehen.
vnc_erlaubt = detailed and current_user.has("device_vnc.execute")
# Das Fenster setzt eine frische Passwortbestaetigung voraus -- dieselbe
# Schwelle wie die Geraeteseite. Fehlt sie, fuehrt der Knopf dorthin: dort
# wird danach gefragt, und ein Fenster, das sich mit "bitte erst
# bestaetigen" oeffnet, waere ein Klick fuer nichts.
from tesm_core.auth.service import reauth_is_fresh
vnc_fenster = vnc_erlaubt and reauth_is_fresh()
vnc_port = (
int(core().settings.get(conn, "vnc_port") or vnc_dienst.STANDARD_PORT)
if vnc_erlaubt and core().settings.get(conn, "vnc_enabled")
@@ -95,9 +102,16 @@ def _tiles(*, authenticated: bool) -> dict[str, Any]:
# Verbindungsversuch je Kachel und Aufbau waere genau die
# Art Hintergrundlast, die niemand bestellt hat.
"vnc": bool(vnc_port and row["vnc_open"]),
"vnc_quelle": (
url_for("screen.session_json", device_id=int(row["id"]))
if vnc_port and row["vnc_open"]
else ""
),
}
)
tiles.append(entry)
# Merker fuer die Vorlage: nur mit frischer Bestaetigung gibt es das
# Fenster; sonst zeigt die Kachel auf die Seite.
sections = [
{
@@ -142,6 +156,7 @@ def _tiles(*, authenticated: bool) -> dict[str, Any]:
"counts": counts,
"sections": sections,
"detailed": detailed,
"vnc_fenster": vnc_fenster,
"interval": int(core().settings.get(conn, "monitor_interval_seconds") or 300),
}
+5 -1
View File
@@ -318,12 +318,16 @@ def _restart_as_job(extension: Any, conn: Any, device: Any, method: Any): # typ
# 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.
live = extension.registry.get("live_log")
with transaction():
monitor.mark_restarting(conn, int(device["id"]))
monitor.mark_restarting(
conn, int(device["id"]), live=live, trigger="manuell", method=method.key
)
maintenance_service.start_background(
maintenance_service.run_reboot,
database=extension.database,
live=live,
job_id=handle.id,
device=dict(device),
username=username,
+239 -4
View File
@@ -11,6 +11,7 @@ einen Dienst starten.
from __future__ import annotations
import shlex
from typing import Any
from flask import Blueprint, abort, flash, redirect, render_template, request, url_for
@@ -20,7 +21,7 @@ from tesm_core.auth.service import client_ip
from tesm_core.db import Database
from tesm_core.extension import core, db, transaction
from ..services import kea
from ..services import inventory, kea
from ..permissions import DHCP_PAGE
bp = Blueprint("dhcp", __name__, url_prefix="/dhcp")
@@ -73,6 +74,12 @@ def overview():
# Zustand aus den laufenden Leases -- wie im Windows-DHCP.
reservations = kea.annotate_reservations(reservations, leases)
reserved_ips = {str(entry["ip"]) for entry in reservations}
# Wozu es schon einen Client gibt, ist nicht mehr zu uebernehmen -- auch
# wenn dessen Adresse (noch) keine Reservierung hat.
bekannte_macs = {
inventory.normalize_mac(str(zeile["mac"]))
for zeile in Database.all(conn, "SELECT mac FROM devices WHERE deleted_at IS NULL")
}
return render_template(
"tesm/dhcp.html",
@@ -90,6 +97,7 @@ def overview():
manual_reservations=Database.all(conn, "SELECT * FROM dhcp_reservations ORDER BY ip"),
leases=leases,
reserved_ips=reserved_ips,
bekannte_macs=bekannte_macs,
networks=networks,
default_gateway=kea.default_gateway(sysops) if sysops else "",
service=kea.service_state(sysops) if sysops else {},
@@ -97,10 +105,31 @@ def overview():
preview=kea.config_to_text(config),
warnings=warnings,
lease_file=kea.LEASE_FILE,
# Was ein Linux-Client braucht, damit die Optionen bei ihm ankommen --
# je Stil einmal, damit die Seite umschalten kann, ohne nachzuladen.
client_stile=kea.CLIENT_STILE,
client_konfig={
stil: kea.client_konfiguration(conn, stil) for stil, _, _ in kea.CLIENT_STILE
},
sysops_available=bool(sysops and sysops.available),
# "gespeichert" heisst nicht "wirksam": lehnt Kea die Datei ab, bleibt
# der Dienst auf dem alten Stand, waehrend die Seite den neuen Wert
# zeigt. Deshalb wird hier verglichen, was auf der Platte liegt.
konfig_stand=_konfig_stand(config),
)
def _konfig_stand(gewuenscht: dict[str, Any]) -> str:
"""``aktuell``, ``abweichend`` oder ``unbekannt``."""
sysops = core().sysops
if sysops is None or not sysops.available:
return "unbekannt"
vorhanden = kea.laufende_konfiguration(sysops)
if vorhanden is None:
return "unbekannt"
return "aktuell" if vorhanden == gewuenscht else "abweichend"
# --------------------------------------------------------------------------- #
# Einstellungen und Subnetze
# --------------------------------------------------------------------------- #
@@ -134,7 +163,13 @@ def add_subnet():
start = (request.form.get("range_start") or "").strip()
end = (request.form.get("range_end") or "").strip()
problem = kea.validate_subnet(networks, interface, start, end)
problem = kea.validate_subnet(
networks,
interface,
start,
end,
vorhandene=Database.all(conn, "SELECT * FROM dhcp_subnets"),
)
if problem:
flash(problem, "danger")
return redirect(url_for("dhcp.overview"))
@@ -169,7 +204,14 @@ def update_subnet(subnet_id: int):
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)
problem = kea.validate_subnet(
networks,
interface,
start,
end,
vorhandene=Database.all(conn, "SELECT * FROM dhcp_subnets"),
eigene_id=subnet_id,
)
if problem:
flash(problem, "danger")
return redirect(url_for("dhcp.overview"))
@@ -347,6 +389,16 @@ def add_custom_option():
)
_audit("dhcp.option_created", name, severity="notice")
flash("Eigene Option angelegt.", "success")
if code not in kea.PRIVATBEREICH:
# Kein Fehler -- andere Clients nehmen auch andere Codes. Aber ein
# Linux-Client mit systemd-networkd hebt eine unbekannte Option nur
# dann auf, wenn ihr Code im vorgesehenen Bereich liegt. Wer das
# nicht weiss, sucht den Fehler danach beim Server.
flash(
f"Code {code} liegt außerhalb von 224–254. Clients mit systemd-networkd "
"verwerfen Optionen außerhalb dieses Bereichs, auch wenn sie sie anfordern.",
"warning",
)
return redirect(url_for("dhcp.overview"))
@@ -447,6 +499,91 @@ def add_reservation():
return _write_and_redirect()
@bp.post("/lease/adopt")
# Zwei Rechte, mit Absicht: die Uebernahme ist eine eigene Funktion, das
# Ergebnis aber ein Client. Wer keine Clients anlegen darf, legt auch hier
# keine an -- sonst waere diese Schaltflaeche eine Hintertuer an der
# Geraeteverwaltung vorbei.
@permission_required("dhcp_lease_adopt.execute", "devices.create")
def adopt_lease():
"""Macht aus einer dynamisch vergebenen Adresse einen ueberwachten Client.
Die Angaben kommen **nicht** aus dem Formular, sondern aus der Lease-Datei:
geschickt wird nur die MAC, alles Weitere liest TESM frisch nach. Damit
heisst "Uebernahme" auch Uebernahme -- und eine Lease, die zwischen
Seitenaufbau und Klick abgelaufen oder umgezogen ist, faellt auf, statt
stillschweigend einen falschen Datensatz anzulegen.
"""
extension = core()
conn = db()
sysops = extension.sysops
gesucht = inventory.normalize_mac(request.form.get("mac") or "")
leases = kea.read_leases(sysops) if sysops else []
lease = next((eintrag for eintrag in leases if eintrag.mac == gesucht), None)
if lease is None:
flash(
"Zu dieser MAC liegt keine Lease mehr vor. Die Liste war nicht mehr aktuell.",
"warning",
)
return redirect(url_for("dhcp.overview"))
# Nur dynamische. Eine Adresse, die schon reserviert ist, gehoert bereits
# zu einem gepflegten Datensatz -- da gibt es nichts zu uebernehmen.
networks = kea.detect_networks(sysops) if sysops else []
reserviert = {
str(eintrag["ip"])
for eintrag in kea.collect_reservations(
conn, only_networks=kea.configured_networks(conn, networks)
)
}
if lease.address in reserviert:
flash(
f"{lease.address} ist bereits reserviert – diese Lease ist nicht dynamisch.",
"warning",
)
return redirect(url_for("dhcp.overview"))
# Der Hostname aus der Lease ist der Name, den das Geraet selbst nennt.
# Fehlt er, steht die Adresse da: ein Name wie "Gerät 7" sagt weniger.
name = (lease.hostname or "").strip() or f"Client {lease.address}"
try:
with transaction():
device_id = inventory.create_device(
conn,
name=name,
mac=lease.mac,
ip=lease.address,
switch_id=None,
port="",
credential_id=None,
ssh_port=None,
# Ohne Switch und Port gibt es keinen PoE-Neustart. Mit
# eingeschaltetem Automatismus schriebe die Ueberwachung bei
# jedem Ausfall einen Fehlschlag ins Protokoll, den niemand
# abstellen kann. Einschalten kann man ihn, sobald der Port
# eingetragen ist.
auto_restart=False,
actor=current_user.username,
)
except inventory.ValidationError as exc:
flash(str(exc), "danger")
return redirect(url_for("dhcp.overview"))
_audit(
"device.created",
name,
f"aus dynamischer Lease {lease.address} ({lease.mac}) übernommen",
severity="notice",
)
flash(
f"{name} wurde als Client übernommen. Switch, Port und Zugangsdaten fehlen noch – "
"ohne sie gibt es keinen PoE-Neustart und keine Wartung. Die feste Adresse entsteht "
"beim nächsten Schreiben der DHCP-Konfiguration.",
"success",
)
return redirect(url_for("devices.detail", device_id=device_id))
@bp.post("/reservation/<int:reservation_id>/delete")
@permission_required("dhcp_reservations.delete")
def delete_reservation(reservation_id: int):
@@ -509,12 +646,110 @@ def _write_and_redirect():
for warning in result.get("warnings", []):
flash(warning, "warning")
if not result["ok"] and result.get("message"):
flash(result["message"], "warning")
# Rot, nicht orange: die Aenderung steht in der Datenbank und auf der
# Seite, wirkt aber nicht. Das ist der Zustand, in dem man stundenlang
# sucht, warum ein Client die Option nicht bekommt.
flash(
result["message"] + " Die Änderung ist gespeichert, aber noch nicht wirksam.",
"danger",
)
elif result["ok"]:
flash(result["message"], "info")
return redirect(url_for("dhcp.overview"))
@bp.post("/client-config/push")
# Verteilt wird ueber dieselbe Maschinerie wie ein Massenbefehl: Zugangsdaten
# aus dem Datensatz, ein Auftrag je Geraet, Ausgabe zum Mitlesen. Deshalb
# haengt es auch an demselben Recht -- wer Befehle auf Clients ausfuehren darf,
# darf auch diese Datei schreiben; es ist dieselbe Wirkung.
@permission_required("servicing_command.execute")
def push_client_config():
"""Schreibt die Client-Konfiguration per SSH auf ausgewaehlte Clients.
Der Inhalt ist fuer alle gleich -- angefordert wird, was der Server sendet,
und das haengt nicht am einzelnen Geraet. Die **Werte** kommen weiterhin
vom Server; hier geht es nur darum, dass der Client danach fragt.
"""
from ..services import maintenance as wartung
extension = core()
conn = db()
stil = (request.form.get("stil") or "").strip()
if stil not in dict((s[0], s) for s in kea.CLIENT_STILE):
flash("Unbekannter Client-Stil.", "danger")
return redirect(url_for("dhcp.overview"))
konfig = kea.client_konfiguration(conn, stil)
if not konfig["optionen"]:
flash(
"Es gibt nichts zu verteilen: dieser Server sendet derzeit keine Option, "
"die ein Client eigens anfordern müsste.",
"info",
)
return redirect(url_for("dhcp.overview"))
device_ids = [int(v) for v in request.form.getlist("device_ids") if v.isdigit()]
if not device_ids:
flash("Es wurde kein Gerät ausgewählt.", "warning")
return redirect(url_for("dhcp.overview"))
# Ein Skript, ein Aufruf. Der frühere Weg schrieb auf einen festen Pfad --
# bei networkd auf einen, den kein Dienst liest -- und überschrieb bei
# dhcpcd/dhclient die Datei des Clients. Das Skript sucht sich das Ziel
# selbst und ersetzt nur seinen eigenen Block; es geht als **Argument**
# durch, damit stdin für das sudo-Passwort frei bleibt.
befehl = "{sudo} sh -c " + shlex.quote(konfig["skript"])
gestartet = 0
for device_id in device_ids:
device = inventory.get_device(conn, device_id)
if device is None or not device["credential_id"]:
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():
auftrag = wartung.create_job(
conn,
kind=wartung.JOB_KIND_COMMAND,
subject=str(device["name"]),
actor=current_user.username,
message="Wartet auf Start",
)
wartung.start_background(
wartung.run_command,
database=extension.database,
job_id=auftrag.id,
device=dict(device),
username=username,
password=password,
befehl=befehl,
timeout=int(extension.settings.get(conn, "maintenance_command_timeout") or 900),
)
gestartet += 1
if gestartet:
_audit(
"dhcp.client_config_pushed",
f"{gestartet} Client(s)",
f"Stil {stil}, Datei {konfig['pfad']}",
severity="notice",
)
flash(
f"{gestartet} Auftrag/Aufträge gestartet. Der Verlauf und die Ausgabe stehen "
"unter Wartung.",
"success",
)
else:
flash("Kein Gerät mit hinterlegten Zugangsdaten ausgewählt.", "warning")
return redirect(url_for("dhcp.overview"))
@bp.post("/write")
@permission_required("dhcp_config.execute")
def write_config():
+26 -1
View File
@@ -19,7 +19,11 @@ from tesm_core.auth.models import (
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
from tesm_core.licensing_client import (
ABFRAGE_WARNSCHWELLE_TAGE,
LICENSED_FEATURES,
LicenseError,
)
import tesm_licensing as lic
@@ -50,8 +54,29 @@ def overview():
manager = extension.license
state = manager.reload(conn) if manager else None
pending = manager._pending(conn) if manager else {} # noqa: SLF001 - eigenes Paket
# Der Befehl, den ein Agent auf dieser Maschine ausfuehrt. Zusammengesetzt
# aus dem, was hier wirklich installiert ist -- der Shim traegt den Namen
# der Instanz, und der Dienstbenutzer auch. Abgeschrieben aus der Doku
# waere er auf einer benannten Instanz schlicht falsch.
werkzeug = f"/usr/local/bin/{extension.config.site_key}-admin"
# Der Dienstbenutzer traegt den Namen der **Anwendung**, nicht den der
# Instanz: install.sh setzt SERVICE_USER="$APP". Auf "tesm-test" heisst er
# also "tesm" -- und auf dem Lizenzserver "tesm-license". Aus dem
# Instanznamen ableiten waere genau dort falsch.
dienstbenutzer = extension.config.app_key
rmm_befehl = f"sudo -u {dienstbenutzer} {werkzeug} license"
return render_template(
"tesm/license.html",
rmm_befehl=rmm_befehl,
rmm_befehl_tage=f"{rmm_befehl} --days",
rmm_warnschwelle=ABFRAGE_WARNSCHWELLE_TAGE,
rmm_powershell=(
f'$j = (& {rmm_befehl} 2>$null) | ConvertFrom-Json' + chr(10)
+ 'if (-not $j.valid) { exit 2 }' + chr(10)
+ 'if ($j.lifetime) { exit 0 }' + chr(10)
+ f'if ($j.days_left -le {ABFRAGE_WARNSCHWELLE_TAGE}) ' + '{ exit 1 }' + chr(10)
+ 'exit 0'
),
state=state,
pending=pending,
server_certificate=extension.settings.get(conn, "license_server_ca") or "",
+1
View File
@@ -66,4 +66,5 @@ def restarts():
method=method,
methods=tuple(monitor.METHOD_LABELS.items()),
method_labels=monitor.METHOD_LABELS,
trigger_labels=monitor.TRIGGER_LABELS,
)
+78 -17
View File
@@ -18,7 +18,7 @@ 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.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
@@ -77,18 +77,20 @@ def _query_devices(conn) -> list[dict]: # type: ignore[no-untyped-def]
@bp.get("/")
@permission_required(*SERVICING_PAGE, mode="any")
def overview():
def overview(): # noqa: C901 - eine Seite, drei Aufgaben
conn = db()
return render_template(
"tesm/maintenance.html",
devices=_eligible_devices(conn),
jobs=service.list_jobs(conn),
auftragslage=service.letzter_auftrag_je_geraet(conn),
category_labels=dict(inventory.CREDENTIAL_CATEGORIES),
category_effects=inventory.CATEGORY_EFFECTS,
update_category=inventory.MAINTENANCE_CATEGORY,
terminal_enabled=bool(core().settings.get(conn, "terminal_clients_enabled")),
windows_tool_missing=_windows_tool_missing(conn),
windows_package=windowsops.REQUIRED_PACKAGE,
befehlsvorlagen=service.BEFEHLSVORLAGEN,
)
@@ -144,12 +146,19 @@ def install_windows_tool():
def status():
"""Zustand der Auftraege -- aus der Datenbank, nicht aus dem Prozessspeicher."""
conn = db()
jobs = service.list_jobs(conn, limit=30)
jobs = service.laufende_und_letzte(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,
# Der Zustand der Geraete gehoert dazu: die Seite steht sonst mit
# dem Stand ihres Aufbaus da, und wer nach einem Neustart auf die
# Rueckkehr wartet, sieht "unbekannt", bis er von Hand neu laedt.
"devices": [
{"id": int(row["id"]), "state": str(row["state"] or "unknown")}
for row in _eligible_devices(conn)
],
"jobs": [
{
"id": job["id"],
@@ -159,6 +168,16 @@ def status():
"progress": job["progress"],
"message": job["message"],
"updated_at": job["updated_at"],
# Die letzten Zeilen -- nur von dem, was gerade laeuft.
# Mitlesen heisst mitlesen: wer nach der Wartung wissen
# will, was war, oeffnet den Auftrag. Alles mitzuschicken
# hiesse, alle zwei Sekunden das gesamte Protokoll aller
# Geraete zu uebertragen.
"output": (
service.letzte_zeilen(str(job["output"] or ""))
if service.eben_fertig(dict(job))
else ""
),
}
for job in jobs
],
@@ -187,7 +206,7 @@ def job_output(job_id: str):
return render_template("tesm/_maintenance_output.html", job=job)
def _start(kind: str) -> None:
def _start(kind: str, befehl: str = "") -> None:
extension = core()
conn = db()
device_ids = [int(value) for value in request.form.getlist("device_ids") if value.isdigit()]
@@ -233,12 +252,27 @@ def _start(kind: str) -> None:
actor_id=current_user.id,
action=kind,
target=str(device["name"]),
detail=f"Auftrag {handle.id}",
# Beim Befehl gehoert sein Wortlaut hierher und nicht nur die
# Auftragsnummer: was gelaufen ist, muss aus dem Protokoll
# hervorgehen, auch wenn der Auftrag laengst aus dem Verlauf
# gerollt ist.
detail=f"Auftrag {handle.id}" + (f": {befehl}" if befehl else ""),
ip=client_ip(),
severity="notice",
severity="warning" if befehl else "notice",
)
if kind == service.JOB_KIND_UPDATE:
if kind == service.JOB_KIND_COMMAND:
service.start_background(
service.run_command,
database=extension.database,
job_id=handle.id,
device=dict(device),
username=username,
password=password,
befehl=befehl,
timeout=int(extension.settings.get(conn, "maintenance_command_timeout") or 900),
)
elif kind == service.JOB_KIND_UPDATE:
service.start_background(
service.run_update,
database=extension.database,
@@ -249,27 +283,35 @@ def _start(kind: str) -> None:
timeout=int(extension.settings.get(conn, "maintenance_command_timeout") or 900),
)
else:
weg = (
service.METHOD_RPC
if str(device["category"]) == inventory.NON_SSH_CATEGORY
else service.METHOD_SSH
)
# Sofort sichtbar, nicht erst wenn das Geraet zurueck ist.
with transaction() as verbindung:
monitor.mark_restarting(verbindung, int(device["id"]))
monitor.mark_restarting(
verbindung,
int(device["id"]),
live=extension.registry.get("live_log"),
trigger="maintenance",
method=weg,
)
service.start_background(
service.run_reboot,
database=extension.database,
live=extension.registry.get("live_log"),
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
),
# Der Weg steht oben: in ``category`` liegt die Kategorie der
# *Zugangsdaten* (siehe _query_devices), daraus folgt er.
# Uebergeben statt in run_reboot erraten -- dort bedeutet
# derselbe Schluessel je Herkunft etwas anderes.
method=weg,
)
started += 1
@@ -289,3 +331,22 @@ def start_update():
def start_reboot():
_start(service.JOB_KIND_REBOOT)
return redirect(url_for("maintenance.overview"))
@bp.post("/command")
@permission_required("servicing_command.execute")
@reauth_required
def start_command():
"""Eine Befehlszeile auf allen ausgewaehlten Clients.
Dieselbe Schwelle wie die Konsole: eigenes Recht **und** eine frische
Passwortbestaetigung. Wer hier etwas eintippt, tut es auf einem Dutzend
Maschinen gleichzeitig, und niemand sieht dabei zu.
"""
try:
befehl = service.befehl_pruefen(request.form.get("befehl", ""))
except ValueError as exc:
flash(str(exc), "danger")
return redirect(url_for("maintenance.overview"))
_start(service.JOB_KIND_COMMAND, befehl=befehl)
return redirect(url_for("maintenance.overview"))
+161 -33
View File
@@ -30,7 +30,15 @@ import threading
import time
from typing import Any
from flask import Blueprint, abort, flash, redirect, render_template, url_for
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, reauth_is_fresh
@@ -62,6 +70,30 @@ def _geraet(conn: Any, device_id: int) -> dict[str, Any] | None:
return device
def _erklaerung(fehler: OSError) -> str:
"""Die Fehlernummer in einem Satz, der weiterhilft.
"No route to host" und "Connection refused" sehen im Protokoll gleich
harmlos aus und bedeuten das Gegenteil voneinander: einmal kommt man gar
nicht hin, einmal kommt man hin und niemand hoert zu. Und beides hat mit
einem gelungenen Ping nichts zu tun -- der prueft eine andere Schicht.
"""
import errno as fehlernummern
nummer = getattr(fehler, "errno", None)
if nummer == fehlernummern.EHOSTUNREACH:
return (
f"{fehler} – kein Weg dorthin. Eine Firewall oder eine Route steht "
"dazwischen; ein gelungener Ping sagt darüber nichts, der prüft eine "
"andere Schicht"
)
if nummer == fehlernummern.ECONNREFUSED:
return f"{fehler} – das Gerät ist erreichbar, aber auf diesem Port hört niemand"
if nummer == fehlernummern.ETIMEDOUT:
return f"{fehler} – keine Antwort; Paketfilter verwerfen meist stillschweigend"
return str(fehler)
def _port(conn: Any) -> int:
extension = core()
if not extension.settings.get(conn, "vnc_enabled"):
@@ -92,42 +124,51 @@ def screen(device_id: int):
abort(404)
zugang = vnc.zugang_gemerkt(device, port=port)
benutzer, passwort = "", ""
darf_geheimnis = bool(current_user.has("credentials.secrets"))
if device.get("credential_id") and darf_geheimnis:
try:
benutzer, passwort = inventory.credential_secret(
conn, core().keystore, int(device["credential_id"])
)
except inventory.ValidationError:
benutzer, passwort = "", ""
if passwort:
with core().database.transaction(conn):
audit.log(
conn,
actor=current_user.username,
actor_id=current_user.id,
action="device.vnc_credentials_used",
target=str(device["name"]),
detail=(
f"Hinterlegte Zugangsdaten ({benutzer}) an die Bildschirmsitzung "
"übergeben"
),
ip=client_ip(),
severity="warning",
)
return render_template(
"tesm/screen.html",
device=device,
zugang=zugang,
websocket_path=f"/ws/screen/{device_id}",
vorgaben={"username": benutzer, "password": passwort},
darf_geheimnis=darf_geheimnis,
quelle=url_for("screen.session_json", device_id=device_id),
darf_geheimnis=bool(current_user.has("credentials.secrets")),
verfahren_name=vnc.verfahren_name,
)
def _zugangsdaten(conn: Any, device: dict[str, Any]) -> tuple[str, str]:
"""Hinterlegte Zugangsdaten -- nur fuer den, der Geheimnisse sehen darf.
Weil der Browser die Anmeldung fuehrt, muessen sie in die Seite. Wer
Geheimnisse nicht sehen darf, bekommt sie auch hier nicht: sonst waere die
Bildschirmsitzung der Umweg, auf dem jeder das Passwort liest. Jede
Herausgabe steht im Aenderungsprotokoll -- das ist kein Zusehen, sondern
ein Zugriff auf etwas, das sonst niemand sieht.
"""
if not device.get("credential_id") or not current_user.has("credentials.secrets"):
return "", ""
try:
benutzer, passwort = inventory.credential_secret(
conn, core().keystore, int(device["credential_id"])
)
except inventory.ValidationError:
return "", ""
if passwort:
with core().database.transaction(conn):
audit.log(
conn,
actor=current_user.username,
actor_id=current_user.id,
action="device.vnc_credentials_used",
target=str(device["name"]),
detail=(
f"Hinterlegte Zugangsdaten ({benutzer}) an die Bildschirmsitzung "
"übergeben"
),
ip=client_ip(),
severity="warning",
)
return benutzer, passwort
@bp.post("/monitoring/clients/<int:device_id>/screen/probe")
@permission_required("device_vnc.execute")
@reauth_required
@@ -165,6 +206,73 @@ def probe(device_id: int):
return redirect(url_for("screen.screen", device_id=device_id))
@bp.get("/monitoring/clients/<int:device_id>/screen/session")
@permission_required("device_vnc.execute")
def session_json(device_id: int):
"""Alles, was der Client fuer eine Sitzung braucht -- als Auskunft.
Warum nicht in die Seite geschrieben: dieselbe Sitzung laeuft auch im
Fenster der Statusuebersicht, und dort stuenden sonst die Zugangsdaten
**jedes** Geraets im Quelltext der Seite. So holt sie sich nur, wer eine
Sitzung wirklich oeffnet -- und nur fuer das eine Geraet.
Die frische Passwortbestaetigung wird hier von Hand geprueft statt ueber
den Dekorator: der wuerde auf eine Seite umleiten, und eine Umleitung ist
fuer einen Abruf im Hintergrund keine Auskunft. 409 mit einem Hinweis ist
eine.
"""
conn = db()
device = _geraet(conn, device_id)
if device is None:
abort(404)
port = _port(conn)
if not port:
abort(404)
if not reauth_is_fresh():
return {"reauth": True}, 409
zugang = vnc.zugang_gemerkt(device, port=port)
benutzer, passwort = _zugangsdaten(conn, device)
return {
"name": str(device["name"]),
"socket": f"/ws/screen/{device_id}",
"ziel": f"{zugang.ziel}:{zugang.port}",
"verfahren": list(zugang.verfahren),
"bewaehrt": zugang.bewaehrt,
"hinderungsgrund": zugang.hinderungsgrund,
"meldeweg": url_for("screen.merke_verfahren", device_id=device_id),
"vorgaben": {"username": benutzer, "password": passwort},
}
@bp.post("/monitoring/clients/<int:device_id>/screen/method")
@permission_required("device_vnc.execute")
def merke_verfahren(device_id: int):
"""Der Browser meldet, womit die Sitzung zustande kam.
Nur er weiss es: die Strecke reicht Bytes durch und liest das Protokoll
nicht. Beim naechsten Mal faengt die Seite damit an, statt wieder bei der
ersten Wahl des Servers -- die hier schon einmal abgerissen ist und deren
Absage sich ein sperrender RealVNC Server Minuten Zeit laesst.
"""
conn = db()
device = _geraet(conn, device_id)
if device is None:
abort(404)
try:
verfahren = int(request.json.get("verfahren", 0)) if request.json else 0
except (TypeError, ValueError):
verfahren = 0
if not 0 < verfahren < 256:
return {"ok": False}, 400
from ..services import monitor
with core().database.transaction(conn):
monitor.merke_verfahren_das_traegt(conn, int(device["id"]), verfahren)
return {"ok": True}
def init_app(app) -> None: # type: ignore[no-untyped-def]
"""Meldet Seite und -- falls verfuegbar -- die WebSocket-Strecke an."""
app.register_blueprint(bp)
@@ -230,10 +338,26 @@ def _strecke(ws, device_id: int) -> None: # type: ignore[no-untyped-def]
ws.close(1008, "Für diesen Client ist kein Ziel bekannt.")
return
try:
gegenstelle = socket.create_connection((host, port), timeout=VERBINDUNGSLIMIT)
except OSError as exc:
ws.close(1011, f"{host}:{port} antwortet nicht ({exc}).")
gegenstelle = None
ausweich = str(device.get("ip") or "").strip()
for versuch, ziel_host in enumerate([host] + ([ausweich] if ausweich != host else [])):
try:
gegenstelle = socket.create_connection((ziel_host, port), timeout=VERBINDUNGSLIMIT)
if versuch:
# Der Name fuehrte ins Leere, die Adresse nicht. Das ist eine
# Auskunft ueber das Netz, keine Nebensaechlichkeit: irgendwo
# zeigt ein Eintrag woandershin.
if live_log := extension.registry.get("live_log"):
live_log.warning(
"vnc",
f"{host} war nicht erreichbar – Sitzung läuft über {ziel_host}",
)
host = ziel_host
break
except OSError as exc:
letzter = exc
if gegenstelle is None:
ws.close(1011, f"{host}:{port} antwortet nicht ({_erklaerung(letzter)}).")
return
# Das Zeitlimit galt dem *Aufbau* -- als Abbruchgrund taugt es nicht.
#
@@ -246,6 +370,10 @@ def _strecke(ws, device_id: int) -> None: # type: ignore[no-untyped-def]
# Stattdessen ein kurzer Takt: das Lesen laeuft immer wieder in ein
# Zeitlimit, und *das* ist kein Abbruch, sondern der Anlass nachzusehen,
# ob die Sitzung ueberhaupt noch etwas tut.
# Ohne das sammelt der Kern kleine Pakete, bis genug beisammen ist
# (Nagle) -- bei einem Bildschirm sind Tastendruck und Mausbewegung genau
# solche kleinen Pakete, und das Warten darauf faellt als Ruckeln auf.
gegenstelle.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
gegenstelle.settimeout(TAKT)
leerlauf = int(extension.settings.get(conn, "vnc_idle_timeout") or 0)
+10
View File
@@ -76,6 +76,16 @@ CLIENT_COMMANDS: tuple[tuple[str, str], ...] = (
("journalctl -p err -n 50 --no-pager", "Letzte Fehler im Journal"),
("apt list --upgradable 2>/dev/null", "Verfügbare Aktualisierungen"),
("uname -a", "Kernel und Architektur"),
# Die beiden schreiben -- deshalb stehen sie am Ende und sagen es im Namen.
# Eine Vorlage fuellt nur das Eingabefeld; abgeschickt wird von Hand.
(
'echo "Encryption=AlwaysOff" | sudo tee -a /etc/vnc/config.d/common.custom',
"VNC: Streckenverschlüsselung abschalten (schreibt)",
),
(
"sudo systemctl restart vncserver-x11-serviced",
"VNC-Dienst neu starten (schreibt)",
),
)
+38
View File
@@ -110,6 +110,36 @@ def cmd_create_admin(args: argparse.Namespace) -> int:
return 0
def cmd_license(args: argparse.Namespace) -> int:
"""Die Lizenz als Auskunft fuer ein Ueberwachungswerkzeug.
Gedacht fuer einen RMM-Agenten, der auf dieser Maschine laeuft: kein Netz,
kein Token, keine Anmeldung. Die Antwort steht auf **stdout**, alles andere
(Protokollzeilen des Starts) auf stderr -- ein ``| ConvertFrom-Json`` soll
nicht daran scheitern, dass eine Migration etwas erzaehlt hat.
Der Ausstiegscode traegt die Aussage: 0 heil, 1 laeuft bald ab, 2
ungueltig. Das ist die Sprache, in der ein RMM Alarm schlaegt -- Text
auszuwerten waere der Umweg.
"""
from tesm_core.licensing_client import abfrage_ausstiegscode, zustand_als_auskunft
app, extension = _app_and_core()
if extension.license is None:
print("{}")
return 2
with app.app_context(), extension.database.session() as conn:
auskunft = zustand_als_auskunft(extension.license.reload(conn))
if getattr(args, "days", False):
# Eine Zahl, sonst nichts -- fuer Werkzeuge, die keinen JSON-Parser
# mitbringen. Unbefristet hat keine Zahl und sagt das auch.
tage = auskunft.get("days_left")
print("unbefristet" if auskunft.get("lifetime") else ("" if tage is None else int(tage)))
else:
print(json.dumps(auskunft, ensure_ascii=False, sort_keys=True))
return abfrage_ausstiegscode(auskunft)
def cmd_status(args: argparse.Namespace) -> int:
app, extension = _app_and_core()
with app.app_context(), extension.database.session() as conn:
@@ -552,6 +582,14 @@ def build_parser() -> argparse.ArgumentParser:
status.add_argument("--json", action="store_true")
status.set_defaults(func=cmd_status)
lizenz = sub.add_parser(
"license", help="Lizenzzustand als JSON ausgeben (für Überwachungswerkzeuge)"
)
lizenz.add_argument(
"--days", action="store_true", help="Nur die Restlaufzeit in Tagen ausgeben"
)
lizenz.set_defaults(func=cmd_license)
house = sub.add_parser("housekeeping", help="Sitzungen, Ratenereignisse, Protokolle aufräumen")
house.set_defaults(func=cmd_housekeeping)
+1 -1
View File
@@ -128,7 +128,7 @@ NAVIGATION = NavTree(
children=(
NavItem(
key="logs_live",
label="Laufend",
label="Live",
icon="terminal",
endpoint="logs.live",
permissions=("logs_live.read",),
+53 -2
View File
@@ -187,6 +187,24 @@ SERVICING_TOOL = Resource(
action_hints={A.EXECUTE: "Installation auslösen."},
)
#: Ein freier Befehl auf vielen Geraeten ist das Maechtigste, was diese
#: Anwendung kann -- und das Einzige, dessen Wirkung sie nicht kennt. Deshalb
#: ein eigenes Recht, das niemand nebenbei bekommt: wer Pakete aktualisieren
#: oder neu starten darf, tut etwas Umrissenes; wer hier etwas eintippt, tut
#: alles, was ein Konto auf dem Geraet tun kann. Die Vorlagen nehmen dem den
#: Alltag ab, nicht die Verantwortung.
SERVICING_COMMAND = Resource(
key="servicing_command",
label="Befehl auf mehreren Geräten",
actions=(A.READ, A.EXECUTE),
hint="Eine Befehlszeile über SSH, auf allen ausgewählten Clients",
action_hints={
A.READ: "Die Karte sehen – und damit, was zuletzt lief.",
A.EXECUTE: "Befehl absenden. Verlangt eine frische Passwortbestätigung; "
"jeder Auftrag steht mit seinem Wortlaut im Änderungsprotokoll.",
},
)
#: Die Wartungsseite zeigt alle drei Aufgaben. Sie oeffnet, wer **eine** davon
#: sehen darf -- und zeigt dann nur die. Als Konstante, damit die vier Routen
#: dieselbe Antwort geben; einzeln getippt liefen sie irgendwann auseinander.
@@ -194,6 +212,7 @@ SERVICING_PAGE: tuple[str, ...] = (
"servicing_update.read",
"servicing_reboot.read",
"servicing_tool.read",
"servicing_command.read",
)
#: Elf Funktionen hingen an ``dhcp.edit``: Grundeinstellungen, Subnetze,
@@ -245,6 +264,31 @@ DHCP_SERVICE = Resource(
action_hints={A.EXECUTE: "Dienstzustand ändern."},
)
#: Eine dynamische Lease in einen Client verwandeln.
#:
#: Eigene Zeile, weil es eine eigene Entscheidung ist: hier wird aus einer
#: Beobachtung ("dieses Geraet hat sich eine Adresse geholt") ein gepflegter
#: Datensatz, den die Ueberwachung ab sofort anpingt und der beim naechsten
#: Schreiben der Konfiguration eine feste Adresse bekommt. Wer Leases sehen
#: darf, soll deshalb nicht automatisch welche uebernehmen duerfen.
#:
#: **Und es reicht allein nicht.** Die Uebernahme legt einen Client an, also
#: verlangt die Route zusaetzlich "Clients: Anlegen". Sonst waere dies eine
#: Hintertuer an der Geraeteverwaltung vorbei -- dieselbe Wirkung, anderes
#: Formular. Beide Haken zusammen, oder die Schaltflaeche erscheint nicht.
DHCP_LEASE_ADOPT = Resource(
key="dhcp_lease_adopt",
parent="dhcp_subnets",
label="DHCP: Lease als Client übernehmen",
actions=(A.READ, A.EXECUTE),
hint="Aus einer dynamisch vergebenen Adresse einen überwachten Client machen",
action_hints={
A.READ: "Die Schaltfläche an dynamischen Leases sehen.",
A.EXECUTE: "Client aus der Lease anlegen. Verlangt zusätzlich "
"'Clients: Anlegen' – es entsteht ein Gerätedatensatz.",
},
)
#: Die DHCP-Seite zeigt alle Abschnitte; jeder Abschnitt haengt an seiner
#: eigenen Zeile. Geoeffnet wird sie, wer einen davon sehen darf.
DHCP_PAGE: tuple[str, ...] = (
@@ -354,14 +398,21 @@ MAINTENANCE_AREA = Area(
icon="wrench",
hint="Paketpflege und Neustarts. Ohne diesen Zugang bleibt der Bereich "
"verborgen – auch wenn einzelne Rechte darunter gesetzt sind.",
resources=(SERVICING_UPDATE, SERVICING_REBOOT, SERVICING_TOOL),
resources=(SERVICING_UPDATE, SERVICING_REBOOT, SERVICING_COMMAND, SERVICING_TOOL),
)
NETWORK_AREA = Area(
key="network",
label="Netzwerkdienste",
icon="network",
resources=(DHCP_SUBNETS, DHCP_OPTIONS, DHCP_RESERVATIONS, DHCP_CONFIG, DHCP_SERVICE),
resources=(
DHCP_SUBNETS,
DHCP_OPTIONS,
DHCP_RESERVATIONS,
DHCP_LEASE_ADOPT,
DHCP_CONFIG,
DHCP_SERVICE,
),
)
FILES_AREA = Area(
+14
View File
@@ -539,6 +539,19 @@ ALTER TABLE device_status ADD COLUMN vnc_types_at TEXT;
"""
# Version 14 ----------------------------------------------------------------
#
# Welches Anmeldeverfahren zuletzt wirklich getragen hat. Der Server nennt
# seine Reihenfolge, und die gilt -- aber wenn seine erste Wahl schon einmal
# abgerissen ist und eine andere zum Bild gefuehrt hat, ist das ein Befund.
# Ihn zu vergessen kostet bei jedem Oeffnen einen Fehlversuch, und RealVNC
# laesst sich mit der Absage Zeit: gemessen zwei bis drei Minuten, bis das
# Bild stand.
V14 = """
ALTER TABLE device_status ADD COLUMN vnc_method INTEGER NOT NULL DEFAULT 0;
"""
def uebergang_standardgruppen(conn) -> None: # type: ignore[no-untyped-def]
"""Aus sieben Standardgruppen werden drei -- ohne jemandem etwas wegzunehmen.
@@ -585,4 +598,5 @@ TESM_MIGRATIONS: tuple[Migration, ...] = (
Migration(version=11, name="vnc-ziel", sql=V11),
Migration(version=12, name="vnc-verfahren", sql=V12),
Migration(version=13, name="vnc-absage", sql=V13),
Migration(version=14, name="vnc-verfahren-das-traegt", sql=V14),
)
+2 -1
View File
@@ -545,7 +545,8 @@ def list_devices(
st.fail_count, COALESCE(st.vnc_open, 0) AS vnc_open, st.vnc_checked_at,
COALESCE(st.vnc_target, '') AS vnc_target,
COALESCE(st.vnc_types, '') AS vnc_types,
COALESCE(st.vnc_reason, '') AS vnc_reason, st.vnc_types_at
COALESCE(st.vnc_reason, '') AS vnc_reason, st.vnc_types_at,
COALESCE(st.vnc_method, 0) AS vnc_method
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
+547 -9
View File
@@ -186,9 +186,20 @@ def matching_network(
def validate_subnet(
networks: Iterable[InterfaceNetwork], interface: str, range_start: str, range_end: str
networks: Iterable[InterfaceNetwork],
interface: str,
range_start: str,
range_end: str,
*,
vorhandene: Iterable[Any] = (),
eigene_id: int | None = None,
) -> str:
"""Rueckgabe: leerer String bei Erfolg, sonst die Fehlermeldung."""
"""Rueckgabe: leerer String bei Erfolg, sonst die Fehlermeldung.
``vorhandene`` sind die schon eingetragenen Bereiche. Ueberschneidet sich
der neue mit einem davon, entsteht eine Konfiguration, die Kea als Ganzes
ablehnt -- besser hier sagen als spaeter beim Schreiben.
"""
if not interface:
return "Es ist kein Interface ausgewählt."
try:
@@ -203,9 +214,41 @@ def validate_subnet(
f"Der Bereich {range_start}-{range_end} passt in kein Netz von {interface}. "
"Subnetz und Maske werden vom laufenden System gelesen."
)
for zeile in vorhandene:
if eigene_id is not None and int(zeile["id"]) == eigene_id:
continue
if str(zeile["interface"]) != interface:
continue
try:
anfang = int(ipaddress.ip_address(str(zeile["range_start"])))
ende = int(ipaddress.ip_address(str(zeile["range_end"])))
except ValueError:
continue
if int(start) <= ende and int(end) >= anfang:
return (
f"Der Bereich überschneidet sich mit {zeile['range_start']}-"
f"{zeile['range_end']} auf {interface}. Erweitern Sie den vorhandenen "
"Bereich, statt einen zweiten anzulegen."
)
return ""
def laufende_konfiguration(sysops: SysOps) -> dict[str, Any] | None:
"""Die Konfiguration, die tatsaechlich auf der Platte liegt.
``None`` heisst: nicht lesbar (kein Helfer, Datei fehlt, kein JSON) -- das
ist etwas anderes als "weicht ab" und wird auch anders angezeigt.
"""
result = sysops.read_privileged_file(DEFAULT_CONFIG_PATH)
if not result.ok or not (result.stdout or "").strip():
return None
try:
geladen = json.loads(result.stdout)
except (json.JSONDecodeError, ValueError):
return None
return geladen if isinstance(geladen, dict) else None
# --------------------------------------------------------------------------- #
# Reservierungen
# --------------------------------------------------------------------------- #
@@ -393,8 +436,16 @@ def render_config(
return entries
subnet_entries: list[dict[str, Any]] = []
# Ein Netz, ein Eintrag. Kea lehnt die **ganze** Konfiguration ab, sobald
# derselbe Praefix zweimal darin steht ("subnet with the prefix of
# '192.168.82.0/24' already exists") -- und dann laeuft der Dienst
# unbemerkt mit dem alten Stand weiter. Zwei Zeilen im selben Netz sind
# aber nichts Unsinniges: es sind zwei Bereiche. Sie werden deshalb zu
# einem Subnetz mit mehreren Pools zusammengefasst.
nach_netz: dict[str, dict[str, Any]] = {}
belegte_bereiche: dict[str, list[tuple[int, int]]] = {}
interfaces: set[str] = set()
for index, subnet in enumerate(subnets, start=1):
for subnet in subnets:
interface = str(subnet["interface"])
match = matching_network(
networks, interface, str(subnet["range_start"]), str(subnet["range_end"])
@@ -433,15 +484,40 @@ def render_config(
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']}"}],
schluessel = str(network)
eintrag = nach_netz.get(schluessel)
if eintrag is None:
eintrag = {
# Die Nummer zaehlt die Eintraege, nicht die Zeilen der
# Tabelle -- sonst haette ein uebersprungenes Subnetz eine
# Luecke hinterlassen, und Kea verlangt eindeutige IDs.
"id": len(subnet_entries) + 1,
"subnet": schluessel,
"pools": [],
**({"option-data": subnet_options} if subnet_options else {}),
"reservations": subnet_reservations,
}
nach_netz[schluessel] = eintrag
belegte_bereiche[schluessel] = []
subnet_entries.append(eintrag)
elif subnet_options and subnet_options != eintrag.get("option-data", []):
warnings.append(
f"Für {schluessel} sind mehrere Bereiche mit unterschiedlichem Gateway oder "
"DNS eingetragen. Ein Netz kann nur eines haben | es gilt der erste Eintrag."
)
# Auch zwei Pools, die sich ueberschneiden, bringen Kea zu Fall.
von, bis = int(ipaddress.ip_address(str(subnet["range_start"]))), int(
ipaddress.ip_address(str(subnet["range_end"]))
)
if any(von <= ende and bis >= anfang for anfang, ende in belegte_bereiche[schluessel]):
warnings.append(
f"Der Bereich {subnet['range_start']}-{subnet['range_end']} überschneidet sich "
"mit einem bereits eingetragenen und wurde übersprungen."
)
continue
belegte_bereiche[schluessel].append((von, bis))
eintrag["pools"].append({"pool": f"{subnet['range_start']} - {subnet['range_end']}"})
if not subnet_entries:
warnings.append("Es ist kein gültiges Subnetz konfiguriert | Kea würde nicht starten.")
@@ -469,6 +545,30 @@ def render_config(
if not int(row["is_standard"] or 0)
]
# Das Verzeichnis: Code, Name und Typ der eigenen Optionen, die dieser
# Server tatsaechlich sendet. Es wird nicht gespeichert, sondern aus dem
# Katalog abgeleitet -- gespeichert waere es eine zweite Wahrheit.
belegte_codes = {int(row["code"]) for row in option_rows}
gesendete_ids = set(global_values) | {
option_id for werte in device_values.values() for option_id in werte
}
verzeichnis = " ".join(
f"{int(row['code'])}:{row['name']}:{row['type']}"
for row in option_rows
if not int(row["is_standard"] or 0) and int(row["id"]) in gesendete_ids
)
if verzeichnis and KATALOG_CODE not in belegte_codes:
custom_defs.append(
{"name": KATALOG_NAME, "code": KATALOG_CODE, "type": "string", "space": "dhcp4"}
)
global_option_data.append({"name": KATALOG_NAME, "data": verzeichnis})
elif verzeichnis:
warnings.append(
f"Code {KATALOG_CODE} ist von einer eigenen Option belegt | das Verzeichnis für "
"die Clients wird nicht mitgesendet. Neue Optionen sind dort dann nur unter "
"ihrer Nummer zu sehen, bis die Konfiguration erneut verteilt wird."
)
config = {
"Dhcp4": {
"interfaces-config": {"interfaces": sorted(interfaces)},
@@ -653,9 +753,447 @@ def read_leases(sysops: SysOps, *, path: str = LEASE_FILE) -> list[Lease]:
return sorted(latest.values(), key=lambda entry: tuple(int(p) for p in entry.address.split(".") if p.isdigit()))
#: Der Bereich, den DHCP fuer eigene Optionen vorsieht (RFC 2132: "site
#: specific"). **Nur** hier hinein gehoeren eigene Codes, und zwar nicht aus
#: Ordnungsliebe: systemd-networkd hebt eine unbekannte Option ausschliesslich
#: dann auf, wenn ihr Code in diesem Bereich liegt. Alles darunter wirft es
#: weg, ohne dass irgendwo etwas danebengeht.
PRIVATBEREICH = range(224, 255)
#: Code und Name des Verzeichnisses, das der Server ueber sich selbst mitsendet.
#:
#: **Warum es das gibt.** Auf der Leitung reisen nur Code, Laenge und Bytes --
#: kein Name, kein Typ. Ein Client kann darum nicht wissen, dass 225 "url"
#: heisst und Text enthaelt. Bisher bekam er die Zuordnung beim Verteilen
#: mit; kam spaeter eine Option dazu, musste man erneut verteilen. Also sendet
#: der Server sein Verzeichnis einfach mit: "225:url:string 226:monitor:...".
#: Der Uebersetzer auf dem Client liest es bei jeder Lease neu -- eine neue
#: Option ist damit sofort unter ihrem Namen lesbar, ohne den Client
#: anzufassen.
KATALOG_CODE = 224
KATALOG_NAME = "tesm-katalog"
#: Optionen, die jeder DHCP-Client von sich aus erfragt. Sie in die
#: Client-Konfiguration zu schreiben waere Ballast -- und wer Ballast liest,
#: liest irgendwann gar nicht mehr.
IMMER_ANGEFRAGT = frozenset({1, 3, 6, 12, 15, 28, 42, 51, 54, 58, 59, 119, 121})
#: Die Stile, in denen eine Client-Konfiguration ausgegeben wird.
#:
#: Drei, weil es auf den Zielsystemen drei gibt, und die Wahl nicht am
#: Geschmack haengt, sondern daran, was die Distribution mitbringt:
#:
#: * **networkd** -- Ubuntu-Server 22.04 bis 26.04 (netplan erzeugt die
#: .network-Dateien), auch auf Debian verbreitet. Angeforderte Fremdoptionen
#: landen als ``OPTION_<code>`` in der Lease unter /run/systemd/netif/leases.
#: * **dhcpcd** -- Debian 12/13, Raspberry Pi OS, ab Ubuntu 24.04 verfuegbar.
#: * **dhclient** -- Debian 11 bis 13 und Ubuntu 22.04, dort wo isc-dhcp-client
#: noch installiert ist.
CLIENT_STILE: tuple[tuple[str, str, str], ...] = (
# Der Zusatz gehoert zu der .network-Datei, die fuer die Schnittstelle
# wirklich gilt -- bei netplan heisst die 10-netplan-<if>.network und liegt
# unter /run. Ein Verzeichnis "10-tesm.network.d" liest niemand: es gibt
# keine 10-tesm.network. Den Namen ermittelt das Skript auf dem Client.
("networkd", "systemd-networkd", "/etc/systemd/network/<aktive>.network.d/50-tesm.conf"),
("dhcpcd", "dhcpcd", "/etc/dhcpcd.conf"),
("dhclient", "dhclient (isc-dhcp-client)", "/etc/dhcp/dhclient.conf"),
)
#: Wie ein Typ aus dem Optionskatalog in den jeweiligen Clients heisst.
_DHCPCD_TYPEN = {
"string": "string",
"ipv4-address": "ipaddress",
"uint8": "byte",
"uint16": "uint16",
"uint32": "uint32",
"boolean": "byte",
}
_DHCLIENT_TYPEN = {
"string": "text",
"ipv4-address": "ip-address",
"uint8": "unsigned integer 8",
"uint16": "unsigned integer 16",
"uint32": "unsigned integer 32",
"boolean": "boolean",
}
def gesendete_optionen(conn: sqlite3.Connection) -> list[dict[str, Any]]:
"""Die Optionen, die dieser Server tatsaechlich ausliefert.
Gemeint ist: wofuer irgendwo ein Wert gesetzt ist -- global oder an einem
einzelnen Geraet. Was im Katalog steht, aber leer ist, wird nicht gesendet
und muss vom Client auch nicht angefordert werden.
"""
return Database.all(
conn,
"""
SELECT DISTINCT o.code, o.name, o.type, o.description, o.is_standard
FROM dhcp_options o
JOIN dhcp_option_values v ON v.option_id = o.id
WHERE TRIM(COALESCE(v.value, '')) <> ''
ORDER BY o.code
""",
)
#: Traegt den Zusatz dort ein, wo networkd ihn auch liest.
NETWORKD_SKRIPT = """set -e
schnittstelle=$(ip -o -4 route show default 2>/dev/null | awk '{print $5; exit}')
[ -n "$schnittstelle" ] || { echo "Keine Standardroute -- Schnittstelle unbekannt." >&2; exit 1; }
datei=$(COLUMNS=400 SYSTEMD_COLORS=0 networkctl status "$schnittstelle" 2>/dev/null | sed -n 's/^ *Network File: *//p' | head -n1)
case "${datei:-}" in
""|n/a|*not*) datei=$(grep -ls "Name=$schnittstelle" /run/systemd/network/*.network /etc/systemd/network/*.network 2>/dev/null | head -n1) ;;
esac
[ -n "${datei:-}" ] || { echo "Keine .network-Datei fuer $schnittstelle gefunden." >&2; exit 1; }
ziel="/etc/systemd/network/$(basename "$datei").d/50-tesm.conf"
mkdir -p "$(dirname "$ziel")"
printf %s '@FRACHT@' | base64 -d > "$ziel"
chmod 0644 "$ziel"
echo "geschrieben: $ziel"
# Der Uebersetzer: systemd legt fremde Optionen roh und unter ihrer Nummer ab
# ("OPTION_225=687474..."), weil im DHCP-Paket weder Name noch Typ steht.
# Beides weiss nur dieser Server -- also schreibt er die Zuordnung mit.
mkdir -p /usr/local/lib/tesm
printf %s '@UEBERSETZER@' | base64 -d > /usr/local/lib/tesm/dhcp-options
chmod 0755 /usr/local/lib/tesm/dhcp-options
printf %s '@DIENST@' | base64 -d > /etc/systemd/system/tesm-dhcp-options.service
printf %s '@PFADEINHEIT@' | base64 -d > /etc/systemd/system/tesm-dhcp-options.path
chmod 0644 /etc/systemd/system/tesm-dhcp-options.service /etc/systemd/system/tesm-dhcp-options.path
systemctl daemon-reload
systemctl enable --now tesm-dhcp-options.path >/dev/null 2>&1 || true
systemctl enable tesm-dhcp-options.service >/dev/null 2>&1 || true
networkctl reload
networkctl reconfigure "$schnittstelle"
# Der Pfadwaechter loest bei der naechsten Lease aus; einmal von Hand, damit
# die Datei sofort dasteht.
sleep 2
systemctl start tesm-dhcp-options.service || true
echo "Lease: /run/systemd/netif/leases/$(cat /sys/class/net/"$schnittstelle"/ifindex)"
echo "Klartext: /run/tesm-dhcp-options.env"
cat /run/tesm-dhcp-options.env 2>/dev/null || true
"""
#: Uebersetzt die rohen Optionen der Lease in "name=wert".
#:
#: Warum es das braucht: in einem DHCP-Paket stehen nur Nummer, Laenge und
#: Bytes. Dass 225 "url" heisst und eine Zeichenkette ist, steht allein im
#: Katalog dieses Servers -- systemd kann es nicht wissen und legt deshalb
#: Hex unter der Nummer ab. Diese Zuordnung reist mit.
UEBERSETZER = r"""#!/bin/sh
# Von TESM erzeugt. Schreibt die eigenen DHCP-Optionen im Klartext nach
# /run/tesm-dhcp-options.env -- eine Zeile "name=wert" je Option.
#
# Uebersetzt wird **alles**, was in der Lease steht, nicht eine feste Liste:
# Namen und Typen kommen aus dem Verzeichnis, das der Server als Option
# @KATALOG@ mitsendet. Eine neue Option ist damit sofort unter ihrem Namen
# lesbar, ohne dass hier etwas geaendert werden muesste. Fehlt das
# Verzeichnis, gilt die Zuordnung vom Tag des Verteilens; fehlt auch die,
# heisst die Option nach ihrer Nummer.
set -e
ziel=/run/tesm-dhcp-options.env
ersatz="@PAARE@"
schnittstelle=$(ip -o -4 route show default 2>/dev/null | awk '{print $5; exit}')
[ -n "$schnittstelle" ] || exit 0
lease=/run/systemd/netif/leases/$(cat /sys/class/net/"$schnittstelle"/ifindex 2>/dev/null)
[ -f "$lease" ] || exit 0
text() { /usr/bin/printf "$(echo "$1" | sed 's/../@BS@x&/g')"; }
entziffere() {
# $1 = Hexfolge, $2 = Typ. Ohne den Typ waere jede Adresse Buchstabensalat:
# eine IPv4-Adresse reist als vier rohe Bytes, nicht als "192.168.0.1".
case "$2" in
ipv4-address)
set -- $(echo "$1" | sed 's/../& /g')
[ $# -ge 4 ] || return 0
printf '%d.%d.%d.%d' "0x$1" "0x$2" "0x$3" "0x$4"
;;
uint8|uint16|uint32)
printf '%d' "0x$1"
;;
boolean)
case "$1" in 00) printf 'nein' ;; *) printf 'ja' ;; esac
;;
*)
text "$1"
;;
esac
}
nachschlagen() {
# Erst im mitgesendeten Verzeichnis, dann in der Zuordnung vom Verteilen.
for eintrag in $verzeichnis $ersatz; do
case "$eintrag" in
"$1":*)
rest=${eintrag#*:}
echo "${rest%%:*} ${rest#*:}"
return 0
;;
esac
done
echo "option_$1 string"
}
verzeichnis=$(text "$(sed -n 's/^OPTION_@KATALOG@=//p' "$lease" | head -n1)")
zwischen=$(mktemp)
sed -n 's/^OPTION_\([0-9]*\)=\(.*\)$/\1 \2/p' "$lease" | while read -r code hex; do
[ "$code" = "@KATALOG@" ] && continue
angabe=$(nachschlagen "$code")
name=${angabe%% *}
typ=${angabe#* }
wert=$(entziffere "$hex" "$typ")
echo "$name=$wert" >> "$zwischen"
done
mv -f "$zwischen" "$ziel"
chmod 0644 "$ziel"
"""
#: Einmal beim Start und nach jeder Lease.
DIENST_EINHEIT = """[Unit]
Description=TESM -- DHCP-Optionen im Klartext
After=systemd-networkd.service
[Service]
Type=oneshot
ExecStart=/usr/local/lib/tesm/dhcp-options
RemainAfterExit=no
[Install]
WantedBy=multi-user.target
"""
PFAD_EINHEIT = """[Unit]
Description=TESM -- wacht ueber die DHCP-Lease
[Path]
PathModified=/run/systemd/netif/leases
Unit=tesm-dhcp-options.service
[Install]
WantedBy=multi-user.target
"""
#: Ersetzt den eigenen Block in einer Datei, die dem Client gehoert.
BLOCK_SKRIPT = """set -e
ziel=@ZIEL@
mkdir -p "$(dirname "$ziel")"
[ -f "$ziel" ] || : > "$ziel"
zwischen=$(mktemp)
sed '/^# >>> TESM/,/^# <<< TESM/d' "$ziel" > "$zwischen"
printf %s '@FRACHT@' | base64 -d >> "$zwischen"
cat "$zwischen" > "$ziel"
rm -f "$zwischen"
echo "ergaenzt: $ziel"
@DIENST@
"""
def client_konfiguration(conn: sqlite3.Connection, stil: str) -> dict[str, Any]:
"""Was ein Linux-Client braucht, damit die Optionen bei ihm ankommen.
**Warum es das braucht.** Ein DHCP-Client bekommt nur, wonach er fragt.
Standardoptionen (Gateway, DNS, Domain) fragt er von sich aus; alles
darueber hinaus -- und dazu gehoert jede eigene Option -- muss er
ausdruecklich anfordern. Ohne diese Zeilen sendet der Server brav und der
Client wirft es weg, ohne dass irgendwo etwas danebengeht.
Rueckgabe: ``{"pfad", "inhalt", "nachher", "skript", "optionen"}``.
``skript`` ist die Fassung zum Ausfuehren -- sie sucht sich das Ziel auf
dem Client selbst und traegt den Inhalt ein, ohne Vorhandenes zu
verlieren.
"""
optionen = [
eintrag
for eintrag in gesendete_optionen(conn)
if int(eintrag["code"]) not in IMMER_ANGEFRAGT
]
codes = [int(eintrag["code"]) for eintrag in optionen]
pfad = dict((s[0], s[2]) for s in CLIENT_STILE).get(stil, "")
if stil == "networkd":
zeilen = [
"# Von TESM erzeugt. Fordert die Optionen an, die dieser Server sendet --",
"# ohne diese Zeilen verwirft der Client sie stillschweigend.",
"#",
"# Diese Datei ist ein Zusatz zu der .network-Datei, die fuer die",
"# Schnittstelle gilt. Wie sie heisst, sagt 'networkctl status <if>';",
"# bei netplan ist es /run/systemd/network/10-netplan-<if>.network, der",
"# Zusatz gehoert dann nach",
"# /etc/systemd/network/10-netplan-<if>.network.d/50-tesm.conf.",
"#",
"# Die Werte stehen danach in /run/systemd/netif/leases/<ifindex>",
"# als OPTION_<code>= -- hexkodiert, weil systemd fremde Optionen nicht",
"# deutet. Den Klartext schreibt der mitgelieferte Uebersetzer nach",
"# /run/tesm-dhcp-options.env.",
"#",
"# Angefordert wird der ganze Bereich 224-254, damit eine spaeter",
"# hinzugefuegte Option ohne Zutun ankommt. Eigene Optionen ausserhalb",
"# dieses Bereichs hebt systemd-networkd nicht auf.",
"[DHCPv4]",
]
# Angefordert wird der **ganze** eigene Bereich, nicht nur das, was
# heute gesendet wird: sonst muesste man nach jeder neuen Option zu
# jedem Client. Ein Client bekommt trotzdem nur, was der Server hat.
angefragt = sorted(set(PRIVATBEREICH) | set(codes))
zeilen.append("RequestOptions=" + " ".join(str(code) for code in angefragt))
nachher = "networkctl reload && networkctl reconfigure <Schnittstelle>"
elif stil == "dhcpcd":
zeilen = [
"# >>> TESM -- nicht von Hand aendern, wird beim naechsten Mal ersetzt",
"# 'define' macht die Option bekannt, 'option' fordert sie an.",
"# Danach steht der Wert in der Lease (/var/lib/dhcpcd/<if>.lease)",
"# und laesst sich mit 'dhcpcd -U <if>' als Variable auslesen.",
]
for eintrag in optionen:
typ = _DHCPCD_TYPEN.get(str(eintrag["type"]), "string")
zeilen.append(f"define {eintrag['code']} {typ} {eintrag['name']}")
for eintrag in optionen:
zeilen.append(f"option {eintrag['name']}")
zeilen.append("# <<< TESM")
nachher = "systemctl restart dhcpcd"
else:
zeilen = [
"# >>> TESM -- nicht von Hand aendern, wird beim naechsten Mal ersetzt",
"# Die Werte stehen danach in /var/lib/dhcp/dhclient.leases,",
"# und in Hooks unter $new_<name>.",
]
for eintrag in optionen:
typ = _DHCLIENT_TYPEN.get(str(eintrag["type"]), "text")
zeilen.append(f"option {eintrag['name']} code {eintrag['code']} = {typ};")
if optionen:
zeilen.append(
"also request " + ", ".join(str(eintrag["name"]) for eintrag in optionen) + ";"
)
zeilen.append("# <<< TESM")
nachher = "systemctl restart networking || dhclient -r && dhclient"
inhalt = chr(10).join(zeilen) + chr(10)
return {
"pfad": pfad,
"inhalt": inhalt,
"nachher": nachher,
"skript": _uebernahme_skript(stil, inhalt, optionen),
"auslesen": _auslesen(stil, optionen),
"optionen": optionen,
}
def _auslesen(stil: str, benannt: list[dict[str, Any]]) -> dict[str, str]:
"""Wie der Wert auf dem Client wieder herauskommt.
Gehoert dazu, weil "angekommen" sonst niemand pruefen kann -- und weil ein
RMM-Agent genau diese Zeile braucht.
Zwei Wege bei systemd-networkd, und das hat einen Grund: in der Lease
steht der Wert **hexkodiert unter seiner Nummer**. Im DHCP-Paket reisen
nur Code, Laenge und Bytes; dass 225 "url" heisst und Text enthaelt, weiss
allein der Katalog dieses Servers. Deshalb legt der SSH-Push einen kleinen
Uebersetzer mit, der nach jeder Lease ``/run/tesm-dhcp-options.env``
schreibt -- "name=wert", lesbar. ``roh`` bleibt fuer den Fall, dass nur
die Datei von Hand kopiert wurde.
"""
if not benannt:
return {}
code = int(benannt[0]["code"])
name = str(benannt[0]["name"])
if stil == "networkd":
return {
"einfach": f"grep '^{name}=' /run/tesm-dhcp-options.env",
"roh": (
"lease=/run/systemd/netif/leases/$(cat /sys/class/net/$IF/ifindex); "
f"/usr/bin/printf \"$(sed -n 's/^OPTION_{code}=//p' \"$lease\" "
"| sed 's/../@BS@x&/g')\""
).replace("@BS@", "\\" * 2),
}
if stil == "dhcpcd":
# dhcpcd kennt den Namen, weil 'define' ihn ihm beigebracht hat.
return {"einfach": f"dhcpcd -U $IF | grep -i '^{name}='", "roh": ""}
return {
"einfach": f"grep -i 'option {name}' /var/lib/dhcp/dhclient.leases | tail -n1",
"roh": "",
}
def _b64(text: str) -> str:
import base64
return base64.b64encode(text.encode("utf-8")).decode("ascii")
def _uebernahme_skript(stil: str, inhalt: str, benannt: list[dict[str, Any]] | None = None) -> str:
"""Das Skript, das die Konfiguration auf dem Client eintraegt.
Zwei Dinge macht es anders als ein blosses ``tee``, und beide sind mit
Schaden bezahlt worden:
* Bei ``networkd`` **sucht es das Ziel auf dem Client**. Der Name des
Zusatzverzeichnisses muss zur aktiven .network-Datei passen; ein fester
Pfad legt eine Datei an, die kein Dienst je liest -- sie stand da, und
die Option fehlte trotzdem in der Lease.
* Bei ``dhcpcd`` und ``dhclient`` gehoeren die Zeilen in eine Datei, die
dem Client gehoert. Sie zu ueberschreiben haette seine uebrige
Konfiguration geloescht. Stattdessen wird nur der eigene Block zwischen
den Marken ersetzt -- beliebig oft wiederholbar.
Der Inhalt reist base64-kodiert und als **Argument**, nicht ueber stdin:
dort steht bei einem Client mit Passwort das Kennwort fuer ``sudo``.
"""
fracht = _b64(inhalt)
benannt = benannt or []
if stil == "networkd":
paare = " ".join(
f"{eintrag['code']}:{eintrag['name']}:{eintrag['type']}" for eintrag in benannt
)
uebersetzer = (
UEBERSETZER.replace("@PAARE@", paare)
.replace("@KATALOG@", str(KATALOG_CODE))
.replace("@BS@", "\\\\") # sed gibt aus zwei Zeichen eines aus
)
return (
NETWORKD_SKRIPT.replace("@FRACHT@", fracht)
.replace("@UEBERSETZER@", _b64(uebersetzer))
.replace("@DIENST@", _b64(DIENST_EINHEIT))
.replace("@PFADEINHEIT@", _b64(PFAD_EINHEIT))
)
ziel = "/etc/dhcpcd.conf" if stil == "dhcpcd" else "/etc/dhcp/dhclient.conf"
dienst = (
"systemctl restart dhcpcd"
if stil == "dhcpcd"
else "systemctl restart networking 2>/dev/null || { dhclient -r || true; dhclient; }"
)
return (
BLOCK_SKRIPT.replace("@ZIEL@", ziel)
.replace("@FRACHT@", fracht)
.replace("@DIENST@", dienst)
)
def service_state(sysops: SysOps) -> dict[str, str]:
state = sysops.service_state(SERVICE_UNIT)
import shutil
import subprocess
state["binary"] = "vorhanden" if shutil.which("kea-dhcp4") else "fehlt"
pfad = shutil.which("kea-dhcp4")
state["binary"] = "vorhanden" if pfad else "fehlt"
# Die Fassung gehoert auf die Seite: zwischen Kea 2.4 (Ubuntu 24.04) und
# 3.0 (26.04) liegen Aenderungen, und wer eine Konfiguration beurteilt,
# will nicht erst auf der Kommandozeile nachsehen muessen, wogegen.
state["version"] = ""
if pfad:
try:
fertig = subprocess.run(
[pfad, "-V"], capture_output=True, text=True, timeout=5, check=False
)
# ``kea-dhcp4 -V`` antwortet mit "3.0.3 (3.0.3 (tarball))" -- auf
# einer Kachel steht davon die Nummer, nicht die Herkunft.
state["version"] = (fertig.stdout or "").strip().split()[0].strip()
except (OSError, subprocess.SubprocessError, IndexError):
state["version"] = ""
return state
+275 -4
View File
@@ -29,7 +29,7 @@ import sqlite3
import threading
import time
from dataclasses import dataclass
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
from typing import Any, Callable
from tesm_core import audit
@@ -44,6 +44,51 @@ logger = logging.getLogger(__name__)
JOB_KIND_UPDATE = "maintenance.update"
JOB_KIND_REBOOT = "maintenance.reboot"
JOB_KIND_COMMAND = "maintenance.command"
#: Vorlagen fuer den Massenbefehl: Name, Befehl, Erklaerung.
#:
#: ``{sudo}`` wird zur Laufzeit ersetzt -- mit ``sudo -n``, wo es ohne Passwort
#: geht, sonst mit ``sudo -S`` und dem hinterlegten Passwort ueber stdin. Wer
#: eine Vorlage waehlt, sieht den Befehl im Feld und kann ihn aendern: eine
#: Vorlage ist ein Vorschlag, kein Automat.
BEFEHLSVORLAGEN: tuple[tuple[str, str, str], ...] = (
(
"VNC: Streckenverschlüsselung abschalten",
'echo "Encryption=AlwaysOff" | {sudo} tee -a /etc/vnc/config.d/common.custom',
"Danach bietet RealVNC auch RA2ne und „ohne Authentifizierung“ an – nur damit "
"kommt eine Sitzung im Browser zustande. Die Strecke ist dann unverschlüsselt; "
"im Netz zwischen Server und Gerät ist sie es ohnehin.",
),
(
"VNC-Dienst neu starten",
"{sudo} systemctl restart vncserver-x11-serviced",
"Räumt auch eine Sperre ab, die der Server nach zu vielen abgebrochenen "
"Verbindungsversuchen gesetzt hat.",
),
(
"VNC: aktuelle Einstellungen zeigen",
"cat /etc/vnc/config.d/common.custom 2>/dev/null || echo '(keine eigene Konfiguration)'",
"Liest nur – gut, um vor dem Ändern zu sehen, was schon dasteht.",
),
(
"Zeit und Zeitzone",
"timedatectl",
"Liest nur.",
),
(
"Belegung der Dateisysteme",
"df -h",
"Liest nur.",
),
)
#: Was in einer Befehlszeile nichts zu suchen hat. Nicht als Schutz vor einem
#: Menschen mit diesem Recht -- er darf ohnehin alles, was sein Konto auf dem
#: Geraet darf --, sondern gegen Unfaelle: ein Zeilenumbruch aus der
#: Zwischenablage macht aus einem Befehl unbemerkt zwei.
VERBOTENE_ZEICHEN = ("\n", "\r", "\x00")
MAX_BEFEHLSLAENGE = 500
MAX_OUTPUT_LINES = 400
CONNECT_TIMEOUT = 25
@@ -231,16 +276,89 @@ def update_job(
def list_jobs(
conn: sqlite3.Connection, *, kinds: tuple[str, ...] = (JOB_KIND_UPDATE, JOB_KIND_REBOOT), limit: int = 60
conn: sqlite3.Connection,
*,
kinds: tuple[str, ...] = (JOB_KIND_UPDATE, JOB_KIND_REBOOT, JOB_KIND_COMMAND),
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 ?",
# ``rowid`` als zweites Ordnungsmerkmal: ``created_at`` hat
# Sekundenaufloesung, und ein Massenbefehl legt vierzig Auftraege in
# derselben Sekunde an. Ohne das entschied bei Gleichstand die
# Speicherreihenfolge -- die Liste sah bei jedem Aufruf anders aus.
f"SELECT * FROM jobs WHERE kind IN ({placeholders}) "
"ORDER BY created_at DESC, rowid DESC LIMIT ?",
(*kinds, limit),
)
def laufende_und_letzte(
conn: sqlite3.Connection,
*,
kinds: tuple[str, ...] = (JOB_KIND_UPDATE, JOB_KIND_REBOOT, JOB_KIND_COMMAND),
limit: int = 30,
) -> list[dict[str, Any]]:
"""Alles, was laeuft oder eben fertig wurde -- und dazu die letzten Auftraege.
Fuer die Statusabfrage der Wartungsseite. Eine feste Obergrenze allein
reicht dort nicht, und das war ein echter Fehler: ein Massenbefehl auf
vierzig Geraete legt vierzig Auftraege an, ``LIMIT 30`` schnitt zehn davon
ab -- und weil alle denselben Zeitstempel auf die Sekunde tragen, waren es
zehn willkuerliche. Auf der Seite sah das aus, als haetten die letzten
Geraete den Befehl nie bekommen: sie liefen, ihre Ausgabe kam nur nie an.
Was laeuft, faellt hier deshalb nie heraus, und was gerade fertig wurde,
behaelt seine letzten Zeilen so lange wie ``eben_fertig`` es zusagt.
"""
platzhalter = ",".join("?" * len(kinds))
schwelle = (
datetime.now(timezone.utc) - timedelta(seconds=EBEN_FERTIG_SEKUNDEN)
).isoformat(timespec="seconds")
gesammelt: dict[str, dict[str, Any]] = {}
for sql, params in (
(
f"SELECT * FROM jobs WHERE kind IN ({platzhalter}) "
"AND state IN ('pending','running') ORDER BY created_at DESC, rowid DESC",
(*kinds,),
),
(
f"SELECT * FROM jobs WHERE kind IN ({platzhalter}) "
"AND COALESCE(finished_at, updated_at) >= ? ORDER BY created_at DESC, rowid DESC",
(*kinds, schwelle),
),
(
f"SELECT * FROM jobs WHERE kind IN ({platzhalter}) "
"ORDER BY created_at DESC, rowid DESC LIMIT ?",
(*kinds, limit),
),
):
for job in Database.all(conn, sql, params):
gesammelt.setdefault(str(job["id"]), job)
return sorted(
gesammelt.values(), key=lambda job: str(job["created_at"] or ""), reverse=True
)
def letzter_auftrag_je_geraet(
conn: sqlite3.Connection,
*,
kinds: tuple[str, ...] = (JOB_KIND_UPDATE, JOB_KIND_REBOOT, JOB_KIND_COMMAND),
limit: int = 400,
) -> dict[str, dict[str, Any]]:
"""Zu jedem Geraetenamen sein neuester Auftrag.
Damit die Wartungsseite schon beim Aufbau zeigt, was wo laeuft -- und nicht
erst, wenn die erste Statusabfrage zurueck ist. Der Schluessel ist der
Name, denn genau den traegt ein Auftrag als ``subject``.
"""
stand: dict[str, dict[str, Any]] = {}
for job in list_jobs(conn, kinds=kinds, limit=limit):
stand.setdefault(str(job["subject"]), job)
return stand
def get_job(conn: sqlite3.Connection, job_id: str) -> dict[str, Any] | None:
return Database.one(conn, "SELECT * FROM jobs WHERE id=?", (job_id,))
@@ -447,6 +565,149 @@ def run_update(
)
#: So lange gilt ein Auftrag als "eben fertig geworden" und schickt seine
#: letzten Zeilen noch mit. Ohne das verschwaende die Ausgabe genau in dem
#: Augenblick, in dem sie interessant wird: wenn der Befehl durch ist.
EBEN_FERTIG_SEKUNDEN = 120
#: So viele Zeilen gehen ins Mitlesen. Genug, um zu sehen, was gerade
#: passiert; wenig genug, dass eine Abfrage alle zwei Sekunden nichts kostet.
MITLESEN_ZEILEN = 40
def letzte_zeilen(ausgabe: str, anzahl: int = MITLESEN_ZEILEN) -> str:
"""Das Ende einer Ausgabe -- das ist der Teil, der gerade entsteht."""
zeilen = [zeile for zeile in ausgabe.splitlines() if zeile.strip()]
return "\n".join(zeilen[-anzahl:])
def eben_fertig(job: dict[str, Any], sekunden: int = EBEN_FERTIG_SEKUNDEN) -> bool:
"""Ist dieser Auftrag gerade eben fertig geworden?"""
if job.get("state") in {"pending", "running"}:
return True
stempel = str(job.get("finished_at") or job.get("updated_at") or "")
if not stempel:
return False
try:
moment = datetime.fromisoformat(stempel)
except ValueError:
return False
if moment.tzinfo is None:
moment = moment.replace(tzinfo=timezone.utc)
return (datetime.now(timezone.utc) - moment).total_seconds() < sekunden
def befehl_pruefen(befehl: str) -> str:
"""Gibt den bereinigten Befehl zurueck -- oder wirft mit Klartext.
Eine eigene Funktion, weil genau diese Frage sich pruefen lassen muss,
ohne ein Geraet zu brauchen.
"""
sauber = (befehl or "").strip()
if not sauber:
raise ValueError("Es wurde kein Befehl eingegeben.")
if len(sauber) > MAX_BEFEHLSLAENGE:
raise ValueError(
f"Der Befehl ist zu lang (mehr als {MAX_BEFEHLSLAENGE} Zeichen). "
"Für längere Abläufe ist ein Skript auf dem Gerät der bessere Weg."
)
for zeichen in VERBOTENE_ZEICHEN:
if zeichen in sauber:
raise ValueError(
"Der Befehl enthält einen Zeilenumbruch. Hier läuft genau eine "
"Befehlszeile – alles danach würde stillschweigend mitlaufen."
)
return sauber
def run_command(
database: Any,
*,
job_id: str,
device: dict[str, Any],
username: str,
password: str,
befehl: str,
timeout: int,
) -> None:
"""Fuehrt **eine** Befehlszeile auf einem Geraet aus. Eigener Thread.
Bewusst ohne Deutung des Ergebnisses: was der Befehl tut, weiss der
Mensch, der ihn geschrieben hat. Festgehalten wird, was zurueckkam und mit
welchem Rueckgabewert -- mehr kann ein Werkzeug hier nicht beitragen, und
weniger waere zu wenig.
"""
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
status = 1
try:
sudo, sudo_stdin = _sudo_mode(transport, password, emit)
# Nur wer {sudo} benutzt, bekommt es auch: ein Befehl ohne diese Marke
# laeuft als der angemeldete Benutzer, und das ist die richtige
# Vorgabe -- Rechte holt man sich hier ausdruecklich.
ausgefuehrt = befehl.replace("{sudo}", sudo) if "{sudo}" in befehl else befehl
emit(f"$ {ausgefuehrt}")
update_job(database, job_id, progress=40, message="Befehl läuft")
status = _run_remote_command(
transport,
ausgefuehrt,
timeout=timeout,
on_line=emit,
stdin_text=sudo_stdin if "{sudo}" in befehl else "",
)
except Exception as exc: # noqa: BLE001 - der Auftrag soll das Ergebnis tragen
emit(f"FEHLER: {exc}")
finally:
try:
transport.close()
except Exception: # noqa: BLE001
pass
emit(COMPLETION_MARKER)
update_job(
database,
job_id,
state="succeeded" if status == 0 else "failed",
progress=100,
message=(
"Befehl ausgeführt."
if status == 0
else f"Der Befehl endete mit Rückgabewert {status}."
),
finished=True,
)
with database.session() as conn, database.transaction(conn):
audit.log(
conn,
actor="system",
action="maintenance.command_finished",
target=name,
detail=f"Rückgabewert {status}",
severity="notice" if status == 0 else "warning",
)
def _reboot_windows(
database: Any,
*,
@@ -457,6 +718,7 @@ def _reboot_windows(
actor: str,
emit: Callable[[str], None],
elapsed: Callable[[], int],
live: Any = None,
) -> bool:
"""Neustart eines Windows-Geraets ueber RPC. ``True`` = weiter warten.
@@ -487,6 +749,7 @@ def _reboot_windows(
duration_ms=elapsed(),
detail=detail,
method=METHOD_RPC,
live=live,
)
return False
@@ -501,6 +764,7 @@ def run_reboot(
wait_seconds: int,
actor: str = "system",
method: str = METHOD_SSH,
live: Any = None,
) -> None:
"""Loest einen Neustart aus und wartet auf die Rueckkehr.
@@ -553,6 +817,7 @@ def run_reboot(
actor=actor,
emit=emit,
elapsed=elapsed,
live=live,
):
return
else:
@@ -576,6 +841,7 @@ def run_reboot(
duration_ms=elapsed(),
detail=str(exc),
method=method,
live=live,
)
return
@@ -618,6 +884,7 @@ def run_reboot(
duration_ms=elapsed(),
detail=problem,
method=method,
live=live,
)
return
@@ -658,6 +925,7 @@ def run_reboot(
duration_ms=elapsed(),
detail="Neustart über SSH, Gerät ist zurückgekehrt.",
method=method,
live=live,
)
return
time.sleep(REBOOT_POLL_SECONDS)
@@ -682,7 +950,8 @@ def run_reboot(
f"{wait_seconds}s nicht wieder geantwortet."
),
method=method,
)
live=live,
)
def _log_restart(
@@ -694,6 +963,7 @@ def _log_restart(
duration_ms: int,
detail: str,
method: str = METHOD_SSH,
live: Any = None,
) -> None:
"""Traegt den Neustart in die geraeteweite Neustart-Historie ein.
@@ -716,6 +986,7 @@ def _log_restart(
duration_ms=duration_ms,
detail=detail,
method=method,
live=live,
)
except Exception: # noqa: BLE001 - Protokollieren darf den Auftrag nie kippen
logger.exception("Neustart konnte nicht protokolliert werden (Gerät %s)", device_id)
+122 -14
View File
@@ -156,6 +156,16 @@ class LiveLog:
def info(self, scope: str, message: str) -> None:
self.write("INFO", scope, message)
def notice(self, scope: str, message: str) -> None:
"""Etwas ist passiert, und es ist in Ordnung -- aber es gehoert gesehen.
Zwischen "laeuft" und "stimmt etwas nicht" fehlte eine Stufe. Ein
Geraet, das neu gestartet wird, ist kein Fehler; es einfach als INFO
zwischen zweihundert Erreichbarkeitsmeldungen zu stellen heisst aber,
dass es niemand findet.
"""
self.write("HINWEIS", scope, message)
def warning(self, scope: str, message: str) -> None:
self.write("WARNUNG", scope, message)
@@ -278,6 +288,39 @@ METHOD_LABELS = {
METHOD_RPC: "Neustart über RPC",
}
#: Wer den Neustart ausgeloest hat -- in Worten statt in Schluesseln.
#:
#: Die Schluessel stehen so in der Datenbank und bleiben dort; eine Spalte
#: umzuschreiben, um eine Anzeige zu verbessern, waere der falsche Weg. Die
#: Uebersetzung gehoert an genau eine Stelle, sonst heisst derselbe Vorgang im
#: Protokoll "auto" und auf der Seite "automatisch".
TRIGGER_LABELS = {
"auto": "automatisch",
"manuell": "manuell",
"maintenance": "aus der Wartung",
}
def trigger_label(trigger: str) -> str:
"""Der Ausloeser als Wort -- unbekannte Schluessel bleiben, wie sie sind."""
return TRIGGER_LABELS.get(str(trigger or ""), str(trigger or "unbekannt"))
def _geraet_kurz(conn: sqlite3.Connection, device_id: int) -> str:
"""``Name (IP)`` fuer das laufende Protokoll.
Die Neustart-Funktionen bekommen nur eine Kennung; im Protokoll steht aber
eine Zeile, die ein Mensch liest -- und "Geraet 17 wird neu gestartet"
hilft niemandem.
"""
row = conn.execute("SELECT name, ip FROM devices WHERE id=?", (int(device_id),)).fetchone()
if row is None:
return f"Gerät {device_id}"
name = str(row["name"] or "").strip() or f"Gerät {device_id}"
ip = str(row["ip"] or "").strip()
return f"{name} ({ip})" if ip else name
#: Zustand zwischen ausgeloestem Neustart und Rueckkehr.
#:
#: Ohne ihn blieb die Kachel auf "online" stehen: der Zustand wechselt erst
@@ -303,6 +346,7 @@ def record_restart(
duration_ms: int,
detail: str,
method: str = METHOD_POE,
live: Any = None,
) -> None:
"""Schreibt einen Neustart ins Protokoll -- unabhaengig davon, wie er lief.
@@ -311,6 +355,12 @@ def record_restart(
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.
Mit ``live`` steht das Ergebnis auch im laufenden Protokoll. Diese
Funktion ist der Trichter, durch den **jeder** Neustart laeuft -- PoE
automatisch wie von Hand, SSH und RPC aus der Wartung. Die Zeile hier zu
schreiben statt bei jeder Quelle einzeln ist der Unterschied zwischen
"alle Wege sind abgedeckt" und "alle Wege, an die jemand gedacht hat".
"""
conn.execute(
"INSERT INTO restart_events "
@@ -318,6 +368,17 @@ def record_restart(
"VALUES (?,?,?,?,?,?,?,?,?)",
(device_id, _now(), trigger, actor, action, result, duration_ms, detail[:8000], method),
)
if live is not None:
wer = _geraet_kurz(conn, device_id)
weg = METHOD_LABELS.get(method, method)
if result == "ok":
live.info("neustart", f"{wer}: Neustart über {weg} erfolgreich")
elif result == "skipped":
# Warnstufe: ein Geraet, das haette neu gestartet werden sollen und
# es nicht wurde, ist kein Hinweis am Rande.
live.warning("neustart", f"{wer}: Neustart nicht ausgeführt – {detail}")
else:
live.error("neustart", f"{wer}: Neustart fehlgeschlagen – {detail}")
if result == "ok":
# Hier faellt nur noch der Zaehler und der Zeitstempel an. Den Zustand
# setzt ``mark_restarting`` schon beim Ausloesen -- dieses Ergebnis
@@ -330,7 +391,14 @@ def record_restart(
)
def mark_restarting(conn: sqlite3.Connection, device_id: int) -> None:
def mark_restarting(
conn: sqlite3.Connection,
device_id: int,
*,
live: Any = None,
trigger: str = "",
method: str = METHOD_POE,
) -> None:
"""Setzt den Zustand auf "startet neu" -- beim **Ausloesen**, nicht am Ende.
Ein Neustart ueber SSH oder RPC laeuft als Auftrag: Kommando schicken, dann
@@ -342,6 +410,12 @@ def mark_restarting(conn: sqlite3.Connection, device_id: int) -> None:
Absicht. Und ohne ``last_restart_at``: das Kulanzfenster laeuft ab dem
Zeitpunkt, an dem der Neustart tatsaechlich ausgeloest wurde
(``record_restart``).
Mit ``live`` geht der Beginn ins laufende Protokoll -- als Hinweis, nicht
als Warnung: ein Neustart ist kein Stoerfall, aber er erklaert die Luecke,
die gleich in der Erreichbarkeit steht. Dass die Zeile hier entsteht und
nicht bei den Aufrufern, ist Absicht: dies ist der Punkt, an dem ein
Neustart **ausgeloest** wird, gleich von welcher Quelle.
"""
conn.execute(
"""
@@ -355,6 +429,12 @@ def mark_restarting(conn: sqlite3.Connection, device_id: int) -> None:
""",
(device_id, STATE_RESTARTING, _now(), _now()),
)
if live is not None:
weg = METHOD_LABELS.get(method, method)
woher = f" ({trigger_label(trigger)})" if trigger else ""
live.notice(
"neustart", f"{_geraet_kurz(conn, device_id)} wird neu gestartet – {weg}{woher}"
)
# --------------------------------------------------------------------------- #
@@ -402,7 +482,9 @@ def restart_device_poe(
# wie fuer den automatischen Neustart, denn beide laufen hier durch.
if port and switch_ip:
with write():
mark_restarting(conn, int(device["id"]))
mark_restarting(
conn, int(device["id"]), live=live, trigger=trigger, method=METHOD_POE
)
if not port or not switch_ip:
message = (
@@ -418,6 +500,7 @@ def restart_device_poe(
actor=actor,
duration_ms=0,
detail=message,
live=live,
)
return {"ok": False, "message": message, "transcript": ""}
@@ -462,9 +545,8 @@ def restart_device_poe(
actor=actor,
duration_ms=duration,
detail=str(exc),
live=live,
)
if live:
live.error("poe", f"{name}: {exc}")
return {"ok": False, "message": str(exc), "transcript": ""}
duration = int((time.monotonic() - started) * 1000)
@@ -478,12 +560,8 @@ def restart_device_poe(
actor=actor,
duration_ms=duration,
detail=(outcome.error or "; ".join(outcome.steps))[:8000],
live=live,
)
if live:
if outcome.ok:
live.info("poe", f"{name}: PoE-Neustart auf {device.get('switch_hostname')} {port} erfolgreich")
else:
live.error("poe", f"{name}: PoE-Neustart fehlgeschlagen -- {outcome.error}")
return {
"ok": outcome.ok,
"message": outcome.error or "PoE-Neustart durchgeführt.",
@@ -540,6 +618,15 @@ def merke_verfahren(
)
def merke_verfahren_das_traegt(
conn: sqlite3.Connection, device_id: int, verfahren: int
) -> None:
"""Haelt fest, womit die letzte Sitzung wirklich zustande kam."""
conn.execute(
"UPDATE device_status SET vnc_method=? WHERE device_id=?", (int(verfahren), device_id)
)
def run_sweep(
*,
database: Any,
@@ -585,7 +672,16 @@ def run_sweep(
# bekannten Stand stehen. Ein Geraet, das gerade neu startet, soll
# seinen Knopf nicht verlieren.
return device, ergebnis, None
return device, ergebnis, vnc_dienst.zugang_kurz(device, port=vnc_port)
try:
return device, ergebnis, vnc_dienst.zugang_kurz(device, port=vnc_port)
except Exception: # noqa: BLE001
# Die Ueberwachung ist die Kernaufgabe, der Bildschirmzugriff eine
# Zugabe. Was hier schiefgeht -- ein Name, den der Resolver nicht
# verdaut, ein Netz, das seltsam antwortet --, darf niemals den
# ganzen Durchlauf kosten: sonst stuenden alle Geraete auf
# "unbekannt", und niemand brächte das mit VNC in Verbindung.
logger.warning("VNC-Pruefung fuer %s uebersprungen", device.get("name"), exc_info=True)
return device, ergebnis, None
with ThreadPoolExecutor(max_workers=min(MAX_PARALLEL_CHECKS, len(devices))) as pool:
results = list(pool.map(pruefen, devices))
@@ -623,10 +719,22 @@ def run_sweep(
name = str(device["name"])
if not license_allows_auto_restart:
report.skipped_unlicensed += 1
live.warning(
"poe",
f"{name}: automatischer PoE-Neustart übersprungen – keine gültige Lizenz.",
)
# Als Zeile in der Neustart-Historie, nicht nur als Protokollsatz:
# wer auf der Seite "Neustarts" nachsieht, warum ein Geraet nicht
# zurueckkam, soll dort auch lesen, dass TESM es versucht haette.
# Ein leerer Verlauf sieht sonst aus wie "es war nichts noetig".
with database.session() as conn, database.transaction(conn):
record_restart(
conn,
device_id=int(device["id"]),
action="cycle",
result="skipped",
trigger="auto",
actor="system",
duration_ms=0,
detail="keine gültige Lizenz für den automatischen PoE-Neustart",
live=live,
)
continue
report.restarts_triggered += 1
# Bewusst **keine** umschliessende Transaktion: der Neustart oeffnet eine
+7 -1
View File
@@ -64,6 +64,9 @@ class Zugang:
#: Der Text, mit dem der Server die Verbindung abgelehnt hat -- er nennt
#: dann kein Verfahren, sondern eine 0 und einen Grund.
absage: str = ""
#: Das Verfahren, mit dem zuletzt wirklich eine Sitzung zustande kam
#: (0: noch keine). Damit faengt der naechste Versuch an.
bewaehrt: int = 0
@property
def browsertauglich(self) -> bool:
@@ -129,7 +132,9 @@ def aufloesbar(name: str) -> bool:
try:
socket.getaddrinfo(name, None)
ergebnis = True
except OSError:
except Exception: # noqa: BLE001
# Nicht nur OSError: ein Name mit Sonderzeichen wirft UnicodeError,
# und der ist kein OSError. Aufloesbar ist er so oder so nicht.
ergebnis = False
_AUFLOESUNG[name] = (jetzt, ergebnis)
return ergebnis
@@ -350,6 +355,7 @@ def zugang_gemerkt(device: Mapping[str, Any], *, port: int = STANDARD_PORT) -> Z
int(teil) for teil in gespeichert.split(",") if teil.strip().isdigit()
),
absage=str(device.get("vnc_reason") or ""),
bewaehrt=int(device.get("vnc_method") or 0),
)
@@ -36,14 +36,13 @@
{% 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">
<summary class="card__header">
<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">
@@ -102,11 +101,25 @@
<div class="tile__actions">
{% if tile.vnc %}
{# Die Sitzung laeuft in TESM selbst: ein Klick, kein
Viewer auf dem Arbeitsplatz, keine Datei. #}
<a class="btn btn--sm" href="{{ url_for('screen.screen', device_id=tile.id) }}"
title="Bildschirm im Browser öffnen">
{{ icon('monitor', size=14) }} VNC
</a>
Viewer auf dem Arbeitsplatz, keine Datei.
Mit frischer Passwortbestätigung öffnet sie sich hier
im Fenster -- man bleibt bei der Übersicht und sieht
die anderen Geräte weiter. Ohne sie führt der Knopf
auf die Geräteseite, wo danach gefragt wird. #}
{% if vnc_fenster %}
<button class="btn btn--sm" type="button"
data-vnc-oeffnen="{{ tile.vnc_quelle }}"
data-vnc-name="{{ tile.name }}"
title="Bildschirm im Fenster öffnen">
{{ icon('monitor', size=14) }} VNC
</button>
{% else %}
<a class="btn btn--sm" href="{{ url_for('screen.screen', device_id=tile.id) }}"
title="Bildschirm im Browser öffnen">
{{ icon('monitor', size=14) }} VNC
</a>
{% endif %}
{% else %}
<span class="tile__spacer" aria-hidden="true"></span>
{% endif %}
@@ -0,0 +1,42 @@
{# Die Bildschirmfläche -- einmal geschrieben, an zwei Orten benutzt: auf der
Geräteseite und im Fenster der Statusübersicht.
Die Teile finden sich über `data-rolle`, nicht über Kennungen: im Fenster
gäbe es sie sonst zweimal auf derselben Seite, und die zweite wäre die, die
niemand findet. #}
{% macro vnc_flaeche(quelle='') %}
<div class="screen" {% if quelle %}data-sitzung="{{ quelle }}"{% endif %}>
<div data-rolle="leinwand" class="screen__canvas" hidden></div>
</div>
{% endmacro %}
{% macro vnc_status() %}
<div data-rolle="status" class="screen__status">Wird vorbereitet …</div>
{% endmacro %}
{% macro vnc_zugangsdaten() %}
<form data-rolle="zugangsdaten" class="row row--tight" hidden onsubmit="return false">
<label class="field">
<span class="field__label">Benutzer</span>
<input class="input" type="text" data-rolle="benutzer" autocomplete="off">
</label>
<label class="field">
<span class="field__label">Passwort</span>
<input class="input" type="password" data-rolle="passwort" autocomplete="off">
</label>
<button class="btn btn--primary" type="button" data-rolle="anmelden">Anmelden</button>
</form>
{% endmacro %}
{# Tasten, die der Arbeitsplatz selbst abfängt und die deshalb einen Knopf
brauchen: Strg+Alt+Entf schaltet der Browser gar nicht erst durch, Alt+F4
schlösse das Fenster hier statt das Fenster dort. #}
{% macro vnc_tasten() %}
<button class="btn btn--sm" type="button" data-rolle="taste-cad" hidden
title="Strg+Alt+Entf an das Gerät senden">Strg+Alt+Entf</button>
<button class="btn btn--sm" type="button" data-rolle="taste-altf4" hidden
title="Alt+F4 an das Gerät senden – schließt dort das oberste Fenster">Alt+F4</button>
<button class="btn btn--sm" type="button" data-rolle="taste-f5" hidden
title="F5 an das Gerät senden – der Browser des Arbeitsplatzes würde sonst diese Seite neu laden">F5</button>
<button class="btn btn--sm" type="button" data-rolle="trennen" hidden>Trennen</button>
{% endmacro %}
@@ -1,5 +1,6 @@
{% extends "tesm_core/base.html" %}
{% from "tesm_core/macros.html" import search_box %}
{% from "tesm_core/macros.html" import search_box, modal %}
{% from "tesm/_vnc_flaeche.html" import vnc_flaeche, vnc_status, vnc_zugangsdaten, vnc_tasten %}
{% block page_title %}Statusübersicht{% endblock %}
{% block page_sub %}
@@ -19,4 +20,27 @@
data-refresh-interval="20000">
{% include "tesm/_dashboard_tiles.html" %}
</div>
{% if vnc_fenster %}
{# Ein Fenster für alle Kacheln: welches Gerät gemeint ist, bringt der Knopf
beim Öffnen mit. Ein Dialog je Kachel wäre dieselbe Sache dutzendfach im
Quelltext -- und dutzendfach eine Sitzung, die niemand aufräumt. #}
{% call modal('vnc-fenster', 'Bildschirm', 'Tastatur und Maus gehen an das Gerät. Die Zwischenablage bleibt getrennt.', 'wide') %}
<div class="modal__body stack" data-rolle="behaelter">
<div class="row row--tight">
<strong data-rolle="titel" class="flex-1"></strong>
{{ vnc_tasten() }}
</div>
{{ vnc_status() }}
{{ vnc_zugangsdaten() }}
{{ vnc_flaeche() }}
</div>
{% endcall %}
{% endif %}
{% endblock %}
{% block scripts %}
{% if vnc_fenster %}
<script type="module" src="{{ asset_url('js/screen.js') }}" nonce="{{ csp_nonce }}"></script>
{% endif %}
{% endblock %}
+319 -69
View File
@@ -7,6 +7,29 @@
{% endblock %}
{% block content %}
{# Der wichtigste Satz der Seite, wenn er zutrifft: was hier steht, ist nicht,
womit der Dienst arbeitet. Lehnt Kea eine Datei ab, bleibt der alte Stand
aktiv -- die Oberflaeche zeigte bisher trotzdem den neuen Wert, und man
sucht den Fehler beim Client. #}
{% if konfig_stand == 'abweichend' %}
<div class="alert alert--danger">
{{ icon('alert', size=18) }}
<div class="alert__body flex-1">
<div class="alert__title">Der Dienst arbeitet nicht mit dieser Konfiguration</div>
Auf der Platte liegt ein anderer Stand als der hier gezeigte &ndash; entweder wurde
die Datei zuletzt abgelehnt oder seither nichts geschrieben. Änderungen an Optionen
und Reservierungen sind gespeichert, aber <strong>nicht wirksam</strong>.
{% if current_user.has('dhcp_config.execute') %}
<form method="post" action="{{ url_for('dhcp.write_config') }}" class="mt-2" data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<button class="btn btn--sm btn--primary" type="submit" data-busy-label="Schreibt ...">
Jetzt schreiben und Fehler anzeigen
</button>
</form>
{% endif %}
</div>
</div>
{% endif %}
{% for warning in warnings %}{{ alert(warning, 'warning') }}{% endfor %}
{% if not sysops_available %}
{{ alert('Systemhelfer nicht verfügbar -- Konfiguration und Dienstzustand können hier nur
@@ -21,8 +44,13 @@
</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>
{# Die Fassung statt nur "vorhanden": zwischen Kea 2.4 (Ubuntu 24.04) und
3.0 (26.04) liegen Aenderungen, und wer eine Konfiguration beurteilt,
will wissen, wogegen. #}
<span class="stat__value fs-lg">{{ service.get('version') or service.get('binary', '?') }}</span>
<span class="stat__hint">
kea-dhcp4{% if service.get('version') %} &middot; {{ service.get('binary', '?') }}{% endif %}
</span>
</div>
<div class="stat">
<span class="stat__label">Reservierungen</span>
@@ -39,6 +67,10 @@
</div>
</div>
{# Dienst und Grundeinstellungen sind beides Kleinkram, den man selten
anfasst -- nebeneinander statt untereinander, damit die Seite mit dem
anfaengt, worum es geht: den Subnetzen und Optionen. #}
<div class="grid grid--2">
{% if current_user.has('dhcp_service.execute') %}
<section class="card">
<div class="card__header">
@@ -66,6 +98,31 @@
</section>
{% endif %}
{% if current_user.has('dhcp_subnets.write') %}
<section class="card">
<div class="card__header">
<div class="flex-1"><div class="card__title">{{ icon('sliders', size=17) }} Globale Einstellungen</div></div>
</div>
<form method="post" action="{{ url_for('dhcp.save_settings') }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="card__body">
<div class="form-grid">
{{ field('dhcp_domain', 'Domainname', value=values.dhcp_domain) }}
{{ field('dhcp_lease_default', 'Lease-Dauer (s)', value=values.dhcp_lease_default, type='number') }}
{{ field('dhcp_lease_max', 'Maximale Lease-Dauer (s)', value=values.dhcp_lease_max, type='number') }}
{{ field('dhcp_output_path', 'Zielpfad der Konfiguration', value=values.dhcp_output_path,
extra_class='field--full') }}
</div>
</div>
<div class="card__footer">
<button class="btn btn--primary" type="submit">Speichern und schreiben</button>
</div>
</form>
</section>
{% endif %}
</div>
{# -- Subnetze ---------------------------------------------------------- #}
<section class="card">
<div class="card__header">
@@ -134,45 +191,111 @@
{% endif %}
</section>
{# -- Globale Einstellungen --------------------------------------------- #}
{% if current_user.has('dhcp_subnets.write') %}
<section class="card">
<div class="card__header">
<div class="flex-1"><div class="card__title">{{ icon('sliders', size=17) }} Globale Einstellungen</div></div>
</div>
<form method="post" action="{{ url_for('dhcp.save_settings') }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="card__body">
<div class="form-grid">
{{ field('dhcp_domain', 'Domainname', value=values.dhcp_domain) }}
{{ field('dhcp_lease_default', 'Lease-Dauer (s)', value=values.dhcp_lease_default, type='number') }}
{{ field('dhcp_lease_max', 'Maximale Lease-Dauer (s)', value=values.dhcp_lease_max, type='number') }}
{{ field('dhcp_output_path', 'Zielpfad der Konfiguration', value=values.dhcp_output_path,
extra_class='field--full') }}
</div>
</div>
<div class="card__footer">
<button class="btn btn--primary" type="submit">Speichern und schreiben</button>
</div>
</form>
</section>
{% endif %}
{# -- Optionen ---------------------------------------------------------- #}
{# Zwei Makros, weil dieselbe Zeile zweimal vorkommt: einmal in der kurzen
Liste dessen, was wirklich gesendet wird, und einmal im vollen Katalog. #}
{% macro optionswert(option) %}
{% if current_user.has('dhcp_options.write') %}
<form method="post" action="{{ url_for('dhcp.set_option_value') }}"
class="row row--tight" style="flex-wrap:nowrap" data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<input type="hidden" name="option_id" value="{{ option.id }}">
{# min-width:0 und flex:1 statt einer festen Mindestbreite: sonst passte
das Feld nicht in die Spalte, die Zeile brach um, und der Haken stand
allein darunter -- es sah aus wie ein Bedienfehler der Seite. #}
<input class="input input--mono" type="text" name="value"
value="{{ option.value or '' }}" style="min-width:0;flex:1"
placeholder="leer = nicht senden">
<button class="btn btn--sm" type="submit" title="Wert übernehmen">
{{ icon('check', size=14) }}
</button>
</form>
{% else %}
<span class="mono fs-sm">{{ option.value or '&ndash;' | safe }}</span>
{% endif %}
{% endmacro %}
{% macro optionsloeschen(option) %}
{% if current_user.has('dhcp_options.write') and not option.is_standard %}
<form method="post" action="{{ url_for('dhcp.delete_custom_option', option_id=option.id) }}"
data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<button class="btn btn--sm btn--ghost danger-text" type="submit"
data-confirm="Eigene Option {{ option.name }} löschen?"
data-confirm-title="Option löschen" data-confirm-ok="Löschen">
{{ icon('trash', size=14) }}
</button>
</form>
{% endif %}
{% endmacro %}
{% set gesetzte = options | selectattr('value') | list %}
<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__title">{{ icon('puzzle', size=17) }} Gesendete Optionen</div>
<div class="card__sub">
{{ options | length }} Optionen &middot; Standardoptionen sind nicht löschbar, ihr Wert
lässt sich leeren
{% if gesetzte %}
<strong>{{ gesetzte | length }}</strong> von {{ options | length }} Optionen tragen
einen Wert. Was leer ist, wird nicht gesendet.
{% else %}
Derzeit trägt keine Option einen Wert – es wird also nichts über die
Grundangaben hinaus gesendet.
{% endif %}
</div>
</div>
{{ search_box('options-table', 'Option suchen ...') }}
</div>
<div class="table-wrap" style="max-height:420px;overflow:auto">
{% if gesetzte %}
<div class="table-wrap">
<table class="table table--compact">
<thead><tr>
<th class="numeric">Code</th><th>Name</th><th>Typ</th>
<th style="width:42%">Wert</th><th class="actions"></th>
</tr></thead>
<tbody>
{% for option in gesetzte %}
<tr>
<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>{{ optionswert(option) }}</td>
<td class="actions">{{ optionsloeschen(option) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
{{ empty_state('Nichts gesetzt', 'Tragen Sie unten im Katalog einen Wert ein.', 'puzzle') }}
{% endif %}
</section>
{# Der Katalog hat 57 Zeilen. Er stand vorher ganz oben, in einem eigenen
Rollbereich innerhalb der scrollenden Seite -- und das Gesuchte ging darin
unter. Jetzt liegt er hinter einem Klick, mit eigener Suche. #}
<details class="card" data-persist="dhcp-katalog">
<summary class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('list', size=17) }} Optionskatalog</div>
<div class="card__sub">
Alle {{ options | length }} bekannten Optionen. Standardoptionen sind nicht löschbar,
ihr Wert lässt sich leeren.
</div>
</div>
</summary>
<div class="card__body pt-0">{{ search_box('options-table', 'Option suchen ...') }}</div>
<div class="table-wrap">
<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>
<thead><tr>
<th class="numeric">Code</th><th>Name</th><th>Typ</th>
<th style="width:42%">Wert</th><th class="actions"></th>
</tr></thead>
<tbody>
{% for option in options %}
<tr data-filter-text="{{ option.code }} {{ option.name }} {{ option.description }}">
@@ -182,43 +305,33 @@
<div class="fs-xs faint">{{ option.description }}</div>
</td>
<td class="fs-xs mono">{{ option.type }}</td>
<td>
{% if current_user.has('dhcp_options.write') %}
<form method="post" action="{{ url_for('dhcp.set_option_value') }}" class="row row--tight"
data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<input type="hidden" name="option_id" value="{{ option.id }}">
<input class="input input--mono" type="text" name="value" value="{{ option.value or '' }}"
style="min-width:180px" placeholder="leer = nicht senden">
<button class="btn btn--sm" type="submit">{{ icon('check', size=14) }}</button>
</form>
{% else %}
<span class="mono fs-sm">{{ option.value or '&ndash;' | safe }}</span>
{% endif %}
</td>
<td class="actions">
{% if current_user.has('dhcp_options.write') and not option.is_standard %}
<form method="post" action="{{ url_for('dhcp.delete_custom_option', option_id=option.id) }}"
data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<button class="btn btn--sm btn--ghost danger-text" type="submit"
data-confirm="Eigene Option {{ option.name }} löschen?"
data-confirm-title="Option löschen" data-confirm-ok="Löschen">
{{ icon('trash', size=14) }}</button>
</form>
{% endif %}
</td>
<td>{{ optionswert(option) }}</td>
<td class="actions">{{ optionsloeschen(option) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if current_user.has('dhcp_options.write') %}
</details>
{% if current_user.has('dhcp_options.write') %}
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('plus', size=17) }} Eigene Option anlegen</div>
<div class="card__sub">
Für alles jenseits des Katalogs – etwa eine URL, die der Client beim Start öffnet.
Code <strong>224–254</strong>: nur diesen Bereich sieht DHCP für eigene Optionen vor,
und nur daraus hebt ein Linux-Client mit systemd-networkd sie überhaupt auf.
</div>
</div>
</div>
<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('code', 'Code', type='number', required=True, placeholder='226',
hint_text='224 bis 254') }}
{{ field('name', 'Name', required=True, placeholder='meine-option') }}
<label class="field">
<span class="field__label">Typ</span>
@@ -231,13 +344,15 @@
</div>
<div class="card__footer"><button class="btn" type="submit">Eigene Option anlegen</button></div>
</form>
</section>
{# 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)">
{# 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. #}
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('sliders', size=17) }} Option nur für ein Gerät</div>
<div class="card__sub">
@@ -276,6 +391,110 @@
<button class="btn" type="submit">Für dieses Gerät speichern</button>
</div>
</form>
</section>
{% endif %}
{# -- Was der Client dafür braucht -------------------------------------- #}
{#
Ein DHCP-Client bekommt nur, wonach er fragt. Gateway, DNS und Domain
fragt er von sich aus; alles darüber hinaus -- und dazu gehört jede eigene
Option -- muss er ausdrücklich anfordern. Ohne diese Zeilen sendet der
Server brav, und der Client wirft es weg, ohne dass irgendwo etwas
danebengeht. Genau deshalb steht das hier und nicht in einer Anleitung.
#}
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('terminal', size=17) }} Konfiguration für Linux-Clients</div>
<div class="card__sub">
Ein Client erhält nur die Optionen, die er anfordert. Diese Zeilen sorgen dafür –
erzeugt aus dem, was dieser Server tatsächlich sendet.
</div>
</div>
</div>
{% set beispiel = client_konfig.get('networkd') %}
{% if not beispiel or not beispiel.optionen %}
<div class="card__body">
{{ alert('Dieser Server sendet derzeit keine Option, die ein Client eigens anfordern
müsste. Sobald eine eigene Option einen Wert trägt, steht hier die passende
Konfiguration.', 'info') }}
</div>
{% else %}
<div class="card__body pt-0">
<p class="fs-sm faint">
Betrifft
{% for eintrag in beispiel.optionen %}
<code>{{ eintrag.code }} {{ eintrag.name }}</code>{% if not loop.last %}, {% endif %}
{% endfor %}.
Welcher Abschnitt gilt, entscheidet die Distribution des Clients.
</p>
</div>
{% for stil, bezeichnung, pfad in client_stile %}
{% set konfig = client_konfig.get(stil) %}
<details class="card__body pt-0">
<summary class="row row--tight">
<strong class="fs-sm">{{ bezeichnung }}</strong>
<span class="fs-xs faint mono">{{ pfad }}</span>
</summary>
<div class="stack stack--tight mt-2">
<div class="row row--tight">
<span class="fs-xs faint flex-1">Inhalt der Datei</span>
<button class="btn btn--sm btn--ghost" type="button" data-copy="#konfig-{{ stil }}"
data-copy-message="Konfiguration kopiert.">
{{ icon('copy', size=14) }} Kopieren
</button>
</div>
<pre class="code-block code-block--wrap" id="konfig-{{ stil }}">{{ konfig.inhalt }}</pre>
<div class="fs-xs faint">Danach: <code>{{ konfig.nachher }}</code></div>
{% if konfig.auslesen.einfach %}
{# Ohne diese Zeile kann niemand pruefen, ob es angekommen ist --
und ein RMM-Agent braucht genau sie. #}
<div class="fs-xs faint">
Auslesen auf dem Client:
<code class="break-all">{{ konfig.auslesen.einfach }}</code>
</div>
{% if konfig.auslesen.roh %}
<div class="fs-xs faint">
systemd legt fremde Optionen unter ihrer Nummer und hexkodiert ab – den
Klartext schreibt ein kleiner Übersetzer, den der SSH-Push mitinstalliert
(<code>/run/tesm-dhcp-options.env</code>, nach jeder Lease erneuert).
Direkt aus der Lease:
<code class="break-all">{{ konfig.auslesen.roh }}</code>
</div>
{% endif %}
{% endif %}
{% if current_user.has('servicing_command.execute') %}
<form method="post" action="{{ url_for('dhcp.push_client_config') }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<input type="hidden" name="stil" value="{{ stil }}">
<div class="divider"></div>
<div class="fs-xs faint mb-2">
Oder direkt schreiben lassen – über SSH, mit den hinterlegten Zugangsdaten.
Jeder Client bekommt einen eigenen Auftrag; die Ausgabe steht unter Wartung.
</div>
<div class="row row--tight" style="flex-wrap:wrap">
{% for reservation in reservations if reservation.device_id %}
<label class="check">
<input type="checkbox" name="device_ids" value="{{ reservation.device_id }}">
<span>{{ reservation.name or reservation.hostname }}</span>
</label>
{% endfor %}
</div>
<div class="mt-2">
<button class="btn btn--sm btn--primary" type="submit"
data-confirm="Die Konfiguration für {{ bezeichnung }} auf die ausgewählten Clients schreiben ({{ pfad }})? Der DHCP-Client wird dort anschließend neu gestartet."
data-confirm-title="Konfiguration verteilen" data-confirm-ok="Verteilen">
{{ icon('upload', size=15) }} An ausgewählte Clients senden
</button>
</div>
</form>
{% endif %}
</div>
</details>
{% endfor %}
{% endif %}
</section>
@@ -377,6 +596,10 @@
<div class="card__title">{{ icon('clock', size=17) }} Leases</div>
<div class="card__sub">
Direkt aus {{ lease_file }} gelesen &ndash; je Adresse gilt der letzte Eintrag.
{% if current_user.has('dhcp_lease_adopt.read') and current_user.has('devices.create') %}
Eine <strong>dynamische</strong> Lease lässt sich als Client übernehmen; sie bekommt
damit eine feste Adresse und wird überwacht.
{% endif %}
</div>
</div>
</div>
@@ -394,7 +617,7 @@
<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>
<th>Läuft ab</th><th></th></tr></thead>
<tbody>
{% for lease in leases %}
<tr>
@@ -413,6 +636,34 @@
<td class="fs-sm">{{ lease.expires_text }}
<div class="fs-xs faint">{{ lease.remaining_text }}</div>
</td>
<td class="nowrap">
{# Übernehmen gibt es nur für dynamische Leases: was reserviert
ist, gehört schon zu einem gepflegten Datensatz. Und nur,
wenn beide Rechte stehen -- hier entsteht ein Client. #}
{% if lease.address not in reserved_ips
and current_user.has('dhcp_lease_adopt.read')
and current_user.has('devices.create') %}
{% if lease.mac in bekannte_macs %}
<span class="faint fs-xs" title="Zu dieser MAC gibt es bereits einen Client">
bereits Client
</span>
{% else %}
<form method="post" action="{{ url_for('dhcp.adopt_lease') }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<input type="hidden" name="mac" value="{{ lease.mac }}">
{# Kein Geisterknopf mehr: die Uebernahme ist die eine
Handlung in dieser Tabelle, und sie war als
"btn--ghost" kaum von der Zeile zu unterscheiden. #}
<button class="btn btn--sm btn--primary" type="submit"
data-confirm="{{ lease.hostname or lease.address }} als überwachten Client anlegen? Switch, Port und Zugangsdaten tragen Sie danach nach."
data-confirm-title="Als Client übernehmen"
data-confirm-ok="Übernehmen">
{{ icon('plus', size=14) }} Als Client
</button>
</form>
{% endif %}
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
@@ -426,12 +677,11 @@
{# -- Vorschau ---------------------------------------------------------- #}
<details class="card" data-persist="dhcp-preview">
<summary class="card__header" style="cursor:pointer">
<summary class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('file-text', size=17) }} Erzeugte Kea-Konfiguration</div>
<div class="card__sub">Vorschau &ndash; so würde die Datei geschrieben</div>
</div>
{{ icon('chevron-down', size=16) }}
</summary>
<pre class="log-view">{{ preview }}</pre>
{% if current_user.has('dhcp_config.execute') %}
@@ -242,8 +242,7 @@
data-viewer-pdflib="{{ asset_url('vendor/pdflib/pdf-lib.min.js') }}"
data-viewer-konto="{{ current_user.username }}"
data-viewer-zeuge="{{ '1' if signature_witness else '0' }}"
data-viewer-zeit="{{ serverzeit }}"
data-viewer-ordnerliste="{{ verzeichnisse | tojson }}">
data-viewer-zeit="{{ serverzeit }}">
<div class="viewer">
<div class="viewer__stage" data-viewer-bildbuehne hidden>
<img class="viewer__img" alt="" data-viewer-bild>
@@ -249,6 +249,39 @@
</div>
</section>
{# Fuer ein Ueberwachungswerkzeug, das auf dieser Maschine laeuft. Bewusst
kein HTTP-Endpunkt mit Token: der Agent sitzt ohnehin hier, und was ohne
Netz auskommt, kann auch niemand aus dem Netz abfragen. #}
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('terminal', size=17) }} Abfrage durch ein RMM</div>
<div class="card__sub">
Ein Agent auf dieser Maschine liest die Restlaufzeit ohne Anmeldung und ohne Netz.
</div>
</div>
<button class="btn btn--sm btn--ghost" type="button" data-copy="#rmm-json"
data-copy-message="Befehl kopiert.">{{ icon('copy', size=14) }} Kopieren</button>
</div>
<div class="card__body stack stack--tight">
<pre class="code-block code-block--wrap" id="rmm-json">{{ rmm_befehl }}</pre>
<p class="fs-xs faint">
Antwortet mit einer Zeile JSON auf <strong>stdout</strong>
(<code>valid</code>, <code>days_left</code>, <code>expires_at</code>,
<code>lifetime</code>, <code>modules</code>). Der <strong>Ausstiegscode</strong> trägt
die Aussage: <strong>0</strong> in Ordnung, <strong>1</strong> läuft in
{{ rmm_warnschwelle }} Tagen oder weniger ab, <strong>2</strong> ungültig oder nicht
vorhanden. Meldungen des Starts gehen nach stderr und stören das Auswerten nicht.
</p>
<div class="divider"></div>
<div class="fs-xs faint">Nur die Restlaufzeit, ohne JSON-Auswertung:</div>
<pre class="code-block code-block--wrap" id="rmm-tage">{{ rmm_befehl_tage }}</pre>
<div class="divider"></div>
<div class="fs-xs faint">Beispiel für einen Agenten mit PowerShell:</div>
<pre class="code-block code-block--wrap">{{ rmm_powershell }}</pre>
</div>
</section>
<section class="card">
<div class="card__header">
<div class="flex-1">
@@ -6,7 +6,8 @@
{% block content %}
{{ alert('Jeder Neustart wird mit Methode, Ergebnis, Dauer und Auslöser festgehalten -- automatisch
wie manuell, über den PoE-Port des Switches ebenso wie per Neustart-Befehl aus der Wartung.
Der Vorgänger protokollierte Neustarts ausdrücklich nicht.', 'info') }}
Auch ein Neustart, den die Überwachung auslösen wollte und nicht durfte, steht hier:
als "übersprungen" mit dem Grund. Der Vorgänger protokollierte Neustarts ausdrücklich nicht.', 'info') }}
<section class="card">
<div class="card__header">
@@ -32,7 +33,7 @@ Der Vorgänger protokollierte Neustarts ausdrücklich nicht.', 'info') }}
</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 }}">
<tr data-filter-text="{{ row.device_name }} {{ row.result }} {{ row.actor }} {{ method_labels.get(row.method, row.method) }} {{ trigger_labels.get(row.trigger, row.trigger) }} {{ 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 '&ndash;' | safe }}</td>
<td class="fs-sm">
@@ -41,10 +42,15 @@ Der Vorgänger protokollierte Neustarts ausdrücklich nicht.', 'info') }}
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', '') }}
{# 'poe', nicht 'pö'. Der Umlaut stand hier durch eine
Ersetzung, die "oe" fuer eine Umschrift hielt -- und traf den
haeufigsten Fall von allen: jeder automatische Neustart laeuft
ueber den PoE-Port. Sie standen damit als orangefarbenes
"poe" da, wo "PoE-Port" stehen sollte. #}
{% elif row.method == 'poe' %}{{ pill('PoE-Port', '') }}
{% else %}{{ pill(row.method or 'unbekannt', 'warning') }}{% endif %}
</td>
<td class="fs-sm">{{ row.trigger }}</td>
<td class="fs-sm">{{ trigger_labels.get(row.trigger, row.trigger) }}</td>
<td class="fs-sm">{{ row.actor }}</td>
<td>
{% if row.result == 'ok' %}{{ pill('erfolgreich', 'online') }}
@@ -45,15 +45,78 @@ brauchen dafür weder SSH noch einen Host-Schlüssel, sondern die offene Datei-
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') }}">
{# ``stack``: Massenbefehl und Geräteauswahl sind zwei Bereiche und sollen auch
so aussehen. Ohne das stossen die beiden Karten aneinander. #}
<form method="post" id="maintenance-form" class="stack"
action="{{ url_for('maintenance.start_update') }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
{% if current_user.has('servicing_command.read') %}
{# Zugeklappt beim Laden: der Massenbefehl ist das seltenere Werkzeug, die
Geräteliste darunter das tägliche. Aufgeklappt schob er sie aus dem
Bild. #}
<section class="card">
<div class="card__header">
<button class="btn btn--ghost btn--sm" type="button" data-befehl-schalter
aria-expanded="false" title="Massenbefehl ein- und ausklappen">
{{ icon('chevron-right', size=14) }}
</button>
<div class="flex-1">
<div class="card__title">{{ icon('terminal', size=17) }} Massenbefehl</div>
<div class="card__sub">
Eine Befehlszeile über SSH, auf allen unten ausgewählten Clients – jeder als
eigener Auftrag mit eigener Ausgabe. <strong>{{ '{sudo}' }}</strong> im Befehl
wird ersetzt: mit <code>sudo -n</code>, wo es ohne Passwort geht, sonst mit dem
hinterlegten Passwort über stdin. Ohne diese Marke läuft der Befehl als das
angemeldete Konto.
</div>
</div>
</div>
<div class="card__body stack" data-befehl-feld hidden>
<label class="field">
<span class="field__label">Vorlage</span>
<select class="select" id="befehlsvorlage">
<option value="">– eigene Eingabe –</option>
{% for name, befehl, erklaerung in befehlsvorlagen %}
<option value="{{ befehl }}" data-erklaerung="{{ erklaerung }}">{{ name }}</option>
{% endfor %}
</select>
<span class="field__hint" id="befehlserklaerung">
Eine Vorlage füllt nur das Feld darunter – abgeschickt wird von Hand.
</span>
</label>
<label class="field">
<span class="field__label">Befehl</span>
<input class="input mono" type="text" name="befehl" id="befehl"
maxlength="500" autocomplete="off"
placeholder='echo "Encryption=AlwaysOff" | {sudo} tee -a /etc/vnc/config.d/common.custom'
{% if not current_user.has('servicing_command.execute') %}disabled{% endif %}>
<span class="field__hint">
Genau eine Zeile. Was der Befehl bewirkt, weiß der, der ihn schreibt – TESM
führt ihn aus und hält fest, was zurückkam.
</span>
</label>
</div>
</section>
{% endif %}
<section class="card">
<div class="table-toolbar">
<label class="check">
<input type="checkbox" id="select-all">
<span>Alle auswählen</span>
</label>
<button class="btn btn--sm btn--ghost" type="button" data-alle-einklappen>
{{ icon('chevron-up', size=14) }} Alle einklappen
</button>
<span class="table-toolbar__spacer"></span>
{% if current_user.has('servicing_command.execute') %}
<button class="btn btn--sm" type="submit"
formaction="{{ url_for('maintenance.start_command') }}"
data-confirm="Den eingetragenen Befehl auf allen ausgewählten Geräten ausführen? Was er bewirkt, entscheidet er selbst – TESM führt ihn nur aus."
data-confirm-title="Befehl senden" data-confirm-ok="Senden" data-confirm-style="danger">
{{ icon('terminal', size=15) }} Befehl senden
</button>
{% endif %}
{% if current_user.has('servicing_update.execute') %}
<button class="btn btn--sm btn--primary" type="submit"
formaction="{{ url_for('maintenance.start_update') }}"
@@ -79,6 +142,7 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
<th class="sortable">Gerät</th>
<th class="sortable">IP</th>
<th class="sortable">Zustand</th>
<th class="sortable">Auftrag</th>
<th class="sortable">Zugangsdaten</th>
<th class="sortable">Host-Schlüssel</th>
<th></th>
@@ -95,13 +159,36 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
<span class="sr-only">{{ device.name }} auswählen</span>
</label>
</td>
<td class="fw-600">{{ device.name }}</td>
<td class="fw-600">
{# Mitlesen, ohne die Seite zu verlassen -- so war es in der
Fassung 1.2.8, und es fehlte seither. Die Zeile darunter
klappt auf, sobald für dieses Gerät etwas läuft. #}
<button class="btn btn--ghost btn--sm" type="button"
data-ausgabe-schalter="{{ device.name }}"
title="Ausgabe dieses Geräts mitlesen">
{{ icon('chevron-right', size=13) }}
</button>
{{ device.name }}
</td>
<td class="mono fs-sm">{{ device.ip }}</td>
<td>
<td data-zustand="{{ device.id }}">
{% if device.state == 'online' %}{{ pill('online', 'online') }}
{% elif device.state == 'offline' %}{{ pill('offline', 'offline') }}
{% else %}{{ pill('unbekannt', 'warning') }}{% endif %}
</td>
{# Wie weit ist der Auftrag dieses Geräts? Ohne diese Spalte
blieb nur die Auftragsliste weiter unten -- und dort steht
nicht, welche Zeile zu welchem Gerät gehört, wenn vierzig
Aufträge in derselben Sekunde starten. #}
<td data-auftragslage="{{ device.name }}">
{% set lage = auftragslage.get(device.name) %}
{% if not lage %}<span class="faint fs-xs">–</span>
{% elif lage.state == 'running' %}{{ pill('läuft', 'info') }}
{% elif lage.state == 'pending' %}{{ pill('wartet', '') }}
{% elif lage.state == 'succeeded' %}{{ pill('fertig', 'online') }}
{% elif lage.state == 'failed' %}{{ pill('Fehler', 'danger') }}
{% else %}{{ pill('unklar', 'warning') }}{% endif %}
</td>
<td class="fs-sm">
{{ device.credential_name }}
<div class="fs-xs faint">{{ category_labels.get(device.category, device.category) }}</div>
@@ -129,6 +216,11 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
{% endif %}
</td>
</tr>
<tr data-ausgabe-zeile="{{ device.name }}" hidden>
<td colspan="8" class="p-0">
<pre class="job-output" data-ausgabe="{{ device.name }}">Noch keine Ausgabe.</pre>
</td>
</tr>
{% endfor %}
</tbody>
</table>
@@ -161,13 +253,13 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
<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>
<tr data-auftrag="{{ job.id }}">
<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>
<td data-auftrag-zustand>
{% if job.state == 'succeeded' %}{{ pill('erfolgreich', 'online') }}
{% elif job.state == 'failed' %}{{ pill('fehlgeschlagen', 'danger') }}
{% elif job.state == 'running' %}{{ pill('läuft', 'info') }}
@@ -175,11 +267,12 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
{% 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 {{ 'progress--success' if job.state == 'succeeded' else ('progress--danger' if job.state == 'failed' else '') }}"
data-auftrag-balken>
<div class="progress__bar" style="width: {{ job.progress }}%"></div>
</div>
</td>
<td class="fs-xs muted">{{ job.message }}</td>
<td class="fs-xs muted" data-auftrag-meldung>{{ job.message }}</td>
</tr>
{% endfor %}
</tbody>
@@ -193,6 +286,18 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
{% block scripts %}
<script type="module" nonce="{{ csp_nonce }}">
// Eine Vorlage füllt das Feld und erklärt sich -- abgeschickt wird von Hand.
const vorlage = document.getElementById("befehlsvorlage");
const befehl = document.getElementById("befehl");
const erklaerung = document.getElementById("befehlserklaerung");
vorlage?.addEventListener("change", () => {
const gewaehlt = vorlage.selectedOptions[0];
if (vorlage.value) befehl.value = vorlage.value;
erklaerung.textContent =
gewaehlt?.dataset.erklaerung ||
"Eine Vorlage füllt nur das Feld darunter – abgeschickt wird von Hand.";
});
// 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.
@@ -205,14 +310,172 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
.forEach((box) => (box.checked = all.checked));
});
// Auftragsliste nachladen, solange etwas läuft.
// Mitlesen: die Zeile unter dem Gerät zeigt, was dort gerade passiert.
//
// Vorher lud diese Seite sich alle zwei Sekunden komplett neu, solange etwas
// lief -- das verwarf jede Auswahl, jeden Tastendruck im Befehlsfeld und
// sprang nach oben. Jetzt kommen nur die letzten Zeilen, und die Seite
// bleibt stehen.
// Der Massenbefehl: zugeklappt geladen, per Kopfzeile aufgeklappt.
const befehlSchalter = document.querySelector("[data-befehl-schalter]");
const befehlFeld = document.querySelector("[data-befehl-feld]");
befehlSchalter?.addEventListener("click", () => {
const zu = befehlFeld.hidden;
befehlFeld.hidden = !zu;
befehlSchalter.setAttribute("aria-expanded", zu ? "true" : "false");
befehlSchalter.style.transform = zu ? "rotate(90deg)" : "";
if (zu) document.getElementById("befehl")?.focus();
});
const schalter = Array.from(document.querySelectorAll("[data-ausgabe-schalter]"));
// Wer von Hand zuklappt, will es zu haben. Ohne dieses Gedaechtnis riss die
// naechste Statusabfrage die Zeile wieder auf, weil dort noch Ausgabe stand
// -- zwei Sekunden spaeter, jedes Mal.
const zugeklappt = new Set();
// Beim ersten Fuellen ans Ende springen, danach nur, wenn man ohnehin unten
// steht. So steht nach dem Neuladen der Seite das Aktuelle im Bild und nicht
// der Anfang von vorgestern -- und wer hochscrollt, um etwas zu lesen, wird
// nicht wieder heruntergezogen.
const gefuellt = new Set();
function klappen(name, zu) {
const zeile = document.querySelector(`[data-ausgabe-zeile="${CSS.escape(name)}"]`);
if (!zeile) return;
zeile.hidden = zu;
if (zu) zugeklappt.add(name);
else zugeklappt.delete(name);
}
schalter.forEach((knopf) => {
knopf.addEventListener("click", () => {
const name = knopf.dataset.ausgabeSchalter;
const zeile = document.querySelector(`[data-ausgabe-zeile="${CSS.escape(name)}"]`);
if (zeile) klappen(name, !zeile.hidden);
});
});
document.querySelector("[data-alle-einklappen]")?.addEventListener("click", () => {
schalter.forEach((knopf) => klappen(knopf.dataset.ausgabeSchalter, true));
});
function zeigen(name, text) {
const feld = document.querySelector(`[data-ausgabe="${CSS.escape(name)}"]`);
const zeile = document.querySelector(`[data-ausgabe-zeile="${CSS.escape(name)}"]`);
if (!feld || !zeile) return;
const erstmals = !gefuellt.has(name);
const unten = feld.scrollTop + feld.clientHeight >= feld.scrollHeight - 24;
feld.textContent = text || "Noch keine Ausgabe.";
if (text) gefuellt.add(name);
if (erstmals || unten) feld.scrollTop = feld.scrollHeight;
// Was läuft, klappt von selbst auf -- wer zusieht, will nicht erst suchen.
if (text && zeile.hidden && !zugeklappt.has(name)) zeile.hidden = false;
}
// Zustand, Fortschritt und Meldung eines Auftrags nachziehen.
//
// Vorher lud die Seite sich dafür komplett neu, sobald der letzte Auftrag
// durch war -- und riss damit genau das weg, was man lesen wollte: die
// Ausgabe stand da, dann sprang die Seite, und danach war sie leer.
const ZUSTAENDE = {
succeeded: ["erfolgreich", "pill--online", "progress--success"],
failed: ["fehlgeschlagen", "pill--danger", "progress--danger"],
running: ["läuft", "pill--info", ""],
unknown: ["unklar", "pill--warning", ""],
pending: ["wartet", "", ""],
};
function auftragZeigen(job) {
const zeile = document.querySelector(`[data-auftrag="${CSS.escape(job.id)}"]`);
if (!zeile) return false;
const [text, pillenKlasse, balkenKlasse] = ZUSTAENDE[job.state] || ZUSTAENDE.pending;
const pille = zeile.querySelector("[data-auftrag-zustand] .pill");
if (pille && pille.textContent.trim() !== text) {
pille.textContent = text;
pille.className = `pill ${pillenKlasse}`.trim();
}
const balken = zeile.querySelector("[data-auftrag-balken]");
if (balken) {
balken.className = `progress ${balkenKlasse}`.trim();
const inner = balken.querySelector(".progress__bar");
if (inner) inner.style.width = `${job.progress || 0}%`;
}
const meldung = zeile.querySelector("[data-auftrag-meldung]");
if (meldung && meldung.textContent.trim() !== (job.message || "")) {
meldung.textContent = job.message || "";
}
return true;
}
// Dieselben fünf Zustände wie in der Auftragsliste -- hier aber neben dem
// Gerät, wo man sie sucht.
const LAGE = {
pending: ["wartet", ""],
running: ["läuft", "pill--info"],
succeeded: ["fertig", "pill--online"],
failed: ["Fehler", "pill--danger"],
unknown: ["unklar", "pill--warning"],
};
function lageZeigen(name, zustand) {
const zelle = document.querySelector(`[data-auftragslage="${CSS.escape(name)}"]`);
if (!zelle) return;
const [text, klasse] = LAGE[zustand] || LAGE.pending;
const vorhanden = zelle.querySelector(".pill");
if (vorhanden && vorhanden.textContent.trim() === text) return;
const pille = document.createElement("span");
pille.className = `pill ${klasse}`.trim();
pille.textContent = text;
zelle.replaceChildren(pille);
}
let neueAuftraege = false;
async function poll() {
let weiter = 10000;
try {
const state = await window.tesm.api("{{ url_for('maintenance.status') }}");
if (state.running) window.setTimeout(() => location.reload(), state.poll_ms);
const state = await window.tesm.api("{{ url_for('maintenance.status') }}", { auto: true });
weiter = state.poll_ms || 10000;
// Der neueste Auftrag je Gerät gewinnt; die Liste kommt neueste zuerst.
const gesehen = new Set();
state.jobs.forEach((job) => {
if (!gesehen.has(job.subject)) {
gesehen.add(job.subject);
lageZeigen(job.subject, job.state);
}
if (job.output) zeigen(job.subject, job.output);
// Ein Auftrag, der in der Tabelle noch gar nicht steht, ist neu --
// dafür braucht es die Seite einmal frisch, aber erst, wenn nichts
// mehr läuft.
if (!auftragZeigen(job)) neueAuftraege = true;
});
// Erreichbarkeit mitziehen -- sonst steht die Seite mit dem Stand ihres
// Aufbaus da, und wer nach einem Neustart auf die Rückkehr wartet, sieht
// "unbekannt", bis er von Hand neu lädt.
(state.devices || []).forEach((geraet) => {
const zelle = document.querySelector(`[data-zustand="${geraet.id}"]`);
if (!zelle) return;
const [text, art] =
geraet.state === "online"
? ["online", "pill--online"]
: geraet.state === "offline"
? ["offline", "pill--offline"]
: ["unbekannt", "pill--warning"];
const pille = zelle.querySelector(".pill");
if (pille && pille.textContent.trim() !== text) {
pille.textContent = text;
pille.className = `pill ${art}`;
}
});
// Neu laden nur, wenn Aufträge dazugekommen sind, die in der Tabelle
// fehlen -- und auch dann erst, wenn nichts mehr läuft. Wer zusieht,
// soll nicht mitten im Lesen weggerissen werden.
if (neueAuftraege && !state.running) {
window.setTimeout(() => location.reload(), 8000);
neueAuftraege = false;
}
} catch {
/* stumm bleiben – die Seite ist auch ohne Aktualisierung nutzbar */
}
window.setTimeout(poll, weiter);
}
poll();
</script>
+9 -35
View File
@@ -1,5 +1,6 @@
{% extends "tesm_core/base.html" %}
{% from "tesm_core/macros.html" import alert, pill %}
{% from "tesm/_vnc_flaeche.html" import vnc_flaeche, vnc_status, vnc_zugangsdaten, vnc_tasten %}
{% block page_title %}Bildschirm: {{ device.name }}{% endblock %}
{% block page_sub %}{{ zugang.ziel }}:{{ zugang.port }}{% endblock %}
@@ -13,13 +14,6 @@
{% endblock %}
{% block content %}
{# Die Zugangsdaten stehen in einem eigenen JSON-Block statt in einem
data-Attribut: ein Attribut steht im Quelltext der Seite zwischen der
Beschriftung und wird beim Kopieren einer Zeile mitgenommen. Beides ist
derselbe Inhalt -- der Unterschied ist, wie leicht er einem versehentlich
unter die Augen kommt. #}
<script type="application/json" id="screen-credentials-data">{{ vorgaben | tojson }}</script>
<div class="stack">
{% if zugang.hinderungsgrund %}
{{ alert(zugang.hinderungsgrund, 'warning') }}
@@ -49,9 +43,7 @@
</div>
</div>
<div class="row row--tight">
<button class="btn btn--sm" type="button" id="screen-cad" hidden
title="Strg+Alt+Entf an das Gerät senden">Strg+Alt+Entf</button>
<button class="btn btn--sm" type="button" id="screen-disconnect" hidden>Trennen</button>
{{ vnc_tasten() }}
{# Ausdrücklich ein Knopf und keine Nebenwirkung des Seitenaufbaus: die
Prüfung bricht eine Protokollverhandlung ab, und RealVNC zählt das.
Wer sucht, drückt ihn; wer nur arbeiten will, merkt nichts davon. #}
@@ -59,7 +51,9 @@
action="{{ url_for('screen.probe', device_id=device.id) }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<button class="btn btn--sm btn--ghost" type="submit"
title="Fragt das Gerät, welche Anmeldeverfahren es anbietet">
title="Fragt das Gerät, welche Anmeldeverfahren es anbietet. Das ist
eine eigene Verbindung, die abgebrochen wird – ein RealVNC Server zählt so etwas mit.
Nicht unmittelbar vor dem Verbinden drücken.">
Verfahren prüfen
</button>
</form>
@@ -70,30 +64,10 @@
</div>
</div>
<div class="card__body stack">
<div id="screen-status" class="screen__status">Wird vorbereitet …</div>
<form id="screen-credentials" class="row row--tight" hidden onsubmit="return false">
<label class="field">
<span class="field__label">Benutzer</span>
<input class="input" type="text" id="screen-username" autocomplete="off"
value="{{ vorgaben.username }}">
</label>
<label class="field">
<span class="field__label">Passwort</span>
<input class="input" type="password" id="screen-password" autocomplete="off">
</label>
<button class="btn btn--primary" type="button" id="screen-send">Anmelden</button>
</form>
<div id="screen" class="screen"
data-socket="{{ websocket_path }}"
data-ziel="{{ zugang.ziel }}:{{ zugang.port }}"
data-verfahren="{{ zugang.verfahren | join(',') }}"
data-blockiert="{{ '1' if zugang.hinderungsgrund else '0' }}"
data-grund="{{ zugang.hinderungsgrund }}">
<div id="screen-canvas" class="screen__canvas" hidden></div>
</div>
<div class="card__body stack" id="screen" data-sitzung="{{ quelle }}">
{{ vnc_status() }}
{{ vnc_zugangsdaten() }}
{{ vnc_flaeche() }}
{% if not darf_geheimnis and device.credential_id %}
<p class="fs-xs faint">
+5 -1
View File
@@ -113,7 +113,11 @@ class MonitorDaemon:
time.sleep(min(1.0, remaining))
remaining -= 1.0
live.info("monitor", "Überwachungsprozess beendet")
# Warnstufe, nicht INFO: dass die Ueberwachung *laeuft*, ist der
# Normalfall und darf gruen sein. Dass sie **steht**, ist der Zustand,
# in dem niemand mehr erfaehrt, dass ein Geraet weg ist -- und genau
# diese Zeile sucht man hinterher im Protokoll.
live.warning("monitor", "Überwachungsprozess beendet")
return 0
+33 -20
View File
@@ -26,6 +26,23 @@
# =============================================================================
set -euo pipefail
# Ein Lauf dieses Skripts gehoert in eine definierte Sprachumgebung -- nicht in
# die, die das Terminal des Administrators zufaellig ankuendigt. Per SSH kommt
# LC_ALL mit (sshd: "AcceptEnv LANG LC_*"), und ist diese Locale auf dem Ziel
# nicht erzeugt, warnt **jede neu gestartete Shell**:
#
# bash: warning: setlocale: LC_ALL: cannot change locale (en_US.UTF-8)
#
# Mitten in einer Aktualisierung sieht das nach einem Fehler aus und ist
# keiner. Nebenbei sind damit auch die Meldungen der Werkzeuge, die hier
# laufen, in einer Sprache -- ein Protokoll, das je nach Arbeitsplatz anders
# klingt, liest sich schlecht.
if locale -a 2>/dev/null | grep -qix 'c\.utf-\?8'; then
export LC_ALL=C.UTF-8 LANG=C.UTF-8
else
export LC_ALL=C LANG=C
fi
APP=""
INSTANCE=""
#: Verzeichnis der Umgebungsdateien. Ueberschreibbar, damit sich die
@@ -418,26 +435,16 @@ 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
# Aufbauen, pruefen und reparieren steht in genau einem Programm -- und
# dasselbe laeuft als ExecStartPre vor jedem Dienststart. Zwei getrennte
# Paketlisten waeren zwei Listen, die auseinanderlaufen: die Reparatur baute
# dann eine Umgebung, die nicht mehr der Installation entspricht.
# Ueber "bash" und nicht als Programm: Dateien im Baum tragen kein
# Ausfuehrungsrecht -- git kennt auf einer Windows-Buildmaschine keines, und
# install.sh, tesm-helper und tesm-motd werden aus demselben Grund ebenso
# aufgerufen oder mit ausdruecklichem Modus installiert. Ohne das brach die
# Aktualisierung mit "Permission denied" ab (und rollte zurueck).
bash "$ROOT/deploy/tesm-venv" "$ROOT" "$APP" --immer
info "Abhaengigkeiten installiert"
# -- Statische Dateien fuer nginx --------------------------------------------
@@ -467,6 +474,12 @@ install_if_changed() {
install_if_changed "$ROOT/deploy/tesm-helper" /usr/local/lib/tesm/tesm-helper 0755 && \
info "Helfer aktualisiert" || info "Helfer unveraendert"
# Derselbe Baustein, der oben die Umgebung gebaut hat: die Dienste rufen ihn
# vor jedem Start auf und reparieren damit, was ein Release-Upgrade des
# Betriebssystems ihnen unter den Fuessen weggezogen hat.
install_if_changed "$ROOT/deploy/tesm-venv" /usr/local/lib/tesm/tesm-venv 0755 >/dev/null \
&& info "Umgebungspfleger aktualisiert" || true
# visudo -c prueft die Datei, bevor sie aktiv wird -- eine kaputte sudoers-Datei
# waere sonst ein Totalausfall der Administration.
TMP_SUDO="$(mktemp)"
+12
View File
@@ -15,11 +15,23 @@ Environment=TESM_LICENSE_LOG_DIR=/var/log/tesm-license
Environment=TESM_LICENSE_WEB_PROCESS=1
Environment=TESM_LICENSE_COOKIE_SECURE=1
Environment=PYTHONUNBUFFERED=1
# Vor jedem Start: traegt die Python-Umgebung noch? Ein Release-Upgrade des
# Betriebssystems tauscht die Minor-Fassung von Python aus, und der venv haengt
# daran -- danach findet der Dienst kein einziges Paket mehr und scheitert mit
# "No module named gunicorn". Ist alles heil, kostet diese Zeile Millisekunden;
# sonst baut sie die Umgebung neu, einmal je Systemstart.
#
# Das "+" laesst den Schritt als root laufen. Noetig, weil der venv root
# gehoert, der Dienst aber als unprivilegierter Benutzer laeuft.
ExecStartPre=+/usr/local/lib/tesm/tesm-venv /srv/tesm-license tesm-license
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
# Ein Neuaufbau der Python-Umgebung laedt Pakete und dauert Minuten --
# die Vorgabe von 90 Sekunden wuerde ihn mittendrin abschneiden.
TimeoutStartSec=900
Restart=always
RestartSec=5
+12
View File
@@ -12,7 +12,19 @@ Environment=TESM_BASE_DIR=/srv/tesm
Environment=TESM_DATA_DIR=/srv/tesm/data
Environment=TESM_LOG_DIR=/var/log/tesm
Environment=PYTHONUNBUFFERED=1
# Vor jedem Start: traegt die Python-Umgebung noch? Ein Release-Upgrade des
# Betriebssystems tauscht die Minor-Fassung von Python aus, und der venv haengt
# daran -- danach findet der Dienst kein einziges Paket mehr und scheitert mit
# "No module named gunicorn". Ist alles heil, kostet diese Zeile Millisekunden;
# sonst baut sie die Umgebung neu, einmal je Systemstart.
#
# Das "+" laesst den Schritt als root laufen. Noetig, weil der venv root
# gehoert, der Dienst aber als unprivilegierter Benutzer laeuft.
ExecStartPre=+/usr/local/lib/tesm/tesm-venv /srv/tesm tesm
ExecStart=/srv/tesm/venv/bin/tesm-monitor
# Ein Neuaufbau der Python-Umgebung laedt Pakete und dauert Minuten --
# die Vorgabe von 90 Sekunden wuerde ihn mittendrin abschneiden.
TimeoutStartSec=900
Restart=always
RestartSec=10
+12
View File
@@ -15,11 +15,23 @@ Environment=TESM_LOG_DIR=/var/log/tesm
Environment=TESM_WEB_PROCESS=1
Environment=TESM_COOKIE_SECURE=1
Environment=PYTHONUNBUFFERED=1
# Vor jedem Start: traegt die Python-Umgebung noch? Ein Release-Upgrade des
# Betriebssystems tauscht die Minor-Fassung von Python aus, und der venv haengt
# daran -- danach findet der Dienst kein einziges Paket mehr und scheitert mit
# "No module named gunicorn". Ist alles heil, kostet diese Zeile Millisekunden;
# sonst baut sie die Umgebung neu, einmal je Systemstart.
#
# Das "+" laesst den Schritt als root laufen. Noetig, weil der venv root
# gehoert, der Dienst aber als unprivilegierter Benutzer laeuft.
ExecStartPre=+/usr/local/lib/tesm/tesm-venv /srv/tesm tesm
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
# Ein Neuaufbau der Python-Umgebung laedt Pakete und dauert Minuten --
# die Vorgabe von 90 Sekunden wuerde ihn mittendrin abschneiden.
TimeoutStartSec=900
Restart=always
RestartSec=5
+4
View File
@@ -100,6 +100,10 @@ check_readable_path() {
case "$1" in
/var/log/kea/kea-dhcp4.log) ;;
/var/lib/kea/kea-leases4.csv) ;;
# Die eigene Kea-Konfiguration: 0640 root:_kea, also fuer den
# Dienstbenutzer nicht lesbar. Die Anwendung braucht sie, um zu sehen, ob
# der Dienst ueberhaupt mit dem laeuft, was sie geschrieben hat.
/etc/kea/kea-dhcp4.conf) ;;
/etc/netplan/90-*.yaml)
check_app_key "$(basename "$1" .yaml | sed 's/^90-//')" ;;
/etc/nginx/sites-available/*)
+16
View File
@@ -150,6 +150,22 @@ def main(argv: list[str] | None = None) -> int:
globals().update(AMBER="", DIM="", BOLD="", RESET="")
root = Path(args.root)
# Gibt es diese Installation ueberhaupt noch? Wenn nicht, ist hier nichts
# zu melden -- auch kein Fehler.
#
# Der Hook unter /etc/update-motd.d bleibt liegen, wenn jemand eine
# Installation von Hand entfernt; einen Rueckbau bringt TESM nicht mit.
# Vorher druckte jeder dieser Hooks trotzdem seinen vollen Banner und
# darunter "Datenbank nicht lesbar". Auf dem Testserver standen so
# vierzehn Bloecke fuer vierzehn laengst geloeschte Probeinstallationen
# vor jeder Anmeldung -- und der echte Banner ging darin unter.
#
# Eine entfernte Installation ist keine kaputte. Eine Datenbank, die da
# ist und sich nicht lesen laesst, dagegen schon: die meldet sich weiter.
if not (root / "data" / "app.db").is_file():
return 0
# 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.
+173
View File
@@ -0,0 +1,173 @@
#!/usr/bin/env bash
# =============================================================================
# Die Python-Umgebung einer Installation -- aufbauen, pruefen, reparieren.
#
# tesm-venv /srv/tesm tesm # reparieren, falls noetig (Dienststart)
# tesm-venv /srv/tesm tesm --immer # neu bestuecken (Installation)
# tesm-venv /srv/tesm tesm --pruefen # nur melden: 0 heil, 1 kaputt
#
# **Warum es das gibt.** Ein venv haengt an der Minor-Fassung von Python: die
# Pakete liegen unter lib/python3.X. Ein Release-Upgrade des Betriebssystems
# tauscht diese Fassung aus (Ubuntu 24.04 bringt 3.12, 26.04 bringt 3.14) --
# und danach zeigt venv/bin/python3 weiter auf /usr/bin/python3, also auf den
# **neuen** Interpreter. Der venv ist ausfuehrbar und trotzdem tot: er findet
# kein einziges Paket mehr, nicht einmal pip. Jeder Dienst scheitert mit
# "ModuleNotFoundError: No module named 'gunicorn'", nginx meldet 502.
#
# Real passiert, auf beiden Testservern, am 16.09.2026.
#
# Dieses Programm laeuft deshalb als ExecStartPre vor jedem Dienststart. Ist
# alles heil, endet es nach Millisekunden. Sonst baut es die Umgebung neu --
# aus denselben Paketen, die auch die Installation einspielt, denn es ist
# dasselbe Programm: install.sh ruft es mit --immer auf. Zwei getrennte Listen
# waeren zwei Listen, die auseinanderlaufen.
#
# **Was es nicht tut:** die alten Pakete unter lib/python3.X uebernehmen.
# cryptography, argon2-cffi und bcrypt sind kompiliert und fuer die alte
# Fassung gebaut; ein halb uebernommener venv waere schlimmer als ein neuer.
# =============================================================================
set -euo pipefail
# **Der venv muss fuer den Dienstbenutzer lesbar sein.**
#
# Dieses Programm laeuft als ExecStartPre der Dienst-Unit -- und erbt damit
# deren UMask=0027. Alles, was es anlegt, gehoerte root und waere 0750: der
# Dienst selbst laeuft unprivilegiert und koennte sein eigenes site-packages
# nicht einmal betreten. Gemessen: nach der Reparatur ging "import gunicorn"
# als root, als Benutzer tesm nicht -- die Reparatur baute etwas, das genau
# der Prozess nicht benutzen konnte, fuer den sie gedacht war.
#
# Im venv liegt nichts Geheimes; Schluessel und Datenbank stehen woanders.
umask 022
ROOT="${1:-}"
APP="${2:-}"
MODUS="reparieren"
[[ "${3:-}" == "--immer" ]] && MODUS="immer"
[[ "${3:-}" == "--pruefen" ]] && MODUS="pruefen"
[[ -n "$ROOT" && -n "$APP" ]] || {
echo "Aufruf: tesm-venv <verzeichnis> <tesm|tesm-license> [--immer|--pruefen]" >&2
exit 2
}
[[ "$APP" == "tesm" || "$APP" == "tesm-license" ]] || {
echo "tesm-venv: unbekannte Anwendung '$APP'" >&2
exit 2
}
[[ -d "$ROOT" ]] || { echo "tesm-venv: $ROOT gibt es nicht" >&2; exit 2; }
NAME="$(basename "$ROOT")"
VENV="$ROOT/venv"
PY="$VENV/bin/python3"
MODUL="tesm"; [[ "$APP" == "tesm-license" ]] && MODUL="tesm_license"
LOG_DIR="${TESM_LOG_DIR:-/var/log/$NAME}"
#: Ein Versuch je Systemstart. /run ist ein tmpfs, der Merker verschwindet
#: also beim naechsten Start von selbst. Ohne ihn wuerde "Restart=always" nach
#: einem misslungenen Neuaufbau (kein Netz, kein PyPI) alle fuenf Sekunden
#: einen neuen pip-Lauf starten -- gegen einen Server, der ohnehin gerade
#: nicht erreichbar ist.
MERKER="/run/tesm-venv-$NAME.versucht"
melden() {
local stufe="$1" text="$2"
# Ins Journal: ExecStartPre-Ausgabe landet dort ohnehin.
printf 'tesm-venv: %s\n' "$text" >&2
# Und ins laufende Protokoll, damit es in der Oberflaeche sichtbar ist.
# Fehlschlaege hier duerfen den Start nicht kosten -- ein Protokoll ist
# kein Grund, einen Dienst nicht zu starten.
if [[ -d "$LOG_DIR" ]]; then
printf '%s %-7s %-10s %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$stufe" "venv" "$text" \
>> "$LOG_DIR/live.log" 2>/dev/null || true
chown "$APP:$APP" "$LOG_DIR/live.log" 2>/dev/null || true
fi
}
#: Traegt diese Umgebung? Gefragt wird nicht nach der Versionsnummer, sondern
#: nach dem, was gebraucht wird -- so faellt auch ein venv auf, dem jemand ein
#: Paket herausgeloescht hat.
traegt() {
[[ -x "$PY" ]] || return 1
"$PY" -c "import pip" >/dev/null 2>&1 || return 1
# Beim allerersten Lauf ist die Anwendung noch nicht eingespielt -- dann
# zaehlt allein, dass pip benutzbar ist.
[[ "$MODUS" == "immer" ]] && return 0
"$PY" -c "import $MODUL" >/dev/null 2>&1
}
bestuecken() {
# **Jeder pip-Lauf wird einzeln geprueft.**
#
# "set -e" hilft hier nicht: diese Funktion wird in einer Bedingung
# aufgerufen ("if ! bestuecken"), und darin ist errexit fuer den **ganzen**
# Rumpf abgeschaltet. Ein gescheitertes pip lief deshalb einfach weiter bis
# zum "return 0" -- der Lauf meldete "Fertig", und im venv lagen weiter die
# alten Pakete.
#
# Real passiert: der Lizenzserver blieb ueber drei Auslieferungen hinweg auf
# 2.8.16 stehen, weil eine Abhaengigkeit nicht aufloesbar war. Gesehen hat es
# niemand, denn die Fehlerzeile von pip ging im Lauf unter und der
# Ausstiegscode war 0.
"$PY" -m pip install --quiet --upgrade pip setuptools wheel || return 1
local pakete=("$ROOT/packages/tesm-licensing" "$ROOT/packages/tesm-core")
if [[ "$APP" == "tesm-license" ]]; then
[[ -d "$ROOT/packages/tesm-licensing-server" ]] || {
echo "tesm-venv: packages/tesm-licensing-server fehlt" >&2
return 1
}
pakete+=("$ROOT/packages/tesm-licensing-server")
fi
"$PY" -m pip install --quiet "${pakete[@]}" "$ROOT/apps/$APP" gunicorn || return 1
# Die WebSocket-Bruecke braucht nur TESM (Terminal und Bildschirm).
if [[ "$APP" == "tesm" ]]; then
"$PY" -m pip install --quiet flask-sock || return 1
fi
return 0
}
# --------------------------------------------------------------------------- #
if [[ "$MODUS" == "pruefen" ]]; then
traegt && exit 0 || exit 1
fi
if traegt && [[ "$MODUS" != "immer" ]]; then
exit 0
fi
if [[ "$MODUS" != "immer" && -e "$MERKER" ]]; then
melden "WARNUNG" \
"Die Python-Umgebung von $NAME ist unbrauchbar, und der Neuaufbau ist seit dem letzten Systemstart schon einmal gescheitert. Erneuter Versuch: 'bash $ROOT/deploy/install.sh --app $APP --yes'."
exit 1
fi
touch "$MERKER" 2>/dev/null || true
if ! [[ -x "$PY" ]] || ! "$PY" -c "import pip" >/dev/null 2>&1; then
# Ohne --clear: ein erneutes "venv" ergaenzt das fehlende lib-Verzeichnis
# und holt pip zurueck. Gemessen, nicht angenommen.
python3 -m venv "$VENV"
[[ "$MODUS" == "immer" ]] ||
melden "HINWEIS" \
"Python-Umgebung von $NAME neu aufgebaut ($(python3 -V 2>&1)) -- das System hat seine Python-Fassung gewechselt."
fi
if ! bestuecken; then
melden "FEHLER" \
"Die Pakete fuer $NAME liessen sich nicht einspielen. Hat dieser Server Zugriff auf das Paketverzeichnis?"
exit 1
fi
# Und noch einmal ausdruecklich, fuer den Fall, dass ein frueherer Lauf mit
# strengerer Maske gebaut hat: was hier liegt, muss lesbar sein. Idempotent
# und in einer Sekunde durch.
chmod -R a+rX "$VENV" 2>/dev/null || true
# Vertrauen ist gut: nach dem Einspielen muss die Anwendung auch importierbar
# sein. Ohne diese Probe hiesse "Fertig" nur, dass pip nicht geschimpft hat.
if ! "$PY" -c "import $MODUL" >/dev/null 2>&1; then
melden "FEHLER" "Die Pakete fuer $NAME wurden eingespielt, aber '$MODUL' laesst sich nicht importieren."
exit 1
fi
[[ "$MODUS" == "immer" ]] || melden "INFO" "Python-Umgebung von $NAME ist wieder vollstaendig."
rm -f "$MERKER" 2>/dev/null || true
exit 0
+320
View File
@@ -0,0 +1,320 @@
#!/usr/bin/env bash
# =============================================================================
# Rueckbau einer Installation von TESM oder TESM-Lizenzserver.
#
# Aufruf:
# sudo bash deploy/uninstall.sh --app tesm
# sudo bash deploy/uninstall.sh --app tesm-license --instance test
# sudo bash deploy/uninstall.sh --app tesm --dry-run
#
# Das genaue Gegenstueck zu install.sh -- und es fehlte. Wer eine Installation
# von Hand loeschte ("rm -rf /srv/tesm-probe"), liess zurueck: die systemd-Unit
# (die systemd danach in einer Dauerschleife neu zu starten versuchte), den
# Begruessungshaken (der bei jeder Anmeldung seinen Banner druckte), den
# Shim unter /usr/local/bin, die Umgebungsdatei, die nginx-Site und den
# Dienstbenutzer. Auf dem Testserver standen so neun tote Dienste und vierzehn
# Banner. Genau das raeumt dieses Skript ab.
#
# Zwei Regeln, die den Unterschied machen:
#
# * **Daten bleiben.** Datenbank, Schluessel und Lizenz werden vorher in ein
# Archiv gelegt, nicht geloescht. Wer sie wirklich los sein will, sagt es
# mit --purge. Ein Rueckbau, der beilaeufig die Datenbank mitnimmt, ist
# keiner, sondern ein Unfall.
# * **Geteiltes bleibt, solange es jemand braucht.** sudoers-Datei und
# Dienstbenutzer gehoeren der *Anwendung* (nicht der Instanz), der Helfer
# unter /usr/local/lib/tesm allen Installationen beider Anwendungen. Sie
# verschwinden erst, wenn die letzte geht -- sonst legt der Rueckbau einer
# Testinstanz die Produktion daneben lahm.
# =============================================================================
set -euo pipefail
# Ein Lauf dieses Skripts gehoert in eine definierte Sprachumgebung -- nicht in
# die, die das Terminal des Administrators zufaellig ankuendigt. Per SSH kommt
# LC_ALL mit (sshd: "AcceptEnv LANG LC_*"), und ist diese Locale auf dem Ziel
# nicht erzeugt, warnt **jede neu gestartete Shell**:
#
# bash: warning: setlocale: LC_ALL: cannot change locale (en_US.UTF-8)
#
# Mitten in einer Aktualisierung sieht das nach einem Fehler aus und ist
# keiner. Nebenbei sind damit auch die Meldungen der Werkzeuge, die hier
# laufen, in einer Sprache -- ein Protokoll, das je nach Arbeitsplatz anders
# klingt, liest sich schlecht.
if locale -a 2>/dev/null | grep -qix 'c\.utf-\?8'; then
export LC_ALL=C.UTF-8 LANG=C.UTF-8
else
export LC_ALL=C LANG=C
fi
APP=""
INSTANCE=""
PURGE=0
ASSUME_YES=0
DRY_RUN=0
#: Ueberschreibbar, damit sich die Entscheidungen dieses Skripts in einem Test
#: wirklich ausfuehren lassen -- und nicht nur als Textmuster geprueft werden.
ETC_TESM="${TESM_ETC_DIR:-/etc/tesm}"
SRV_DIR="${TESM_SRV_DIR:-/srv}"
UNIT_DIR="${TESM_SYSTEMD_DIR:-/etc/systemd/system}"
MOTD_DIR="${TESM_MOTD_DIR:-/etc/update-motd.d}"
BIN_DIR="${TESM_BIN_DIR:-/usr/local/bin}"
LIB_DIR="${TESM_LIB_DIR:-/usr/local/lib/tesm}"
SUDOERS_DIR="${TESM_SUDOERS_DIR:-/etc/sudoers.d}"
LOGROTATE_DIR="${TESM_LOGROTATE_DIR:-/etc/logrotate.d}"
NGINX_DIR="${TESM_NGINX_DIR:-/etc/nginx}"
LOG_BASE="${TESM_LOG_BASE:-/var/log}"
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 <<'TEXT'
Rueckbau einer Installation.
--app <tesm|tesm-license> welche Anwendung (Pflicht)
--instance <name> benannte Instanz (sonst die Hauptinstallation)
--purge Daten nicht sichern, sondern loeschen
--dry-run nur zeigen, was geschehen wuerde
--yes, -y nicht nachfragen
TEXT
}
while [[ $# -gt 0 ]]; do
case "$1" in
--app) APP="${2:?}"; shift 2 ;;
--instance) INSTANCE="${2:?}"; shift 2 ;;
--purge) PURGE=1; shift ;;
--dry-run) DRY_RUN=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"
[[ -z "$INSTANCE" || "$INSTANCE" =~ ^[a-z0-9-]{1,16}$ ]] || fail "ungueltiger Instanzname"
# Root braucht es wegen /srv, /etc und systemd. Wer die Pfade umbiegt (Test),
# fasst nichts davon an -- dann ist die Huerde keine Sicherheit, sondern nur im
# Weg, und ein Rueckbau, der sich nicht pruefen laesst, ist ein Risiko fuer sich.
if [[ $DRY_RUN -eq 0 && "$SRV_DIR" == "/srv" && "$(id -u)" -ne 0 ]]; then
fail "bitte mit sudo ausfuehren"
fi
NAME="$APP"
[[ -n "$INSTANCE" ]] && NAME="$APP-$INSTANCE"
ROOT="$SRV_DIR/$NAME"
# Das Datenverzeichnis kann verschoben sein -- dann steht es in der
# Umgebungsdatei, und nur dort. Ein Rueckbau, der stattdessen "$ROOT/data"
# annimmt, sichert ein leeres Verzeichnis und laesst die echte Datenbank liegen.
ENV_FILE="$ETC_TESM/$NAME.env"
DATEN_DIR="$(sed -n 's/^TESM_DATA_DIR=//p' "$ENV_FILE" 2>/dev/null | tail -1 || true)"
DATEN_DIR="${DATEN_DIR:-$ROOT/data}"
# --------------------------------------------------------------------------- #
# Wer teilt sich was?
# --------------------------------------------------------------------------- #
#: Gibt es neben dieser noch eine weitere Installation **derselben** Anwendung?
#: Daran haengen sudoers-Datei und Dienstbenutzer -- beide tragen den Namen der
#: Anwendung, nicht den der Instanz.
weitere_dieser_anwendung() {
local eintrag name
for eintrag in "$ETC_TESM/$APP.env" "$ETC_TESM/$APP-"*.env; do
[[ -f "$eintrag" ]] || continue
name="$(basename "$eintrag" .env)"
[[ "$name" == "$NAME" ]] && continue
return 0
done
return 1
}
#: Gibt es ueberhaupt noch irgendeine Installation? Daran haengen der Helfer
#: und das Banner-Programm unter /usr/local/lib/tesm -- die gehoeren allen.
weitere_ueberhaupt() {
local eintrag name
for eintrag in "$ETC_TESM"/*.env; do
[[ -f "$eintrag" ]] || continue
name="$(basename "$eintrag" .env)"
[[ "$name" == "$NAME" ]] && continue
return 0
done
return 1
}
# --------------------------------------------------------------------------- #
# Der Plan -- erst zeigen, dann tun
# --------------------------------------------------------------------------- #
UNITS=("$NAME.service" "$NAME-monitor.service" "$NAME-cleanup.service" "$NAME-cleanup.timer")
DATEIEN=(
"$NGINX_DIR/sites-enabled/$NAME"
"$NGINX_DIR/sites-available/$NAME"
"$MOTD_DIR/50-$NAME"
"$BIN_DIR/$NAME-admin"
"$LOGROTATE_DIR/$NAME"
"$ENV_FILE"
)
VERZEICHNISSE=("$ETC_TESM/certs/$NAME" "$LOG_BASE/$NAME")
gefunden=0
step "Rueckbau von $NAME"
info "Anwendung : $APP${INSTANCE:+ (Instanz $INSTANCE)}"
info "Verzeichnis : $ROOT"
echo
echo " Dienste:"
for unit in "${UNITS[@]}"; do
if [[ -f "$UNIT_DIR/$unit" ]]; then info "- $unit"; gefunden=1; fi
done
[[ $gefunden -eq 1 ]] || info "(keine)"
echo
echo " Dateien und Verzeichnisse:"
for pfad in "${DATEIEN[@]}" "${VERZEICHNISSE[@]}"; do
if [[ -e "$pfad" ]]; then info "- $pfad"; gefunden=1; fi
done
if [[ -d "$ROOT" ]]; then info "- $ROOT"; gefunden=1; fi
if [[ $gefunden -eq 0 ]]; then
echo
info "Von '$NAME' ist nichts mehr da -- nichts zu tun."
exit 0
fi
echo
echo " Daten (Datenbank, Schluessel, Lizenz):"
if [[ -d "$DATEN_DIR" ]]; then
if [[ $PURGE -eq 1 ]]; then
warn "$DATEN_DIR wird GELOESCHT (--purge)"
else
info "$DATEN_DIR wird vorher gesichert"
fi
else
info "(kein Datenverzeichnis gefunden)"
fi
echo
echo " Bleibt stehen:"
if weitere_dieser_anwendung; then
info "sudoers und Dienstbenutzer '$APP' -- eine weitere Installation braucht sie"
else
info "- (sudoers und Dienstbenutzer '$APP' werden entfernt)"
fi
if weitere_ueberhaupt; then
info "Helfer unter $LIB_DIR -- andere Installationen brauchen ihn"
else
info "- (Helfer unter $LIB_DIR wird entfernt)"
fi
if [[ $DRY_RUN -eq 1 ]]; then
echo
info "Probelauf -- es wurde nichts geaendert."
exit 0
fi
if [[ $ASSUME_YES -eq 0 ]]; then
echo
read -r -p "Fortfahren? [j/N] " antwort
[[ "$antwort" =~ ^[jJyY]$ ]] || fail "abgebrochen"
fi
# --------------------------------------------------------------------------- #
# Ausfuehren
# --------------------------------------------------------------------------- #
step "Daten"
if [[ -d "$DATEN_DIR" && $PURGE -eq 0 ]]; then
ARCHIV="$SRV_DIR/$NAME-daten-$(date +%Y%m%d-%H%M%S).tar.gz"
tar -czf "$ARCHIV" -C "$(dirname "$DATEN_DIR")" "$(basename "$DATEN_DIR")"
chmod 600 "$ARCHIV"
info "gesichert: $ARCHIV"
elif [[ -d "$DATEN_DIR" ]]; then
info "wird mit dem Verzeichnis geloescht (--purge)"
else
info "nichts zu sichern"
fi
step "Dienste"
for unit in "${UNITS[@]}"; do
[[ -f "$UNIT_DIR/$unit" ]] || continue
systemctl disable --now "$unit" >/dev/null 2>&1 || true
rm -f "$UNIT_DIR/$unit"
# Ohne das bleibt eine entfernte Unit als "not-found" im Fehlerzustand
# stehen und faerbt "systemctl --failed" rot.
systemctl reset-failed "$unit" >/dev/null 2>&1 || true
info "entfernt: $unit"
done
systemctl daemon-reload >/dev/null 2>&1 || true
step "Webserver"
if [[ -e "$NGINX_DIR/sites-enabled/$NAME" || -e "$NGINX_DIR/sites-available/$NAME" ]]; then
rm -f "$NGINX_DIR/sites-enabled/$NAME" "$NGINX_DIR/sites-available/$NAME"
# Drei Faelle, und sie bedeuten Verschiedenes: kein nginx da (dann gibt es
# nichts neu zu laden), nginx da und zufrieden, nginx da und unzufrieden.
# Sie in einen Zweig zu werfen hiesse, auf einer Maschine ohne nginx eine
# Warnung ueber eine kaputte Konfiguration auszugeben, die es nicht gibt.
if ! command -v nginx >/dev/null 2>&1; then
info "Site entfernt (nginx ist hier nicht installiert)"
elif nginx -t >/dev/null 2>&1; then
systemctl reload nginx >/dev/null 2>&1 || true
info "Site entfernt, nginx neu geladen"
else
warn "Site entfernt -- nginx beanstandet die Konfiguration, bitte 'nginx -t' ansehen"
fi
else
info "keine Site vorhanden"
fi
step "Dateien"
for pfad in "${DATEIEN[@]}"; do
[[ -e "$pfad" ]] && { rm -f "$pfad"; info "entfernt: $pfad"; }
done
for pfad in "${VERZEICHNISSE[@]}"; do
[[ -d "$pfad" ]] && { rm -rf "$pfad"; info "entfernt: $pfad"; }
done
if [[ -d "$ROOT" ]]; then
rm -rf "$ROOT"
info "entfernt: $ROOT"
fi
if [[ $PURGE -eq 1 ]]; then
# Die Rueckwege aelterer Aktualisierungen. Sie enthalten dieselben Daten wie
# die Installation -- deshalb nur hier, nie im Normalfall.
for rest in "$SRV_DIR/$NAME-backup-"* "$SRV_DIR/$NAME-failed-"*; do
[[ -d "$rest" ]] && { rm -rf "$rest"; info "entfernt: $rest"; }
done
fi
step "Geteiltes"
if weitere_dieser_anwendung; then
info "sudoers und Dienstbenutzer '$APP' bleiben -- eine weitere Installation braucht sie"
else
rm -f "$SUDOERS_DIR/$APP"
info "entfernt: $SUDOERS_DIR/$APP"
if getent passwd "$APP" >/dev/null 2>&1; then
# Ein laufender Prozess haelt den Benutzer fest. Das ist kein Grund,
# den Rueckbau scheitern zu lassen -- der Dienst ist oben schon gestoppt,
# und was sonst noch unter diesem Namen laeuft, gehoert angesehen.
if userdel "$APP" >/dev/null 2>&1; then
info "entfernt: Dienstbenutzer $APP"
else
warn "Dienstbenutzer $APP liess sich nicht entfernen -- laeuft noch etwas unter ihm?"
fi
fi
fi
if weitere_ueberhaupt; then
info "Helfer unter $LIB_DIR bleibt -- andere Installationen brauchen ihn"
else
rm -rf "$LIB_DIR"
rmdir "$ETC_TESM/certs" "$ETC_TESM" >/dev/null 2>&1 || true
info "entfernt: $LIB_DIR"
fi
step "Fertig"
info "'$NAME' ist zurueckgebaut."
if [[ $PURGE -eq 0 && -n "${ARCHIV:-}" ]]; then
info "Die Daten liegen unter $ARCHIV -- dort und sonst nirgends."
fi
+17
View File
@@ -18,6 +18,23 @@
# =============================================================================
set -euo pipefail
# Ein Lauf dieses Skripts gehoert in eine definierte Sprachumgebung -- nicht in
# die, die das Terminal des Administrators zufaellig ankuendigt. Per SSH kommt
# LC_ALL mit (sshd: "AcceptEnv LANG LC_*"), und ist diese Locale auf dem Ziel
# nicht erzeugt, warnt **jede neu gestartete Shell**:
#
# bash: warning: setlocale: LC_ALL: cannot change locale (en_US.UTF-8)
#
# Mitten in einer Aktualisierung sieht das nach einem Fehler aus und ist
# keiner. Nebenbei sind damit auch die Meldungen der Werkzeuge, die hier
# laufen, in einer Sprache -- ein Protokoll, das je nach Arbeitsplatz anders
# klingt, liest sich schlecht.
if locale -a 2>/dev/null | grep -qix 'c\.utf-\?8'; then
export LC_ALL=C.UTF-8 LANG=C.UTF-8
else
export LC_ALL=C LANG=C
fi
APP="${1:-}"
TAG="${2:-latest}"
+81
View File
@@ -383,6 +383,87 @@ sudo bash /srv/tesm/deploy/update.sh tesm
---
## 4bb. Release-Upgrade des Betriebssystems
Ein `do-release-upgrade` tauscht die Minor-Fassung von Python aus (24.04 bringt
3.12, 26.04 bringt 3.14). Der venv einer Installation hängt daran: seine Pakete
liegen unter `lib/python3.12`, während `venv/bin/python3` auf `/usr/bin/python3`
zeigt — also nach dem Upgrade auf den **neuen** Interpreter. Er ist ausführbar
und trotzdem tot; jeder Dienst scheitert mit `No module named gunicorn`, nginx
meldet 502.
**Das repariert TESM seit 2.8.33 von selbst.** Vor jedem Dienststart läuft
```
ExecStartPre=+/usr/local/lib/tesm/tesm-venv /srv/tesm tesm
```
Ist die Umgebung heil, kostet das Millisekunden. Sonst wird sie neu gebaut und
mit denselben Paketen bestückt, die auch die Installation einspielt — es ist
dasselbe Programm, `install.sh` ruft es mit `--immer` auf. Nach dem Neustart,
der auf jedes Release-Upgrade folgt, kommt der Dienst also von allein zurück;
im Live-Protokoll steht dann eine Zeile als Hinweis.
Von Hand prüfen oder anstoßen:
```bash
/usr/local/lib/tesm/tesm-venv /srv/tesm tesm --pruefen # 0 heil, 1 kaputt
/usr/local/lib/tesm/tesm-venv /srv/tesm tesm # reparieren
```
**Grenzen.** Der Neuaufbau lädt Pakete und braucht Zugriff auf das
Paketverzeichnis. Ohne Netz kann er nicht gelingen: dann wird **ein** Versuch je
Systemstart unternommen (Merker unter `/run`), und es bleibt bei einer klaren
Zeile im Journal und im Live-Protokoll. Ohne diese Bremse startete
`Restart=always` alle fünf Sekunden einen neuen `pip`-Lauf.
Die alten Pakete unter `lib/python3.<alt>` werden nicht übernommen —
`cryptography`, `argon2-cffi` und `bcrypt` sind kompiliert und für die alte
Fassung gebaut.
Empfohlene Reihenfolge bleibt trotzdem: erst TESM aktualisieren, dann
`do-release-upgrade`, dann prüfen.
---
## 4c. Installation entfernen
Das Gegenstück zu `install.sh`:
```bash
sudo bash /srv/tesm/deploy/uninstall.sh --app tesm --dry-run
sudo bash /srv/tesm/deploy/uninstall.sh --app tesm
sudo bash /srv/tesm/deploy/uninstall.sh --app tesm-license --instance test --yes
```
Entfernt werden Dienste (`.service`, `-monitor`, `-cleanup` samt Timer), die
nginx-Site, der Begrüßungshaken, der Shim unter `/usr/local/bin`, die
Umgebungsdatei, das Zertifikatsverzeichnis, das Protokollverzeichnis und die
Installation selbst.
**Die Daten bleiben.** Datenbank, Schlüssel und Lizenz gehen vorher in ein
Archiv (`/srv/<name>-daten-<zeit>.tar.gz`, nur root). Sie sind das einzige an
einer Installation, was sich nicht wiederherstellen lässt. Wer sie wirklich
los sein will, nimmt `--purge` — das löscht zusätzlich die Rückwege älterer
Aktualisierungen (`/srv/<name>-backup-*`).
**Geteiltes bleibt, solange es jemand braucht.** Die sudoers-Datei und der
Dienstbenutzer tragen den Namen der *Anwendung*, nicht den der Instanz; der
Helfer unter `/usr/local/lib/tesm` gehört allen Installationen beider
Anwendungen. Sie verschwinden erst mit der letzten. Sonst legte der Rückbau
einer Testinstanz die Produktion daneben lahm.
`--dry-run` zeigt den Plan, ohne etwas zu ändern. Ein zweiter Lauf auf eine
bereits entfernte Installation sagt das und endet mit 0.
Warum es das gibt: wer eine Installation von Hand löscht (`rm -rf /srv/…`),
lässt Unit, Begrüßungshaken, Shim, sudoers-Datei und nginx-Site zurück.
systemd versucht die Unit danach in einer Dauerschleife zu starten, und der
Haken druckt bei jeder Anmeldung seinen Banner. Auf dem Testserver standen so
neun tote Dienste und vierzehn Banner.
---
## 5. Dienste
```bash
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "tesm-core"
version = "2.8.11"
version = "2.8.22"
description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver"
requires-python = ">=3.11"
dependencies = [
@@ -266,6 +266,35 @@ def _bitte_bestaetigen(permission: str) -> Any:
return redirect(url_for("auth.reauth"))
def _heikel(permissions: tuple[str, ...], mode: str) -> str:
"""Welches Recht verlangt hier eine frische Bestaetigung? Sonst leer.
Bei ``mode="all"`` ist die Antwort einfach: jedes genannte Recht wird
gebraucht, also zaehlt jedes heikle darunter.
Bei ``mode="any"`` nicht. Diese Seiten nennen mehrere **unabhaengige**
Wege hinein, und welcher genommen wird, entscheidet das Konto. Der
Protokollverlauf etwa nennt "logs_history.read" und "logs_history.export":
lesen ist harmlos, mitnehmen nicht. Gezaehlt wurde aber ueber alle
genannten Rechte -- und so verlangte das blosse **Ansehen** des Verlaufs
nach fuenfzehn Minuten eine Passworteingabe, mit dem Hinweis, man greife
auf einen Datenexport zu. Das stimmte nicht, und wer es dreimal liest,
glaubt die Meldung beim vierten Mal auch dann nicht, wenn sie zutrifft.
Gefragt wird deshalb: haette dieses Konto die Seite auch ohne das heikle
Recht geoeffnet? Dann gibt es nichts zu bestaetigen. Die Ausfuhr selbst
haengt an ihrer eigenen Route, und die nennt nur ein Recht.
"""
heikle = [p for p in permissions if _verlangt_wiederanmeldung(p)]
if not heikle:
return ""
if mode == "any":
harmlos = [p for p in permissions if p not in heikle]
if any(current_user.has(p) for p in harmlos):
return ""
return heikle[0]
def permission_required(*permissions: str, mode: str = "all") -> Callable[..., Any]:
"""Erzwingt Rechte serverseitig.
@@ -289,9 +318,9 @@ def permission_required(*permissions: str, mode: str = "all") -> Callable[..., A
# in jeder Route einzeln. Vorher hing es daran, dass jemand an den
# Dekorator dachte: dieselbe Lizenz herunterzuladen fragte nach,
# sie per Mail zu verschicken nicht.
heikel = [p for p in permissions if _verlangt_wiederanmeldung(p)]
heikel = _heikel(permissions, mode)
if heikel and not _wiederanmeldung_frisch():
return _bitte_bestaetigen(heikel[0])
return _bitte_bestaetigen(heikel)
return view(*args, **kwargs)
wrapper._required_permissions = permissions # type: ignore[attr-defined]
@@ -42,6 +42,8 @@ def _config_from_settings(extension: Any, conn: Any) -> webserver.WebConfig:
],
)
return webserver.WebConfig(
# Die neue Schreibweise, sobald der Webserver sie kennt.
http2_directive=webserver.nginx_kann_http2(),
app_key=extension.config.site_key,
upstream=str(extension.registry.get("upstream", "127.0.0.1:5000")),
static_root=str(
@@ -162,6 +162,67 @@ class LicenseState:
return dict(self.status.vendor) if self.status else {}
#: Ausstiegscodes der Abfrage -- danach schlaegt ein RMM Alarm, nicht nach Text.
ABFRAGE_OK = 0
ABFRAGE_LAEUFT_AB = 1
ABFRAGE_UNGUELTIG = 2
#: Ab hier gilt eine Lizenz als "laeuft bald ab". Dieselbe Zahl wie in der
#: Oberflaeche -- zwei Schwellen fuer dieselbe Frage waeren eine zu viel.
ABFRAGE_WARNSCHWELLE_TAGE = 30
def zustand_als_auskunft(state: "LicenseState") -> dict[str, Any]:
"""Der Lizenzzustand als schlichtes Woerterbuch -- fuer JSON und Skripte.
Gedacht fuer ein RMM, das auf der Maschine selbst laeuft: kein Netz, kein
Token, keine Anmeldung. Was hier steht, beantwortet die eine Frage, die ein
Kunde wirklich hat -- wie lange laeuft die Lizenz noch --, und nichts
darueber hinaus: keine Schluessel, keine Kennungen anderer Installationen.
"""
status = state.status
return {
"valid": bool(state.valid),
"activated": bool(state.activated),
"type": str(status.type) if status else "",
"role": state.role,
"lifetime": bool(status.lifetime) if status else False,
"expires_at": lic.iso(status.expires_at) if status and status.expires_at else "",
# Bei einer unbefristeten Lizenz ist die Zahl bewusst ``null`` und
# keine grosse Zahl: "noch 99999 Tage" waere eine Behauptung.
"days_left": (status.days_left if status and not status.lifetime else None),
"expiring_soon": bool(status.expiring_soon) if status else False,
"grace_active": bool(status.grace_active) if status else False,
# Drei Angaben, weil zwei davon verschieden sein koennen und die
# Verwechslung teuer ist: "modules" ist, was die **Lizenz** freigibt --
# dieselbe Liste, die der Lizenzserver zeigt. Was davon vor Ort
# abgeschaltet wurde, steht daneben.
#
# Vorher stand hier allein das Laufende. Beim Kunden fehlte darin
# "dhcp", waehrend die Lizenzseite es auswies -- es war lediglich unter
# Verwaltung -> System abgeschaltet. Aus der Antwort ging das nicht
# hervor, und damit sah es nach einem Lizenzproblem aus.
"modules": sorted(state.modules),
"modules_active": sorted(state.modules_in_use),
"modules_off": sorted(state.modules_off),
"customer": state.customer_name,
"last_heartbeat_at": state.last_heartbeat_at,
"error": state.error,
}
def abfrage_ausstiegscode(auskunft: dict[str, Any]) -> int:
"""0 heil, 1 laeuft bald ab, 2 ungueltig -- die Sprache eines RMM."""
if not auskunft.get("valid"):
return ABFRAGE_UNGUELTIG
if auskunft.get("lifetime"):
return ABFRAGE_OK
tage = auskunft.get("days_left")
if tage is None or int(tage) <= ABFRAGE_WARNSCHWELLE_TAGE:
return ABFRAGE_LAEUFT_AB
return ABFRAGE_OK
class LicenseManager:
"""Haelt den Lizenzzustand und fuehrt die Handshakes durch."""
@@ -129,7 +129,7 @@ DIAGNOSTICS = Resource(
LOGS_LIVE = Resource(
key="logs_live",
label="Laufendes Protokoll",
label="Live-Protokoll",
actions=(A.READ,),
)
@@ -657,9 +657,19 @@
gap: var(--sp-5);
}
/* Bleibt stehen wie die Kopfzeile: Fassung und angemeldeter Benutzer sind
Angaben, nach denen man mitten in einer langen Seite fragt -- und bis
hierher zu scrollen, nur um zu sehen, welche Fassung laeuft, ist eine
Zumutung. Derselbe milchige Untergrund wie oben, damit Inhalt, der
darunter durchlaeuft, nicht mitliest. */
.page-footer {
padding: var(--sp-4) var(--sp-5);
position: sticky;
bottom: 0;
z-index: var(--z-sticky);
padding: var(--sp-3) var(--sp-5);
border-top: 1px solid var(--border-subtle);
background: color-mix(in srgb, var(--canvas) 82%, transparent);
backdrop-filter: blur(12px) saturate(1.4);
color: var(--text-faint);
font-size: var(--fs-xs);
display: flex;
@@ -1131,8 +1131,55 @@
white-space: pre-wrap;
word-break: break-word;
}
/* ---------------------------------------------------------------------- #
Aufklappbares
---------------------------------------------------------------------- #
Der native Marker eines <summary> verschwindet, sobald das Element ein
Flex-Container ist -- und genau das sind unsere Kartenkoepfe. Auf der
DHCP-Seite stand der Optionskatalog deshalb als Kasten da, dem man nicht
ansah, dass er sich oeffnen laesst.
Der Pfeil wird deshalb selbst gezeichnet: als Flex- bzw. Inline-Element,
das in beiden Faellen erscheint, in Akzentfarbe (das ist die Farbe, mit
der diese Oberflaeche "hier kannst du etwas tun" sagt) und mit einer
Drehung beim Oeffnen. Eine Stelle fuer alle Aufklapper -- Karten,
Vorschauen, Abschnitte. */
details > summary {
list-style: none;
cursor: pointer;
}
details > summary::-webkit-details-marker { display: none; }
details:not(.nav-tree) > summary::before {
content: "";
display: inline-block;
flex: 0 0 auto;
align-self: center;
width: 0;
height: 0;
margin-right: var(--sp-2);
border-left: 6px solid var(--accent);
border-top: 5px solid transparent;
border-bottom: 5px solid transparent;
transition: transform var(--dur) var(--ease);
}
details:not(.nav-tree)[open] > summary::before { transform: rotate(90deg); }
/* Der Navigationsbaum bringt seinen eigenen Winkel mit -- der bekommt
dieselbe Farbe, damit "aufklappbar" ueberall gleich aussieht. */
.nav-tree__chevron { color: var(--accent); }
.log-view mark { background: var(--warning-soft); color: var(--warning); border-radius: 3px; }
/* Stufen im laufenden Protokoll. Die Zuordnung der Farbrollen ist Absicht:
--warning ist bernsteinfarben und traegt den HINWEIS, --danger ist rot und
traegt die WARNUNG. Wer das nach den Namen "aufraeumt", dreht die Bedeutung
um: ein Geraet, das nicht antwortet, waere dann orange und eines, das neu
startet, rot. */
.log-view__zeile--info { color: var(--success); }
.log-view__zeile--hinweis { color: var(--warning); }
.log-view__zeile--warnung { color: var(--danger); }
.log-view__zeile--fehler { color: var(--danger); font-weight: 600; }
.kv { display: grid; grid-template-columns: auto 1fr; gap: var(--sp-2) var(--sp-4); font-size: var(--fs-base); }
.kv dt { color: var(--text-faint); white-space: nowrap; }
.kv dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
@@ -1275,6 +1322,24 @@
color: var(--text-faint);
}
/* Mitlesen auf der Wartungsseite: die Ausgabe eines laufenden Auftrags,
direkt unter seinem Geraet. Dunkler Grund wie in jeder Konsole -- was
dort steht, kommt von einer fremden Maschine und nicht aus dieser
Oberflaeche. */
.job-output {
margin: 0;
max-height: 220px;
overflow: auto;
padding: var(--sp-3) var(--sp-4);
background: #101216;
color: #d7dbe0;
font-family: var(--font-mono);
font-size: var(--fs-xs);
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
}
/* ---------------------------------------------------------------------- #
Bildschirmsitzung (VNC)
@@ -1295,6 +1360,9 @@
sprengen; noVNC skaliert das Bild hinein. */
height: min(72vh, 760px);
}
/* Im Fenster ist weniger Platz -- und das Fenster soll nicht ueber den
Bildschirmrand hinauswachsen, nur weil das Geraet hochaufloesend ist. */
dialog.modal .screen__canvas { height: min(62vh, 620px); }
.screen__status {
font-size: var(--fs-sm);
color: var(--text-faint);
@@ -8,6 +8,11 @@
// Die Meldungen sind der eigentliche Wert. Eine Sitzung, die schwarz bleibt,
// laesst offen, ob es an der Berechtigung, am Netz, am Server oder am
// Passwort liegt -- und wer das nicht sieht, sucht den Fehler in TESM.
//
// **Ein Bauteil, zwei Orte.** Dieselbe Sitzung laeuft auf der Bildschirmseite
// und im Fenster der Statusuebersicht. Sie holt alles, was sie braucht, aus
// einer JSON-Auskunft des Servers -- damit gibt es keine zweite Fassung, die
// man beim naechsten Fund vergisst nachzuziehen.
import RFB from "../vendor/novnc/core/rfb.js";
@@ -18,40 +23,19 @@ import RFB from "../vendor/novnc/core/rfb.js";
//
// Zwei davon holen nur Auskunft (_fail, _socketClose). Der dritte greift in
// die Wahl des Sicherheitsverfahrens ein -- aber erst, **nachdem** eines
// nachweislich nicht getragen hat.
// nachweislich nicht getragen hat, oder weil eines schon einmal getragen hat.
//
// Die Vorgeschichte gehoert dazu, damit niemand den Schritt zurueckgeht: hier
// stand einmal eine Vorauswahl, die ohne hinterlegte Zugangsdaten ein
// Verfahren ohne Anmeldung erzwang, weil RealVNC RA2ne zuerst nennt. Das war
// geraten und falsch. Was jetzt hier steht, ist das Gegenteil einer
// Vermutung: der Server nennt seine Reihenfolge, die gilt -- und wenn seine
// erste Wahl die Verbindung abreissen laesst, ist das ein Befund, auf den man
// antworten darf. Gemessen am Geraet: RealVNC bietet 6 (RA2ne) an, waehlt man
// es, legt es auf; daneben liegt 1 (ohne Authentifizierung), und darueber
// laeuft auch der Viewer.
// geraten und falsch. Was jetzt hier steht, ist das Gegenteil einer Vermutung:
// ein Befund aus einer vorherigen Sitzung.
const istUnterstuetzt = RFB.prototype._isSupportedSecurityType;
//: Verfahren, die in dieser Sitzung schon abgerissen sind.
const gescheitert = new Set();
//: Hoechstens ein zweiter Anlauf. Jeder Versuch ist eine Verbindung, und ein
//: RealVNC Server zaehlt abgebrochene Versuche mit -- wer dreimal anklopft,
//: sperrt sich aus.
const HOECHSTENS = 2;
//: So lange darf "wird aufgebaut" stehen, bevor die Seite selbst nachsieht.
//:
//: Der Server hat eigene Grenzen und meldet sich, wenn das Geraet schweigt --
//: aber nur, wenn die Leitung dorthin ueberhaupt steht. Kommt die
//: WebSocket-Verbindung gar nicht erst zustande, wartet noVNC ohne jedes
//: Ereignis, und die Seite stuende stumm bis zum Seitenwechsel. Genau so
//: gemeldet: "Verbindung wird aufgebaut ..." und sonst nichts.
const AUFBAU_GRENZE = 12000;
// Den *Grund* eines Fehlschlags kennt nur ``_fail`` -- das Ereignis
// "disconnect" traegt ihn nicht, und der Weg ueber die Protokollausgabe
// funktioniert nicht: die Bibliothek bindet ``console.error`` beim Laden, also
// bevor eine Seite etwas daran aendern koennte. (Genau daran ist der erste
// Versuch gescheitert -- die Meldung blieb leer.)
//: Den *Grund* eines Fehlschlags kennt nur ``_fail`` -- das Ereignis
//: "disconnect" traegt ihn nicht, und der Weg ueber die Protokollausgabe
//: funktioniert nicht: die Bibliothek bindet ``console.error`` beim Laden,
//: also bevor eine Seite etwas daran aendern koennte.
let letzterGrund = "";
const originalFail = RFB.prototype._fail;
RFB.prototype._fail = function (details) {
@@ -59,9 +43,9 @@ RFB.prototype._fail = function (details) {
return originalFail.call(this, details);
};
// Und der Abschluss der Leitung selbst: der Schliesscode sagt, ob TESM
// abgewiesen hat (1008), ob es ein Serverfehler war (1011) -- oder ob die
// Verbindung nie zustande kam (1006).
//: Und der Abschluss der Leitung selbst: der Schliesscode sagt, ob TESM
//: abgewiesen hat (1008), ob es ein Serverfehler war (1011) -- oder ob die
//: Verbindung nie zustande kam (1006).
let letzterCode = 0;
const originalSocketClose = RFB.prototype._socketClose;
RFB.prototype._socketClose = function (ereignis) {
@@ -72,40 +56,80 @@ RFB.prototype._socketClose = function (ereignis) {
return originalSocketClose.call(this, ereignis);
};
const wurzel = document.getElementById("screen");
if (wurzel) {
starten(wurzel);
}
//: Hoechstens ein zweiter Anlauf. Jeder Versuch ist eine Verbindung, und ein
//: RealVNC Server zaehlt abgebrochene Versuche mit -- wer dreimal anklopft,
//: sperrt sich aus.
const HOECHSTENS = 2;
function starten(wurzel) {
const leinwand = document.getElementById("screen-canvas");
const zeile = document.getElementById("screen-status");
const abfrage = document.getElementById("screen-credentials");
const feldBenutzer = document.getElementById("screen-username");
const feldPasswort = document.getElementById("screen-password");
const knopfSenden = document.getElementById("screen-send");
const knopfTrennen = document.getElementById("screen-disconnect");
const knopfStrg = document.getElementById("screen-cad");
//: So lange darf "wird aufgebaut" stehen, bevor die Seite selbst nachsieht.
//: Kommt die WebSocket-Verbindung gar nicht erst zustande, wartet noVNC ohne
//: jedes Ereignis, und die Seite stuende stumm bis zum Seitenwechsel.
const AUFBAU_GRENZE = 12000;
const pfad = wurzel.dataset.socket;
const schema = window.location.protocol === "https:" ? "wss:" : "ws:";
const adresse = `${schema}//${window.location.host}${pfad}`;
//: Verfahren, die im Browser keinen Schluesseltausch kosten.
//:
//: Der Grund steht in der Bibliothek, `core/ra2.js`: fuer RA2ne erzeugt der
//: **Browser** bei jeder Sitzung ein eigenes 2048-Bit-RSA-Schluesselpaar, in
//: JavaScript. Die Primzahlsuche dafuer dauert ein bis drei Minuten -- genau
//: die Zeit, die zwischen Klick und Bild lag. "Ohne Authentifizierung" (1)
//: kostet davon nichts.
//:
//: Deshalb kommt es zuerst, **wenn der Server es anbietet** und wir nicht
//: ohnehin schon wissen, was hier traegt. Das ist nicht dieselbe Vorauswahl
//: wie in 2.8.14: die war geraten, diese steht auf einer Messung. Traegt sie
//: nicht, nimmt der zweite Anlauf die Reihenfolge des Servers -- dann dauert
//: es eben.
const OHNE_SCHLUESSELTAUSCH = new Set([1]);
const vorgabe = JSON.parse(
document.getElementById("screen-credentials-data")?.textContent || "{}",
);
//: RA2ne. Wird es ausgehandelt, ist die Wartezeit erklaerbar und kein Fehler.
const RA2NE = 6;
//: Tastenkombinationen, die der Arbeitsplatz selbst abfaengt und die deshalb
//: einen Knopf brauchen. X11-Keysyms.
const ALT_LINKS = 0xffe9;
const F4 = 0xffc1;
const F5 = 0xffc2;
const schema = window.location.protocol === "https:" ? "wss:" : "ws:";
/**
* Baut eine Sitzung in diesem Behaelter auf.
*
* ``quelle`` ist die Adresse der JSON-Auskunft; alles Weitere steht dort.
* Rueckgabe: eine Funktion, die die Sitzung wieder abbaut.
*/
export function starteSitzung(wurzel, quelle, { beiEnde = null } = {}) {
const teil = (rolle) => wurzel.querySelector(`[data-rolle="${rolle}"]`);
const leinwand = teil("leinwand");
const zeile = teil("status");
const abfrage = teil("zugangsdaten");
const feldBenutzer = teil("benutzer");
const feldPasswort = teil("passwort");
const knopfSenden = teil("anmelden");
const knopfTrennen = teil("trennen");
const knoepfeSitzung = [...wurzel.querySelectorAll("[data-rolle^='taste-']"), knopfTrennen];
const gescheitert = new Set();
let rfb = null;
let aufbauUhr = 0;
let versuche = 0;
let stand = false;
let daten = null;
let letzterZugang = {};
let beendet = false;
function melden(text, art = "") {
if (!zeile) return;
zeile.textContent = text;
zeile.className = `screen__status${art ? " screen__status--" + art : ""}`;
}
function sitzungsknoepfe(sichtbar) {
knoepfeSitzung.forEach((knopf) => {
if (knopf) knopf.hidden = !sichtbar;
});
}
function grund() {
const teile = [];
if (letzterGrund) {
@@ -120,29 +144,6 @@ function starten(wurzel) {
return teile.join(" · ");
}
// Hat das gewählte Verfahren die Verbindung abreissen lassen? Dann ist das
// ein Befund und keine Vermutung -- der naechste Anlauf laesst es aus.
//
// Nur solange nie ein Bild stand: bricht eine **laufende** Sitzung ab, liegt
// es nicht am Anmeldeverfahren, und ein stiller Neuaufbau wuerde die Ursache
// verdecken.
function naechsterVersuch() {
const verfahren = rfb && rfb._rfbAuthScheme > 0 ? rfb._rfbAuthScheme : 0;
if (!verfahren || versuche >= HOECHSTENS || gescheitert.has(verfahren)) {
return false;
}
gescheitert.add(verfahren);
melden(
`Verfahren ${verfahren} hat die Verbindung nicht getragen – nächster Anlauf ` +
"mit dem nächsten, das der Server anbietet …",
"warnung",
);
const zugang = letzterZugang;
rfb = null;
window.setTimeout(() => verbinden(zugang), 400);
return true;
}
// Steht die WebSocket-Strecke zu TESM überhaupt? Zwei sehr verschiedene
// Ursachen sehen von aussen gleich aus: der VNC-Server mag nicht, oder die
// Verbindung kommt nie zustande, weil ein Proxy zwischen Browser und TESM
@@ -168,7 +169,45 @@ function starten(wurzel) {
});
}
// Hat das gewählte Verfahren die Verbindung abreissen lassen? Dann ist das
// ein Befund und keine Vermutung -- der naechste Anlauf laesst es aus. Nur
// solange nie ein Bild stand: bricht eine **laufende** Sitzung ab, liegt es
// nicht am Anmeldeverfahren.
function naechsterVersuch() {
const verfahren = rfb && rfb._rfbAuthScheme > 0 ? rfb._rfbAuthScheme : 0;
if (!verfahren || versuche >= HOECHSTENS || gescheitert.has(verfahren)) {
return false;
}
gescheitert.add(verfahren);
melden(
`Verfahren ${verfahren} hat die Verbindung nicht getragen – nächster Anlauf ` +
"mit dem nächsten, das der Server anbietet …",
"warnung",
);
const zugang = letzterZugang;
rfb = null;
window.setTimeout(() => verbinden(zugang), 400);
return true;
}
function verfahrenMelden(gewaehlt) {
if (!gewaehlt || gewaehlt === daten.bewaehrt || !daten.meldeweg) return;
fetch(daten.meldeweg, {
method: "POST",
credentials: "same-origin",
headers: {
"Content-Type": "application/json",
"X-Requested-With": "fetch",
"X-CSRF-Token": document.body?.dataset.csrf || "",
},
body: JSON.stringify({ verfahren: gewaehlt }),
}).catch(() => {
/* Eine Merkhilfe, kein Teil der Sitzung. */
});
}
function verbinden(zugang) {
if (beendet) return;
melden(versuche ? "Nächster Versuch …" : "Verbindung wird aufgebaut …");
letzterGrund = "";
letzterCode = 0;
@@ -176,8 +215,17 @@ function starten(wurzel) {
versuche += 1;
window.clearTimeout(aufbauUhr);
aufbauUhr = window.setTimeout(() => {
// Weder verbunden noch abgebrochen noch nach Zugangsdaten gefragt --
// dann haengt es an der Leitung, nicht am VNC-Server.
// Ein ausgehandeltes RA2ne ist kein Fehler, sondern Rechenzeit: der
// Browser erzeugt dafuer gerade einen 2048-Bit-Schluessel. Das zu
// melden ist das Gegenteil einer Warnung -- es nimmt die Sorge.
if (rfb && rfb._rfbAuthScheme === RA2NE) {
melden(
"Der Server verlangt RA2ne (RSA-AES). Dafür erzeugt der Browser ein " +
"2048-Bit-Schlüsselpaar – das dauert ein bis drei Minuten. Bitte warten.",
"warnung",
);
return;
}
melden("Es kommt keine Antwort. Wird geprüft, woran es liegt …", "warnung");
streckeTesten().then((steht) => {
melden(
@@ -194,15 +242,32 @@ function starten(wurzel) {
});
}, AUFBAU_GRENZE);
// Was abgerissen ist, wird nicht noch einmal gewaehlt. Sonst faende noVNC
// dasselbe Verfahren wieder zuerst -- und der zweite Anlauf waere eine
// Wiederholung des ersten.
// Drei Regeln, alle aus Messung und keine aus Vermutung:
//
// 1. Was schon einmal getragen hat, kommt zuerst.
// 2. Sonst das Verfahren ohne Schluesseltausch -- aber nur, wenn der
// Server es nachweislich anbietet (sonst faende noVNC gar keines und
// scheiterte sofort). RA2ne kostet sonst ein bis drei Minuten
// Rechenzeit im Browser, jedes Mal.
// 3. Was abgerissen ist, wird nicht noch einmal gewaehlt.
//
// Beides gilt nur fuer den **ersten** Anlauf; danach zaehlt die
// Reihenfolge des Servers, abzueglich dessen, was schon abgerissen ist.
const bewaehrt = daten.bewaehrt || 0;
const angeboten = daten.verfahren || [];
const schnell = angeboten.filter((typ) => OHNE_SCHLUESSELTAUSCH.has(typ));
let bevorzugt = 0;
if (versuche === 1) {
if (bewaehrt > 0 && !gescheitert.has(bewaehrt)) bevorzugt = bewaehrt;
else if (schnell.length && !gescheitert.has(schnell[0])) bevorzugt = schnell[0];
}
RFB.prototype._isSupportedSecurityType = function (typ) {
if (gescheitert.has(typ)) return false;
if (bevorzugt) return typ === bevorzugt;
return istUnterstuetzt.call(this, typ);
};
rfb = new RFB(leinwand, adresse, {
rfb = new RFB(leinwand, `${schema}//${window.location.host}${daten.socket}`, {
credentials: zugang,
// Eine Bildschirmsitzung soll nicht nebenbei die Zwischenablage des
// Arbeitsplatzes mit dem Geraet teilen -- das ist eine eigene
@@ -216,23 +281,25 @@ function starten(wurzel) {
rfb.addEventListener("connect", () => {
window.clearTimeout(aufbauUhr);
stand = true;
melden(`Verbunden mit ${wurzel.dataset.ziel}`, "ok");
abfrage.hidden = true;
verfahrenMelden(rfb && rfb._rfbAuthScheme > 0 ? rfb._rfbAuthScheme : 0);
melden(`Verbunden mit ${daten.ziel}`, "ok");
if (abfrage) abfrage.hidden = true;
leinwand.hidden = false;
knopfTrennen.hidden = false;
knopfStrg.hidden = false;
sitzungsknoepfe(true);
});
rfb.addEventListener("disconnect", (ereignis) => {
window.clearTimeout(aufbauUhr);
leinwand.hidden = true;
knopfTrennen.hidden = true;
knopfStrg.hidden = true;
sitzungsknoepfe(false);
if (ereignis.detail && ereignis.detail.clean) {
// Beim sauberen Schluss steht der Grund im Schliessrahmen -- etwa,
// dass TESM wegen Untätigkeit getrennt hat. Ohne ihn stuende da
// "beendet", und niemand wuesste, warum das Bild weg ist.
// dass TESM wegen Untätigkeit getrennt hat.
melden(letzterGrund ? `Sitzung beendet – ${letzterGrund}` : "Sitzung beendet.");
// Im Fenster gibt es nach dem Trennen nichts mehr zu sehen: ein leerer
// Rahmen, den man wegklicken muss. Bei einem **Abbruch** bleibt es
// stehen -- dort steht der Grund, und der ist der ganze Zweck.
beiEnde?.();
} else if (!stand && naechsterVersuch()) {
return;
} else {
@@ -243,9 +310,6 @@ function starten(wurzel) {
: "Verbindung abgebrochen. Wird geprüft, woran es liegt …",
"fehler",
);
// 1006 heisst: die Leitung ging zu, ohne dass die Gegenseite einen
// Grund geschickt hat -- typisch, wenn die WebSocket-Verbindung gar
// nicht erst zustande kam. Dann sagt die Probe, wer schweigt.
if (letzterCode === 1006 || !erklaerung) {
streckeTesten().then((steht) => {
melden(
@@ -269,10 +333,10 @@ function starten(wurzel) {
rfb.addEventListener("credentialsrequired", (ereignis) => {
window.clearTimeout(aufbauUhr);
const gefragt = ereignis.detail.types || ["password"];
abfrage.hidden = false;
feldBenutzer.closest("label").hidden = !gefragt.includes("username");
if (abfrage) abfrage.hidden = false;
if (feldBenutzer) feldBenutzer.closest("label").hidden = !gefragt.includes("username");
melden("Der VNC-Server verlangt Zugangsdaten.", "warnung");
(gefragt.includes("username") ? feldBenutzer : feldPasswort).focus();
(gefragt.includes("username") ? feldBenutzer : feldPasswort)?.focus();
});
rfb.addEventListener("securityfailure", (ereignis) => {
@@ -281,44 +345,124 @@ function starten(wurzel) {
`Anmeldung abgelehnt: ${ereignis.detail.reason || "kein Grund genannt"}`,
"fehler",
);
abfrage.hidden = false;
});
rfb.addEventListener("desktopname", (ereignis) => {
wurzel.dataset.desktop = ereignis.detail.name;
if (abfrage) abfrage.hidden = false;
});
}
knopfSenden?.addEventListener("click", () => {
const zugang = {};
if (feldBenutzer.value) zugang.username = feldBenutzer.value;
if (feldPasswort.value) zugang.password = feldPasswort.value;
if (rfb) {
rfb.sendCredentials(zugang);
} else {
verbinden(zugang);
}
if (feldBenutzer?.value) zugang.username = feldBenutzer.value;
if (feldPasswort?.value) zugang.password = feldPasswort.value;
if (rfb) rfb.sendCredentials(zugang);
else verbinden(zugang);
});
[feldBenutzer, feldPasswort].forEach((feld) =>
feld?.addEventListener("keydown", (ereignis) => {
if (ereignis.key === "Enter") {
ereignis.preventDefault();
knopfSenden.click();
knopfSenden?.click();
}
}),
);
knopfTrennen?.addEventListener("click", () => rfb && rfb.disconnect());
knopfStrg?.addEventListener("click", () => rfb && rfb.sendCtrlAltDel());
teil("taste-cad")?.addEventListener("click", () => rfb && rfb.sendCtrlAltDel());
// Alt+F4 kennt noVNC nicht als fertigen Griff: der Arbeitsplatz faengt die
// Kombination selbst ab, bevor sie in der Leinwand ankommt. Also einzeln --
// Alt halten, F4 tippen, Alt loslassen -- wie es auf der Tastatur auch
// geschieht.
teil("taste-altf4")?.addEventListener("click", () => {
if (!rfb) return;
rfb.sendKey(ALT_LINKS, "AltLeft", true);
rfb.sendKey(F4, "F4", true);
rfb.sendKey(F4, "F4", false);
rfb.sendKey(ALT_LINKS, "AltLeft", false);
});
// F5 faengt der Browser des Arbeitsplatzes ab -- er wuerde diese Seite neu
// laden statt das Bild auf dem Geraet.
teil("taste-f5")?.addEventListener("click", () => {
if (!rfb) return;
rfb.sendKey(F5, "F5", true);
rfb.sendKey(F5, "F5", false);
});
if (wurzel.dataset.blockiert === "1") {
melden(wurzel.dataset.grund || "Diese Sitzung kann nicht aufgebaut werden.", "fehler");
return;
}
sitzungsknoepfe(false);
melden("Wird vorbereitet …");
const zugang = {};
if (vorgabe.username) zugang.username = vorgabe.username;
if (vorgabe.password) zugang.password = vorgabe.password;
verbinden(zugang);
fetch(quelle, { credentials: "same-origin", headers: { "X-Requested-With": "fetch" } })
.then((antwort) => antwort.json().then((inhalt) => ({ status: antwort.status, inhalt })))
.then(({ status, inhalt }) => {
if (beendet) return;
if (status === 409 && inhalt.reauth) {
melden(
"Für den Bildschirm ist eine frische Passwortbestätigung nötig – bitte über " +
"die Geräteseite öffnen.",
"warnung",
);
return;
}
if (status !== 200) {
melden(inhalt.fehler || "Diese Sitzung lässt sich nicht öffnen.", "fehler");
return;
}
daten = inhalt;
if (daten.hinderungsgrund) {
melden(daten.hinderungsgrund, "fehler");
return;
}
const zugang = {};
if (daten.vorgaben?.username) zugang.username = daten.vorgaben.username;
if (daten.vorgaben?.password) zugang.password = daten.vorgaben.password;
verbinden(zugang);
})
.catch(() => melden("Die Sitzungsdaten sind nicht abrufbar.", "fehler"));
return function beenden() {
beendet = true;
window.clearTimeout(aufbauUhr);
try {
rfb?.disconnect();
} catch (fehler) {
/* schon zu */
}
rfb = null;
};
}
// -- Die beiden Orte -------------------------------------------------------- #
// 1. Die Bildschirmseite: ein Behaelter, der seine Quelle selbst mitbringt.
const seite = document.getElementById("screen");
if (seite?.dataset.sitzung) {
starteSitzung(seite, seite.dataset.sitzung);
}
// 2. Die Statusuebersicht: ein Fenster, das jede Kachel oeffnen kann. Ein
// Dialog fuer alle -- er bekommt beim Oeffnen, welches Geraet gemeint ist.
const fenster = document.getElementById("vnc-fenster");
if (fenster) {
let beenden = null;
const behaelter = fenster.querySelector("[data-rolle='behaelter']");
const titel = fenster.querySelector("[data-rolle='titel']");
document.addEventListener("click", (ereignis) => {
const knopf = ereignis.target.closest("[data-vnc-oeffnen]");
if (!knopf) return;
ereignis.preventDefault();
beenden?.();
titel.textContent = knopf.dataset.vncName || "Bildschirm";
if (typeof fenster.showModal === "function") fenster.showModal();
else fenster.setAttribute("open", "");
beenden = starteSitzung(behaelter, knopf.dataset.vncOeffnen, {
beiEnde: () => fenster.close(),
});
});
// Zu heisst zu: eine Sitzung, die im Hintergrund weiterlaeuft, haelt die
// Verbindung zum Geraet und bei RealVNC den einen Sitzungsplatz.
fenster.addEventListener("close", () => {
beenden?.();
beenden = null;
});
}
@@ -960,15 +960,22 @@ export function initFilePreview(wurzel = document) {
const ersetzenFeld = stueck("ersetzen");
const ersetzenKasten = stueck("ersetzenfeld");
const pruefzeile = stueck("pruefung");
// Die Ordner der geoeffneten Freigabe kommen mit der Seite; die einer
// anderen holt das Auswahlfeld bei Bedarf nach.
const eigeneOrdner = () => {
try {
return JSON.parse(rumpf.dataset.viewerOrdnerliste || "[]");
} catch {
return [];
}
};
// Die Ordner der geoeffneten Freigabe stehen bereits in der Auswahl -- der
// Server hat sie dort hineingeschrieben. Gemerkt werden sie trotzdem, denn
// wer zwischendurch eine andere Freigabe waehlt, ersetzt die Liste; die
// einer anderen Freigabe holt das Auswahlfeld dann bei Bedarf nach.
//
// Vorher stand dieselbe Liste ein zweites Mal daneben, als JSON in einem
// Attribut -- und kam dort nie an: ``tojson`` laesst doppelte
// Anfuehrungszeichen stehen, und in einem doppelt gequoteten Attribut endet
// der Wert damit nach der oeffnenden Klammer. Die Auswahl zeigte danach nur
// noch das Wurzelverzeichnis, und in einen Unterordner liess sich nichts
// mehr speichern. Zwei Fassungen derselben Liste waren die eigentliche
// Ursache; jetzt gibt es nur noch eine.
const gelieferteOrdner = ordnerFeld
? [...ordnerFeld.options].map((eintrag) => eintrag.value).filter(Boolean)
: [];
const eigeneOrdner = () => gelieferteOrdner;
const jetzt = stueck("jetzt");
const abbrechen = stueck("abbrechen");
const modusFeld = stueck("modus");
@@ -58,8 +58,14 @@ class WebConfig:
#: ``listen ... ssl http2;`` und weisen die neue Direktive mit
#: "unknown directive" ab -- der ganze Webserver startet dann nicht mehr.
#: Auf POETEST (nginx 1.24) genau so passiert. Vorgabe ist deshalb die alte
#: Schreibweise: sie funktioniert ueberall und erzeugt auf neuen Versionen
#: nur eine Warnung.
#: Schreibweise: sie funktioniert ueberall.
#:
#: Wer die Konfiguration erzeugt, fragt vorher ``nginx_kann_http2()`` --
#: dann steht auf einem neuen Webserver auch die neue Schreibweise. Das ist
#: nicht nur Kosmetik gegen eine Warnung: nginx hat die alte Form als
#: veraltet erklaert, und was veraltet ist, faellt irgendwann weg. Ein
#: Release-Upgrade von Ubuntu ist genau der Moment, in dem so etwas
#: unbemerkt naeherrueckt (24.04 hat nginx 1.24, 26.04 hat 1.28).
http2_directive: bool = False
@property
@@ -89,6 +95,47 @@ class WebConfig:
return "" if self.https_port == 443 else f":{self.https_port}"
#: Ab dieser Fassung kennt nginx ``http2 on;``.
HTTP2_DIREKTIVE_AB = (1, 25, 1)
def fassung_aus_ausgabe(text: str) -> tuple[int, ...]:
"""``nginx version: nginx/1.28.3 (Ubuntu)`` -> ``(1, 28, 3)``.
Getrennt vom Aufruf, damit sich die Entscheidung pruefen laesst, ohne
einen Webserver zu installieren. Was nicht ins Muster passt, ergibt ein
leeres Tupel -- und damit die Schreibweise, die ueberall funktioniert.
"""
treffer = re.search(r"nginx/(\d+)\.(\d+)\.(\d+)", text or "")
return tuple(int(teil) for teil in treffer.groups()) if treffer else ()
def nginx_version() -> tuple[int, ...]:
"""Die Fassung des installierten nginx, sonst ein leeres Tupel.
``nginx -v`` schreibt nach stderr und braucht keine Rechte. Faellt die
Antwort aus -- kein nginx, kein Ausfuehrungsrecht, fremdes Format --, gibt
es hier nichts zu raten.
"""
import shutil
import subprocess
pfad = shutil.which("nginx") or "/usr/sbin/nginx"
try:
fertig = subprocess.run(
[pfad, "-v"], capture_output=True, text=True, timeout=5, check=False
)
except (OSError, subprocess.SubprocessError):
return ()
return fassung_aus_ausgabe(fertig.stderr + fertig.stdout)
def nginx_kann_http2() -> bool:
"""Versteht der installierte nginx ``http2 on;``?"""
fassung = nginx_version()
return bool(fassung) and fassung >= HTTP2_DIREKTIVE_AB
def validate_domain(value: str) -> str:
text = (value or "").strip().lower().rstrip(".")
if not text:
@@ -1,5 +1,8 @@
{% if lines %}
<pre class="log-view" data-tail>{% for line in lines %}{{ line }}
{# Jede Zeile in einer Huelle, damit die Stufe eine Farbe tragen kann. Der
Zeilenumbruch steht ausserhalb: im <pre> ist er der Umbruch selbst, und
eine Huelle mit display:block wuerde daraus zwei machen. #}
<pre class="log-view" data-tail>{% for line in lines %}{% set z = line | protokollzeile %}<span class="log-view__zeile{% if z.stufe %} log-view__zeile--{{ z.stufe }}{% endif %}">{{ z.text }}</span>
{% endfor %}</pre>
{% else %}
<div class="empty"><div class="empty__title">Keine Zeilen</div>
@@ -25,7 +25,7 @@
<tr>
<td><a href="{{ url_for('logs.history_file', name=entry.name) }}">{{ entry.name }}</a></td>
<td class="numeric fs-sm">{{ entry.size | bytes }}</td>
<td class="fs-sm">{{ entry.modified | int | string }}</td>
<td class="fs-sm">{{ entry.modified | dt }}</td>
</tr>
{% endfor %}
</tbody>
@@ -1,5 +1,5 @@
{% extends "tesm_core/base.html" %}
{% block page_title %}Laufendes Protokoll{% endblock %}
{% block page_title %}Live-Protokoll{% endblock %}
{% block page_sub %}{{ path }} &middot; letzte {{ line_count }} Zeilen{% endblock %}
{% block topbar_extra %}
@@ -73,12 +73,11 @@ blieb dadurch über Jahre bestehen und lieferte alle statischen Dateien als 404.
</section>
<details class="card" data-persist="nginx-preview">
<summary class="card__header" style="cursor:pointer">
<summary class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('file-text', size=17) }} Erzeugte nginx-Konfiguration</div>
<div class="card__sub">Vorschau der Datei, die geschrieben würde</div>
</div>
{{ icon('chevron-down', size=16) }}
</summary>
<pre class="log-view">{{ preview }}</pre>
</details>
+41
View File
@@ -113,6 +113,14 @@ def format_datetime(value: Any, *, with_time: bool = True) -> str:
return "-"
if isinstance(value, datetime):
moment = value
elif isinstance(value, (int, float)) and not isinstance(value, bool):
# Sekunden seit 1970, wie sie aus ``stat()`` kommen. Ohne diesen Zweig
# stand im Protokollverlauf die nackte Zahl (1788472810) in der Spalte
# "Geändert" -- fuer die Datei sichtbar, fuer den Menschen nutzlos.
try:
moment = datetime.fromtimestamp(float(value), timezone.utc)
except (OSError, OverflowError, ValueError):
return str(value)
else:
text = str(value).strip()
if text.endswith("Z"):
@@ -351,6 +359,38 @@ def audit_detail(text: Any) -> Markup:
teile.append(str(escape(roh[ende:])))
return Markup("".join(teile))
#: Stufen im laufenden Protokoll und ihre Farbrolle in der Oberflaeche.
#:
#: Die Zuordnung ist Absicht und keine Verwechslung: die Farbrolle "warning"
#: ist bernsteinfarben und traegt hier den **Hinweis**, "danger" ist rot und
#: traegt die **Warnung**. Ein Geraet, das nicht antwortet, ist rot; ein
#: Geraet, das neu gestartet wird, ist orange; alles Gelungene ist gruen.
PROTOKOLLSTUFEN = {
"INFO": "info",
"HINWEIS": "hinweis",
"WARNUNG": "warnung",
"FEHLER": "fehler",
}
#: ``2026-09-16 09:54:03 INFO ping HAP02 ist wieder erreichbar``
_PROTOKOLLZEILE = re.compile(
r"^\d{4}-\d\d-\d\d[ T]\d\d:\d\d:\d\d\s+([A-ZÄÖÜ]+)\s"
)
def protokollzeile(zeile: str) -> dict[str, str]:
"""Zerlegt eine Protokollzeile so weit, dass die Seite sie faerben kann.
Bewusst nur so weit: der Text bleibt, wie er ist -- eine Protokollansicht,
die ihre Zeilen umbaut, ist keine mehr. Gebraucht wird allein die Stufe.
Was nicht ins Muster passt (fremde Protokolle wie das von Kea), bekommt
keine Farbe statt einer geratenen.
"""
treffer = _PROTOKOLLZEILE.match(str(zeile or ""))
stufe = PROTOKOLLSTUFEN.get(treffer.group(1), "") if treffer else ""
return {"text": str(zeile or ""), "stufe": stufe}
def init_app(app: Flask) -> None:
app.jinja_env.globals.update(
icon=icon,
@@ -371,6 +411,7 @@ def init_app(app: Flask) -> None:
enumerate=enumerate,
)
app.jinja_env.filters.update(
protokollzeile=protokollzeile,
bytes=format_bytes,
dt=format_datetime,
date=format_date,
@@ -204,6 +204,8 @@ def _web_config() -> webserver.WebConfig:
],
)
return webserver.WebConfig(
# Die neue Schreibweise, sobald der Webserver sie kennt.
http2_directive=webserver.nginx_kann_http2(),
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")),
@@ -68,12 +68,17 @@ def live_fragment():
def history():
extension = core()
rotated = []
for path in sorted(extension.config.log_dir.glob("*.log.*"), reverse=True):
for path in extension.config.log_dir.glob("*.log.*"):
try:
stat = path.stat()
except OSError:
continue
rotated.append({"name": path.name, "size": stat.st_size, "modified": stat.st_mtime})
# Nach Alter, nicht nach Namen: ``sorted(..., reverse=True)`` ueber die
# Dateinamen stellte "live.log.9" vor "live.log.27", weil "9" hinter "2"
# kommt. Gefragt ist aber die juengste Kopie zuerst, und das steht im
# Zeitstempel.
rotated.sort(key=lambda eintrag: eintrag["modified"], reverse=True)
return render_template(
"tesm_core/logs_history.html",
rotated=rotated,
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "tesm-licensing"
version = "2.7.7"
version = "2.7.8"
description = "Ed25519-basiertes Lizenz- und Aktivierungsprotokoll fuer TESM und TESM-Lizenzserver"
requires-python = ">=3.11"
dependencies = ["cryptography>=42"]
@@ -46,6 +46,7 @@ from .license import (
key_id,
license_status,
parse_iso,
tage_bis,
split_bundle,
utcnow,
verify_license,
@@ -103,6 +104,7 @@ __all__ = [
"machine_identity",
"new_nonce",
"parse_iso",
"tage_bis",
"sign",
"split_bundle",
"system_fingerprint",
@@ -221,6 +221,27 @@ def verify_license(
)
def tage_bis(expires_at: datetime, *, now: datetime | None = None) -> int:
"""Restlaufzeit in **angefangenen** Tagen.
Aufgerundet, und das ist eine Entscheidung: eine Lizenz, die in zwei
Stunden ablaeuft, hat "1 Tag" und nicht "0". Null bedeutet hier
ausschliesslich *abgelaufen* -- eine gueltige Lizenz, die mit "0 Tage"
dasteht, liest sich wie eine tote.
**Es gibt genau diese eine Rechnung.** Der Lizenzserver hatte eine zweite,
abgerundete: dieselbe Lizenz zeigte in seiner Oberflaeche "21 Tage" und im
Abruf beim Kunden "22". Zwei Zahlen fuer dieselbe Frage sind schlimmer als
eine ungenaue -- sie kosten jemanden einen Nachmittag mit der Suche nach
einem Fehler, den es nicht gibt.
"""
moment = now or utcnow()
sekunden = int((expires_at - moment).total_seconds())
if sekunden <= 0:
return 0
return -(-sekunden // 86400)
def license_status(
document: Mapping[str, Any],
*,
@@ -278,7 +299,7 @@ def license_status(
days_left = 0
days_since_expiry = int((moment - expires_at).total_seconds() // 86400)
else:
days_left = -(-int(delta.total_seconds()) // 86400)
days_left = tage_bis(expires_at, now=moment)
days_since_expiry = None
grace_active = bool(