Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d8f92fd2d | ||
|
|
5324174f90 | ||
|
|
d69d177995 | ||
|
|
f481a91773 | ||
|
|
970f45cdf7 | ||
|
|
c9ceb871b6 | ||
|
|
37c508297f | ||
|
|
13bf07fecf | ||
|
|
849b821501 | ||
|
|
00a27018d9 | ||
|
|
95238b781f | ||
|
|
3ea7937b5b | ||
|
|
80d9e30bd4 | ||
|
|
bde2df2b44 | ||
|
|
76a73017db | ||
|
|
db06ddc054 | ||
|
|
e0a048fbcc | ||
|
|
9d736bb91f |
No files matched your search
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "tesm"
|
||||
version = "2.8.23"
|
||||
version = "2.8.41"
|
||||
description = "TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten und Aruba-Switchen"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -11,6 +11,7 @@ einen Dienst starten.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
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,6 +105,12 @@ 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),
|
||||
)
|
||||
|
||||
@@ -447,6 +461,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):
|
||||
@@ -515,6 +614,102 @@ def _write_and_redirect():
|
||||
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"))
|
||||
|
||||
# Ueber base64 statt ueber ein Here-Dokument: der Inhalt enthaelt
|
||||
# Anfuehrungszeichen, Rauten und Zeilenumbrueche, und er geht durch SSH,
|
||||
# eine Shell und moeglicherweise sudo. Was kodiert reist, kommt an.
|
||||
inhalt = base64.b64encode(konfig["inhalt"].encode("utf-8")).decode("ascii")
|
||||
verzeichnis = str(konfig["pfad"]).rsplit("/", 1)[0]
|
||||
befehl = (
|
||||
f"{{sudo}} mkdir -p {verzeichnis} && "
|
||||
f"echo {inhalt} | base64 -d | {{sudo}} tee {konfig['pfad']} >/dev/null && "
|
||||
f"{{sudo}} sh -c '{konfig['nachher']}' || true"
|
||||
)
|
||||
|
||||
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():
|
||||
|
||||
@@ -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 "",
|
||||
|
||||
@@ -66,4 +66,5 @@ def restarts():
|
||||
method=method,
|
||||
methods=tuple(monitor.METHOD_LABELS.items()),
|
||||
method_labels=monitor.METHOD_LABELS,
|
||||
trigger_labels=monitor.TRIGGER_LABELS,
|
||||
)
|
||||
@@ -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"))
|
||||
@@ -70,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"):
|
||||
@@ -314,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.
|
||||
#
|
||||
|
||||
@@ -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)",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -128,7 +128,7 @@ NAVIGATION = NavTree(
|
||||
children=(
|
||||
NavItem(
|
||||
key="logs_live",
|
||||
label="Laufend",
|
||||
label="Live",
|
||||
icon="terminal",
|
||||
endpoint="logs.live",
|
||||
permissions=("logs_live.read",),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -653,9 +653,157 @@ 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()))
|
||||
|
||||
|
||||
#: 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], ...] = (
|
||||
("networkd", "systemd-networkd", "/etc/systemd/network/10-tesm.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
|
||||
""",
|
||||
)
|
||||
|
||||
|
||||
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", "optionen"}`` -- Datei, Inhalt,
|
||||
der Befehl zum Uebernehmen und die Optionen, um die es geht.
|
||||
"""
|
||||
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.",
|
||||
"#",
|
||||
"# Der Dateiname muss zur .network-Datei der Schnittstelle passen:",
|
||||
"# netplan legt sie unter /run/systemd/network/10-netplan-<if>.network ab,",
|
||||
"# der Zusatz gehoert also 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>=...",
|
||||
"[DHCPv4]",
|
||||
]
|
||||
zeilen.append("RequestOptions=" + " ".join(str(code) for code in codes))
|
||||
nachher = "networkctl reload && networkctl reconfigure <schnittstelle>"
|
||||
elif stil == "dhcpcd":
|
||||
zeilen = [
|
||||
"# Von TESM erzeugt -- an das Ende von /etc/dhcpcd.conf.",
|
||||
"# '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']}")
|
||||
nachher = "systemctl restart dhcpcd"
|
||||
else:
|
||||
zeilen = [
|
||||
"# Von TESM erzeugt -- an das Ende von /etc/dhcp/dhclient.conf.",
|
||||
"# 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) + ";"
|
||||
)
|
||||
nachher = "systemctl restart networking || dhclient -r && dhclient"
|
||||
|
||||
return {
|
||||
"pfad": pfad,
|
||||
"inhalt": chr(10).join(zeilen) + chr(10),
|
||||
"nachher": nachher,
|
||||
"optionen": optionen,
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
@@ -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)
|
||||
|
||||
@@ -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.",
|
||||
@@ -594,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))
|
||||
@@ -632,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
|
||||
|
||||
@@ -132,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
|
||||
|
||||
@@ -36,5 +36,7 @@
|
||||
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 %}
|
||||
@@ -39,6 +39,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 +70,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 +163,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 '–' | 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 · 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" style="cursor:pointer">
|
||||
<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 +277,30 @@
|
||||
<div class="fs-xs faint">{{ option.description }}</div>
|
||||
</td>
|
||||
<td class="fs-xs mono">{{ option.type }}</td>
|
||||
<td>
|
||||
{% if current_user.has('dhcp_options.write') %}
|
||||
<form method="post" action="{{ url_for('dhcp.set_option_value') }}" class="row row--tight"
|
||||
data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="option_id" value="{{ option.id }}">
|
||||
<input class="input input--mono" type="text" name="value" value="{{ option.value or '' }}"
|
||||
style="min-width:180px" placeholder="leer = nicht senden">
|
||||
<button class="btn btn--sm" type="submit">{{ icon('check', size=14) }}</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<span class="mono fs-sm">{{ option.value or '–' | safe }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="actions">
|
||||
{% if current_user.has('dhcp_options.write') and not option.is_standard %}
|
||||
<form method="post" action="{{ url_for('dhcp.delete_custom_option', option_id=option.id) }}"
|
||||
data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<button class="btn btn--sm btn--ghost danger-text" type="submit"
|
||||
data-confirm="Eigene Option {{ option.name }} löschen?"
|
||||
data-confirm-title="Option löschen" data-confirm-ok="Löschen">
|
||||
{{ icon('trash', size=14) }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
<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.
|
||||
</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='225') }}
|
||||
{{ field('name', 'Name', required=True, placeholder='meine-option') }}
|
||||
<label class="field">
|
||||
<span class="field__label">Typ</span>
|
||||
@@ -231,13 +313,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 +360,93 @@
|
||||
<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" style="cursor:pointer">
|
||||
<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 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 +548,10 @@
|
||||
<div class="card__title">{{ icon('clock', size=17) }} Leases</div>
|
||||
<div class="card__sub">
|
||||
Direkt aus {{ lease_file }} gelesen – je Adresse gilt der letzte Eintrag.
|
||||
{% 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 +569,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 +588,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>
|
||||
|
||||
@@ -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 '–' | 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>
|
||||
|
||||
@@ -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
@@ -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)"
|
||||
|
||||
@@ -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,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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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}"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "tesm-core"
|
||||
version = "2.8.13"
|
||||
version = "2.8.20"
|
||||
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,),
|
||||
)
|
||||
|
||||
|
||||
@@ -1133,6 +1133,16 @@
|
||||
}
|
||||
.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 +1285,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)
|
||||
|
||||
|
||||
@@ -88,6 +88,7 @@ const RA2NE = 6;
|
||||
//: einen Knopf brauchen. X11-Keysyms.
|
||||
const ALT_LINKS = 0xffe9;
|
||||
const F4 = 0xffc1;
|
||||
const F5 = 0xffc2;
|
||||
|
||||
const schema = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
|
||||
@@ -97,7 +98,7 @@ const schema = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
* ``quelle`` ist die Adresse der JSON-Auskunft; alles Weitere steht dort.
|
||||
* Rueckgabe: eine Funktion, die die Sitzung wieder abbaut.
|
||||
*/
|
||||
export function starteSitzung(wurzel, quelle) {
|
||||
export function starteSitzung(wurzel, quelle, { beiEnde = null } = {}) {
|
||||
const teil = (rolle) => wurzel.querySelector(`[data-rolle="${rolle}"]`);
|
||||
const leinwand = teil("leinwand");
|
||||
const zeile = teil("status");
|
||||
@@ -295,6 +296,10 @@ export function starteSitzung(wurzel, quelle) {
|
||||
// Beim sauberen Schluss steht der Grund im Schliessrahmen -- etwa,
|
||||
// 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 {
|
||||
@@ -374,6 +379,13 @@ export function starteSitzung(wurzel, quelle) {
|
||||
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);
|
||||
});
|
||||
|
||||
sitzungsknoepfe(false);
|
||||
melden("Wird vorbereitet …");
|
||||
@@ -442,7 +454,9 @@ if (fenster) {
|
||||
titel.textContent = knopf.dataset.vncName || "Bildschirm";
|
||||
if (typeof fenster.showModal === "function") fenster.showModal();
|
||||
else fenster.setAttribute("open", "");
|
||||
beenden = starteSitzung(behaelter, knopf.dataset.vncOeffnen);
|
||||
beenden = starteSitzung(behaelter, knopf.dataset.vncOeffnen, {
|
||||
beiEnde: () => fenster.close(),
|
||||
});
|
||||
});
|
||||
|
||||
// Zu heisst zu: eine Sitzung, die im Hintergrund weiterlaeuft, haelt die
|
||||
|
||||
@@ -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 }} · letzte {{ line_count }} Zeilen{% endblock %}
|
||||
|
||||
{% block topbar_extra %}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user