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]
name = "tesm"
version = "2.8.42"
version = "2.8.44"
description = "TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten und Aruba-Switchen"
requires-python = ">=3.11"
dependencies = [
+44 -14
View File
@@ -11,7 +11,7 @@ einen Dienst starten.
from __future__ import annotations
import base64
import shlex
from typing import Any
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
},
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
# --------------------------------------------------------------------------- #
@@ -148,7 +163,13 @@ def add_subnet():
start = (request.form.get("range_start") or "").strip()
end = (request.form.get("range_end") or "").strip()
problem = kea.validate_subnet(networks, interface, start, end)
problem = kea.validate_subnet(
networks,
interface,
start,
end,
vorhandene=Database.all(conn, "SELECT * FROM dhcp_subnets"),
)
if problem:
flash(problem, "danger")
return redirect(url_for("dhcp.overview"))
@@ -183,7 +204,14 @@ def update_subnet(subnet_id: int):
interface = (request.form.get("interface") or "").strip()
start = (request.form.get("range_start") or "").strip()
end = (request.form.get("range_end") or "").strip()
problem = kea.validate_subnet(networks, interface, start, end)
problem = kea.validate_subnet(
networks,
interface,
start,
end,
vorhandene=Database.all(conn, "SELECT * FROM dhcp_subnets"),
eigene_id=subnet_id,
)
if problem:
flash(problem, "danger")
return redirect(url_for("dhcp.overview"))
@@ -608,7 +636,13 @@ def _write_and_redirect():
for warning in result.get("warnings", []):
flash(warning, "warning")
if not result["ok"] and result.get("message"):
flash(result["message"], "warning")
# Rot, nicht orange: die Aenderung steht in der Datenbank und auf der
# Seite, wirkt aber nicht. Das ist der Zustand, in dem man stundenlang
# sucht, warum ein Client die Option nicht bekommt.
flash(
result["message"] + " Die Änderung ist gespeichert, aber noch nicht wirksam.",
"danger",
)
elif result["ok"]:
flash(result["message"], "info")
return redirect(url_for("dhcp.overview"))
@@ -650,16 +684,12 @@ def push_client_config():
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"
)
# Ein Skript, ein Aufruf. Der frühere Weg schrieb auf einen festen Pfad --
# bei networkd auf einen, den kein Dienst liest -- und überschrieb bei
# dhcpcd/dhclient die Datei des Clients. Das Skript sucht sich das Ziel
# selbst und ersetzt nur seinen eigenen Block; es geht als **Argument**
# durch, damit stdin für das sudo-Passwort frei bleibt.
befehl = "{sudo} sh -c " + shlex.quote(konfig["skript"])
gestartet = 0
for device_id in device_ids:
+179 -19
View File
@@ -186,9 +186,20 @@ def matching_network(
def validate_subnet(
networks: Iterable[InterfaceNetwork], interface: str, range_start: str, range_end: str
networks: Iterable[InterfaceNetwork],
interface: str,
range_start: str,
range_end: str,
*,
vorhandene: Iterable[Any] = (),
eigene_id: int | None = None,
) -> str:
"""Rueckgabe: leerer String bei Erfolg, sonst die Fehlermeldung."""
"""Rueckgabe: leerer String bei Erfolg, sonst die Fehlermeldung.
``vorhandene`` sind die schon eingetragenen Bereiche. Ueberschneidet sich
der neue mit einem davon, entsteht eine Konfiguration, die Kea als Ganzes
ablehnt -- besser hier sagen als spaeter beim Schreiben.
"""
if not interface:
return "Es ist kein Interface ausgewählt."
try:
@@ -203,9 +214,41 @@ def validate_subnet(
f"Der Bereich {range_start}-{range_end} passt in kein Netz von {interface}. "
"Subnetz und Maske werden vom laufenden System gelesen."
)
for zeile in vorhandene:
if eigene_id is not None and int(zeile["id"]) == eigene_id:
continue
if str(zeile["interface"]) != interface:
continue
try:
anfang = int(ipaddress.ip_address(str(zeile["range_start"])))
ende = int(ipaddress.ip_address(str(zeile["range_end"])))
except ValueError:
continue
if int(start) <= ende and int(end) >= anfang:
return (
f"Der Bereich überschneidet sich mit {zeile['range_start']}-"
f"{zeile['range_end']} auf {interface}. Erweitern Sie den vorhandenen "
"Bereich, statt einen zweiten anzulegen."
)
return ""
def laufende_konfiguration(sysops: SysOps) -> dict[str, Any] | None:
"""Die Konfiguration, die tatsaechlich auf der Platte liegt.
``None`` heisst: nicht lesbar (kein Helfer, Datei fehlt, kein JSON) -- das
ist etwas anderes als "weicht ab" und wird auch anders angezeigt.
"""
result = sysops.read_privileged_file(DEFAULT_CONFIG_PATH)
if not result.ok or not (result.stdout or "").strip():
return None
try:
geladen = json.loads(result.stdout)
except (json.JSONDecodeError, ValueError):
return None
return geladen if isinstance(geladen, dict) else None
# --------------------------------------------------------------------------- #
# Reservierungen
# --------------------------------------------------------------------------- #
@@ -393,8 +436,16 @@ def render_config(
return entries
subnet_entries: list[dict[str, Any]] = []
# Ein Netz, ein Eintrag. Kea lehnt die **ganze** Konfiguration ab, sobald
# derselbe Praefix zweimal darin steht ("subnet with the prefix of
# '192.168.82.0/24' already exists") -- und dann laeuft der Dienst
# unbemerkt mit dem alten Stand weiter. Zwei Zeilen im selben Netz sind
# aber nichts Unsinniges: es sind zwei Bereiche. Sie werden deshalb zu
# einem Subnetz mit mehreren Pools zusammengefasst.
nach_netz: dict[str, dict[str, Any]] = {}
belegte_bereiche: dict[str, list[tuple[int, int]]] = {}
interfaces: set[str] = set()
for index, subnet in enumerate(subnets, start=1):
for subnet in subnets:
interface = str(subnet["interface"])
match = matching_network(
networks, interface, str(subnet["range_start"]), str(subnet["range_end"])
@@ -433,15 +484,40 @@ def render_config(
entry["option-data"] = option_data(overrides)
subnet_reservations.append(entry)
subnet_entries.append(
{
"id": index,
"subnet": str(network),
"pools": [{"pool": f"{subnet['range_start']} - {subnet['range_end']}"}],
schluessel = str(network)
eintrag = nach_netz.get(schluessel)
if eintrag is None:
eintrag = {
# Die Nummer zaehlt die Eintraege, nicht die Zeilen der
# Tabelle -- sonst haette ein uebersprungenes Subnetz eine
# Luecke hinterlassen, und Kea verlangt eindeutige IDs.
"id": len(subnet_entries) + 1,
"subnet": schluessel,
"pools": [],
**({"option-data": subnet_options} if subnet_options else {}),
"reservations": subnet_reservations,
}
nach_netz[schluessel] = eintrag
belegte_bereiche[schluessel] = []
subnet_entries.append(eintrag)
elif subnet_options and subnet_options != eintrag.get("option-data", []):
warnings.append(
f"Für {schluessel} sind mehrere Bereiche mit unterschiedlichem Gateway oder "
"DNS eingetragen. Ein Netz kann nur eines haben | es gilt der erste Eintrag."
)
# Auch zwei Pools, die sich ueberschneiden, bringen Kea zu Fall.
von, bis = int(ipaddress.ip_address(str(subnet["range_start"]))), int(
ipaddress.ip_address(str(subnet["range_end"]))
)
if any(von <= ende and bis >= anfang for anfang, ende in belegte_bereiche[schluessel]):
warnings.append(
f"Der Bereich {subnet['range_start']}-{subnet['range_end']} überschneidet sich "
"mit einem bereits eingetragenen und wurde übersprungen."
)
continue
belegte_bereiche[schluessel].append((von, bis))
eintrag["pools"].append({"pool": f"{subnet['range_start']} - {subnet['range_end']}"})
if not subnet_entries:
warnings.append("Es ist kein gültiges Subnetz konfiguriert | Kea würde nicht starten.")
@@ -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
#: noch installiert ist.
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"),
("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]:
"""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
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.
Rueckgabe: ``{"pfad", "inhalt", "nachher", "skript", "optionen"}``.
``skript`` ist die Fassung zum Ausfuehren -- sie sucht sich das Ziel auf
dem Client selbst und traegt den Inhalt ein, ohne Vorhandenes zu
verlieren.
"""
optionen = [
eintrag
@@ -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 --",
"# 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",
"# Diese Datei ist ein Zusatz zu der .network-Datei, die fuer die",
"# Schnittstelle gilt. Wie sie heisst, sagt 'networkctl status <if>';",
"# bei netplan ist es /run/systemd/network/10-netplan-<if>.network, der",
"# Zusatz gehoert dann nach",
"# /etc/systemd/network/10-netplan-<if>.network.d/50-tesm.conf.",
"#",
"# Die Werte stehen danach in /run/systemd/netif/leases/<ifindex>",
"# als OPTION_<code>=...",
"# als OPTION_<code>= -- hexkodiert, weil systemd fremde Optionen nicht",
"# deutet.",
"[DHCPv4]",
]
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":
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.",
"# Danach steht der Wert in der Lease (/var/lib/dhcpcd/<if>.lease)",
"# 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']}")
for eintrag in optionen:
zeilen.append(f"option {eintrag['name']}")
zeilen.append("# <<< TESM")
nachher = "systemctl restart dhcpcd"
else:
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,",
"# und in Hooks unter $new_<name>.",
]
@@ -775,16 +894,57 @@ def client_konfiguration(conn: sqlite3.Connection, stil: str) -> dict[str, Any]:
zeilen.append(
"also request " + ", ".join(str(eintrag["name"]) for eintrag in optionen) + ";"
)
zeilen.append("# <<< TESM")
nachher = "systemctl restart networking || dhclient -r && dhclient"
inhalt = chr(10).join(zeilen) + chr(10)
return {
"pfad": pfad,
"inhalt": chr(10).join(zeilen) + chr(10),
"inhalt": inhalt,
"nachher": nachher,
"skript": _uebernahme_skript(stil, inhalt),
"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]:
state = sysops.service_state(SERVICE_UNIT)
import shutil
+30 -2
View File
@@ -7,6 +7,29 @@
{% endblock %}
{% block content %}
{# Der wichtigste Satz der Seite, wenn er zutrifft: was hier steht, ist nicht,
womit der Dienst arbeitet. Lehnt Kea eine Datei ab, bleibt der alte Stand
aktiv -- die Oberflaeche zeigte bisher trotzdem den neuen Wert, und man
sucht den Fehler beim Client. #}
{% if konfig_stand == 'abweichend' %}
<div class="alert alert--danger">
{{ icon('alert', size=18) }}
<div class="alert__body flex-1">
<div class="alert__title">Der Dienst arbeitet nicht mit dieser Konfiguration</div>
Auf der Platte liegt ein anderer Stand als der hier gezeigte &ndash; entweder wurde
die Datei zuletzt abgelehnt oder seither nichts geschrieben. Änderungen an Optionen
und Reservierungen sind gespeichert, aber <strong>nicht wirksam</strong>.
{% if current_user.has('dhcp_config.execute') %}
<form method="post" action="{{ url_for('dhcp.write_config') }}" class="mt-2" data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<button class="btn btn--sm btn--primary" type="submit" data-busy-label="Schreibt ...">
Jetzt schreiben und Fehler anzeigen
</button>
</form>
{% endif %}
</div>
</div>
{% endif %}
{% for warning in warnings %}{{ alert(warning, 'warning') }}{% endfor %}
{% if not sysops_available %}
{{ alert('Systemhelfer nicht verfügbar -- Konfiguration und Dienstzustand können hier nur
@@ -21,8 +44,13 @@
</div>
<div class="stat">
<span class="stat__label">Programm</span>
<span class="stat__value fs-lg">{{ service.get('binary', '?') }}</span>
<span class="stat__hint">kea-dhcp4</span>
{# Die Fassung statt nur "vorhanden": zwischen Kea 2.4 (Ubuntu 24.04) und
3.0 (26.04) liegen Aenderungen, und wer eine Konfiguration beurteilt,
will wissen, wogegen. #}
<span class="stat__value fs-lg">{{ service.get('version') or service.get('binary', '?') }}</span>
<span class="stat__hint">
kea-dhcp4{% if service.get('version') %} &middot; {{ service.get('binary', '?') }}{% endif %}
</span>
</div>
<div class="stat">
<span class="stat__label">Reservierungen</span>
+4
View File
@@ -100,6 +100,10 @@ check_readable_path() {
case "$1" in
/var/log/kea/kea-dhcp4.log) ;;
/var/lib/kea/kea-leases4.csv) ;;
# Die eigene Kea-Konfiguration: 0640 root:_kea, also fuer den
# Dienstbenutzer nicht lesbar. Die Anwendung braucht sie, um zu sehen, ob
# der Dienst ueberhaupt mit dem laeuft, was sie geschrieben hat.
/etc/kea/kea-dhcp4.conf) ;;
/etc/netplan/90-*.yaml)
check_app_key "$(basename "$1" .yaml | sed 's/^90-//')" ;;
/etc/nginx/sites-available/*)
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
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"
requires-python = ">=3.11"
dependencies = [
@@ -657,9 +657,19 @@
gap: var(--sp-5);
}
/* Bleibt stehen wie die Kopfzeile: Fassung und angemeldeter Benutzer sind
Angaben, nach denen man mitten in einer langen Seite fragt -- und bis
hierher zu scrollen, nur um zu sehen, welche Fassung laeuft, ist eine
Zumutung. Derselbe milchige Untergrund wie oben, damit Inhalt, der
darunter durchlaeuft, nicht mitliest. */
.page-footer {
padding: var(--sp-4) var(--sp-5);
position: sticky;
bottom: 0;
z-index: var(--z-sticky);
padding: var(--sp-3) var(--sp-5);
border-top: 1px solid var(--border-subtle);
background: color-mix(in srgb, var(--canvas) 82%, transparent);
backdrop-filter: blur(12px) saturate(1.4);
color: var(--text-faint);
font-size: var(--fs-xs);
display: flex;