Compare commits

...
10 Commits
10 changed files with 1134 additions and 92 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.39" 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 = [
+212 -8
View File
@@ -11,6 +11,7 @@ einen Dienst starten.
from __future__ import annotations from __future__ import annotations
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
@@ -80,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")
} }
return render_template( options = Database.all(
"tesm/dhcp.html",
subnets=Database.all(conn, "SELECT * FROM dhcp_subnets ORDER BY sort_order, id"),
options=Database.all(
conn, conn,
"SELECT o.*, v.value, v.id AS value_id FROM dhcp_options o " "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 " "LEFT JOIN dhcp_option_values v ON v.option_id = o.id AND v.device_id IS NULL "
"ORDER BY o.code", "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(
"tesm/dhcp.html",
subnets=Database.all(conn, "SELECT * FROM dhcp_subnets ORDER BY sort_order, id"),
options=options,
katalog=katalog,
gesendet=gesendet,
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,
@@ -104,10 +155,31 @@ def overview():
preview=kea.config_to_text(config), preview=kea.config_to_text(config),
warnings=warnings, warnings=warnings,
lease_file=kea.LEASE_FILE, lease_file=kea.LEASE_FILE,
# Was ein Linux-Client braucht, damit die Optionen bei ihm ankommen --
# je Stil einmal, damit die Seite umschalten kann, ohne nachzuladen.
client_stile=kea.CLIENT_STILE,
client_konfig={
stil: kea.client_konfiguration(conn, stil) for stil, _, _ in kea.CLIENT_STILE
},
sysops_available=bool(sysops and sysops.available), 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
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@@ -141,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"))
@@ -176,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"))
@@ -340,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"))
@@ -354,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"))
@@ -601,12 +707,110 @@ 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"))
@bp.post("/client-config/push")
# Verteilt wird ueber dieselbe Maschinerie wie ein Massenbefehl: Zugangsdaten
# aus dem Datensatz, ein Auftrag je Geraet, Ausgabe zum Mitlesen. Deshalb
# haengt es auch an demselben Recht -- wer Befehle auf Clients ausfuehren darf,
# darf auch diese Datei schreiben; es ist dieselbe Wirkung.
@permission_required("servicing_command.execute")
def push_client_config():
"""Schreibt die Client-Konfiguration per SSH auf ausgewaehlte Clients.
Der Inhalt ist fuer alle gleich -- angefordert wird, was der Server sendet,
und das haengt nicht am einzelnen Geraet. Die **Werte** kommen weiterhin
vom Server; hier geht es nur darum, dass der Client danach fragt.
"""
from ..services import maintenance as wartung
extension = core()
conn = db()
stil = (request.form.get("stil") or "").strip()
if stil not in dict((s[0], s) for s in kea.CLIENT_STILE):
flash("Unbekannter Client-Stil.", "danger")
return redirect(url_for("dhcp.overview"))
konfig = kea.client_konfiguration(conn, stil)
if not konfig["optionen"]:
flash(
"Es gibt nichts zu verteilen: dieser Server sendet derzeit keine Option, "
"die ein Client eigens anfordern müsste.",
"info",
)
return redirect(url_for("dhcp.overview"))
device_ids = [int(v) for v in request.form.getlist("device_ids") if v.isdigit()]
if not device_ids:
flash("Es wurde kein Gerät ausgewählt.", "warning")
return redirect(url_for("dhcp.overview"))
# Ein Skript, ein Aufruf. Der frühere Weg schrieb auf einen festen Pfad --
# bei networkd auf einen, den kein Dienst liest -- und überschrieb bei
# dhcpcd/dhclient die Datei des Clients. Das Skript sucht sich das Ziel
# selbst und ersetzt nur seinen eigenen Block; es geht als **Argument**
# durch, damit stdin für das sudo-Passwort frei bleibt.
befehl = "{sudo} sh -c " + shlex.quote(konfig["skript"])
gestartet = 0
for device_id in device_ids:
device = inventory.get_device(conn, device_id)
if device is None or not device["credential_id"]:
continue
try:
username, password = inventory.credential_secret(
conn, extension.keystore, int(device["credential_id"])
)
except inventory.ValidationError as exc:
flash(f"{device['name']}: {exc}", "danger")
continue
with transaction():
auftrag = wartung.create_job(
conn,
kind=wartung.JOB_KIND_COMMAND,
subject=str(device["name"]),
actor=current_user.username,
message="Wartet auf Start",
)
wartung.start_background(
wartung.run_command,
database=extension.database,
job_id=auftrag.id,
device=dict(device),
username=username,
password=password,
befehl=befehl,
timeout=int(extension.settings.get(conn, "maintenance_command_timeout") or 900),
)
gestartet += 1
if gestartet:
_audit(
"dhcp.client_config_pushed",
f"{gestartet} Client(s)",
f"Stil {stil}, Datei {konfig['pfad']}",
severity="notice",
)
flash(
f"{gestartet} Auftrag/Aufträge gestartet. Der Verlauf und die Ausgabe stehen "
"unter Wartung.",
"success",
)
else:
flash("Kein Gerät mit hinterlegten Zugangsdaten ausgewählt.", "warning")
return redirect(url_for("dhcp.overview"))
@bp.post("/write") @bp.post("/write")
@permission_required("dhcp_config.execute") @permission_required("dhcp_config.execute")
def write_config(): def write_config():
+554 -9
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,16 +484,41 @@ 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,9 +746,461 @@ 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
#: Client-Konfiguration zu schreiben waere Ballast -- und wer Ballast liest,
#: liest irgendwann gar nicht mehr.
IMMER_ANGEFRAGT = frozenset({1, 3, 6, 12, 15, 28, 42, 51, 54, 58, 59, 119, 121})
#: Die Stile, in denen eine Client-Konfiguration ausgegeben wird.
#:
#: Drei, weil es auf den Zielsystemen drei gibt, und die Wahl nicht am
#: Geschmack haengt, sondern daran, was die Distribution mitbringt:
#:
#: * **networkd** -- Ubuntu-Server 22.04 bis 26.04 (netplan erzeugt die
#: .network-Dateien), auch auf Debian verbreitet. Angeforderte Fremdoptionen
#: landen als ``OPTION_<code>`` in der Lease unter /run/systemd/netif/leases.
#: * **dhcpcd** -- Debian 12/13, Raspberry Pi OS, ab Ubuntu 24.04 verfuegbar.
#: * **dhclient** -- Debian 11 bis 13 und Ubuntu 22.04, dort wo isc-dhcp-client
#: noch installiert ist.
CLIENT_STILE: tuple[tuple[str, str, str], ...] = (
# Der Zusatz gehoert zu der .network-Datei, die fuer die Schnittstelle
# wirklich gilt -- bei netplan heisst die 10-netplan-<if>.network und liegt
# unter /run. Ein Verzeichnis "10-tesm.network.d" liest niemand: es gibt
# keine 10-tesm.network. Den Namen ermittelt das Skript auf dem Client.
("networkd", "systemd-networkd", "/etc/systemd/network/<aktive>.network.d/50-tesm.conf"),
("dhcpcd", "dhcpcd", "/etc/dhcpcd.conf"),
("dhclient", "dhclient (isc-dhcp-client)", "/etc/dhcp/dhclient.conf"),
)
#: Wie ein Typ aus dem Optionskatalog in den jeweiligen Clients heisst.
_DHCPCD_TYPEN = {
"string": "string",
"ipv4-address": "ipaddress",
"uint8": "byte",
"uint16": "uint16",
"uint32": "uint32",
"boolean": "byte",
}
_DHCLIENT_TYPEN = {
"string": "text",
"ipv4-address": "ip-address",
"uint8": "unsigned integer 8",
"uint16": "unsigned integer 16",
"uint32": "unsigned integer 32",
"boolean": "boolean",
}
def gesendete_optionen(conn: sqlite3.Connection) -> list[dict[str, Any]]:
"""Die Optionen, die dieser Server tatsaechlich ausliefert.
Gemeint ist: wofuer irgendwo ein Wert gesetzt ist -- global oder an einem
einzelnen Geraet. Was im Katalog steht, aber leer ist, wird nicht gesendet
und muss vom Client auch nicht angefordert werden.
"""
return Database.all(
conn,
"""
SELECT DISTINCT o.code, o.name, o.type, o.description, o.is_standard
FROM dhcp_options o
JOIN dhcp_option_values v ON v.option_id = o.id
WHERE TRIM(COALESCE(v.value, '')) <> ''
ORDER BY o.code
""",
)
#: Traegt den Zusatz dort ein, wo networkd ihn auch liest.
NETWORKD_SKRIPT = """set -e
schnittstelle=$(ip -o -4 route show default 2>/dev/null | awk '{print $5; exit}')
[ -n "$schnittstelle" ] || { echo "Keine Standardroute -- Schnittstelle unbekannt." >&2; exit 1; }
datei=$(COLUMNS=400 SYSTEMD_COLORS=0 networkctl status "$schnittstelle" 2>/dev/null | sed -n 's/^ *Network File: *//p' | head -n1)
case "${datei:-}" in
""|n/a|*not*) datei=$(grep -ls "Name=$schnittstelle" /run/systemd/network/*.network /etc/systemd/network/*.network 2>/dev/null | head -n1) ;;
esac
[ -n "${datei:-}" ] || { echo "Keine .network-Datei fuer $schnittstelle gefunden." >&2; exit 1; }
ziel="/etc/systemd/network/$(basename "$datei").d/50-tesm.conf"
mkdir -p "$(dirname "$ziel")"
printf %s '@FRACHT@' | base64 -d > "$ziel"
chmod 0644 "$ziel"
echo "geschrieben: $ziel"
# Der Uebersetzer: systemd legt fremde Optionen roh und unter ihrer Nummer ab
# ("OPTION_225=687474..."), weil im DHCP-Paket weder Name noch Typ steht.
# Beides weiss nur dieser Server -- also schreibt er die Zuordnung mit.
mkdir -p /usr/local/lib/tesm
printf %s '@UEBERSETZER@' | base64 -d > /usr/local/lib/tesm/dhcp-options
chmod 0755 /usr/local/lib/tesm/dhcp-options
printf %s '@DIENST@' | base64 -d > /etc/systemd/system/tesm-dhcp-options.service
printf %s '@PFADEINHEIT@' | base64 -d > /etc/systemd/system/tesm-dhcp-options.path
chmod 0644 /etc/systemd/system/tesm-dhcp-options.service /etc/systemd/system/tesm-dhcp-options.path
systemctl daemon-reload
systemctl enable --now tesm-dhcp-options.path >/dev/null 2>&1 || true
systemctl enable tesm-dhcp-options.service >/dev/null 2>&1 || true
networkctl reload
networkctl reconfigure "$schnittstelle"
# Der Pfadwaechter loest bei der naechsten Lease aus; einmal von Hand, damit
# die Datei sofort dasteht.
sleep 2
systemctl start tesm-dhcp-options.service || true
echo "Lease: /run/systemd/netif/leases/$(cat /sys/class/net/"$schnittstelle"/ifindex)"
echo "Klartext: /run/tesm-dhcp-options.env"
cat /run/tesm-dhcp-options.env 2>/dev/null || true
"""
#: Uebersetzt die rohen Optionen der Lease in "name=wert".
#:
#: Warum es das braucht: in einem DHCP-Paket stehen nur Nummer, Laenge und
#: Bytes. Dass 225 "url" heisst und eine Zeichenkette ist, steht allein im
#: Katalog dieses Servers -- systemd kann es nicht wissen und legt deshalb
#: Hex unter der Nummer ab. Diese Zuordnung reist mit.
UEBERSETZER = r"""#!/bin/sh
# Von TESM erzeugt. Schreibt die eigenen DHCP-Optionen im Klartext nach
# /run/tesm-dhcp-options.env -- eine Zeile "name=wert" je Option.
#
# Uebersetzt wird **alles**, was in der Lease steht, nicht eine feste Liste:
# Namen und Typen kommen aus dem Verzeichnis, das der Server als Option
# @KATALOG@ mitsendet. Eine neue Option ist damit sofort unter ihrem Namen
# lesbar, ohne dass hier etwas geaendert werden muesste. Fehlt das
# Verzeichnis, gilt die Zuordnung vom Tag des Verteilens; fehlt auch die,
# heisst die Option nach ihrer Nummer.
set -e
ziel=/run/tesm-dhcp-options.env
ersatz="@PAARE@"
schnittstelle=$(ip -o -4 route show default 2>/dev/null | awk '{print $5; exit}')
[ -n "$schnittstelle" ] || exit 0
lease=/run/systemd/netif/leases/$(cat /sys/class/net/"$schnittstelle"/ifindex 2>/dev/null)
[ -f "$lease" ] || exit 0
text() { /usr/bin/printf "$(echo "$1" | sed 's/../@BS@x&/g')"; }
entziffere() {
# $1 = Hexfolge, $2 = Typ. Ohne den Typ waere jede Adresse Buchstabensalat:
# eine IPv4-Adresse reist als vier rohe Bytes, nicht als "192.168.0.1".
case "$2" in
ipv4-address)
set -- $(echo "$1" | sed 's/../& /g')
[ $# -ge 4 ] || return 0
printf '%d.%d.%d.%d' "0x$1" "0x$2" "0x$3" "0x$4"
;;
uint8|uint16|uint32)
printf '%d' "0x$1"
;;
boolean)
case "$1" in 00) printf 'nein' ;; *) printf 'ja' ;; esac
;;
*)
text "$1"
;;
esac
}
nachschlagen() {
# Erst im mitgesendeten Verzeichnis, dann in der Zuordnung vom Verteilen.
for eintrag in $verzeichnis $ersatz; do
case "$eintrag" in
"$1":*)
rest=${eintrag#*:}
echo "${rest%%:*} ${rest#*:}"
return 0
;;
esac
done
echo "option_$1 string"
}
verzeichnis=$(text "$(sed -n 's/^OPTION_@KATALOG@=//p' "$lease" | head -n1)")
zwischen=$(mktemp)
sed -n 's/^OPTION_\([0-9]*\)=\(.*\)$/\1 \2/p' "$lease" | while read -r code hex; do
[ "$code" = "@KATALOG@" ] && continue
angabe=$(nachschlagen "$code")
name=${angabe%% *}
typ=${angabe#* }
wert=$(entziffere "$hex" "$typ")
echo "$name=$wert" >> "$zwischen"
done
mv -f "$zwischen" "$ziel"
chmod 0644 "$ziel"
"""
#: Einmal beim Start und nach jeder Lease.
DIENST_EINHEIT = """[Unit]
Description=TESM -- DHCP-Optionen im Klartext
After=systemd-networkd.service
[Service]
Type=oneshot
ExecStart=/usr/local/lib/tesm/dhcp-options
RemainAfterExit=no
[Install]
WantedBy=multi-user.target
"""
PFAD_EINHEIT = """[Unit]
Description=TESM -- wacht ueber die DHCP-Lease
[Path]
PathModified=/run/systemd/netif/leases
Unit=tesm-dhcp-options.service
[Install]
WantedBy=multi-user.target
"""
#: Ersetzt den eigenen Block in einer Datei, die dem Client gehoert.
BLOCK_SKRIPT = """set -e
ziel=@ZIEL@
mkdir -p "$(dirname "$ziel")"
[ -f "$ziel" ] || : > "$ziel"
zwischen=$(mktemp)
sed '/^# >>> TESM/,/^# <<< TESM/d' "$ziel" > "$zwischen"
printf %s '@FRACHT@' | base64 -d >> "$zwischen"
cat "$zwischen" > "$ziel"
rm -f "$zwischen"
echo "ergaenzt: $ziel"
@DIENST@
"""
def 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]:
"""Was ein Linux-Client braucht, damit die Optionen bei ihm ankommen.
**Warum es das braucht.** Ein DHCP-Client bekommt nur, wonach er fragt.
Standardoptionen (Gateway, DNS, Domain) fragt er von sich aus; alles
darueber hinaus -- und dazu gehoert jede eigene Option -- muss er
ausdruecklich anfordern. Ohne diese Zeilen sendet der Server brav und der
Client wirft es weg, ohne dass irgendwo etwas danebengeht.
Rueckgabe: ``{"pfad", "inhalt", "nachher", "skript", "optionen"}``.
``skript`` ist die Fassung zum Ausfuehren -- sie sucht sich das Ziel auf
dem Client selbst und traegt den Inhalt ein, ohne Vorhandenes zu
verlieren.
"""
optionen = [
eintrag
for eintrag in gesendete_optionen(conn)
if int(eintrag["code"]) not in IMMER_ANGEFRAGT
]
codes = [int(eintrag["code"]) for eintrag in optionen]
pfad = dict((s[0], s[2]) for s in CLIENT_STILE).get(stil, "")
if stil == "networkd":
zeilen = [
"# Von TESM erzeugt. Fordert die Optionen an, die dieser Server sendet --",
"# ohne diese Zeilen verwirft der Client sie stillschweigend.",
"#",
"# Diese Datei ist ein Zusatz zu der .network-Datei, die fuer die",
"# Schnittstelle gilt. Wie sie heisst, sagt 'networkctl status <if>';",
"# bei netplan ist es /run/systemd/network/10-netplan-<if>.network, der",
"# Zusatz gehoert dann nach",
"# /etc/systemd/network/10-netplan-<if>.network.d/50-tesm.conf.",
"#",
"# Die Werte stehen danach in /run/systemd/netif/leases/<ifindex>",
"# als OPTION_<code>= -- hexkodiert, weil systemd fremde Optionen nicht",
"# deutet. Den Klartext schreibt der mitgelieferte Uebersetzer nach",
"# /run/tesm-dhcp-options.env.",
"#",
"# Angefordert wird der ganze Bereich 224-254, damit eine spaeter",
"# hinzugefuegte Option ohne Zutun ankommt. Eigene Optionen ausserhalb",
"# dieses Bereichs hebt systemd-networkd nicht auf.",
"[DHCPv4]",
]
# Angefordert wird der **ganze** eigene Bereich, nicht nur das, was
# heute gesendet wird: sonst muesste man nach jeder neuen Option zu
# jedem Client. Ein Client bekommt trotzdem nur, was der Server hat.
angefragt = sorted(set(PRIVATBEREICH) | set(codes))
zeilen.append("RequestOptions=" + " ".join(str(code) for code in angefragt))
nachher = "networkctl reload && networkctl reconfigure <Schnittstelle>"
elif stil == "dhcpcd":
zeilen = [
"# >>> TESM -- nicht von Hand aendern, wird beim naechsten Mal ersetzt",
"# 'define' macht die Option bekannt, 'option' fordert sie an.",
"# Danach steht der Wert in der Lease (/var/lib/dhcpcd/<if>.lease)",
"# und laesst sich mit 'dhcpcd -U <if>' als Variable auslesen.",
]
for eintrag in optionen:
typ = _DHCPCD_TYPEN.get(str(eintrag["type"]), "string")
zeilen.append(f"define {eintrag['code']} {typ} {eintrag['name']}")
for eintrag in optionen:
zeilen.append(f"option {eintrag['name']}")
zeilen.append("# <<< TESM")
nachher = "systemctl restart dhcpcd"
else:
zeilen = [
"# >>> TESM -- nicht von Hand aendern, wird beim naechsten Mal ersetzt",
"# Die Werte stehen danach in /var/lib/dhcp/dhclient.leases,",
"# und in Hooks unter $new_<name>.",
]
for eintrag in optionen:
typ = _DHCLIENT_TYPEN.get(str(eintrag["type"]), "text")
zeilen.append(f"option {eintrag['name']} code {eintrag['code']} = {typ};")
if optionen:
zeilen.append(
"also request " + ", ".join(str(eintrag["name"]) for eintrag in optionen) + ";"
)
zeilen.append("# <<< TESM")
nachher = "systemctl restart networking || dhclient -r && dhclient"
inhalt = chr(10).join(zeilen) + chr(10)
return {
"pfad": pfad,
"inhalt": inhalt,
"nachher": nachher,
"skript": _uebernahme_skript(stil, inhalt, optionen),
"auslesen": _auslesen(stil, optionen),
"optionen": optionen,
}
def _auslesen(stil: str, benannt: list[dict[str, Any]]) -> dict[str, str]:
"""Wie der Wert auf dem Client wieder herauskommt.
Gehoert dazu, weil "angekommen" sonst niemand pruefen kann -- und weil ein
RMM-Agent genau diese Zeile braucht.
Zwei Wege bei systemd-networkd, und das hat einen Grund: in der Lease
steht der Wert **hexkodiert unter seiner Nummer**. Im DHCP-Paket reisen
nur Code, Laenge und Bytes; dass 225 "url" heisst und Text enthaelt, weiss
allein der Katalog dieses Servers. Deshalb legt der SSH-Push einen kleinen
Uebersetzer mit, der nach jeder Lease ``/run/tesm-dhcp-options.env``
schreibt -- "name=wert", lesbar. ``roh`` bleibt fuer den Fall, dass nur
die Datei von Hand kopiert wurde.
"""
if not benannt:
return {}
code = int(benannt[0]["code"])
name = str(benannt[0]["name"])
if stil == "networkd":
return {
"einfach": f"grep '^{name}=' /run/tesm-dhcp-options.env",
"roh": (
"lease=/run/systemd/netif/leases/$(cat /sys/class/net/$IF/ifindex); "
f"/usr/bin/printf \"$(sed -n 's/^OPTION_{code}=//p' \"$lease\" "
"| sed 's/../@BS@x&/g')\""
).replace("@BS@", "\\" * 2),
}
if stil == "dhcpcd":
# dhcpcd kennt den Namen, weil 'define' ihn ihm beigebracht hat.
return {"einfach": f"dhcpcd -U $IF | grep -i '^{name}='", "roh": ""}
return {
"einfach": f"grep -i 'option {name}' /var/lib/dhcp/dhclient.leases | tail -n1",
"roh": "",
}
def _b64(text: str) -> str:
import base64
return base64.b64encode(text.encode("utf-8")).decode("ascii")
def _uebernahme_skript(stil: str, inhalt: str, benannt: list[dict[str, Any]] | None = None) -> str:
"""Das Skript, das die Konfiguration auf dem Client eintraegt.
Zwei Dinge macht es anders als ein blosses ``tee``, und beide sind mit
Schaden bezahlt worden:
* Bei ``networkd`` **sucht es das Ziel auf dem Client**. Der Name des
Zusatzverzeichnisses muss zur aktiven .network-Datei passen; ein fester
Pfad legt eine Datei an, die kein Dienst je liest -- sie stand da, und
die Option fehlte trotzdem in der Lease.
* Bei ``dhcpcd`` und ``dhclient`` gehoeren die Zeilen in eine Datei, die
dem Client gehoert. Sie zu ueberschreiben haette seine uebrige
Konfiguration geloescht. Stattdessen wird nur der eigene Block zwischen
den Marken ersetzt -- beliebig oft wiederholbar.
Der Inhalt reist base64-kodiert und als **Argument**, nicht ueber stdin:
dort steht bei einem Client mit Passwort das Kennwort fuer ``sudo``.
"""
fracht = _b64(inhalt)
benannt = benannt or []
if stil == "networkd":
paare = " ".join(
f"{eintrag['code']}:{eintrag['name']}:{eintrag['type']}" for eintrag in benannt
)
uebersetzer = (
UEBERSETZER.replace("@PAARE@", paare)
.replace("@KATALOG@", str(KATALOG_CODE))
.replace("@BS@", "\\\\") # sed gibt aus zwei Zeichen eines aus
)
return (
NETWORKD_SKRIPT.replace("@FRACHT@", fracht)
.replace("@UEBERSETZER@", _b64(uebersetzer))
.replace("@DIENST@", _b64(DIENST_EINHEIT))
.replace("@PFADEINHEIT@", _b64(PFAD_EINHEIT))
)
ziel = "/etc/dhcpcd.conf" if stil == "dhcpcd" else "/etc/dhcp/dhclient.conf"
dienst = (
"systemctl restart dhcpcd"
if stil == "dhcpcd"
else "systemctl restart networking 2>/dev/null || { dhclient -r || true; dhclient; }"
)
return (
BLOCK_SKRIPT.replace("@ZIEL@", ziel)
.replace("@FRACHT@", fracht)
.replace("@DIENST@", dienst)
)
def service_state(sysops: SysOps) -> dict[str, str]: def service_state(sysops: SysOps) -> dict[str, str]:
state = sysops.service_state(SERVICE_UNIT) state = sysops.service_state(SERVICE_UNIT)
import shutil import shutil
import subprocess
state["binary"] = "vorhanden" if shutil.which("kea-dhcp4") else "fehlt" pfad = shutil.which("kea-dhcp4")
state["binary"] = "vorhanden" if pfad else "fehlt"
# Die Fassung gehoert auf die Seite: zwischen Kea 2.4 (Ubuntu 24.04) und
# 3.0 (26.04) liegen Aenderungen, und wer eine Konfiguration beurteilt,
# will nicht erst auf der Kommandozeile nachsehen muessen, wogegen.
state["version"] = ""
if pfad:
try:
fertig = subprocess.run(
[pfad, "-V"], capture_output=True, text=True, timeout=5, check=False
)
# ``kea-dhcp4 -V`` antwortet mit "3.0.3 (3.0.3 (tarball))" -- auf
# einer Kachel steht davon die Nummer, nicht die Herkunft.
state["version"] = (fertig.stdout or "").strip().split()[0].strip()
except (OSError, subprocess.SubprocessError, IndexError):
state["version"] = ""
return state 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">
+312 -68
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>
@@ -39,6 +67,10 @@
</div> </div>
</div> </div>
{# Dienst und Grundeinstellungen sind beides Kleinkram, den man selten
anfasst -- nebeneinander statt untereinander, damit die Seite mit dem
anfaengt, worum es geht: den Subnetzen und Optionen. #}
<div class="grid grid--2">
{% if current_user.has('dhcp_service.execute') %} {% if current_user.has('dhcp_service.execute') %}
<section class="card"> <section class="card">
<div class="card__header"> <div class="card__header">
@@ -66,6 +98,31 @@
</section> </section>
{% endif %} {% endif %}
{% if current_user.has('dhcp_subnets.write') %}
<section class="card">
<div class="card__header">
<div class="flex-1"><div class="card__title">{{ icon('sliders', size=17) }} Globale Einstellungen</div></div>
</div>
<form method="post" action="{{ url_for('dhcp.save_settings') }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="card__body">
<div class="form-grid">
{{ field('dhcp_domain', 'Domainname', value=values.dhcp_domain) }}
{{ field('dhcp_lease_default', 'Lease-Dauer (s)', value=values.dhcp_lease_default, type='number') }}
{{ field('dhcp_lease_max', 'Maximale Lease-Dauer (s)', value=values.dhcp_lease_max, type='number') }}
{{ field('dhcp_output_path', 'Zielpfad der Konfiguration', value=values.dhcp_output_path,
extra_class='field--full') }}
</div>
</div>
<div class="card__footer">
<button class="btn btn--primary" type="submit">Speichern und schreiben</button>
</div>
</form>
</section>
{% endif %}
</div>
{# -- Subnetze ---------------------------------------------------------- #} {# -- Subnetze ---------------------------------------------------------- #}
<section class="card"> <section class="card">
<div class="card__header"> <div class="card__header">
@@ -134,69 +191,32 @@
{% endif %} {% endif %}
</section> </section>
{# -- Globale Einstellungen --------------------------------------------- #}
{% if current_user.has('dhcp_subnets.write') %}
<section class="card">
<div class="card__header">
<div class="flex-1"><div class="card__title">{{ icon('sliders', size=17) }} Globale Einstellungen</div></div>
</div>
<form method="post" action="{{ url_for('dhcp.save_settings') }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="card__body">
<div class="form-grid">
{{ field('dhcp_domain', 'Domainname', value=values.dhcp_domain) }}
{{ field('dhcp_lease_default', 'Lease-Dauer (s)', value=values.dhcp_lease_default, type='number') }}
{{ field('dhcp_lease_max', 'Maximale Lease-Dauer (s)', value=values.dhcp_lease_max, type='number') }}
{{ field('dhcp_output_path', 'Zielpfad der Konfiguration', value=values.dhcp_output_path,
extra_class='field--full') }}
</div>
</div>
<div class="card__footer">
<button class="btn btn--primary" type="submit">Speichern und schreiben</button>
</div>
</form>
</section>
{% endif %}
{# -- Optionen ---------------------------------------------------------- #} {# -- Optionen ---------------------------------------------------------- #}
<section class="card">
<div class="card__header"> {# Zwei Makros, weil dieselbe Zeile zweimal vorkommt: einmal in der kurzen
<div class="flex-1"> Liste dessen, was wirklich gesendet wird, und einmal im vollen Katalog. #}
<div class="card__title">{{ icon('puzzle', size=17) }} DHCP-Optionen</div> {% macro optionswert(option) %}
<div class="card__sub">
{{ options | length }} Optionen &middot; Standardoptionen sind nicht löschbar, ihr Wert
lässt sich leeren
</div>
</div>
{{ search_box('options-table', 'Option suchen ...') }}
</div>
<div class="table-wrap" style="max-height:420px;overflow:auto">
<table class="table table--compact" id="options-table">
<thead><tr><th class="numeric">Code</th><th>Name</th><th>Typ</th><th>Wert</th><th class="actions"></th></tr></thead>
<tbody>
{% for option in options %}
<tr data-filter-text="{{ option.code }} {{ option.name }} {{ option.description }}">
<td class="numeric fs-sm">{{ option.code }}</td>
<td class="fs-sm">
<div class="mono">{{ option.name }}</div>
<div class="fs-xs faint">{{ option.description }}</div>
</td>
<td class="fs-xs mono">{{ option.type }}</td>
<td>
{% if current_user.has('dhcp_options.write') %} {% if current_user.has('dhcp_options.write') %}
<form method="post" action="{{ url_for('dhcp.set_option_value') }}" class="row row--tight" <form method="post" action="{{ url_for('dhcp.set_option_value') }}"
data-no-guard> class="row row--tight" style="flex-wrap:nowrap" data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}"> <input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<input type="hidden" name="option_id" value="{{ option.id }}"> <input type="hidden" name="option_id" value="{{ option.id }}">
<input class="input input--mono" type="text" name="value" value="{{ option.value or '' }}" {# min-width:0 und flex:1 statt einer festen Mindestbreite: sonst passte
style="min-width:180px" placeholder="leer = nicht senden"> das Feld nicht in die Spalte, die Zeile brach um, und der Haken stand
<button class="btn btn--sm" type="submit">{{ icon('check', size=14) }}</button> allein darunter -- es sah aus wie ein Bedienfehler der Seite. #}
<input class="input input--mono" type="text" name="value"
value="{{ option.value or '' }}" style="min-width:0;flex:1"
placeholder="leer = nicht senden">
<button class="btn btn--sm" type="submit" title="Wert übernehmen">
{{ icon('check', size=14) }}
</button>
</form> </form>
{% else %} {% else %}
<span class="mono fs-sm">{{ option.value or '&ndash;' | safe }}</span> <span class="mono fs-sm">{{ option.value or '&ndash;' | safe }}</span>
{% endif %} {% endif %}
</td> {% endmacro %}
<td class="actions">
{% macro optionsloeschen(option) %}
{% if current_user.has('dhcp_options.write') and not option.is_standard %} {% if current_user.has('dhcp_options.write') and not option.is_standard %}
<form method="post" action="{{ url_for('dhcp.delete_custom_option', option_id=option.id) }}" <form method="post" action="{{ url_for('dhcp.delete_custom_option', option_id=option.id) }}"
data-no-guard> data-no-guard>
@@ -204,21 +224,137 @@
<button class="btn btn--sm btn--ghost danger-text" type="submit" <button class="btn btn--sm btn--ghost danger-text" type="submit"
data-confirm="Eigene Option {{ option.name }} löschen?" data-confirm="Eigene Option {{ option.name }} löschen?"
data-confirm-title="Option löschen" data-confirm-ok="Löschen"> data-confirm-title="Option löschen" data-confirm-ok="Löschen">
{{ icon('trash', size=14) }}</button> {{ icon('trash', size=14) }}
</button>
</form> </form>
{% endif %} {% endif %}
{% endmacro %}
{# "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">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('puzzle', size=17) }} Gesendete Optionen</div>
<div class="card__sub">
{% if gesendet %}
<strong>{{ gesendet | length }}</strong> von {{ options | length }} Optionen tragen
einen Wert. Was leer ist, wird nicht gesendet.
{% else %}
Derzeit trägt keine Option einen Wert – es wird also nichts über die
Grundangaben hinaus gesendet.
{% endif %}
</div>
</div>
</div>
{% if gesendet %}
<div class="table-wrap">
<table class="table table--compact">
<thead><tr>
<th class="numeric">Code</th><th>Name</th><th>Typ</th>
<th style="width:42%">Wert</th><th class="actions"></th>
</tr></thead>
<tbody>
{% for option in gesendet %}
<tr>
<td class="numeric fs-sm">{{ option.code }}</td>
<td class="fs-sm">
<div class="mono">{{ option.name }}</div>
<div class="fs-xs faint">{{ option.description }}</div>
</td> </td>
<td class="fs-xs mono">{{ option.type }}</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>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div> </div>
{% if current_user.has('dhcp_options.write') %} {% else %}
{{ empty_state('Nichts gesetzt', 'Tragen Sie unten im Katalog einen Wert ein.', 'puzzle') }}
{% endif %}
</section>
{# Der Katalog hat 57 Zeilen. Er stand vorher ganz oben, in einem eigenen
Rollbereich innerhalb der scrollenden Seite -- und das Gesuchte ging darin
unter. Jetzt liegt er hinter einem Klick, mit eigener Suche. #}
<details class="card" data-persist="dhcp-katalog">
<summary class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('list', size=17) }} Optionskatalog</div>
<div class="card__sub">
Alle {{ katalog | length }} bekannten Optionen. Standardoptionen sind nicht löschbar,
ihr Wert lässt sich leeren.
</div>
</div>
</summary>
<div class="card__body pt-0">{{ search_box('options-table', 'Option suchen ...') }}</div>
<div class="table-wrap">
<table class="table table--compact" id="options-table">
<thead><tr>
<th class="numeric">Code</th><th>Name</th><th>Typ</th>
<th style="width:42%">Wert</th><th class="actions"></th>
</tr></thead>
<tbody>
{% for option in katalog %}
<tr data-filter-text="{{ option.code }} {{ option.name }} {{ option.description }}">
<td class="numeric fs-sm">{{ option.code }}</td>
<td class="fs-sm">
<div class="mono">{{ option.name }}</div>
<div class="fs-xs faint">{{ option.description }}</div>
</td>
<td class="fs-xs mono">{{ option.type }}</td>
{% if option.reserviert %}
{# 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>
{% endfor %}
</tbody>
</table>
</div>
</details>
{% if current_user.has('dhcp_options.write') %}
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('plus', size=17) }} Eigene Option anlegen</div>
<div class="card__sub">
Für alles jenseits des Katalogs – etwa eine URL, die der Client beim Start öffnet.
Code <strong>224–254</strong>: nur diesen Bereich sieht DHCP für eigene Optionen vor,
und nur daraus hebt ein Linux-Client mit systemd-networkd sie überhaupt auf.
</div>
</div>
</div>
<form method="post" action="{{ url_for('dhcp.add_custom_option') }}"> <form method="post" action="{{ url_for('dhcp.add_custom_option') }}">
<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) }} {{ 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>
@@ -231,13 +367,15 @@
</div> </div>
<div class="card__footer"><button class="btn" type="submit">Eigene Option anlegen</button></div> <div class="card__footer"><button class="btn" type="submit">Eigene Option anlegen</button></div>
</form> </form>
</section>
{# Der Wert einer Option gilt sonst für alle. Genau dafür gibt es {# Der Wert einer Option gilt sonst für alle. Genau dafür gibt es
``dhcp_option_values.device_id``: ein eigener Wert je Gerät, der in ``dhcp_option_values.device_id``: ein eigener Wert je Gerät, der in dessen
dessen Reservierung geschrieben wird -- etwa eine Terminal-URL, die je Reservierung geschrieben wird -- etwa eine Terminal-URL, die je Gerät
Gerät anders lautet. Auswertbar war das von Anfang an, eingeben liess anders lautet. Auswertbar war das von Anfang an, eingeben liess es sich
es sich nicht. #} nicht. #}
<div class="card__header" style="border-top:1px solid var(--border-subtle)"> <section class="card">
<div class="card__header">
<div class="flex-1"> <div class="flex-1">
<div class="card__title">{{ icon('sliders', size=17) }} Option nur für ein Gerät</div> <div class="card__title">{{ icon('sliders', size=17) }} Option nur für ein Gerät</div>
<div class="card__sub"> <div class="card__sub">
@@ -276,6 +414,110 @@
<button class="btn" type="submit">Für dieses Gerät speichern</button> <button class="btn" type="submit">Für dieses Gerät speichern</button>
</div> </div>
</form> </form>
</section>
{% endif %}
{# -- Was der Client dafür braucht -------------------------------------- #}
{#
Ein DHCP-Client bekommt nur, wonach er fragt. Gateway, DNS und Domain
fragt er von sich aus; alles darüber hinaus -- und dazu gehört jede eigene
Option -- muss er ausdrücklich anfordern. Ohne diese Zeilen sendet der
Server brav, und der Client wirft es weg, ohne dass irgendwo etwas
danebengeht. Genau deshalb steht das hier und nicht in einer Anleitung.
#}
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('terminal', size=17) }} Konfiguration für Linux-Clients</div>
<div class="card__sub">
Ein Client erhält nur die Optionen, die er anfordert. Diese Zeilen sorgen dafür –
erzeugt aus dem, was dieser Server tatsächlich sendet.
</div>
</div>
</div>
{% set beispiel = client_konfig.get('networkd') %}
{% if not beispiel or not beispiel.optionen %}
<div class="card__body">
{{ alert('Dieser Server sendet derzeit keine Option, die ein Client eigens anfordern
müsste. Sobald eine eigene Option einen Wert trägt, steht hier die passende
Konfiguration.', 'info') }}
</div>
{% else %}
<div class="card__body pt-0">
<p class="fs-sm faint">
Betrifft
{% for eintrag in beispiel.optionen %}
<code>{{ eintrag.code }} {{ eintrag.name }}</code>{% if not loop.last %}, {% endif %}
{% endfor %}.
Welcher Abschnitt gilt, entscheidet die Distribution des Clients.
</p>
</div>
{% for stil, bezeichnung, pfad in client_stile %}
{% set konfig = client_konfig.get(stil) %}
<details class="card__body pt-0">
<summary class="row row--tight">
<strong class="fs-sm">{{ bezeichnung }}</strong>
<span class="fs-xs faint mono">{{ pfad }}</span>
</summary>
<div class="stack stack--tight mt-2">
<div class="row row--tight">
<span class="fs-xs faint flex-1">Inhalt der Datei</span>
<button class="btn btn--sm btn--ghost" type="button" data-copy="#konfig-{{ stil }}"
data-copy-message="Konfiguration kopiert.">
{{ icon('copy', size=14) }} Kopieren
</button>
</div>
<pre class="code-block code-block--wrap" id="konfig-{{ stil }}">{{ konfig.inhalt }}</pre>
<div class="fs-xs faint">Danach: <code>{{ konfig.nachher }}</code></div>
{% if konfig.auslesen.einfach %}
{# Ohne diese Zeile kann niemand pruefen, ob es angekommen ist --
und ein RMM-Agent braucht genau sie. #}
<div class="fs-xs faint">
Auslesen auf dem Client:
<code class="break-all">{{ konfig.auslesen.einfach }}</code>
</div>
{% if konfig.auslesen.roh %}
<div class="fs-xs faint">
systemd legt fremde Optionen unter ihrer Nummer und hexkodiert ab – den
Klartext schreibt ein kleiner Übersetzer, den der SSH-Push mitinstalliert
(<code>/run/tesm-dhcp-options.env</code>, nach jeder Lease erneuert).
Direkt aus der Lease:
<code class="break-all">{{ konfig.auslesen.roh }}</code>
</div>
{% endif %}
{% endif %}
{% if current_user.has('servicing_command.execute') %}
<form method="post" action="{{ url_for('dhcp.push_client_config') }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<input type="hidden" name="stil" value="{{ stil }}">
<div class="divider"></div>
<div class="fs-xs faint mb-2">
Oder direkt schreiben lassen – über SSH, mit den hinterlegten Zugangsdaten.
Jeder Client bekommt einen eigenen Auftrag; die Ausgabe steht unter Wartung.
</div>
<div class="row row--tight" style="flex-wrap:wrap">
{% for reservation in reservations if reservation.device_id %}
<label class="check">
<input type="checkbox" name="device_ids" value="{{ reservation.device_id }}">
<span>{{ reservation.name or reservation.hostname }}</span>
</label>
{% endfor %}
</div>
<div class="mt-2">
<button class="btn btn--sm btn--primary" type="submit"
data-confirm="Die Konfiguration für {{ bezeichnung }} auf die ausgewählten Clients schreiben ({{ pfad }})? Der DHCP-Client wird dort anschließend neu gestartet."
data-confirm-title="Konfiguration verteilen" data-confirm-ok="Verteilen">
{{ icon('upload', size=15) }} An ausgewählte Clients senden
</button>
</div>
</form>
{% endif %}
</div>
</details>
{% endfor %}
{% endif %} {% endif %}
</section> </section>
@@ -432,7 +674,10 @@
<form method="post" action="{{ url_for('dhcp.adopt_lease') }}"> <form method="post" action="{{ url_for('dhcp.adopt_lease') }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}"> <input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<input type="hidden" name="mac" value="{{ lease.mac }}"> <input type="hidden" name="mac" value="{{ lease.mac }}">
<button class="btn btn--sm btn--ghost" type="submit" {# Kein Geisterknopf mehr: die Uebernahme ist die eine
Handlung in dieser Tabelle, und sie war als
"btn--ghost" kaum von der Zeile zu unterscheiden. #}
<button class="btn btn--sm btn--primary" type="submit"
data-confirm="{{ lease.hostname or lease.address }} als überwachten Client anlegen? Switch, Port und Zugangsdaten tragen Sie danach nach." data-confirm="{{ lease.hostname or lease.address }} als überwachten Client anlegen? Switch, Port und Zugangsdaten tragen Sie danach nach."
data-confirm-title="Als Client übernehmen" data-confirm-title="Als Client übernehmen"
data-confirm-ok="Übernehmen"> data-confirm-ok="Übernehmen">
@@ -455,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>