Compare commits

...
2 Commits
7 changed files with 270 additions and 38 deletions

No files matched your search

+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "tesm" name = "tesm"
version = "2.8.42" version = "2.8.44"
description = "TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten und Aruba-Switchen" description = "TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten und Aruba-Switchen"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [
+44 -14
View File
@@ -11,7 +11,7 @@ einen Dienst starten.
from __future__ import annotations from __future__ import annotations
import base64 import shlex
from typing import Any from typing import Any
from flask import Blueprint, abort, flash, redirect, render_template, request, url_for from flask import Blueprint, abort, flash, redirect, render_template, request, url_for
@@ -112,9 +112,24 @@ def overview():
stil: kea.client_konfiguration(conn, stil) for stil, _, _ in kea.CLIENT_STILE stil: kea.client_konfiguration(conn, stil) for stil, _, _ in kea.CLIENT_STILE
}, },
sysops_available=bool(sysops and sysops.available), sysops_available=bool(sysops and sysops.available),
# "gespeichert" heisst nicht "wirksam": lehnt Kea die Datei ab, bleibt
# der Dienst auf dem alten Stand, waehrend die Seite den neuen Wert
# zeigt. Deshalb wird hier verglichen, was auf der Platte liegt.
konfig_stand=_konfig_stand(config),
) )
def _konfig_stand(gewuenscht: dict[str, Any]) -> str:
"""``aktuell``, ``abweichend`` oder ``unbekannt``."""
sysops = core().sysops
if sysops is None or not sysops.available:
return "unbekannt"
vorhanden = kea.laufende_konfiguration(sysops)
if vorhanden is None:
return "unbekannt"
return "aktuell" if vorhanden == gewuenscht else "abweichend"
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# Einstellungen und Subnetze # Einstellungen und Subnetze
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@@ -148,7 +163,13 @@ def add_subnet():
start = (request.form.get("range_start") or "").strip() start = (request.form.get("range_start") or "").strip()
end = (request.form.get("range_end") or "").strip() end = (request.form.get("range_end") or "").strip()
problem = kea.validate_subnet(networks, interface, start, end) problem = kea.validate_subnet(
networks,
interface,
start,
end,
vorhandene=Database.all(conn, "SELECT * FROM dhcp_subnets"),
)
if problem: if problem:
flash(problem, "danger") flash(problem, "danger")
return redirect(url_for("dhcp.overview")) return redirect(url_for("dhcp.overview"))
@@ -183,7 +204,14 @@ def update_subnet(subnet_id: int):
interface = (request.form.get("interface") or "").strip() interface = (request.form.get("interface") or "").strip()
start = (request.form.get("range_start") or "").strip() start = (request.form.get("range_start") or "").strip()
end = (request.form.get("range_end") or "").strip() end = (request.form.get("range_end") or "").strip()
problem = kea.validate_subnet(networks, interface, start, end) problem = kea.validate_subnet(
networks,
interface,
start,
end,
vorhandene=Database.all(conn, "SELECT * FROM dhcp_subnets"),
eigene_id=subnet_id,
)
if problem: if problem:
flash(problem, "danger") flash(problem, "danger")
return redirect(url_for("dhcp.overview")) return redirect(url_for("dhcp.overview"))
@@ -608,7 +636,13 @@ def _write_and_redirect():
for warning in result.get("warnings", []): for warning in result.get("warnings", []):
flash(warning, "warning") flash(warning, "warning")
if not result["ok"] and result.get("message"): if not result["ok"] and result.get("message"):
flash(result["message"], "warning") # Rot, nicht orange: die Aenderung steht in der Datenbank und auf der
# Seite, wirkt aber nicht. Das ist der Zustand, in dem man stundenlang
# sucht, warum ein Client die Option nicht bekommt.
flash(
result["message"] + " Die Änderung ist gespeichert, aber noch nicht wirksam.",
"danger",
)
elif result["ok"]: elif result["ok"]:
flash(result["message"], "info") flash(result["message"], "info")
return redirect(url_for("dhcp.overview")) return redirect(url_for("dhcp.overview"))
@@ -650,16 +684,12 @@ def push_client_config():
flash("Es wurde kein Gerät ausgewählt.", "warning") flash("Es wurde kein Gerät ausgewählt.", "warning")
return redirect(url_for("dhcp.overview")) return redirect(url_for("dhcp.overview"))
# Ueber base64 statt ueber ein Here-Dokument: der Inhalt enthaelt # Ein Skript, ein Aufruf. Der frühere Weg schrieb auf einen festen Pfad --
# Anfuehrungszeichen, Rauten und Zeilenumbrueche, und er geht durch SSH, # bei networkd auf einen, den kein Dienst liest -- und überschrieb bei
# eine Shell und moeglicherweise sudo. Was kodiert reist, kommt an. # dhcpcd/dhclient die Datei des Clients. Das Skript sucht sich das Ziel
inhalt = base64.b64encode(konfig["inhalt"].encode("utf-8")).decode("ascii") # selbst und ersetzt nur seinen eigenen Block; es geht als **Argument**
verzeichnis = str(konfig["pfad"]).rsplit("/", 1)[0] # durch, damit stdin für das sudo-Passwort frei bleibt.
befehl = ( befehl = "{sudo} sh -c " + shlex.quote(konfig["skript"])
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 gestartet = 0
for device_id in device_ids: for device_id in device_ids:
+179 -19
View File
@@ -186,9 +186,20 @@ def matching_network(
def validate_subnet( def validate_subnet(
networks: Iterable[InterfaceNetwork], interface: str, range_start: str, range_end: str networks: Iterable[InterfaceNetwork],
interface: str,
range_start: str,
range_end: str,
*,
vorhandene: Iterable[Any] = (),
eigene_id: int | None = None,
) -> str: ) -> str:
"""Rueckgabe: leerer String bei Erfolg, sonst die Fehlermeldung.""" """Rueckgabe: leerer String bei Erfolg, sonst die Fehlermeldung.
``vorhandene`` sind die schon eingetragenen Bereiche. Ueberschneidet sich
der neue mit einem davon, entsteht eine Konfiguration, die Kea als Ganzes
ablehnt -- besser hier sagen als spaeter beim Schreiben.
"""
if not interface: if not interface:
return "Es ist kein Interface ausgewählt." return "Es ist kein Interface ausgewählt."
try: try:
@@ -203,9 +214,41 @@ def validate_subnet(
f"Der Bereich {range_start}-{range_end} passt in kein Netz von {interface}. " f"Der Bereich {range_start}-{range_end} passt in kein Netz von {interface}. "
"Subnetz und Maske werden vom laufenden System gelesen." "Subnetz und Maske werden vom laufenden System gelesen."
) )
for zeile in vorhandene:
if eigene_id is not None and int(zeile["id"]) == eigene_id:
continue
if str(zeile["interface"]) != interface:
continue
try:
anfang = int(ipaddress.ip_address(str(zeile["range_start"])))
ende = int(ipaddress.ip_address(str(zeile["range_end"])))
except ValueError:
continue
if int(start) <= ende and int(end) >= anfang:
return (
f"Der Bereich überschneidet sich mit {zeile['range_start']}-"
f"{zeile['range_end']} auf {interface}. Erweitern Sie den vorhandenen "
"Bereich, statt einen zweiten anzulegen."
)
return "" return ""
def laufende_konfiguration(sysops: SysOps) -> dict[str, Any] | None:
"""Die Konfiguration, die tatsaechlich auf der Platte liegt.
``None`` heisst: nicht lesbar (kein Helfer, Datei fehlt, kein JSON) -- das
ist etwas anderes als "weicht ab" und wird auch anders angezeigt.
"""
result = sysops.read_privileged_file(DEFAULT_CONFIG_PATH)
if not result.ok or not (result.stdout or "").strip():
return None
try:
geladen = json.loads(result.stdout)
except (json.JSONDecodeError, ValueError):
return None
return geladen if isinstance(geladen, dict) else None
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# Reservierungen # Reservierungen
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@@ -393,8 +436,16 @@ def render_config(
return entries return entries
subnet_entries: list[dict[str, Any]] = [] subnet_entries: list[dict[str, Any]] = []
# Ein Netz, ein Eintrag. Kea lehnt die **ganze** Konfiguration ab, sobald
# derselbe Praefix zweimal darin steht ("subnet with the prefix of
# '192.168.82.0/24' already exists") -- und dann laeuft der Dienst
# unbemerkt mit dem alten Stand weiter. Zwei Zeilen im selben Netz sind
# aber nichts Unsinniges: es sind zwei Bereiche. Sie werden deshalb zu
# einem Subnetz mit mehreren Pools zusammengefasst.
nach_netz: dict[str, dict[str, Any]] = {}
belegte_bereiche: dict[str, list[tuple[int, int]]] = {}
interfaces: set[str] = set() interfaces: set[str] = set()
for index, subnet in enumerate(subnets, start=1): for subnet in subnets:
interface = str(subnet["interface"]) interface = str(subnet["interface"])
match = matching_network( match = matching_network(
networks, interface, str(subnet["range_start"]), str(subnet["range_end"]) networks, interface, str(subnet["range_start"]), str(subnet["range_end"])
@@ -433,15 +484,40 @@ def render_config(
entry["option-data"] = option_data(overrides) entry["option-data"] = option_data(overrides)
subnet_reservations.append(entry) subnet_reservations.append(entry)
subnet_entries.append( schluessel = str(network)
{ eintrag = nach_netz.get(schluessel)
"id": index, if eintrag is None:
"subnet": str(network), eintrag = {
"pools": [{"pool": f"{subnet['range_start']} - {subnet['range_end']}"}], # Die Nummer zaehlt die Eintraege, nicht die Zeilen der
# Tabelle -- sonst haette ein uebersprungenes Subnetz eine
# Luecke hinterlassen, und Kea verlangt eindeutige IDs.
"id": len(subnet_entries) + 1,
"subnet": schluessel,
"pools": [],
**({"option-data": subnet_options} if subnet_options else {}), **({"option-data": subnet_options} if subnet_options else {}),
"reservations": subnet_reservations, "reservations": subnet_reservations,
} }
nach_netz[schluessel] = eintrag
belegte_bereiche[schluessel] = []
subnet_entries.append(eintrag)
elif subnet_options and subnet_options != eintrag.get("option-data", []):
warnings.append(
f"Für {schluessel} sind mehrere Bereiche mit unterschiedlichem Gateway oder "
"DNS eingetragen. Ein Netz kann nur eines haben | es gilt der erste Eintrag."
)
# Auch zwei Pools, die sich ueberschneiden, bringen Kea zu Fall.
von, bis = int(ipaddress.ip_address(str(subnet["range_start"]))), int(
ipaddress.ip_address(str(subnet["range_end"]))
) )
if any(von <= ende and bis >= anfang for anfang, ende in belegte_bereiche[schluessel]):
warnings.append(
f"Der Bereich {subnet['range_start']}-{subnet['range_end']} überschneidet sich "
"mit einem bereits eingetragenen und wurde übersprungen."
)
continue
belegte_bereiche[schluessel].append((von, bis))
eintrag["pools"].append({"pool": f"{subnet['range_start']} - {subnet['range_end']}"})
if not subnet_entries: if not subnet_entries:
warnings.append("Es ist kein gültiges Subnetz konfiguriert | Kea würde nicht starten.") warnings.append("Es ist kein gültiges Subnetz konfiguriert | Kea würde nicht starten.")
@@ -670,7 +746,11 @@ IMMER_ANGEFRAGT = frozenset({1, 3, 6, 12, 15, 28, 42, 51, 54, 58, 59, 119, 121})
#: * **dhclient** -- Debian 11 bis 13 und Ubuntu 22.04, dort wo isc-dhcp-client #: * **dhclient** -- Debian 11 bis 13 und Ubuntu 22.04, dort wo isc-dhcp-client
#: noch installiert ist. #: noch installiert ist.
CLIENT_STILE: tuple[tuple[str, str, str], ...] = ( CLIENT_STILE: tuple[tuple[str, str, str], ...] = (
("networkd", "systemd-networkd", "/etc/systemd/network/10-tesm.network.d/50-tesm.conf"), # Der Zusatz gehoert zu der .network-Datei, die fuer die Schnittstelle
# wirklich gilt -- bei netplan heisst die 10-netplan-<if>.network und liegt
# unter /run. Ein Verzeichnis "10-tesm.network.d" liest niemand: es gibt
# keine 10-tesm.network. Den Namen ermittelt das Skript auf dem Client.
("networkd", "systemd-networkd", "/etc/systemd/network/<aktive>.network.d/50-tesm.conf"),
("dhcpcd", "dhcpcd", "/etc/dhcpcd.conf"), ("dhcpcd", "dhcpcd", "/etc/dhcpcd.conf"),
("dhclient", "dhclient (isc-dhcp-client)", "/etc/dhcp/dhclient.conf"), ("dhclient", "dhclient (isc-dhcp-client)", "/etc/dhcp/dhclient.conf"),
) )
@@ -713,6 +793,40 @@ def gesendete_optionen(conn: sqlite3.Connection) -> list[dict[str, Any]]:
) )
#: Traegt den Zusatz dort ein, wo networkd ihn auch liest.
NETWORKD_SKRIPT = """set -e
schnittstelle=$(ip -o -4 route show default 2>/dev/null | awk '{print $5; exit}')
[ -n "$schnittstelle" ] || { echo "Keine Standardroute -- Schnittstelle unbekannt." >&2; exit 1; }
datei=$(COLUMNS=400 SYSTEMD_COLORS=0 networkctl status "$schnittstelle" 2>/dev/null | sed -n 's/^ *Network File: *//p' | head -n1)
case "${datei:-}" in
""|n/a|*not*) datei=$(grep -ls "Name=$schnittstelle" /run/systemd/network/*.network /etc/systemd/network/*.network 2>/dev/null | head -n1) ;;
esac
[ -n "${datei:-}" ] || { echo "Keine .network-Datei fuer $schnittstelle gefunden." >&2; exit 1; }
ziel="/etc/systemd/network/$(basename "$datei").d/50-tesm.conf"
mkdir -p "$(dirname "$ziel")"
printf %s '@FRACHT@' | base64 -d > "$ziel"
chmod 0644 "$ziel"
echo "geschrieben: $ziel"
networkctl reload
networkctl reconfigure "$schnittstelle"
echo "Lease: /run/systemd/netif/leases/$(cat /sys/class/net/"$schnittstelle"/ifindex)"
"""
#: Ersetzt den eigenen Block in einer Datei, die dem Client gehoert.
BLOCK_SKRIPT = """set -e
ziel=@ZIEL@
mkdir -p "$(dirname "$ziel")"
[ -f "$ziel" ] || : > "$ziel"
zwischen=$(mktemp)
sed '/^# >>> TESM/,/^# <<< TESM/d' "$ziel" > "$zwischen"
printf %s '@FRACHT@' | base64 -d >> "$zwischen"
cat "$zwischen" > "$ziel"
rm -f "$zwischen"
echo "ergaenzt: $ziel"
@DIENST@
"""
def client_konfiguration(conn: sqlite3.Connection, stil: str) -> dict[str, Any]: def client_konfiguration(conn: sqlite3.Connection, stil: str) -> dict[str, Any]:
"""Was ein Linux-Client braucht, damit die Optionen bei ihm ankommen. """Was ein Linux-Client braucht, damit die Optionen bei ihm ankommen.
@@ -722,8 +836,10 @@ def client_konfiguration(conn: sqlite3.Connection, stil: str) -> dict[str, Any]:
ausdruecklich anfordern. Ohne diese Zeilen sendet der Server brav und der ausdruecklich anfordern. Ohne diese Zeilen sendet der Server brav und der
Client wirft es weg, ohne dass irgendwo etwas danebengeht. Client wirft es weg, ohne dass irgendwo etwas danebengeht.
Rueckgabe: ``{"pfad", "inhalt", "nachher", "optionen"}`` -- Datei, Inhalt, Rueckgabe: ``{"pfad", "inhalt", "nachher", "skript", "optionen"}``.
der Befehl zum Uebernehmen und die Optionen, um die es geht. ``skript`` ist die Fassung zum Ausfuehren -- sie sucht sich das Ziel auf
dem Client selbst und traegt den Inhalt ein, ohne Vorhandenes zu
verlieren.
""" """
optionen = [ optionen = [
eintrag eintrag
@@ -738,20 +854,22 @@ def client_konfiguration(conn: sqlite3.Connection, stil: str) -> dict[str, Any]:
"# Von TESM erzeugt. Fordert die Optionen an, die dieser Server sendet --", "# Von TESM erzeugt. Fordert die Optionen an, die dieser Server sendet --",
"# ohne diese Zeilen verwirft der Client sie stillschweigend.", "# ohne diese Zeilen verwirft der Client sie stillschweigend.",
"#", "#",
"# Der Dateiname muss zur .network-Datei der Schnittstelle passen:", "# Diese Datei ist ein Zusatz zu der .network-Datei, die fuer die",
"# netplan legt sie unter /run/systemd/network/10-netplan-<if>.network ab,", "# Schnittstelle gilt. Wie sie heisst, sagt 'networkctl status <if>';",
"# der Zusatz gehoert also nach", "# bei netplan ist es /run/systemd/network/10-netplan-<if>.network, der",
"# Zusatz gehoert dann nach",
"# /etc/systemd/network/10-netplan-<if>.network.d/50-tesm.conf.", "# /etc/systemd/network/10-netplan-<if>.network.d/50-tesm.conf.",
"#", "#",
"# Die Werte stehen danach in /run/systemd/netif/leases/<ifindex>", "# Die Werte stehen danach in /run/systemd/netif/leases/<ifindex>",
"# als OPTION_<code>=...", "# als OPTION_<code>= -- hexkodiert, weil systemd fremde Optionen nicht",
"# deutet.",
"[DHCPv4]", "[DHCPv4]",
] ]
zeilen.append("RequestOptions=" + " ".join(str(code) for code in codes)) zeilen.append("RequestOptions=" + " ".join(str(code) for code in codes))
nachher = "networkctl reload && networkctl reconfigure <schnittstelle>" nachher = "networkctl reload && networkctl reconfigure <Schnittstelle>"
elif stil == "dhcpcd": elif stil == "dhcpcd":
zeilen = [ zeilen = [
"# Von TESM erzeugt -- an das Ende von /etc/dhcpcd.conf.", "# >>> TESM -- nicht von Hand aendern, wird beim naechsten Mal ersetzt",
"# 'define' macht die Option bekannt, 'option' fordert sie an.", "# 'define' macht die Option bekannt, 'option' fordert sie an.",
"# Danach steht der Wert in der Lease (/var/lib/dhcpcd/<if>.lease)", "# Danach steht der Wert in der Lease (/var/lib/dhcpcd/<if>.lease)",
"# und laesst sich mit 'dhcpcd -U <if>' als Variable auslesen.", "# und laesst sich mit 'dhcpcd -U <if>' als Variable auslesen.",
@@ -761,10 +879,11 @@ def client_konfiguration(conn: sqlite3.Connection, stil: str) -> dict[str, Any]:
zeilen.append(f"define {eintrag['code']} {typ} {eintrag['name']}") zeilen.append(f"define {eintrag['code']} {typ} {eintrag['name']}")
for eintrag in optionen: for eintrag in optionen:
zeilen.append(f"option {eintrag['name']}") zeilen.append(f"option {eintrag['name']}")
zeilen.append("# <<< TESM")
nachher = "systemctl restart dhcpcd" nachher = "systemctl restart dhcpcd"
else: else:
zeilen = [ zeilen = [
"# Von TESM erzeugt -- an das Ende von /etc/dhcp/dhclient.conf.", "# >>> TESM -- nicht von Hand aendern, wird beim naechsten Mal ersetzt",
"# Die Werte stehen danach in /var/lib/dhcp/dhclient.leases,", "# Die Werte stehen danach in /var/lib/dhcp/dhclient.leases,",
"# und in Hooks unter $new_<name>.", "# und in Hooks unter $new_<name>.",
] ]
@@ -775,16 +894,57 @@ def client_konfiguration(conn: sqlite3.Connection, stil: str) -> dict[str, Any]:
zeilen.append( zeilen.append(
"also request " + ", ".join(str(eintrag["name"]) for eintrag in optionen) + ";" "also request " + ", ".join(str(eintrag["name"]) for eintrag in optionen) + ";"
) )
zeilen.append("# <<< TESM")
nachher = "systemctl restart networking || dhclient -r && dhclient" nachher = "systemctl restart networking || dhclient -r && dhclient"
inhalt = chr(10).join(zeilen) + chr(10)
return { return {
"pfad": pfad, "pfad": pfad,
"inhalt": chr(10).join(zeilen) + chr(10), "inhalt": inhalt,
"nachher": nachher, "nachher": nachher,
"skript": _uebernahme_skript(stil, inhalt),
"optionen": optionen, "optionen": optionen,
} }
def _uebernahme_skript(stil: str, inhalt: str) -> str:
"""Das Skript, das die Konfiguration auf dem Client eintraegt.
Zwei Dinge macht es anders als ein blosses ``tee``, und beide sind mit
Schaden bezahlt worden:
* Bei ``networkd`` **sucht es das Ziel auf dem Client**. Der Name des
Zusatzverzeichnisses muss zur aktiven .network-Datei passen; ein fester
Pfad legt eine Datei an, die kein Dienst je liest -- sie stand da, und
die Option fehlte trotzdem in der Lease.
* Bei ``dhcpcd`` und ``dhclient`` gehoeren die Zeilen in eine Datei, die
dem Client gehoert. Sie zu ueberschreiben haette seine uebrige
Konfiguration geloescht. Stattdessen wird nur der eigene Block zwischen
den Marken ersetzt -- beliebig oft wiederholbar.
Der Inhalt reist base64-kodiert und als **Argument**, nicht ueber stdin:
dort steht bei einem Client mit Passwort das Kennwort fuer ``sudo``.
"""
import base64
fracht = base64.b64encode(inhalt.encode("utf-8")).decode("ascii")
if stil == "networkd":
return NETWORKD_SKRIPT.replace("@FRACHT@", fracht)
ziel = "/etc/dhcpcd.conf" if stil == "dhcpcd" else "/etc/dhcp/dhclient.conf"
dienst = (
"systemctl restart dhcpcd"
if stil == "dhcpcd"
else "systemctl restart networking 2>/dev/null || { dhclient -r || true; dhclient; }"
)
return (
BLOCK_SKRIPT.replace("@ZIEL@", ziel)
.replace("@FRACHT@", fracht)
.replace("@DIENST@", dienst)
)
def service_state(sysops: SysOps) -> dict[str, str]: def service_state(sysops: SysOps) -> dict[str, str]:
state = sysops.service_state(SERVICE_UNIT) state = sysops.service_state(SERVICE_UNIT)
import shutil import shutil
+30 -2
View File
@@ -7,6 +7,29 @@
{% endblock %} {% endblock %}
{% block content %} {% block content %}
{# Der wichtigste Satz der Seite, wenn er zutrifft: was hier steht, ist nicht,
womit der Dienst arbeitet. Lehnt Kea eine Datei ab, bleibt der alte Stand
aktiv -- die Oberflaeche zeigte bisher trotzdem den neuen Wert, und man
sucht den Fehler beim Client. #}
{% if konfig_stand == 'abweichend' %}
<div class="alert alert--danger">
{{ icon('alert', size=18) }}
<div class="alert__body flex-1">
<div class="alert__title">Der Dienst arbeitet nicht mit dieser Konfiguration</div>
Auf der Platte liegt ein anderer Stand als der hier gezeigte &ndash; entweder wurde
die Datei zuletzt abgelehnt oder seither nichts geschrieben. Änderungen an Optionen
und Reservierungen sind gespeichert, aber <strong>nicht wirksam</strong>.
{% if current_user.has('dhcp_config.execute') %}
<form method="post" action="{{ url_for('dhcp.write_config') }}" class="mt-2" data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<button class="btn btn--sm btn--primary" type="submit" data-busy-label="Schreibt ...">
Jetzt schreiben und Fehler anzeigen
</button>
</form>
{% endif %}
</div>
</div>
{% endif %}
{% for warning in warnings %}{{ alert(warning, 'warning') }}{% endfor %} {% for warning in warnings %}{{ alert(warning, 'warning') }}{% endfor %}
{% if not sysops_available %} {% if not sysops_available %}
{{ alert('Systemhelfer nicht verfügbar -- Konfiguration und Dienstzustand können hier nur {{ alert('Systemhelfer nicht verfügbar -- Konfiguration und Dienstzustand können hier nur
@@ -21,8 +44,13 @@
</div> </div>
<div class="stat"> <div class="stat">
<span class="stat__label">Programm</span> <span class="stat__label">Programm</span>
<span class="stat__value fs-lg">{{ service.get('binary', '?') }}</span> {# Die Fassung statt nur "vorhanden": zwischen Kea 2.4 (Ubuntu 24.04) und
<span class="stat__hint">kea-dhcp4</span> 3.0 (26.04) liegen Aenderungen, und wer eine Konfiguration beurteilt,
will wissen, wogegen. #}
<span class="stat__value fs-lg">{{ service.get('version') or service.get('binary', '?') }}</span>
<span class="stat__hint">
kea-dhcp4{% if service.get('version') %} &middot; {{ service.get('binary', '?') }}{% endif %}
</span>
</div> </div>
<div class="stat"> <div class="stat">
<span class="stat__label">Reservierungen</span> <span class="stat__label">Reservierungen</span>
+4
View File
@@ -100,6 +100,10 @@ check_readable_path() {
case "$1" in case "$1" in
/var/log/kea/kea-dhcp4.log) ;; /var/log/kea/kea-dhcp4.log) ;;
/var/lib/kea/kea-leases4.csv) ;; /var/lib/kea/kea-leases4.csv) ;;
# Die eigene Kea-Konfiguration: 0640 root:_kea, also fuer den
# Dienstbenutzer nicht lesbar. Die Anwendung braucht sie, um zu sehen, ob
# der Dienst ueberhaupt mit dem laeuft, was sie geschrieben hat.
/etc/kea/kea-dhcp4.conf) ;;
/etc/netplan/90-*.yaml) /etc/netplan/90-*.yaml)
check_app_key "$(basename "$1" .yaml | sed 's/^90-//')" ;; check_app_key "$(basename "$1" .yaml | sed 's/^90-//')" ;;
/etc/nginx/sites-available/*) /etc/nginx/sites-available/*)
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "tesm-core" name = "tesm-core"
version = "2.8.21" version = "2.8.22"
description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver" description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [
@@ -657,9 +657,19 @@
gap: var(--sp-5); gap: var(--sp-5);
} }
/* Bleibt stehen wie die Kopfzeile: Fassung und angemeldeter Benutzer sind
Angaben, nach denen man mitten in einer langen Seite fragt -- und bis
hierher zu scrollen, nur um zu sehen, welche Fassung laeuft, ist eine
Zumutung. Derselbe milchige Untergrund wie oben, damit Inhalt, der
darunter durchlaeuft, nicht mitliest. */
.page-footer { .page-footer {
padding: var(--sp-4) var(--sp-5); position: sticky;
bottom: 0;
z-index: var(--z-sticky);
padding: var(--sp-3) var(--sp-5);
border-top: 1px solid var(--border-subtle); border-top: 1px solid var(--border-subtle);
background: color-mix(in srgb, var(--canvas) 82%, transparent);
backdrop-filter: blur(12px) saturate(1.4);
color: var(--text-faint); color: var(--text-faint);
font-size: var(--fs-xs); font-size: var(--fs-xs);
display: flex; display: flex;