Compare commits

..
9 Commits
10 changed files with 684 additions and 65 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.40" version = "2.8.49"
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 = [
+121 -20
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
@@ -81,15 +81,65 @@ def overview():
for zeile in Database.all(conn, "SELECT mac FROM devices WHERE deleted_at IS NULL") for zeile in Database.all(conn, "SELECT mac FROM devices WHERE deleted_at IS NULL")
} }
options = Database.all(
conn,
"SELECT o.*, v.value, v.id AS value_id FROM dhcp_options o "
"LEFT JOIN dhcp_option_values v ON v.option_id = o.id AND v.device_id IS NULL "
"ORDER BY o.code",
)
# Ein Wert an einem einzelnen Geraet ist ein gesendeter Wert. Die Uebersicht
# zaehlte nur die globalen -- und meldete "Nichts gesetzt", waehrend die
# Vorschau daneben die Option in der Reservierung auswies.
je_geraet: dict[int, list[dict[str, Any]]] = {}
for zeile in Database.all(
conn,
"SELECT o.code, d.name AS geraet, v.value FROM dhcp_option_values v "
"JOIN dhcp_options o ON o.id = v.option_id "
"JOIN devices d ON d.id = v.device_id "
"WHERE v.device_id IS NOT NULL AND d.deleted_at IS NULL "
"AND TRIM(COALESCE(v.value, '')) <> '' ORDER BY o.code, d.name",
):
je_geraet.setdefault(int(zeile["code"]), []).append(dict(zeile))
gesendet = []
for option in options:
geraete = je_geraet.get(int(option["code"]), [])
if str(option["value"] or "").strip() or geraete:
eintrag = dict(option)
eintrag["geraete"] = geraete
gesendet.append(eintrag)
# Der Server belegt einen Code selbst. Ihn aus dem Katalog herauszuhalten
# hiess: er steht in der erzeugten Datei, aber nirgends auf der Seite --
# und wer 224 vergeben will, erfaehrt es erst hinterher.
eigenes_verzeichnis = kea.verzeichnis(conn)
katalog = list(options)
if not any(int(zeile["code"]) == kea.KATALOG_CODE for zeile in katalog):
katalog.append(
{
"id": None,
"code": kea.KATALOG_CODE,
"name": kea.KATALOG_NAME,
"type": "string",
# Die Regel gehoert dorthin, wo man sie sucht: das Verzeichnis
# nennt die Optionen, die **gesendet** werden. Eine angelegte
# Option ohne Wert steht nicht darin -- sie geht ja auch nicht
# hinaus, und ein Client braucht ihren Namen nicht.
"description": "Vom Server belegt | nennt den Clients Name und Typ der "
"Optionen, die gerade gesendet werden",
"value": eigenes_verzeichnis,
"is_standard": 1,
"reserviert": True,
}
)
katalog.sort(key=lambda zeile: int(zeile["code"]))
return render_template( return render_template(
"tesm/dhcp.html", "tesm/dhcp.html",
subnets=Database.all(conn, "SELECT * FROM dhcp_subnets ORDER BY sort_order, id"), subnets=Database.all(conn, "SELECT * FROM dhcp_subnets ORDER BY sort_order, id"),
options=Database.all( options=options,
conn, katalog=katalog,
"SELECT o.*, v.value, v.id AS value_id FROM dhcp_options o " gesendet=gesendet,
"LEFT JOIN dhcp_option_values v ON v.option_id = o.id AND v.device_id IS NULL "
"ORDER BY o.code",
),
option_types=kea.OPTION_TYPES, option_types=kea.OPTION_TYPES,
device_option_values=_device_option_values(conn), device_option_values=_device_option_values(conn),
reservations=reservations, reservations=reservations,
@@ -112,9 +162,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 +213,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 +254,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"))
@@ -347,6 +425,17 @@ def add_custom_option():
if not 1 <= code <= 254: if not 1 <= code <= 254:
flash("Der Optionscode muss zwischen 1 und 254 liegen.", "danger") flash("Der Optionscode muss zwischen 1 und 254 liegen.", "danger")
return redirect(url_for("dhcp.overview")) return redirect(url_for("dhcp.overview"))
if code == kea.KATALOG_CODE:
# Hier abweisen statt spaeter stillschweigend das Verzeichnis
# wegzulassen: sonst funktionierte die Benennung auf den Clients
# nicht mehr, und niemand wuesste warum.
flash(
f"Code {kea.KATALOG_CODE} ist belegt: darunter sendet der Server seinen Clients "
"die Namen und Typen der eigenen Optionen. Ohne dieses Verzeichnis heißt eine "
"neue Option auf dem Client nur noch nach ihrer Nummer.",
"danger",
)
return redirect(url_for("dhcp.overview"))
if not name or option_type not in kea.OPTION_TYPES: if not name or option_type not in kea.OPTION_TYPES:
flash("Name und Typ sind erforderlich.", "danger") flash("Name und Typ sind erforderlich.", "danger")
return redirect(url_for("dhcp.overview")) return redirect(url_for("dhcp.overview"))
@@ -361,6 +450,16 @@ def add_custom_option():
) )
_audit("dhcp.option_created", name, severity="notice") _audit("dhcp.option_created", name, severity="notice")
flash("Eigene Option angelegt.", "success") flash("Eigene Option angelegt.", "success")
if code not in kea.PRIVATBEREICH:
# Kein Fehler -- andere Clients nehmen auch andere Codes. Aber ein
# Linux-Client mit systemd-networkd hebt eine unbekannte Option nur
# dann auf, wenn ihr Code im vorgesehenen Bereich liegt. Wer das
# nicht weiss, sucht den Fehler danach beim Server.
flash(
f"Code {code} liegt außerhalb von 224–254. Clients mit systemd-networkd "
"verwerfen Optionen außerhalb dieses Bereichs, auch wenn sie sie anfordern.",
"warning",
)
return redirect(url_for("dhcp.overview")) return redirect(url_for("dhcp.overview"))
@@ -608,7 +707,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 +755,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:
+420 -21
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.")
@@ -469,6 +545,23 @@ def render_config(
if not int(row["is_standard"] or 0) if not int(row["is_standard"] or 0)
] ]
# Das Verzeichnis: Code, Name und Typ der eigenen Optionen, die dieser
# Server tatsaechlich sendet. Es wird nicht gespeichert, sondern aus dem
# Katalog abgeleitet -- gespeichert waere es eine zweite Wahrheit.
belegte_codes = {int(row["code"]) for row in option_rows}
eintrag = verzeichnis(conn)
if eintrag and KATALOG_CODE not in belegte_codes:
custom_defs.append(
{"name": KATALOG_NAME, "code": KATALOG_CODE, "type": "string", "space": "dhcp4"}
)
global_option_data.append({"name": KATALOG_NAME, "data": eintrag})
elif eintrag:
warnings.append(
f"Code {KATALOG_CODE} ist von einer eigenen Option belegt | das Verzeichnis für "
"die Clients wird nicht mitgesendet. Neue Optionen sind dort dann nur unter "
"ihrer Nummer zu sehen, bis die Konfiguration erneut verteilt wird."
)
config = { config = {
"Dhcp4": { "Dhcp4": {
"interfaces-config": {"interfaces": sorted(interfaces)}, "interfaces-config": {"interfaces": sorted(interfaces)},
@@ -653,6 +746,26 @@ 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())) return sorted(latest.values(), key=lambda entry: tuple(int(p) for p in entry.address.split(".") if p.isdigit()))
#: Der Bereich, den DHCP fuer eigene Optionen vorsieht (RFC 2132: "site
#: specific"). **Nur** hier hinein gehoeren eigene Codes, und zwar nicht aus
#: Ordnungsliebe: systemd-networkd hebt eine unbekannte Option ausschliesslich
#: dann auf, wenn ihr Code in diesem Bereich liegt. Alles darunter wirft es
#: weg, ohne dass irgendwo etwas danebengeht.
PRIVATBEREICH = range(224, 255)
#: Code und Name des Verzeichnisses, das der Server ueber sich selbst mitsendet.
#:
#: **Warum es das gibt.** Auf der Leitung reisen nur Code, Laenge und Bytes --
#: kein Name, kein Typ. Ein Client kann darum nicht wissen, dass 225 "url"
#: heisst und Text enthaelt. Bisher bekam er die Zuordnung beim Verteilen
#: mit; kam spaeter eine Option dazu, musste man erneut verteilen. Also sendet
#: der Server sein Verzeichnis einfach mit: "225:url:string 226:monitor:...".
#: Der Uebersetzer auf dem Client liest es bei jeder Lease neu -- eine neue
#: Option ist damit sofort unter ihrem Namen lesbar, ohne den Client
#: anzufassen.
KATALOG_CODE = 224
KATALOG_NAME = "tesm-katalog"
#: Optionen, die jeder DHCP-Client von sich aus erfragt. Sie in die #: Optionen, die jeder DHCP-Client von sich aus erfragt. Sie in die
#: Client-Konfiguration zu schreiben waere Ballast -- und wer Ballast liest, #: Client-Konfiguration zu schreiben waere Ballast -- und wer Ballast liest,
#: liest irgendwann gar nicht mehr. #: liest irgendwann gar nicht mehr.
@@ -670,7 +783,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 +830,176 @@ 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"
# Der Uebersetzer: systemd legt fremde Optionen roh und unter ihrer Nummer ab
# ("OPTION_225=687474..."), weil im DHCP-Paket weder Name noch Typ steht.
# Beides weiss nur dieser Server -- also schreibt er die Zuordnung mit.
mkdir -p /usr/local/lib/tesm
printf %s '@UEBERSETZER@' | base64 -d > /usr/local/lib/tesm/dhcp-options
chmod 0755 /usr/local/lib/tesm/dhcp-options
printf %s '@DIENST@' | base64 -d > /etc/systemd/system/tesm-dhcp-options.service
printf %s '@PFADEINHEIT@' | base64 -d > /etc/systemd/system/tesm-dhcp-options.path
chmod 0644 /etc/systemd/system/tesm-dhcp-options.service /etc/systemd/system/tesm-dhcp-options.path
systemctl daemon-reload
systemctl enable --now tesm-dhcp-options.path >/dev/null 2>&1 || true
systemctl enable tesm-dhcp-options.service >/dev/null 2>&1 || true
networkctl reload
networkctl reconfigure "$schnittstelle"
# Der Pfadwaechter loest bei der naechsten Lease aus; einmal von Hand, damit
# die Datei sofort dasteht.
sleep 2
systemctl start tesm-dhcp-options.service || true
echo "Lease: /run/systemd/netif/leases/$(cat /sys/class/net/"$schnittstelle"/ifindex)"
echo "Klartext: /run/tesm-dhcp-options.env"
cat /run/tesm-dhcp-options.env 2>/dev/null || true
"""
#: Uebersetzt die rohen Optionen der Lease in "name=wert".
#:
#: Warum es das braucht: in einem DHCP-Paket stehen nur Nummer, Laenge und
#: Bytes. Dass 225 "url" heisst und eine Zeichenkette ist, steht allein im
#: Katalog dieses Servers -- systemd kann es nicht wissen und legt deshalb
#: Hex unter der Nummer ab. Diese Zuordnung reist mit.
UEBERSETZER = r"""#!/bin/sh
# Von TESM erzeugt. Schreibt die eigenen DHCP-Optionen im Klartext nach
# /run/tesm-dhcp-options.env -- eine Zeile "name=wert" je Option.
#
# Uebersetzt wird **alles**, was in der Lease steht, nicht eine feste Liste:
# Namen und Typen kommen aus dem Verzeichnis, das der Server als Option
# @KATALOG@ mitsendet. Eine neue Option ist damit sofort unter ihrem Namen
# lesbar, ohne dass hier etwas geaendert werden muesste. Fehlt das
# Verzeichnis, gilt die Zuordnung vom Tag des Verteilens; fehlt auch die,
# heisst die Option nach ihrer Nummer.
set -e
ziel=/run/tesm-dhcp-options.env
ersatz="@PAARE@"
schnittstelle=$(ip -o -4 route show default 2>/dev/null | awk '{print $5; exit}')
[ -n "$schnittstelle" ] || exit 0
lease=/run/systemd/netif/leases/$(cat /sys/class/net/"$schnittstelle"/ifindex 2>/dev/null)
[ -f "$lease" ] || exit 0
text() { /usr/bin/printf "$(echo "$1" | sed 's/../@BS@x&/g')"; }
entziffere() {
# $1 = Hexfolge, $2 = Typ. Ohne den Typ waere jede Adresse Buchstabensalat:
# eine IPv4-Adresse reist als vier rohe Bytes, nicht als "192.168.0.1".
case "$2" in
ipv4-address)
set -- $(echo "$1" | sed 's/../& /g')
[ $# -ge 4 ] || return 0
printf '%d.%d.%d.%d' "0x$1" "0x$2" "0x$3" "0x$4"
;;
uint8|uint16|uint32)
printf '%d' "0x$1"
;;
boolean)
case "$1" in 00) printf 'nein' ;; *) printf 'ja' ;; esac
;;
*)
text "$1"
;;
esac
}
nachschlagen() {
# Erst im mitgesendeten Verzeichnis, dann in der Zuordnung vom Verteilen.
for eintrag in $verzeichnis $ersatz; do
case "$eintrag" in
"$1":*)
rest=${eintrag#*:}
echo "${rest%%:*} ${rest#*:}"
return 0
;;
esac
done
echo "option_$1 string"
}
verzeichnis=$(text "$(sed -n 's/^OPTION_@KATALOG@=//p' "$lease" | head -n1)")
zwischen=$(mktemp)
sed -n 's/^OPTION_\([0-9]*\)=\(.*\)$/\1 \2/p' "$lease" | while read -r code hex; do
[ "$code" = "@KATALOG@" ] && continue
angabe=$(nachschlagen "$code")
name=${angabe%% *}
typ=${angabe#* }
wert=$(entziffere "$hex" "$typ")
echo "$name=$wert" >> "$zwischen"
done
mv -f "$zwischen" "$ziel"
chmod 0644 "$ziel"
"""
#: Einmal beim Start und nach jeder Lease.
DIENST_EINHEIT = """[Unit]
Description=TESM -- DHCP-Optionen im Klartext
After=systemd-networkd.service
[Service]
Type=oneshot
ExecStart=/usr/local/lib/tesm/dhcp-options
RemainAfterExit=no
[Install]
WantedBy=multi-user.target
"""
PFAD_EINHEIT = """[Unit]
Description=TESM -- wacht ueber die DHCP-Lease
[Path]
PathModified=/run/systemd/netif/leases
Unit=tesm-dhcp-options.service
[Install]
WantedBy=multi-user.target
"""
#: Ersetzt den eigenen Block in einer Datei, die dem Client gehoert.
BLOCK_SKRIPT = """set -e
ziel=@ZIEL@
mkdir -p "$(dirname "$ziel")"
[ -f "$ziel" ] || : > "$ziel"
zwischen=$(mktemp)
sed '/^# >>> TESM/,/^# <<< TESM/d' "$ziel" > "$zwischen"
printf %s '@FRACHT@' | base64 -d >> "$zwischen"
cat "$zwischen" > "$ziel"
rm -f "$zwischen"
echo "ergaenzt: $ziel"
@DIENST@
"""
def verzeichnis(conn: sqlite3.Connection) -> str:
"""Das Verzeichnis, das der Server seinen Clients mitsendet.
Eine Quelle fuer beides -- die Konfiguration und die Anzeige. Zweimal
berechnet waeren es zwei Wahrheiten, und die Seite wuerde irgendwann etwas
anderes zeigen als der Server sendet.
"""
return " ".join(
f"{int(zeile['code'])}:{zeile['name']}:{zeile['type']}"
for zeile in gesendete_optionen(conn)
if not int(zeile["is_standard"] or 0)
)
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 +1009,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 +1027,31 @@ 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. Den Klartext schreibt der mitgelieferte Uebersetzer nach",
"# /run/tesm-dhcp-options.env.",
"#",
"# Angefordert wird der ganze Bereich 224-254, damit eine spaeter",
"# hinzugefuegte Option ohne Zutun ankommt. Eigene Optionen ausserhalb",
"# dieses Bereichs hebt systemd-networkd nicht auf.",
"[DHCPv4]", "[DHCPv4]",
] ]
zeilen.append("RequestOptions=" + " ".join(str(code) for code in codes)) # Angefordert wird der **ganze** eigene Bereich, nicht nur das, was
nachher = "networkctl reload && networkctl reconfigure <schnittstelle>" # heute gesendet wird: sonst muesste man nach jeder neuen Option zu
# jedem Client. Ein Client bekommt trotzdem nur, was der Server hat.
angefragt = sorted(set(PRIVATBEREICH) | set(codes))
zeilen.append("RequestOptions=" + " ".join(str(code) for code in angefragt))
nachher = "networkctl reload && networkctl reconfigure <Schnittstelle>"
elif stil == "dhcpcd": 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 +1061,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 +1076,112 @@ 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),
"auslesen": _auslesen(stil, optionen),
"optionen": optionen, "optionen": optionen,
} }
def _auslesen(stil: str, benannt: list[dict[str, Any]]) -> dict[str, str]:
"""Wie der Wert auf dem Client wieder herauskommt.
Gehoert dazu, weil "angekommen" sonst niemand pruefen kann -- und weil ein
RMM-Agent genau diese Zeile braucht.
Zwei Wege bei systemd-networkd, und das hat einen Grund: in der Lease
steht der Wert **hexkodiert unter seiner Nummer**. Im DHCP-Paket reisen
nur Code, Laenge und Bytes; dass 225 "url" heisst und Text enthaelt, weiss
allein der Katalog dieses Servers. Deshalb legt der SSH-Push einen kleinen
Uebersetzer mit, der nach jeder Lease ``/run/tesm-dhcp-options.env``
schreibt -- "name=wert", lesbar. ``roh`` bleibt fuer den Fall, dass nur
die Datei von Hand kopiert wurde.
"""
if not benannt:
return {}
code = int(benannt[0]["code"])
name = str(benannt[0]["name"])
if stil == "networkd":
return {
"einfach": f"grep '^{name}=' /run/tesm-dhcp-options.env",
"roh": (
"lease=/run/systemd/netif/leases/$(cat /sys/class/net/$IF/ifindex); "
f"/usr/bin/printf \"$(sed -n 's/^OPTION_{code}=//p' \"$lease\" "
"| sed 's/../@BS@x&/g')\""
).replace("@BS@", "\\" * 2),
}
if stil == "dhcpcd":
# dhcpcd kennt den Namen, weil 'define' ihn ihm beigebracht hat.
return {"einfach": f"dhcpcd -U $IF | grep -i '^{name}='", "roh": ""}
return {
"einfach": f"grep -i 'option {name}' /var/lib/dhcp/dhclient.leases | tail -n1",
"roh": "",
}
def _b64(text: str) -> str:
import base64
return base64.b64encode(text.encode("utf-8")).decode("ascii")
def _uebernahme_skript(stil: str, inhalt: str, benannt: list[dict[str, Any]] | None = None) -> str:
"""Das Skript, das die Konfiguration auf dem Client eintraegt.
Zwei Dinge macht es anders als ein blosses ``tee``, und beide sind mit
Schaden bezahlt worden:
* Bei ``networkd`` **sucht es das Ziel auf dem Client**. Der Name des
Zusatzverzeichnisses muss zur aktiven .network-Datei passen; ein fester
Pfad legt eine Datei an, die kein Dienst je liest -- sie stand da, und
die Option fehlte trotzdem in der Lease.
* Bei ``dhcpcd`` und ``dhclient`` gehoeren die Zeilen in eine Datei, die
dem Client gehoert. Sie zu ueberschreiben haette seine uebrige
Konfiguration geloescht. Stattdessen wird nur der eigene Block zwischen
den Marken ersetzt -- beliebig oft wiederholbar.
Der Inhalt reist base64-kodiert und als **Argument**, nicht ueber stdin:
dort steht bei einem Client mit Passwort das Kennwort fuer ``sudo``.
"""
fracht = _b64(inhalt)
benannt = benannt or []
if stil == "networkd":
paare = " ".join(
f"{eintrag['code']}:{eintrag['name']}:{eintrag['type']}" for eintrag in benannt
)
uebersetzer = (
UEBERSETZER.replace("@PAARE@", paare)
.replace("@KATALOG@", str(KATALOG_CODE))
.replace("@BS@", "\\\\") # sed gibt aus zwei Zeichen eines aus
)
return (
NETWORKD_SKRIPT.replace("@FRACHT@", fracht)
.replace("@UEBERSETZER@", _b64(uebersetzer))
.replace("@DIENST@", _b64(DIENST_EINHEIT))
.replace("@PFADEINHEIT@", _b64(PFAD_EINHEIT))
)
ziel = "/etc/dhcpcd.conf" if stil == "dhcpcd" else "/etc/dhcp/dhclient.conf"
dienst = (
"systemctl restart dhcpcd"
if stil == "dhcpcd"
else "systemctl restart networking 2>/dev/null || { dhclient -r || true; dhclient; }"
)
return (
BLOCK_SKRIPT.replace("@ZIEL@", ziel)
.replace("@FRACHT@", fracht)
.replace("@DIENST@", dienst)
)
def service_state(sysops: SysOps) -> dict[str, str]: def service_state(sysops: SysOps) -> dict[str, str]:
state = sysops.service_state(SERVICE_UNIT) state = sysops.service_state(SERVICE_UNIT)
import shutil import shutil
@@ -801,7 +1198,9 @@ def service_state(sysops: SysOps) -> dict[str, str]:
fertig = subprocess.run( fertig = subprocess.run(
[pfad, "-V"], capture_output=True, text=True, timeout=5, check=False [pfad, "-V"], capture_output=True, text=True, timeout=5, check=False
) )
state["version"] = (fertig.stdout or "").strip().splitlines()[0].strip() # ``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): except (OSError, subprocess.SubprocessError, IndexError):
state["version"] = "" state["version"] = ""
return state return state
@@ -36,14 +36,13 @@
{% if section.tiles %} {% if section.tiles %}
<details class="card" data-tile-section="{{ section.key }}" <details class="card" data-tile-section="{{ section.key }}"
data-persist="dash-{{ section.key }}" {% if section.key != 'disabled' %}open{% endif %}> data-persist="dash-{{ section.key }}" {% if section.key != 'disabled' %}open{% endif %}>
<summary class="card__header" style="cursor:pointer"> <summary class="card__header">
<div class="flex-1"> <div class="flex-1">
<div class="card__title"> <div class="card__title">
{{ pill(section.tiles | length, section.kind if section.kind != 'muted' else '') }} {{ pill(section.tiles | length, section.kind if section.kind != 'muted' else '') }}
{{ section.label }} {{ section.label }}
</div> </div>
</div> </div>
{{ icon('chevron-down', size=16) }}
</summary> </summary>
<div class="card__body"> <div class="card__body">
<div class="grid grid--tiles"> <div class="grid grid--tiles">
+87 -17
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>
@@ -202,15 +230,18 @@
{% endif %} {% endif %}
{% endmacro %} {% endmacro %}
{% set gesetzte = options | selectattr('value') | list %} {# "gesetzt" heisst nicht nur "global gesetzt": ein Wert an einem einzelnen
Geraet wird genauso gesendet. Die Liste kommt deshalb fertig vom Server --
in Jinja gerechnet zaehlte sie nur die globalen und meldete "Nichts
gesetzt", waehrend die Vorschau daneben die Option auswies. #}
<section class="card"> <section class="card">
<div class="card__header"> <div class="card__header">
<div class="flex-1"> <div class="flex-1">
<div class="card__title">{{ icon('puzzle', size=17) }} Gesendete Optionen</div> <div class="card__title">{{ icon('puzzle', size=17) }} Gesendete Optionen</div>
<div class="card__sub"> <div class="card__sub">
{% if gesetzte %} {% if gesendet %}
<strong>{{ gesetzte | length }}</strong> von {{ options | length }} Optionen tragen <strong>{{ gesendet | length }}</strong> von {{ options | length }} Optionen tragen
einen Wert. Was leer ist, wird nicht gesendet. einen Wert. Was leer ist, wird nicht gesendet.
{% else %} {% else %}
Derzeit trägt keine Option einen Wert – es wird also nichts über die Derzeit trägt keine Option einen Wert – es wird also nichts über die
@@ -220,7 +251,7 @@
</div> </div>
</div> </div>
{% if gesetzte %} {% if gesendet %}
<div class="table-wrap"> <div class="table-wrap">
<table class="table table--compact"> <table class="table table--compact">
<thead><tr> <thead><tr>
@@ -228,7 +259,7 @@
<th style="width:42%">Wert</th><th class="actions"></th> <th style="width:42%">Wert</th><th class="actions"></th>
</tr></thead> </tr></thead>
<tbody> <tbody>
{% for option in gesetzte %} {% for option in gesendet %}
<tr> <tr>
<td class="numeric fs-sm">{{ option.code }}</td> <td class="numeric fs-sm">{{ option.code }}</td>
<td class="fs-sm"> <td class="fs-sm">
@@ -236,7 +267,19 @@
<div class="fs-xs faint">{{ option.description }}</div> <div class="fs-xs faint">{{ option.description }}</div>
</td> </td>
<td class="fs-xs mono">{{ option.type }}</td> <td class="fs-xs mono">{{ option.type }}</td>
<td>{{ optionswert(option) }}</td> <td>
{{ optionswert(option) }}
{% if option.geraete %}
<div class="fs-xs faint mt-2">
{% for eintrag in option.geraete %}
<div>
nur für <strong>{{ eintrag.geraet }}</strong>:
<span class="mono">{{ eintrag.value }}</span>
</div>
{% endfor %}
</div>
{% endif %}
</td>
<td class="actions">{{ optionsloeschen(option) }}</td> <td class="actions">{{ optionsloeschen(option) }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
@@ -252,11 +295,11 @@
Rollbereich innerhalb der scrollenden Seite -- und das Gesuchte ging darin Rollbereich innerhalb der scrollenden Seite -- und das Gesuchte ging darin
unter. Jetzt liegt er hinter einem Klick, mit eigener Suche. #} unter. Jetzt liegt er hinter einem Klick, mit eigener Suche. #}
<details class="card" data-persist="dhcp-katalog"> <details class="card" data-persist="dhcp-katalog">
<summary class="card__header" style="cursor:pointer"> <summary class="card__header">
<div class="flex-1"> <div class="flex-1">
<div class="card__title">{{ icon('list', size=17) }} Optionskatalog</div> <div class="card__title">{{ icon('list', size=17) }} Optionskatalog</div>
<div class="card__sub"> <div class="card__sub">
Alle {{ options | length }} bekannten Optionen. Standardoptionen sind nicht löschbar, Alle {{ katalog | length }} bekannten Optionen. Standardoptionen sind nicht löschbar,
ihr Wert lässt sich leeren. ihr Wert lässt sich leeren.
</div> </div>
</div> </div>
@@ -269,7 +312,7 @@
<th style="width:42%">Wert</th><th class="actions"></th> <th style="width:42%">Wert</th><th class="actions"></th>
</tr></thead> </tr></thead>
<tbody> <tbody>
{% for option in options %} {% for option in katalog %}
<tr data-filter-text="{{ option.code }} {{ option.name }} {{ option.description }}"> <tr data-filter-text="{{ option.code }} {{ option.name }} {{ option.description }}">
<td class="numeric fs-sm">{{ option.code }}</td> <td class="numeric fs-sm">{{ option.code }}</td>
<td class="fs-sm"> <td class="fs-sm">
@@ -277,8 +320,16 @@
<div class="fs-xs faint">{{ option.description }}</div> <div class="fs-xs faint">{{ option.description }}</div>
</td> </td>
<td class="fs-xs mono">{{ option.type }}</td> <td class="fs-xs mono">{{ option.type }}</td>
<td>{{ optionswert(option) }}</td> {% if option.reserviert %}
<td class="actions">{{ optionsloeschen(option) }}</td> {# Kein Eingabefeld: den Wert bestimmt der Katalog selbst. #}
<td class="fs-xs mono faint">
{{ option.value or '(noch sendet keine eigene Option einen Wert)' }}
</td>
<td class="actions">{{ pill('belegt', '') }}</td>
{% else %}
<td>{{ optionswert(option) }}</td>
<td class="actions">{{ optionsloeschen(option) }}</td>
{% endif %}
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
@@ -293,6 +344,8 @@
<div class="card__title">{{ icon('plus', size=17) }} Eigene Option anlegen</div> <div class="card__title">{{ icon('plus', size=17) }} Eigene Option anlegen</div>
<div class="card__sub"> <div class="card__sub">
Für alles jenseits des Katalogs – etwa eine URL, die der Client beim Start öffnet. Für alles jenseits des Katalogs – etwa eine URL, die der Client beim Start öffnet.
Code <strong>224–254</strong>: nur diesen Bereich sieht DHCP für eigene Optionen vor,
und nur daraus hebt ein Linux-Client mit systemd-networkd sie überhaupt auf.
</div> </div>
</div> </div>
</div> </div>
@@ -300,7 +353,8 @@
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}"> <input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="card__body"> <div class="card__body">
<div class="form-grid"> <div class="form-grid">
{{ field('code', 'Code', type='number', required=True, placeholder='225') }} {{ field('code', 'Code', type='number', required=True, placeholder='226',
hint_text='224 bis 254') }}
{{ field('name', 'Name', required=True, placeholder='meine-option') }} {{ field('name', 'Name', required=True, placeholder='meine-option') }}
<label class="field"> <label class="field">
<span class="field__label">Typ</span> <span class="field__label">Typ</span>
@@ -403,7 +457,7 @@
{% for stil, bezeichnung, pfad in client_stile %} {% for stil, bezeichnung, pfad in client_stile %}
{% set konfig = client_konfig.get(stil) %} {% set konfig = client_konfig.get(stil) %}
<details class="card__body pt-0"> <details class="card__body pt-0">
<summary class="row row--tight" style="cursor:pointer"> <summary class="row row--tight">
<strong class="fs-sm">{{ bezeichnung }}</strong> <strong class="fs-sm">{{ bezeichnung }}</strong>
<span class="fs-xs faint mono">{{ pfad }}</span> <span class="fs-xs faint mono">{{ pfad }}</span>
</summary> </summary>
@@ -417,6 +471,23 @@
</div> </div>
<pre class="code-block code-block--wrap" id="konfig-{{ stil }}">{{ konfig.inhalt }}</pre> <pre class="code-block code-block--wrap" id="konfig-{{ stil }}">{{ konfig.inhalt }}</pre>
<div class="fs-xs faint">Danach: <code>{{ konfig.nachher }}</code></div> <div class="fs-xs faint">Danach: <code>{{ konfig.nachher }}</code></div>
{% if konfig.auslesen.einfach %}
{# Ohne diese Zeile kann niemand pruefen, ob es angekommen ist --
und ein RMM-Agent braucht genau sie. #}
<div class="fs-xs faint">
Auslesen auf dem Client:
<code class="break-all">{{ konfig.auslesen.einfach }}</code>
</div>
{% if konfig.auslesen.roh %}
<div class="fs-xs faint">
systemd legt fremde Optionen unter ihrer Nummer und hexkodiert ab – den
Klartext schreibt ein kleiner Übersetzer, den der SSH-Push mitinstalliert
(<code>/run/tesm-dhcp-options.env</code>, nach jeder Lease erneuert).
Direkt aus der Lease:
<code class="break-all">{{ konfig.auslesen.roh }}</code>
</div>
{% endif %}
{% endif %}
{% if current_user.has('servicing_command.execute') %} {% if current_user.has('servicing_command.execute') %}
<form method="post" action="{{ url_for('dhcp.push_client_config') }}"> <form method="post" action="{{ url_for('dhcp.push_client_config') }}">
@@ -629,12 +700,11 @@
{# -- Vorschau ---------------------------------------------------------- #} {# -- Vorschau ---------------------------------------------------------- #}
<details class="card" data-persist="dhcp-preview"> <details class="card" data-persist="dhcp-preview">
<summary class="card__header" style="cursor:pointer"> <summary class="card__header">
<div class="flex-1"> <div class="flex-1">
<div class="card__title">{{ icon('file-text', size=17) }} Erzeugte Kea-Konfiguration</div> <div class="card__title">{{ icon('file-text', size=17) }} Erzeugte Kea-Konfiguration</div>
<div class="card__sub">Vorschau &ndash; so würde die Datei geschrieben</div> <div class="card__sub">Vorschau &ndash; so würde die Datei geschrieben</div>
</div> </div>
{{ icon('chevron-down', size=16) }}
</summary> </summary>
<pre class="log-view">{{ preview }}</pre> <pre class="log-view">{{ preview }}</pre>
{% if current_user.has('dhcp_config.execute') %} {% if current_user.has('dhcp_config.execute') %}
+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.20" 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;
@@ -1131,6 +1131,43 @@
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-word; word-break: break-word;
} }
/* ---------------------------------------------------------------------- #
Aufklappbares
---------------------------------------------------------------------- #
Der native Marker eines <summary> verschwindet, sobald das Element ein
Flex-Container ist -- und genau das sind unsere Kartenkoepfe. Auf der
DHCP-Seite stand der Optionskatalog deshalb als Kasten da, dem man nicht
ansah, dass er sich oeffnen laesst.
Der Pfeil wird deshalb selbst gezeichnet: als Flex- bzw. Inline-Element,
das in beiden Faellen erscheint, in Akzentfarbe (das ist die Farbe, mit
der diese Oberflaeche "hier kannst du etwas tun" sagt) und mit einer
Drehung beim Oeffnen. Eine Stelle fuer alle Aufklapper -- Karten,
Vorschauen, Abschnitte. */
details > summary {
list-style: none;
cursor: pointer;
}
details > summary::-webkit-details-marker { display: none; }
details:not(.nav-tree) > summary::before {
content: "";
display: inline-block;
flex: 0 0 auto;
align-self: center;
width: 0;
height: 0;
margin-right: var(--sp-2);
border-left: 6px solid var(--accent);
border-top: 5px solid transparent;
border-bottom: 5px solid transparent;
transition: transform var(--dur) var(--ease);
}
details:not(.nav-tree)[open] > summary::before { transform: rotate(90deg); }
/* Der Navigationsbaum bringt seinen eigenen Winkel mit -- der bekommt
dieselbe Farbe, damit "aufklappbar" ueberall gleich aussieht. */
.nav-tree__chevron { color: var(--accent); }
.log-view mark { background: var(--warning-soft); color: var(--warning); border-radius: 3px; } .log-view mark { background: var(--warning-soft); color: var(--warning); border-radius: 3px; }
/* Stufen im laufenden Protokoll. Die Zuordnung der Farbrollen ist Absicht: /* Stufen im laufenden Protokoll. Die Zuordnung der Farbrollen ist Absicht:
@@ -73,12 +73,11 @@ blieb dadurch über Jahre bestehen und lieferte alle statischen Dateien als 404.
</section> </section>
<details class="card" data-persist="nginx-preview"> <details class="card" data-persist="nginx-preview">
<summary class="card__header" style="cursor:pointer"> <summary class="card__header">
<div class="flex-1"> <div class="flex-1">
<div class="card__title">{{ icon('file-text', size=17) }} Erzeugte nginx-Konfiguration</div> <div class="card__title">{{ icon('file-text', size=17) }} Erzeugte nginx-Konfiguration</div>
<div class="card__sub">Vorschau der Datei, die geschrieben würde</div> <div class="card__sub">Vorschau der Datei, die geschrieben würde</div>
</div> </div>
{{ icon('chevron-down', size=16) }}
</summary> </summary>
<pre class="log-view">{{ preview }}</pre> <pre class="log-view">{{ preview }}</pre>
</details> </details>