diff --git a/apps/tesm/pyproject.toml b/apps/tesm/pyproject.toml index ac6466a..8799ae6 100644 --- a/apps/tesm/pyproject.toml +++ b/apps/tesm/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "tesm" -version = "2.8.39" +version = "2.8.40" description = "TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten und Aruba-Switchen" requires-python = ">=3.11" dependencies = [ diff --git a/apps/tesm/src/tesm/blueprints/dhcp.py b/apps/tesm/src/tesm/blueprints/dhcp.py index 016ee11..2324adb 100644 --- a/apps/tesm/src/tesm/blueprints/dhcp.py +++ b/apps/tesm/src/tesm/blueprints/dhcp.py @@ -11,6 +11,7 @@ einen Dienst starten. from __future__ import annotations +import base64 from typing import Any from flask import Blueprint, abort, flash, redirect, render_template, request, url_for @@ -104,6 +105,12 @@ def overview(): preview=kea.config_to_text(config), warnings=warnings, lease_file=kea.LEASE_FILE, + # Was ein Linux-Client braucht, damit die Optionen bei ihm ankommen -- + # je Stil einmal, damit die Seite umschalten kann, ohne nachzuladen. + client_stile=kea.CLIENT_STILE, + client_konfig={ + stil: kea.client_konfiguration(conn, stil) for stil, _, _ in kea.CLIENT_STILE + }, sysops_available=bool(sysops and sysops.available), ) @@ -607,6 +614,102 @@ def _write_and_redirect(): return redirect(url_for("dhcp.overview")) +@bp.post("/client-config/push") +# Verteilt wird ueber dieselbe Maschinerie wie ein Massenbefehl: Zugangsdaten +# aus dem Datensatz, ein Auftrag je Geraet, Ausgabe zum Mitlesen. Deshalb +# haengt es auch an demselben Recht -- wer Befehle auf Clients ausfuehren darf, +# darf auch diese Datei schreiben; es ist dieselbe Wirkung. +@permission_required("servicing_command.execute") +def push_client_config(): + """Schreibt die Client-Konfiguration per SSH auf ausgewaehlte Clients. + + Der Inhalt ist fuer alle gleich -- angefordert wird, was der Server sendet, + und das haengt nicht am einzelnen Geraet. Die **Werte** kommen weiterhin + vom Server; hier geht es nur darum, dass der Client danach fragt. + """ + from ..services import maintenance as wartung + + extension = core() + conn = db() + stil = (request.form.get("stil") or "").strip() + if stil not in dict((s[0], s) for s in kea.CLIENT_STILE): + flash("Unbekannter Client-Stil.", "danger") + return redirect(url_for("dhcp.overview")) + + konfig = kea.client_konfiguration(conn, stil) + if not konfig["optionen"]: + flash( + "Es gibt nichts zu verteilen: dieser Server sendet derzeit keine Option, " + "die ein Client eigens anfordern müsste.", + "info", + ) + return redirect(url_for("dhcp.overview")) + + device_ids = [int(v) for v in request.form.getlist("device_ids") if v.isdigit()] + if not device_ids: + flash("Es wurde kein Gerät ausgewählt.", "warning") + return redirect(url_for("dhcp.overview")) + + # Ueber base64 statt ueber ein Here-Dokument: der Inhalt enthaelt + # Anfuehrungszeichen, Rauten und Zeilenumbrueche, und er geht durch SSH, + # eine Shell und moeglicherweise sudo. Was kodiert reist, kommt an. + inhalt = base64.b64encode(konfig["inhalt"].encode("utf-8")).decode("ascii") + verzeichnis = str(konfig["pfad"]).rsplit("/", 1)[0] + befehl = ( + f"{{sudo}} mkdir -p {verzeichnis} && " + f"echo {inhalt} | base64 -d | {{sudo}} tee {konfig['pfad']} >/dev/null && " + f"{{sudo}} sh -c '{konfig['nachher']}' || true" + ) + + gestartet = 0 + for device_id in device_ids: + device = inventory.get_device(conn, device_id) + if device is None or not device["credential_id"]: + continue + try: + username, password = inventory.credential_secret( + conn, extension.keystore, int(device["credential_id"]) + ) + except inventory.ValidationError as exc: + flash(f"{device['name']}: {exc}", "danger") + continue + with transaction(): + auftrag = wartung.create_job( + conn, + kind=wartung.JOB_KIND_COMMAND, + subject=str(device["name"]), + actor=current_user.username, + message="Wartet auf Start", + ) + wartung.start_background( + wartung.run_command, + database=extension.database, + job_id=auftrag.id, + device=dict(device), + username=username, + password=password, + befehl=befehl, + timeout=int(extension.settings.get(conn, "maintenance_command_timeout") or 900), + ) + gestartet += 1 + + if gestartet: + _audit( + "dhcp.client_config_pushed", + f"{gestartet} Client(s)", + f"Stil {stil}, Datei {konfig['pfad']}", + severity="notice", + ) + flash( + f"{gestartet} Auftrag/Aufträge gestartet. Der Verlauf und die Ausgabe stehen " + "unter Wartung.", + "success", + ) + else: + flash("Kein Gerät mit hinterlegten Zugangsdaten ausgewählt.", "warning") + return redirect(url_for("dhcp.overview")) + + @bp.post("/write") @permission_required("dhcp_config.execute") def write_config(): diff --git a/apps/tesm/src/tesm/services/kea.py b/apps/tesm/src/tesm/services/kea.py index bcd0017..d309484 100644 --- a/apps/tesm/src/tesm/services/kea.py +++ b/apps/tesm/src/tesm/services/kea.py @@ -653,9 +653,155 @@ def read_leases(sysops: SysOps, *, path: str = LEASE_FILE) -> list[Lease]: return sorted(latest.values(), key=lambda entry: tuple(int(p) for p in entry.address.split(".") if p.isdigit())) +#: Optionen, die jeder DHCP-Client von sich aus erfragt. Sie in die +#: Client-Konfiguration zu schreiben waere Ballast -- und wer Ballast liest, +#: liest irgendwann gar nicht mehr. +IMMER_ANGEFRAGT = frozenset({1, 3, 6, 12, 15, 28, 42, 51, 54, 58, 59, 119, 121}) + +#: Die Stile, in denen eine Client-Konfiguration ausgegeben wird. +#: +#: Drei, weil es auf den Zielsystemen drei gibt, und die Wahl nicht am +#: Geschmack haengt, sondern daran, was die Distribution mitbringt: +#: +#: * **networkd** -- Ubuntu-Server 22.04 bis 26.04 (netplan erzeugt die +#: .network-Dateien), auch auf Debian verbreitet. Angeforderte Fremdoptionen +#: landen als ``OPTION_`` in der Lease unter /run/systemd/netif/leases. +#: * **dhcpcd** -- Debian 12/13, Raspberry Pi OS, ab Ubuntu 24.04 verfuegbar. +#: * **dhclient** -- Debian 11 bis 13 und Ubuntu 22.04, dort wo isc-dhcp-client +#: noch installiert ist. +CLIENT_STILE: tuple[tuple[str, str, str], ...] = ( + ("networkd", "systemd-networkd", "/etc/systemd/network/10-tesm.network.d/50-tesm.conf"), + ("dhcpcd", "dhcpcd", "/etc/dhcpcd.conf"), + ("dhclient", "dhclient (isc-dhcp-client)", "/etc/dhcp/dhclient.conf"), +) + +#: Wie ein Typ aus dem Optionskatalog in den jeweiligen Clients heisst. +_DHCPCD_TYPEN = { + "string": "string", + "ipv4-address": "ipaddress", + "uint8": "byte", + "uint16": "uint16", + "uint32": "uint32", + "boolean": "byte", +} +_DHCLIENT_TYPEN = { + "string": "text", + "ipv4-address": "ip-address", + "uint8": "unsigned integer 8", + "uint16": "unsigned integer 16", + "uint32": "unsigned integer 32", + "boolean": "boolean", +} + + +def gesendete_optionen(conn: sqlite3.Connection) -> list[dict[str, Any]]: + """Die Optionen, die dieser Server tatsaechlich ausliefert. + + Gemeint ist: wofuer irgendwo ein Wert gesetzt ist -- global oder an einem + einzelnen Geraet. Was im Katalog steht, aber leer ist, wird nicht gesendet + und muss vom Client auch nicht angefordert werden. + """ + return Database.all( + conn, + """ + SELECT DISTINCT o.code, o.name, o.type, o.description, o.is_standard + FROM dhcp_options o + JOIN dhcp_option_values v ON v.option_id = o.id + WHERE TRIM(COALESCE(v.value, '')) <> '' + ORDER BY o.code + """, + ) + + +def client_konfiguration(conn: sqlite3.Connection, stil: str) -> dict[str, Any]: + """Was ein Linux-Client braucht, damit die Optionen bei ihm ankommen. + + **Warum es das braucht.** Ein DHCP-Client bekommt nur, wonach er fragt. + Standardoptionen (Gateway, DNS, Domain) fragt er von sich aus; alles + darueber hinaus -- und dazu gehoert jede eigene Option -- muss er + ausdruecklich anfordern. Ohne diese Zeilen sendet der Server brav und der + Client wirft es weg, ohne dass irgendwo etwas danebengeht. + + Rueckgabe: ``{"pfad", "inhalt", "nachher", "optionen"}`` -- Datei, Inhalt, + der Befehl zum Uebernehmen und die Optionen, um die es geht. + """ + optionen = [ + eintrag + for eintrag in gesendete_optionen(conn) + if int(eintrag["code"]) not in IMMER_ANGEFRAGT + ] + codes = [int(eintrag["code"]) for eintrag in optionen] + pfad = dict((s[0], s[2]) for s in CLIENT_STILE).get(stil, "") + + if stil == "networkd": + zeilen = [ + "# Von TESM erzeugt. Fordert die Optionen an, die dieser Server sendet --", + "# ohne diese Zeilen verwirft der Client sie stillschweigend.", + "#", + "# Der Dateiname muss zur .network-Datei der Schnittstelle passen:", + "# netplan legt sie unter /run/systemd/network/10-netplan-.network ab,", + "# der Zusatz gehoert also nach", + "# /etc/systemd/network/10-netplan-.network.d/50-tesm.conf.", + "#", + "# Die Werte stehen danach in /run/systemd/netif/leases/", + "# als OPTION_=...", + "[DHCPv4]", + ] + zeilen.append("RequestOptions=" + " ".join(str(code) for code in codes)) + nachher = "networkctl reload && networkctl reconfigure " + elif stil == "dhcpcd": + zeilen = [ + "# Von TESM erzeugt -- an das Ende von /etc/dhcpcd.conf.", + "# 'define' macht die Option bekannt, 'option' fordert sie an.", + "# Danach steht der Wert in der Lease (/var/lib/dhcpcd/.lease)", + "# und laesst sich mit 'dhcpcd -U ' als Variable auslesen.", + ] + for eintrag in optionen: + typ = _DHCPCD_TYPEN.get(str(eintrag["type"]), "string") + zeilen.append(f"define {eintrag['code']} {typ} {eintrag['name']}") + for eintrag in optionen: + zeilen.append(f"option {eintrag['name']}") + nachher = "systemctl restart dhcpcd" + else: + zeilen = [ + "# Von TESM erzeugt -- an das Ende von /etc/dhcp/dhclient.conf.", + "# Die Werte stehen danach in /var/lib/dhcp/dhclient.leases,", + "# und in Hooks unter $new_.", + ] + for eintrag in optionen: + typ = _DHCLIENT_TYPEN.get(str(eintrag["type"]), "text") + zeilen.append(f"option {eintrag['name']} code {eintrag['code']} = {typ};") + if optionen: + zeilen.append( + "also request " + ", ".join(str(eintrag["name"]) for eintrag in optionen) + ";" + ) + nachher = "systemctl restart networking || dhclient -r && dhclient" + + return { + "pfad": pfad, + "inhalt": chr(10).join(zeilen) + chr(10), + "nachher": nachher, + "optionen": optionen, + } + + def service_state(sysops: SysOps) -> dict[str, str]: state = sysops.service_state(SERVICE_UNIT) import shutil + import subprocess - state["binary"] = "vorhanden" if shutil.which("kea-dhcp4") else "fehlt" + pfad = shutil.which("kea-dhcp4") + state["binary"] = "vorhanden" if pfad else "fehlt" + # Die Fassung gehoert auf die Seite: zwischen Kea 2.4 (Ubuntu 24.04) und + # 3.0 (26.04) liegen Aenderungen, und wer eine Konfiguration beurteilt, + # will nicht erst auf der Kommandozeile nachsehen muessen, wogegen. + state["version"] = "" + if pfad: + try: + fertig = subprocess.run( + [pfad, "-V"], capture_output=True, text=True, timeout=5, check=False + ) + state["version"] = (fertig.stdout or "").strip().splitlines()[0].strip() + except (OSError, subprocess.SubprocessError, IndexError): + state["version"] = "" return state diff --git a/apps/tesm/src/tesm/templates/tesm/dhcp.html b/apps/tesm/src/tesm/templates/tesm/dhcp.html index 88664e7..22be464 100644 --- a/apps/tesm/src/tesm/templates/tesm/dhcp.html +++ b/apps/tesm/src/tesm/templates/tesm/dhcp.html @@ -39,6 +39,10 @@ +{# 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. #} +
{% if current_user.has('dhcp_service.execute') %}
@@ -66,6 +70,31 @@
{% endif %} +{% if current_user.has('dhcp_subnets.write') %} +
+
+
{{ icon('sliders', size=17) }} Globale Einstellungen
+
+
+ +
+
+ {{ 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') }} +
+
+ +
+
+{% endif %} + +
+ {# -- Subnetze ---------------------------------------------------------- #}
@@ -134,45 +163,111 @@ {% endif %}
-{# -- Globale Einstellungen --------------------------------------------- #} -{% if current_user.has('dhcp_subnets.write') %} -
-
-
{{ icon('sliders', size=17) }} Globale Einstellungen
-
-
- -
-
- {{ 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') }} -
-
- -
-
-{% endif %} - {# -- Optionen ---------------------------------------------------------- #} + +{# Zwei Makros, weil dieselbe Zeile zweimal vorkommt: einmal in der kurzen + Liste dessen, was wirklich gesendet wird, und einmal im vollen Katalog. #} +{% macro optionswert(option) %} + {% if current_user.has('dhcp_options.write') %} +
+ + + {# min-width:0 und flex:1 statt einer festen Mindestbreite: sonst passte + das Feld nicht in die Spalte, die Zeile brach um, und der Haken stand + allein darunter -- es sah aus wie ein Bedienfehler der Seite. #} + + +
+ {% else %} + {{ option.value or '–' | safe }} + {% endif %} +{% endmacro %} + +{% macro optionsloeschen(option) %} + {% if current_user.has('dhcp_options.write') and not option.is_standard %} +
+ + +
+ {% endif %} +{% endmacro %} + +{% set gesetzte = options | selectattr('value') | list %} +
-
{{ icon('puzzle', size=17) }} DHCP-Optionen
+
{{ icon('puzzle', size=17) }} Gesendete Optionen
- {{ options | length }} Optionen · Standardoptionen sind nicht löschbar, ihr Wert - lässt sich leeren + {% if gesetzte %} + {{ gesetzte | length }} 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 %}
- {{ search_box('options-table', 'Option suchen ...') }}
-
+ + {% if gesetzte %} +
+ + + + + + + {% for option in gesetzte %} + + + + + + + + {% endfor %} + +
CodeNameTypWert
{{ option.code }} +
{{ option.name }}
+
{{ option.description }}
+
{{ option.type }}{{ optionswert(option) }}{{ optionsloeschen(option) }}
+
+ {% else %} + {{ empty_state('Nichts gesetzt', 'Tragen Sie unten im Katalog einen Wert ein.', 'puzzle') }} + {% endif %} +
+ +{# 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. #} +
+ +
+
{{ icon('list', size=17) }} Optionskatalog
+
+ Alle {{ options | length }} bekannten Optionen. Standardoptionen sind nicht löschbar, + ihr Wert lässt sich leeren. +
+
+
+
{{ search_box('options-table', 'Option suchen ...') }}
+
- + + + + {% for option in options %} @@ -182,43 +277,30 @@
{{ option.description }}
- - + + {% endfor %}
CodeNameTypWert
CodeNameTypWert
{{ option.type }} - {% if current_user.has('dhcp_options.write') %} -
- - - - -
- {% else %} - {{ option.value or '–' | safe }} - {% endif %} -
- {% if current_user.has('dhcp_options.write') and not option.is_standard %} -
- - -
- {% endif %} -
{{ optionswert(option) }}{{ optionsloeschen(option) }}
- {% if current_user.has('dhcp_options.write') %} +
+ +{% if current_user.has('dhcp_options.write') %} +
+
+
+
{{ icon('plus', size=17) }} Eigene Option anlegen
+
+ Für alles jenseits des Katalogs – etwa eine URL, die der Client beim Start öffnet. +
+
+
- {{ field('code', 'Code', type='number', required=True) }} + {{ field('code', 'Code', type='number', required=True, placeholder='225') }} {{ field('name', 'Name', required=True, placeholder='meine-option') }}
+
- {# Der Wert einer Option gilt sonst für alle. Genau dafür gibt es - ``dhcp_option_values.device_id``: ein eigener Wert je Gerät, der in - dessen Reservierung geschrieben wird -- etwa eine Terminal-URL, die je - Gerät anders lautet. Auswertbar war das von Anfang an, eingeben liess - es sich nicht. #} -
+ {# Der Wert einer Option gilt sonst für alle. Genau dafür gibt es + ``dhcp_option_values.device_id``: ein eigener Wert je Gerät, der in dessen + Reservierung geschrieben wird -- etwa eine Terminal-URL, die je Gerät + anders lautet. Auswertbar war das von Anfang an, eingeben liess es sich + nicht. #} +
+
{{ icon('sliders', size=17) }} Option nur für ein Gerät
@@ -276,6 +360,93 @@
+
+{% 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. +#} +
+
+
+
{{ icon('terminal', size=17) }} Konfiguration für Linux-Clients
+
+ Ein Client erhält nur die Optionen, die er anfordert. Diese Zeilen sorgen dafür – + erzeugt aus dem, was dieser Server tatsächlich sendet. +
+
+
+ + {% set beispiel = client_konfig.get('networkd') %} + {% if not beispiel or not beispiel.optionen %} +
+ {{ 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') }} +
+ {% else %} +
+

+ Betrifft + {% for eintrag in beispiel.optionen %} + {{ eintrag.code }} {{ eintrag.name }}{% if not loop.last %}, {% endif %} + {% endfor %}. + Welcher Abschnitt gilt, entscheidet die Distribution des Clients. +

+
+ + {% for stil, bezeichnung, pfad in client_stile %} + {% set konfig = client_konfig.get(stil) %} +
+ + {{ bezeichnung }} + {{ pfad }} + +
+
+ Inhalt der Datei + +
+
{{ konfig.inhalt }}
+
Danach: {{ konfig.nachher }}
+ + {% if current_user.has('servicing_command.execute') %} +
+ + +
+
+ Oder direkt schreiben lassen – über SSH, mit den hinterlegten Zugangsdaten. + Jeder Client bekommt einen eigenen Auftrag; die Ausgabe steht unter Wartung. +
+
+ {% for reservation in reservations if reservation.device_id %} + + {% endfor %} +
+
+ +
+
+ {% endif %} +
+
+ {% endfor %} {% endif %}
@@ -432,7 +603,10 @@
-