From 29b62714cb4de3a5bce4a423b8ffb07102520c05 Mon Sep 17 00:00:00 2001 From: Tim Eertmoed Date: Thu, 17 Sep 2026 11:24:17 +0200 Subject: [PATCH] Version 2.8.44 / Lizenzserver 2.8.22: Client-Konfiguration am richtigen Ort, Fusszeile bleibt stehen --- apps/tesm/pyproject.toml | 2 +- apps/tesm/src/tesm/blueprints/dhcp.py | 18 ++- apps/tesm/src/tesm/services/kea.py | 106 ++++++++++++++++-- packages/tesm-core/pyproject.toml | 2 +- .../tesm_core/static/tesm_core/css/base.css | 12 +- 5 files changed, 115 insertions(+), 25 deletions(-) diff --git a/apps/tesm/pyproject.toml b/apps/tesm/pyproject.toml index fb02bfd..0cc3389 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.43" +version = "2.8.44" 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 42646c2..6691532 100644 --- a/apps/tesm/src/tesm/blueprints/dhcp.py +++ b/apps/tesm/src/tesm/blueprints/dhcp.py @@ -11,7 +11,7 @@ einen Dienst starten. from __future__ import annotations -import base64 +import shlex from typing import Any from flask import Blueprint, abort, flash, redirect, render_template, request, url_for @@ -684,16 +684,12 @@ def push_client_config(): flash("Es wurde kein Gerät ausgewählt.", "warning") return redirect(url_for("dhcp.overview")) - # Ueber base64 statt ueber ein Here-Dokument: der Inhalt enthaelt - # Anfuehrungszeichen, Rauten und Zeilenumbrueche, und er geht durch SSH, - # eine Shell und moeglicherweise sudo. Was kodiert reist, kommt an. - inhalt = base64.b64encode(konfig["inhalt"].encode("utf-8")).decode("ascii") - verzeichnis = str(konfig["pfad"]).rsplit("/", 1)[0] - befehl = ( - f"{{sudo}} mkdir -p {verzeichnis} && " - f"echo {inhalt} | base64 -d | {{sudo}} tee {konfig['pfad']} >/dev/null && " - f"{{sudo}} sh -c '{konfig['nachher']}' || true" - ) + # Ein Skript, ein Aufruf. Der frühere Weg schrieb auf einen festen Pfad -- + # bei networkd auf einen, den kein Dienst liest -- und überschrieb bei + # dhcpcd/dhclient die Datei des Clients. Das Skript sucht sich das Ziel + # selbst und ersetzt nur seinen eigenen Block; es geht als **Argument** + # durch, damit stdin für das sudo-Passwort frei bleibt. + befehl = "{sudo} sh -c " + shlex.quote(konfig["skript"]) gestartet = 0 for device_id in device_ids: diff --git a/apps/tesm/src/tesm/services/kea.py b/apps/tesm/src/tesm/services/kea.py index 50d976b..2335f16 100644 --- a/apps/tesm/src/tesm/services/kea.py +++ b/apps/tesm/src/tesm/services/kea.py @@ -746,7 +746,11 @@ IMMER_ANGEFRAGT = frozenset({1, 3, 6, 12, 15, 28, 42, 51, 54, 58, 59, 119, 121}) #: * **dhclient** -- Debian 11 bis 13 und Ubuntu 22.04, dort wo isc-dhcp-client #: noch installiert ist. CLIENT_STILE: tuple[tuple[str, str, str], ...] = ( - ("networkd", "systemd-networkd", "/etc/systemd/network/10-tesm.network.d/50-tesm.conf"), + # Der Zusatz gehoert zu der .network-Datei, die fuer die Schnittstelle + # wirklich gilt -- bei netplan heisst die 10-netplan-.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/.network.d/50-tesm.conf"), ("dhcpcd", "dhcpcd", "/etc/dhcpcd.conf"), ("dhclient", "dhclient (isc-dhcp-client)", "/etc/dhcp/dhclient.conf"), ) @@ -789,6 +793,40 @@ def gesendete_optionen(conn: sqlite3.Connection) -> list[dict[str, Any]]: ) +#: Traegt den Zusatz dort ein, wo networkd ihn auch liest. +NETWORKD_SKRIPT = """set -e +schnittstelle=$(ip -o -4 route show default 2>/dev/null | awk '{print $5; exit}') +[ -n "$schnittstelle" ] || { echo "Keine Standardroute -- Schnittstelle unbekannt." >&2; exit 1; } +datei=$(COLUMNS=400 SYSTEMD_COLORS=0 networkctl status "$schnittstelle" 2>/dev/null | sed -n 's/^ *Network File: *//p' | head -n1) +case "${datei:-}" in + ""|n/a|*not*) datei=$(grep -ls "Name=$schnittstelle" /run/systemd/network/*.network /etc/systemd/network/*.network 2>/dev/null | head -n1) ;; +esac +[ -n "${datei:-}" ] || { echo "Keine .network-Datei fuer $schnittstelle gefunden." >&2; exit 1; } +ziel="/etc/systemd/network/$(basename "$datei").d/50-tesm.conf" +mkdir -p "$(dirname "$ziel")" +printf %s '@FRACHT@' | base64 -d > "$ziel" +chmod 0644 "$ziel" +echo "geschrieben: $ziel" +networkctl reload +networkctl reconfigure "$schnittstelle" +echo "Lease: /run/systemd/netif/leases/$(cat /sys/class/net/"$schnittstelle"/ifindex)" +""" + +#: Ersetzt den eigenen Block in einer Datei, die dem Client gehoert. +BLOCK_SKRIPT = """set -e +ziel=@ZIEL@ +mkdir -p "$(dirname "$ziel")" +[ -f "$ziel" ] || : > "$ziel" +zwischen=$(mktemp) +sed '/^# >>> TESM/,/^# <<< TESM/d' "$ziel" > "$zwischen" +printf %s '@FRACHT@' | base64 -d >> "$zwischen" +cat "$zwischen" > "$ziel" +rm -f "$zwischen" +echo "ergaenzt: $ziel" +@DIENST@ +""" + + def client_konfiguration(conn: sqlite3.Connection, stil: str) -> dict[str, Any]: """Was ein Linux-Client braucht, damit die Optionen bei ihm ankommen. @@ -798,8 +836,10 @@ def client_konfiguration(conn: sqlite3.Connection, stil: str) -> dict[str, Any]: ausdruecklich anfordern. Ohne diese Zeilen sendet der Server brav und der Client wirft es weg, ohne dass irgendwo etwas danebengeht. - Rueckgabe: ``{"pfad", "inhalt", "nachher", "optionen"}`` -- Datei, Inhalt, - der Befehl zum Uebernehmen und die Optionen, um die es geht. + Rueckgabe: ``{"pfad", "inhalt", "nachher", "skript", "optionen"}``. + ``skript`` ist die Fassung zum Ausfuehren -- sie sucht sich das Ziel auf + dem Client selbst und traegt den Inhalt ein, ohne Vorhandenes zu + verlieren. """ optionen = [ eintrag @@ -814,20 +854,22 @@ def client_konfiguration(conn: sqlite3.Connection, stil: str) -> dict[str, Any]: "# Von TESM erzeugt. Fordert die Optionen an, die dieser Server sendet --", "# ohne diese Zeilen verwirft der Client sie stillschweigend.", "#", - "# Der Dateiname muss zur .network-Datei der Schnittstelle passen:", - "# netplan legt sie unter /run/systemd/network/10-netplan-.network ab,", - "# der Zusatz gehoert also nach", + "# Diese Datei ist ein Zusatz zu der .network-Datei, die fuer die", + "# Schnittstelle gilt. Wie sie heisst, sagt 'networkctl status ';", + "# bei netplan ist es /run/systemd/network/10-netplan-.network, der", + "# Zusatz gehoert dann nach", "# /etc/systemd/network/10-netplan-.network.d/50-tesm.conf.", "#", "# Die Werte stehen danach in /run/systemd/netif/leases/", - "# als OPTION_=...", + "# als OPTION_= -- hexkodiert, weil systemd fremde Optionen nicht", + "# deutet.", "[DHCPv4]", ] zeilen.append("RequestOptions=" + " ".join(str(code) for code in codes)) - nachher = "networkctl reload && networkctl reconfigure " + nachher = "networkctl reload && networkctl reconfigure " elif stil == "dhcpcd": zeilen = [ - "# Von TESM erzeugt -- an das Ende von /etc/dhcpcd.conf.", + "# >>> TESM -- nicht von Hand aendern, wird beim naechsten Mal ersetzt", "# 'define' macht die Option bekannt, 'option' fordert sie an.", "# Danach steht der Wert in der Lease (/var/lib/dhcpcd/.lease)", "# und laesst sich mit 'dhcpcd -U ' als Variable auslesen.", @@ -837,10 +879,11 @@ def client_konfiguration(conn: sqlite3.Connection, stil: str) -> dict[str, Any]: zeilen.append(f"define {eintrag['code']} {typ} {eintrag['name']}") for eintrag in optionen: zeilen.append(f"option {eintrag['name']}") + zeilen.append("# <<< TESM") nachher = "systemctl restart dhcpcd" else: zeilen = [ - "# Von TESM erzeugt -- an das Ende von /etc/dhcp/dhclient.conf.", + "# >>> TESM -- nicht von Hand aendern, wird beim naechsten Mal ersetzt", "# Die Werte stehen danach in /var/lib/dhcp/dhclient.leases,", "# und in Hooks unter $new_.", ] @@ -851,16 +894,57 @@ def client_konfiguration(conn: sqlite3.Connection, stil: str) -> dict[str, Any]: zeilen.append( "also request " + ", ".join(str(eintrag["name"]) for eintrag in optionen) + ";" ) + zeilen.append("# <<< TESM") nachher = "systemctl restart networking || dhclient -r && dhclient" + inhalt = chr(10).join(zeilen) + chr(10) return { "pfad": pfad, - "inhalt": chr(10).join(zeilen) + chr(10), + "inhalt": inhalt, "nachher": nachher, + "skript": _uebernahme_skript(stil, inhalt), "optionen": optionen, } +def _uebernahme_skript(stil: str, inhalt: str) -> str: + """Das Skript, das die Konfiguration auf dem Client eintraegt. + + Zwei Dinge macht es anders als ein blosses ``tee``, und beide sind mit + Schaden bezahlt worden: + + * Bei ``networkd`` **sucht es das Ziel auf dem Client**. Der Name des + Zusatzverzeichnisses muss zur aktiven .network-Datei passen; ein fester + Pfad legt eine Datei an, die kein Dienst je liest -- sie stand da, und + die Option fehlte trotzdem in der Lease. + * Bei ``dhcpcd`` und ``dhclient`` gehoeren die Zeilen in eine Datei, die + dem Client gehoert. Sie zu ueberschreiben haette seine uebrige + Konfiguration geloescht. Stattdessen wird nur der eigene Block zwischen + den Marken ersetzt -- beliebig oft wiederholbar. + + Der Inhalt reist base64-kodiert und als **Argument**, nicht ueber stdin: + dort steht bei einem Client mit Passwort das Kennwort fuer ``sudo``. + """ + import base64 + + fracht = base64.b64encode(inhalt.encode("utf-8")).decode("ascii") + + if stil == "networkd": + return NETWORKD_SKRIPT.replace("@FRACHT@", fracht) + + ziel = "/etc/dhcpcd.conf" if stil == "dhcpcd" else "/etc/dhcp/dhclient.conf" + dienst = ( + "systemctl restart dhcpcd" + if stil == "dhcpcd" + else "systemctl restart networking 2>/dev/null || { dhclient -r || true; dhclient; }" + ) + return ( + BLOCK_SKRIPT.replace("@ZIEL@", ziel) + .replace("@FRACHT@", fracht) + .replace("@DIENST@", dienst) + ) + + def service_state(sysops: SysOps) -> dict[str, str]: state = sysops.service_state(SERVICE_UNIT) import shutil diff --git a/packages/tesm-core/pyproject.toml b/packages/tesm-core/pyproject.toml index d07c577..3b17001 100644 --- a/packages/tesm-core/pyproject.toml +++ b/packages/tesm-core/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "tesm-core" -version = "2.8.21" +version = "2.8.22" description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver" requires-python = ">=3.11" dependencies = [ diff --git a/packages/tesm-core/src/tesm_core/static/tesm_core/css/base.css b/packages/tesm-core/src/tesm_core/static/tesm_core/css/base.css index 516a57f..78a138e 100644 --- a/packages/tesm-core/src/tesm_core/static/tesm_core/css/base.css +++ b/packages/tesm-core/src/tesm_core/static/tesm_core/css/base.css @@ -657,9 +657,19 @@ gap: var(--sp-5); } + /* Bleibt stehen wie die Kopfzeile: Fassung und angemeldeter Benutzer sind + Angaben, nach denen man mitten in einer langen Seite fragt -- und bis + hierher zu scrollen, nur um zu sehen, welche Fassung laeuft, ist eine + Zumutung. Derselbe milchige Untergrund wie oben, damit Inhalt, der + darunter durchlaeuft, nicht mitliest. */ .page-footer { - padding: var(--sp-4) var(--sp-5); + position: sticky; + bottom: 0; + z-index: var(--z-sticky); + padding: var(--sp-3) var(--sp-5); border-top: 1px solid var(--border-subtle); + background: color-mix(in srgb, var(--canvas) 82%, transparent); + backdrop-filter: blur(12px) saturate(1.4); color: var(--text-faint); font-size: var(--fs-xs); display: flex;