Compare commits

...
6 Commits
6 changed files with 484 additions and 43 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.43" 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 = [
+84 -17
View File
@@ -11,7 +11,7 @@ einen Dienst starten.
from __future__ import annotations from __future__ import annotations
import base64 import shlex
from typing import Any from typing import Any
from flask import Blueprint, abort, flash, redirect, render_template, request, url_for from flask import Blueprint, abort, flash, redirect, render_template, request, url_for
@@ -81,15 +81,65 @@ def overview():
for zeile in Database.all(conn, "SELECT mac FROM devices WHERE deleted_at IS NULL") for zeile in Database.all(conn, "SELECT mac FROM devices WHERE deleted_at IS NULL")
} }
options = Database.all(
conn,
"SELECT o.*, v.value, v.id AS value_id FROM dhcp_options o "
"LEFT JOIN dhcp_option_values v ON v.option_id = o.id AND v.device_id IS NULL "
"ORDER BY o.code",
)
# Ein Wert an einem einzelnen Geraet ist ein gesendeter Wert. Die Uebersicht
# zaehlte nur die globalen -- und meldete "Nichts gesetzt", waehrend die
# Vorschau daneben die Option in der Reservierung auswies.
je_geraet: dict[int, list[dict[str, Any]]] = {}
for zeile in Database.all(
conn,
"SELECT o.code, d.name AS geraet, v.value FROM dhcp_option_values v "
"JOIN dhcp_options o ON o.id = v.option_id "
"JOIN devices d ON d.id = v.device_id "
"WHERE v.device_id IS NOT NULL AND d.deleted_at IS NULL "
"AND TRIM(COALESCE(v.value, '')) <> '' ORDER BY o.code, d.name",
):
je_geraet.setdefault(int(zeile["code"]), []).append(dict(zeile))
gesendet = []
for option in options:
geraete = je_geraet.get(int(option["code"]), [])
if str(option["value"] or "").strip() or geraete:
eintrag = dict(option)
eintrag["geraete"] = geraete
gesendet.append(eintrag)
# Der Server belegt einen Code selbst. Ihn aus dem Katalog herauszuhalten
# hiess: er steht in der erzeugten Datei, aber nirgends auf der Seite --
# und wer 224 vergeben will, erfaehrt es erst hinterher.
eigenes_verzeichnis = kea.verzeichnis(conn)
katalog = list(options)
if not any(int(zeile["code"]) == kea.KATALOG_CODE for zeile in katalog):
katalog.append(
{
"id": None,
"code": kea.KATALOG_CODE,
"name": kea.KATALOG_NAME,
"type": "string",
# Die Regel gehoert dorthin, wo man sie sucht: das Verzeichnis
# nennt die Optionen, die **gesendet** werden. Eine angelegte
# Option ohne Wert steht nicht darin -- sie geht ja auch nicht
# hinaus, und ein Client braucht ihren Namen nicht.
"description": "Vom Server belegt | nennt den Clients Name und Typ der "
"Optionen, die gerade gesendet werden",
"value": eigenes_verzeichnis,
"is_standard": 1,
"reserviert": True,
}
)
katalog.sort(key=lambda zeile: int(zeile["code"]))
return render_template( return render_template(
"tesm/dhcp.html", "tesm/dhcp.html",
subnets=Database.all(conn, "SELECT * FROM dhcp_subnets ORDER BY sort_order, id"), subnets=Database.all(conn, "SELECT * FROM dhcp_subnets ORDER BY sort_order, id"),
options=Database.all( options=options,
conn, katalog=katalog,
"SELECT o.*, v.value, v.id AS value_id FROM dhcp_options o " gesendet=gesendet,
"LEFT JOIN dhcp_option_values v ON v.option_id = o.id AND v.device_id IS NULL "
"ORDER BY o.code",
),
option_types=kea.OPTION_TYPES, option_types=kea.OPTION_TYPES,
device_option_values=_device_option_values(conn), device_option_values=_device_option_values(conn),
reservations=reservations, reservations=reservations,
@@ -375,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"))
@@ -389,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"))
@@ -684,16 +755,12 @@ def push_client_config():
flash("Es wurde kein Gerät ausgewählt.", "warning") flash("Es wurde kein Gerät ausgewählt.", "warning")
return redirect(url_for("dhcp.overview")) return redirect(url_for("dhcp.overview"))
# Ueber base64 statt ueber ein Here-Dokument: der Inhalt enthaelt # Ein Skript, ein Aufruf. Der frühere Weg schrieb auf einen festen Pfad --
# Anfuehrungszeichen, Rauten und Zeilenumbrueche, und er geht durch SSH, # bei networkd auf einen, den kein Dienst liest -- und überschrieb bei
# eine Shell und moeglicherweise sudo. Was kodiert reist, kommt an. # dhcpcd/dhclient die Datei des Clients. Das Skript sucht sich das Ziel
inhalt = base64.b64encode(konfig["inhalt"].encode("utf-8")).decode("ascii") # selbst und ersetzt nur seinen eigenen Block; es geht als **Argument**
verzeichnis = str(konfig["pfad"]).rsplit("/", 1)[0] # durch, damit stdin für das sudo-Passwort frei bleibt.
befehl = ( befehl = "{sudo} sh -c " + shlex.quote(konfig["skript"])
f"{{sudo}} mkdir -p {verzeichnis} && "
f"echo {inhalt} | base64 -d | {{sudo}} tee {konfig['pfad']} >/dev/null && "
f"{{sudo}} sh -c '{konfig['nachher']}' || true"
)
gestartet = 0 gestartet = 0
for device_id in device_ids: for device_id in device_ids:
+333 -12
View File
@@ -545,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)},
@@ -729,6 +746,26 @@ def read_leases(sysops: SysOps, *, path: str = LEASE_FILE) -> list[Lease]:
return sorted(latest.values(), key=lambda entry: tuple(int(p) for p in entry.address.split(".") if p.isdigit())) return sorted(latest.values(), key=lambda entry: tuple(int(p) for p in entry.address.split(".") if p.isdigit()))
#: Der Bereich, den DHCP fuer eigene Optionen vorsieht (RFC 2132: "site
#: specific"). **Nur** hier hinein gehoeren eigene Codes, und zwar nicht aus
#: Ordnungsliebe: systemd-networkd hebt eine unbekannte Option ausschliesslich
#: dann auf, wenn ihr Code in diesem Bereich liegt. Alles darunter wirft es
#: weg, ohne dass irgendwo etwas danebengeht.
PRIVATBEREICH = range(224, 255)
#: Code und Name des Verzeichnisses, das der Server ueber sich selbst mitsendet.
#:
#: **Warum es das gibt.** Auf der Leitung reisen nur Code, Laenge und Bytes --
#: kein Name, kein Typ. Ein Client kann darum nicht wissen, dass 225 "url"
#: heisst und Text enthaelt. Bisher bekam er die Zuordnung beim Verteilen
#: mit; kam spaeter eine Option dazu, musste man erneut verteilen. Also sendet
#: der Server sein Verzeichnis einfach mit: "225:url:string 226:monitor:...".
#: Der Uebersetzer auf dem Client liest es bei jeder Lease neu -- eine neue
#: Option ist damit sofort unter ihrem Namen lesbar, ohne den Client
#: anzufassen.
KATALOG_CODE = 224
KATALOG_NAME = "tesm-katalog"
#: Optionen, die jeder DHCP-Client von sich aus erfragt. Sie in die #: Optionen, die jeder DHCP-Client von sich aus erfragt. Sie in die
#: Client-Konfiguration zu schreiben waere Ballast -- und wer Ballast liest, #: Client-Konfiguration zu schreiben waere Ballast -- und wer Ballast liest,
#: liest irgendwann gar nicht mehr. #: liest irgendwann gar nicht mehr.
@@ -746,7 +783,11 @@ IMMER_ANGEFRAGT = frozenset({1, 3, 6, 12, 15, 28, 42, 51, 54, 58, 59, 119, 121})
#: * **dhclient** -- Debian 11 bis 13 und Ubuntu 22.04, dort wo isc-dhcp-client #: * **dhclient** -- Debian 11 bis 13 und Ubuntu 22.04, dort wo isc-dhcp-client
#: noch installiert ist. #: noch installiert ist.
CLIENT_STILE: tuple[tuple[str, str, str], ...] = ( CLIENT_STILE: tuple[tuple[str, str, str], ...] = (
("networkd", "systemd-networkd", "/etc/systemd/network/10-tesm.network.d/50-tesm.conf"), # Der Zusatz gehoert zu der .network-Datei, die fuer die Schnittstelle
# wirklich gilt -- bei netplan heisst die 10-netplan-<if>.network und liegt
# unter /run. Ein Verzeichnis "10-tesm.network.d" liest niemand: es gibt
# keine 10-tesm.network. Den Namen ermittelt das Skript auf dem Client.
("networkd", "systemd-networkd", "/etc/systemd/network/<aktive>.network.d/50-tesm.conf"),
("dhcpcd", "dhcpcd", "/etc/dhcpcd.conf"), ("dhcpcd", "dhcpcd", "/etc/dhcpcd.conf"),
("dhclient", "dhclient (isc-dhcp-client)", "/etc/dhcp/dhclient.conf"), ("dhclient", "dhclient (isc-dhcp-client)", "/etc/dhcp/dhclient.conf"),
) )
@@ -789,6 +830,176 @@ def gesendete_optionen(conn: sqlite3.Connection) -> list[dict[str, Any]]:
) )
#: Traegt den Zusatz dort ein, wo networkd ihn auch liest.
NETWORKD_SKRIPT = """set -e
schnittstelle=$(ip -o -4 route show default 2>/dev/null | awk '{print $5; exit}')
[ -n "$schnittstelle" ] || { echo "Keine Standardroute -- Schnittstelle unbekannt." >&2; exit 1; }
datei=$(COLUMNS=400 SYSTEMD_COLORS=0 networkctl status "$schnittstelle" 2>/dev/null | sed -n 's/^ *Network File: *//p' | head -n1)
case "${datei:-}" in
""|n/a|*not*) datei=$(grep -ls "Name=$schnittstelle" /run/systemd/network/*.network /etc/systemd/network/*.network 2>/dev/null | head -n1) ;;
esac
[ -n "${datei:-}" ] || { echo "Keine .network-Datei fuer $schnittstelle gefunden." >&2; exit 1; }
ziel="/etc/systemd/network/$(basename "$datei").d/50-tesm.conf"
mkdir -p "$(dirname "$ziel")"
printf %s '@FRACHT@' | base64 -d > "$ziel"
chmod 0644 "$ziel"
echo "geschrieben: $ziel"
# Der Uebersetzer: systemd legt fremde Optionen roh und unter ihrer Nummer ab
# ("OPTION_225=687474..."), weil im DHCP-Paket weder Name noch Typ steht.
# Beides weiss nur dieser Server -- also schreibt er die Zuordnung mit.
mkdir -p /usr/local/lib/tesm
printf %s '@UEBERSETZER@' | base64 -d > /usr/local/lib/tesm/dhcp-options
chmod 0755 /usr/local/lib/tesm/dhcp-options
printf %s '@DIENST@' | base64 -d > /etc/systemd/system/tesm-dhcp-options.service
printf %s '@PFADEINHEIT@' | base64 -d > /etc/systemd/system/tesm-dhcp-options.path
chmod 0644 /etc/systemd/system/tesm-dhcp-options.service /etc/systemd/system/tesm-dhcp-options.path
systemctl daemon-reload
systemctl enable --now tesm-dhcp-options.path >/dev/null 2>&1 || true
systemctl enable tesm-dhcp-options.service >/dev/null 2>&1 || true
networkctl reload
networkctl reconfigure "$schnittstelle"
# Der Pfadwaechter loest bei der naechsten Lease aus; einmal von Hand, damit
# die Datei sofort dasteht.
sleep 2
systemctl start tesm-dhcp-options.service || true
echo "Lease: /run/systemd/netif/leases/$(cat /sys/class/net/"$schnittstelle"/ifindex)"
echo "Klartext: /run/tesm-dhcp-options.env"
cat /run/tesm-dhcp-options.env 2>/dev/null || true
"""
#: Uebersetzt die rohen Optionen der Lease in "name=wert".
#:
#: Warum es das braucht: in einem DHCP-Paket stehen nur Nummer, Laenge und
#: Bytes. Dass 225 "url" heisst und eine Zeichenkette ist, steht allein im
#: Katalog dieses Servers -- systemd kann es nicht wissen und legt deshalb
#: Hex unter der Nummer ab. Diese Zuordnung reist mit.
UEBERSETZER = r"""#!/bin/sh
# Von TESM erzeugt. Schreibt die eigenen DHCP-Optionen im Klartext nach
# /run/tesm-dhcp-options.env -- eine Zeile "name=wert" je Option.
#
# Uebersetzt wird **alles**, was in der Lease steht, nicht eine feste Liste:
# Namen und Typen kommen aus dem Verzeichnis, das der Server als Option
# @KATALOG@ mitsendet. Eine neue Option ist damit sofort unter ihrem Namen
# lesbar, ohne dass hier etwas geaendert werden muesste. Fehlt das
# Verzeichnis, gilt die Zuordnung vom Tag des Verteilens; fehlt auch die,
# heisst die Option nach ihrer Nummer.
set -e
ziel=/run/tesm-dhcp-options.env
ersatz="@PAARE@"
schnittstelle=$(ip -o -4 route show default 2>/dev/null | awk '{print $5; exit}')
[ -n "$schnittstelle" ] || exit 0
lease=/run/systemd/netif/leases/$(cat /sys/class/net/"$schnittstelle"/ifindex 2>/dev/null)
[ -f "$lease" ] || exit 0
text() { /usr/bin/printf "$(echo "$1" | sed 's/../@BS@x&/g')"; }
entziffere() {
# $1 = Hexfolge, $2 = Typ. Ohne den Typ waere jede Adresse Buchstabensalat:
# eine IPv4-Adresse reist als vier rohe Bytes, nicht als "192.168.0.1".
case "$2" in
ipv4-address)
set -- $(echo "$1" | sed 's/../& /g')
[ $# -ge 4 ] || return 0
printf '%d.%d.%d.%d' "0x$1" "0x$2" "0x$3" "0x$4"
;;
uint8|uint16|uint32)
printf '%d' "0x$1"
;;
boolean)
case "$1" in 00) printf 'nein' ;; *) printf 'ja' ;; esac
;;
*)
text "$1"
;;
esac
}
nachschlagen() {
# Erst im mitgesendeten Verzeichnis, dann in der Zuordnung vom Verteilen.
for eintrag in $verzeichnis $ersatz; do
case "$eintrag" in
"$1":*)
rest=${eintrag#*:}
echo "${rest%%:*} ${rest#*:}"
return 0
;;
esac
done
echo "option_$1 string"
}
verzeichnis=$(text "$(sed -n 's/^OPTION_@KATALOG@=//p' "$lease" | head -n1)")
zwischen=$(mktemp)
sed -n 's/^OPTION_\([0-9]*\)=\(.*\)$/\1 \2/p' "$lease" | while read -r code hex; do
[ "$code" = "@KATALOG@" ] && continue
angabe=$(nachschlagen "$code")
name=${angabe%% *}
typ=${angabe#* }
wert=$(entziffere "$hex" "$typ")
echo "$name=$wert" >> "$zwischen"
done
mv -f "$zwischen" "$ziel"
chmod 0644 "$ziel"
"""
#: Einmal beim Start und nach jeder Lease.
DIENST_EINHEIT = """[Unit]
Description=TESM -- DHCP-Optionen im Klartext
After=systemd-networkd.service
[Service]
Type=oneshot
ExecStart=/usr/local/lib/tesm/dhcp-options
RemainAfterExit=no
[Install]
WantedBy=multi-user.target
"""
PFAD_EINHEIT = """[Unit]
Description=TESM -- wacht ueber die DHCP-Lease
[Path]
PathModified=/run/systemd/netif/leases
Unit=tesm-dhcp-options.service
[Install]
WantedBy=multi-user.target
"""
#: Ersetzt den eigenen Block in einer Datei, die dem Client gehoert.
BLOCK_SKRIPT = """set -e
ziel=@ZIEL@
mkdir -p "$(dirname "$ziel")"
[ -f "$ziel" ] || : > "$ziel"
zwischen=$(mktemp)
sed '/^# >>> TESM/,/^# <<< TESM/d' "$ziel" > "$zwischen"
printf %s '@FRACHT@' | base64 -d >> "$zwischen"
cat "$zwischen" > "$ziel"
rm -f "$zwischen"
echo "ergaenzt: $ziel"
@DIENST@
"""
def verzeichnis(conn: sqlite3.Connection) -> str:
"""Das Verzeichnis, das der Server seinen Clients mitsendet.
Eine Quelle fuer beides -- die Konfiguration und die Anzeige. Zweimal
berechnet waeren es zwei Wahrheiten, und die Seite wuerde irgendwann etwas
anderes zeigen als der Server sendet.
"""
return " ".join(
f"{int(zeile['code'])}:{zeile['name']}:{zeile['type']}"
for zeile in gesendete_optionen(conn)
if not int(zeile["is_standard"] or 0)
)
def client_konfiguration(conn: sqlite3.Connection, stil: str) -> dict[str, Any]: def client_konfiguration(conn: sqlite3.Connection, stil: str) -> dict[str, Any]:
"""Was ein Linux-Client braucht, damit die Optionen bei ihm ankommen. """Was ein Linux-Client braucht, damit die Optionen bei ihm ankommen.
@@ -798,8 +1009,10 @@ def client_konfiguration(conn: sqlite3.Connection, stil: str) -> dict[str, Any]:
ausdruecklich anfordern. Ohne diese Zeilen sendet der Server brav und der ausdruecklich anfordern. Ohne diese Zeilen sendet der Server brav und der
Client wirft es weg, ohne dass irgendwo etwas danebengeht. Client wirft es weg, ohne dass irgendwo etwas danebengeht.
Rueckgabe: ``{"pfad", "inhalt", "nachher", "optionen"}`` -- Datei, Inhalt, Rueckgabe: ``{"pfad", "inhalt", "nachher", "skript", "optionen"}``.
der Befehl zum Uebernehmen und die Optionen, um die es geht. ``skript`` ist die Fassung zum Ausfuehren -- sie sucht sich das Ziel auf
dem Client selbst und traegt den Inhalt ein, ohne Vorhandenes zu
verlieren.
""" """
optionen = [ optionen = [
eintrag eintrag
@@ -814,20 +1027,31 @@ def client_konfiguration(conn: sqlite3.Connection, stil: str) -> dict[str, Any]:
"# Von TESM erzeugt. Fordert die Optionen an, die dieser Server sendet --", "# Von TESM erzeugt. Fordert die Optionen an, die dieser Server sendet --",
"# ohne diese Zeilen verwirft der Client sie stillschweigend.", "# ohne diese Zeilen verwirft der Client sie stillschweigend.",
"#", "#",
"# Der Dateiname muss zur .network-Datei der Schnittstelle passen:", "# Diese Datei ist ein Zusatz zu der .network-Datei, die fuer die",
"# netplan legt sie unter /run/systemd/network/10-netplan-<if>.network ab,", "# Schnittstelle gilt. Wie sie heisst, sagt 'networkctl status <if>';",
"# der Zusatz gehoert also nach", "# bei netplan ist es /run/systemd/network/10-netplan-<if>.network, der",
"# Zusatz gehoert dann nach",
"# /etc/systemd/network/10-netplan-<if>.network.d/50-tesm.conf.", "# /etc/systemd/network/10-netplan-<if>.network.d/50-tesm.conf.",
"#", "#",
"# Die Werte stehen danach in /run/systemd/netif/leases/<ifindex>", "# Die Werte stehen danach in /run/systemd/netif/leases/<ifindex>",
"# als OPTION_<code>=...", "# als OPTION_<code>= -- hexkodiert, weil systemd fremde Optionen nicht",
"# deutet. Den Klartext schreibt der mitgelieferte Uebersetzer nach",
"# /run/tesm-dhcp-options.env.",
"#",
"# Angefordert wird der ganze Bereich 224-254, damit eine spaeter",
"# hinzugefuegte Option ohne Zutun ankommt. Eigene Optionen ausserhalb",
"# dieses Bereichs hebt systemd-networkd nicht auf.",
"[DHCPv4]", "[DHCPv4]",
] ]
zeilen.append("RequestOptions=" + " ".join(str(code) for code in codes)) # Angefordert wird der **ganze** eigene Bereich, nicht nur das, was
nachher = "networkctl reload && networkctl reconfigure <schnittstelle>" # heute gesendet wird: sonst muesste man nach jeder neuen Option zu
# jedem Client. Ein Client bekommt trotzdem nur, was der Server hat.
angefragt = sorted(set(PRIVATBEREICH) | set(codes))
zeilen.append("RequestOptions=" + " ".join(str(code) for code in angefragt))
nachher = "networkctl reload && networkctl reconfigure <Schnittstelle>"
elif stil == "dhcpcd": elif stil == "dhcpcd":
zeilen = [ zeilen = [
"# Von TESM erzeugt -- an das Ende von /etc/dhcpcd.conf.", "# >>> TESM -- nicht von Hand aendern, wird beim naechsten Mal ersetzt",
"# 'define' macht die Option bekannt, 'option' fordert sie an.", "# 'define' macht die Option bekannt, 'option' fordert sie an.",
"# Danach steht der Wert in der Lease (/var/lib/dhcpcd/<if>.lease)", "# Danach steht der Wert in der Lease (/var/lib/dhcpcd/<if>.lease)",
"# und laesst sich mit 'dhcpcd -U <if>' als Variable auslesen.", "# und laesst sich mit 'dhcpcd -U <if>' als Variable auslesen.",
@@ -837,10 +1061,11 @@ def client_konfiguration(conn: sqlite3.Connection, stil: str) -> dict[str, Any]:
zeilen.append(f"define {eintrag['code']} {typ} {eintrag['name']}") zeilen.append(f"define {eintrag['code']} {typ} {eintrag['name']}")
for eintrag in optionen: for eintrag in optionen:
zeilen.append(f"option {eintrag['name']}") zeilen.append(f"option {eintrag['name']}")
zeilen.append("# <<< TESM")
nachher = "systemctl restart dhcpcd" nachher = "systemctl restart dhcpcd"
else: else:
zeilen = [ zeilen = [
"# Von TESM erzeugt -- an das Ende von /etc/dhcp/dhclient.conf.", "# >>> TESM -- nicht von Hand aendern, wird beim naechsten Mal ersetzt",
"# Die Werte stehen danach in /var/lib/dhcp/dhclient.leases,", "# Die Werte stehen danach in /var/lib/dhcp/dhclient.leases,",
"# und in Hooks unter $new_<name>.", "# und in Hooks unter $new_<name>.",
] ]
@@ -851,16 +1076,112 @@ def client_konfiguration(conn: sqlite3.Connection, stil: str) -> dict[str, Any]:
zeilen.append( zeilen.append(
"also request " + ", ".join(str(eintrag["name"]) for eintrag in optionen) + ";" "also request " + ", ".join(str(eintrag["name"]) for eintrag in optionen) + ";"
) )
zeilen.append("# <<< TESM")
nachher = "systemctl restart networking || dhclient -r && dhclient" nachher = "systemctl restart networking || dhclient -r && dhclient"
inhalt = chr(10).join(zeilen) + chr(10)
return { return {
"pfad": pfad, "pfad": pfad,
"inhalt": chr(10).join(zeilen) + chr(10), "inhalt": inhalt,
"nachher": nachher, "nachher": nachher,
"skript": _uebernahme_skript(stil, inhalt, optionen),
"auslesen": _auslesen(stil, optionen),
"optionen": optionen, "optionen": optionen,
} }
def _auslesen(stil: str, benannt: list[dict[str, Any]]) -> dict[str, str]:
"""Wie der Wert auf dem Client wieder herauskommt.
Gehoert dazu, weil "angekommen" sonst niemand pruefen kann -- und weil ein
RMM-Agent genau diese Zeile braucht.
Zwei Wege bei systemd-networkd, und das hat einen Grund: in der Lease
steht der Wert **hexkodiert unter seiner Nummer**. Im DHCP-Paket reisen
nur Code, Laenge und Bytes; dass 225 "url" heisst und Text enthaelt, weiss
allein der Katalog dieses Servers. Deshalb legt der SSH-Push einen kleinen
Uebersetzer mit, der nach jeder Lease ``/run/tesm-dhcp-options.env``
schreibt -- "name=wert", lesbar. ``roh`` bleibt fuer den Fall, dass nur
die Datei von Hand kopiert wurde.
"""
if not benannt:
return {}
code = int(benannt[0]["code"])
name = str(benannt[0]["name"])
if stil == "networkd":
return {
"einfach": f"grep '^{name}=' /run/tesm-dhcp-options.env",
"roh": (
"lease=/run/systemd/netif/leases/$(cat /sys/class/net/$IF/ifindex); "
f"/usr/bin/printf \"$(sed -n 's/^OPTION_{code}=//p' \"$lease\" "
"| sed 's/../@BS@x&/g')\""
).replace("@BS@", "\\" * 2),
}
if stil == "dhcpcd":
# dhcpcd kennt den Namen, weil 'define' ihn ihm beigebracht hat.
return {"einfach": f"dhcpcd -U $IF | grep -i '^{name}='", "roh": ""}
return {
"einfach": f"grep -i 'option {name}' /var/lib/dhcp/dhclient.leases | tail -n1",
"roh": "",
}
def _b64(text: str) -> str:
import base64
return base64.b64encode(text.encode("utf-8")).decode("ascii")
def _uebernahme_skript(stil: str, inhalt: str, benannt: list[dict[str, Any]] | None = None) -> str:
"""Das Skript, das die Konfiguration auf dem Client eintraegt.
Zwei Dinge macht es anders als ein blosses ``tee``, und beide sind mit
Schaden bezahlt worden:
* Bei ``networkd`` **sucht es das Ziel auf dem Client**. Der Name des
Zusatzverzeichnisses muss zur aktiven .network-Datei passen; ein fester
Pfad legt eine Datei an, die kein Dienst je liest -- sie stand da, und
die Option fehlte trotzdem in der Lease.
* Bei ``dhcpcd`` und ``dhclient`` gehoeren die Zeilen in eine Datei, die
dem Client gehoert. Sie zu ueberschreiben haette seine uebrige
Konfiguration geloescht. Stattdessen wird nur der eigene Block zwischen
den Marken ersetzt -- beliebig oft wiederholbar.
Der Inhalt reist base64-kodiert und als **Argument**, nicht ueber stdin:
dort steht bei einem Client mit Passwort das Kennwort fuer ``sudo``.
"""
fracht = _b64(inhalt)
benannt = benannt or []
if stil == "networkd":
paare = " ".join(
f"{eintrag['code']}:{eintrag['name']}:{eintrag['type']}" for eintrag in benannt
)
uebersetzer = (
UEBERSETZER.replace("@PAARE@", paare)
.replace("@KATALOG@", str(KATALOG_CODE))
.replace("@BS@", "\\\\") # sed gibt aus zwei Zeichen eines aus
)
return (
NETWORKD_SKRIPT.replace("@FRACHT@", fracht)
.replace("@UEBERSETZER@", _b64(uebersetzer))
.replace("@DIENST@", _b64(DIENST_EINHEIT))
.replace("@PFADEINHEIT@", _b64(PFAD_EINHEIT))
)
ziel = "/etc/dhcpcd.conf" if stil == "dhcpcd" else "/etc/dhcp/dhclient.conf"
dienst = (
"systemctl restart dhcpcd"
if stil == "dhcpcd"
else "systemctl restart networking 2>/dev/null || { dhclient -r || true; dhclient; }"
)
return (
BLOCK_SKRIPT.replace("@ZIEL@", ziel)
.replace("@FRACHT@", fracht)
.replace("@DIENST@", dienst)
)
def service_state(sysops: SysOps) -> dict[str, str]: def service_state(sysops: SysOps) -> dict[str, str]:
state = sysops.service_state(SERVICE_UNIT) state = sysops.service_state(SERVICE_UNIT)
import shutil import shutil
+54 -11
View File
@@ -230,15 +230,18 @@
{% endif %} {% endif %}
{% endmacro %} {% endmacro %}
{% set gesetzte = options | selectattr('value') | list %} {# "gesetzt" heisst nicht nur "global gesetzt": ein Wert an einem einzelnen
Geraet wird genauso gesendet. Die Liste kommt deshalb fertig vom Server --
in Jinja gerechnet zaehlte sie nur die globalen und meldete "Nichts
gesetzt", waehrend die Vorschau daneben die Option auswies. #}
<section class="card"> <section class="card">
<div class="card__header"> <div class="card__header">
<div class="flex-1"> <div class="flex-1">
<div class="card__title">{{ icon('puzzle', size=17) }} Gesendete Optionen</div> <div class="card__title">{{ icon('puzzle', size=17) }} Gesendete Optionen</div>
<div class="card__sub"> <div class="card__sub">
{% if gesetzte %} {% if gesendet %}
<strong>{{ gesetzte | length }}</strong> von {{ options | length }} Optionen tragen <strong>{{ gesendet | length }}</strong> von {{ options | length }} Optionen tragen
einen Wert. Was leer ist, wird nicht gesendet. einen Wert. Was leer ist, wird nicht gesendet.
{% else %} {% else %}
Derzeit trägt keine Option einen Wert – es wird also nichts über die Derzeit trägt keine Option einen Wert – es wird also nichts über die
@@ -248,7 +251,7 @@
</div> </div>
</div> </div>
{% if gesetzte %} {% if gesendet %}
<div class="table-wrap"> <div class="table-wrap">
<table class="table table--compact"> <table class="table table--compact">
<thead><tr> <thead><tr>
@@ -256,7 +259,7 @@
<th style="width:42%">Wert</th><th class="actions"></th> <th style="width:42%">Wert</th><th class="actions"></th>
</tr></thead> </tr></thead>
<tbody> <tbody>
{% for option in gesetzte %} {% for option in gesendet %}
<tr> <tr>
<td class="numeric fs-sm">{{ option.code }}</td> <td class="numeric fs-sm">{{ option.code }}</td>
<td class="fs-sm"> <td class="fs-sm">
@@ -264,7 +267,19 @@
<div class="fs-xs faint">{{ option.description }}</div> <div class="fs-xs faint">{{ option.description }}</div>
</td> </td>
<td class="fs-xs mono">{{ option.type }}</td> <td class="fs-xs mono">{{ option.type }}</td>
<td>{{ optionswert(option) }}</td> <td>
{{ optionswert(option) }}
{% if option.geraete %}
<div class="fs-xs faint mt-2">
{% for eintrag in option.geraete %}
<div>
nur für <strong>{{ eintrag.geraet }}</strong>:
<span class="mono">{{ eintrag.value }}</span>
</div>
{% endfor %}
</div>
{% endif %}
</td>
<td class="actions">{{ optionsloeschen(option) }}</td> <td class="actions">{{ optionsloeschen(option) }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
@@ -284,7 +299,7 @@
<div class="flex-1"> <div class="flex-1">
<div class="card__title">{{ icon('list', size=17) }} Optionskatalog</div> <div class="card__title">{{ icon('list', size=17) }} Optionskatalog</div>
<div class="card__sub"> <div class="card__sub">
Alle {{ options | length }} bekannten Optionen. Standardoptionen sind nicht löschbar, Alle {{ katalog | length }} bekannten Optionen. Standardoptionen sind nicht löschbar,
ihr Wert lässt sich leeren. ihr Wert lässt sich leeren.
</div> </div>
</div> </div>
@@ -297,7 +312,7 @@
<th style="width:42%">Wert</th><th class="actions"></th> <th style="width:42%">Wert</th><th class="actions"></th>
</tr></thead> </tr></thead>
<tbody> <tbody>
{% for option in options %} {% for option in katalog %}
<tr data-filter-text="{{ option.code }} {{ option.name }} {{ option.description }}"> <tr data-filter-text="{{ option.code }} {{ option.name }} {{ option.description }}">
<td class="numeric fs-sm">{{ option.code }}</td> <td class="numeric fs-sm">{{ option.code }}</td>
<td class="fs-sm"> <td class="fs-sm">
@@ -305,8 +320,16 @@
<div class="fs-xs faint">{{ option.description }}</div> <div class="fs-xs faint">{{ option.description }}</div>
</td> </td>
<td class="fs-xs mono">{{ option.type }}</td> <td class="fs-xs mono">{{ option.type }}</td>
<td>{{ optionswert(option) }}</td> {% if option.reserviert %}
<td class="actions">{{ optionsloeschen(option) }}</td> {# Kein Eingabefeld: den Wert bestimmt der Katalog selbst. #}
<td class="fs-xs mono faint">
{{ option.value or '(noch sendet keine eigene Option einen Wert)' }}
</td>
<td class="actions">{{ pill('belegt', '') }}</td>
{% else %}
<td>{{ optionswert(option) }}</td>
<td class="actions">{{ optionsloeschen(option) }}</td>
{% endif %}
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
@@ -321,6 +344,8 @@
<div class="card__title">{{ icon('plus', size=17) }} Eigene Option anlegen</div> <div class="card__title">{{ icon('plus', size=17) }} Eigene Option anlegen</div>
<div class="card__sub"> <div class="card__sub">
Für alles jenseits des Katalogs – etwa eine URL, die der Client beim Start öffnet. Für alles jenseits des Katalogs – etwa eine URL, die der Client beim Start öffnet.
Code <strong>224–254</strong>: nur diesen Bereich sieht DHCP für eigene Optionen vor,
und nur daraus hebt ein Linux-Client mit systemd-networkd sie überhaupt auf.
</div> </div>
</div> </div>
</div> </div>
@@ -328,7 +353,8 @@
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}"> <input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="card__body"> <div class="card__body">
<div class="form-grid"> <div class="form-grid">
{{ field('code', 'Code', type='number', required=True, placeholder='225') }} {{ field('code', 'Code', type='number', required=True, placeholder='226',
hint_text='224 bis 254') }}
{{ field('name', 'Name', required=True, placeholder='meine-option') }} {{ field('name', 'Name', required=True, placeholder='meine-option') }}
<label class="field"> <label class="field">
<span class="field__label">Typ</span> <span class="field__label">Typ</span>
@@ -445,6 +471,23 @@
</div> </div>
<pre class="code-block code-block--wrap" id="konfig-{{ stil }}">{{ konfig.inhalt }}</pre> <pre class="code-block code-block--wrap" id="konfig-{{ stil }}">{{ konfig.inhalt }}</pre>
<div class="fs-xs faint">Danach: <code>{{ konfig.nachher }}</code></div> <div class="fs-xs faint">Danach: <code>{{ konfig.nachher }}</code></div>
{% if konfig.auslesen.einfach %}
{# Ohne diese Zeile kann niemand pruefen, ob es angekommen ist --
und ein RMM-Agent braucht genau sie. #}
<div class="fs-xs faint">
Auslesen auf dem Client:
<code class="break-all">{{ konfig.auslesen.einfach }}</code>
</div>
{% if konfig.auslesen.roh %}
<div class="fs-xs faint">
systemd legt fremde Optionen unter ihrer Nummer und hexkodiert ab – den
Klartext schreibt ein kleiner Übersetzer, den der SSH-Push mitinstalliert
(<code>/run/tesm-dhcp-options.env</code>, nach jeder Lease erneuert).
Direkt aus der Lease:
<code class="break-all">{{ konfig.auslesen.roh }}</code>
</div>
{% endif %}
{% endif %}
{% if current_user.has('servicing_command.execute') %} {% if current_user.has('servicing_command.execute') %}
<form method="post" action="{{ url_for('dhcp.push_client_config') }}"> <form method="post" action="{{ url_for('dhcp.push_client_config') }}">
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "tesm-core" 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" 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;