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]
name = "tesm"
version = "2.8.43"
version = "2.8.49"
description = "TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten und Aruba-Switchen"
requires-python = ">=3.11"
dependencies = [
+84 -17
View File
@@ -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
@@ -81,15 +81,65 @@ def overview():
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(
"tesm/dhcp.html",
subnets=Database.all(conn, "SELECT * FROM dhcp_subnets ORDER BY sort_order, id"),
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",
),
options=options,
katalog=katalog,
gesendet=gesendet,
option_types=kea.OPTION_TYPES,
device_option_values=_device_option_values(conn),
reservations=reservations,
@@ -375,6 +425,17 @@ def add_custom_option():
if not 1 <= code <= 254:
flash("Der Optionscode muss zwischen 1 und 254 liegen.", "danger")
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:
flash("Name und Typ sind erforderlich.", "danger")
return redirect(url_for("dhcp.overview"))
@@ -389,6 +450,16 @@ def add_custom_option():
)
_audit("dhcp.option_created", name, severity="notice")
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"))
@@ -684,16 +755,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:
+333 -12
View File
@@ -545,6 +545,23 @@ def render_config(
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 = {
"Dhcp4": {
"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()))
#: 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.
@@ -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
#: 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-<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"),
)
@@ -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]:
"""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
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 +1027,31 @@ 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-<if>.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 <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>=...",
"# 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]",
]
zeilen.append("RequestOptions=" + " ".join(str(code) for code in codes))
nachher = "networkctl reload && networkctl reconfigure <schnittstelle>"
# 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 = [
"# 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/<if>.lease)",
"# 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']}")
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_<name>.",
]
@@ -851,16 +1076,112 @@ 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),
"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]:
state = sysops.service_state(SERVICE_UNIT)
import shutil
+54 -11
View File
@@ -230,15 +230,18 @@
{% endif %}
{% 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">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('puzzle', size=17) }} Gesendete Optionen</div>
<div class="card__sub">
{% if gesetzte %}
<strong>{{ gesetzte | length }}</strong> von {{ options | length }} Optionen tragen
{% 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
@@ -248,7 +251,7 @@
</div>
</div>
{% if gesetzte %}
{% if gesendet %}
<div class="table-wrap">
<table class="table table--compact">
<thead><tr>
@@ -256,7 +259,7 @@
<th style="width:42%">Wert</th><th class="actions"></th>
</tr></thead>
<tbody>
{% for option in gesetzte %}
{% for option in gesendet %}
<tr>
<td class="numeric fs-sm">{{ option.code }}</td>
<td class="fs-sm">
@@ -264,7 +267,19 @@
<div class="fs-xs faint">{{ option.description }}</div>
</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>
</tr>
{% endfor %}
@@ -284,7 +299,7 @@
<div class="flex-1">
<div class="card__title">{{ icon('list', size=17) }} Optionskatalog</div>
<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.
</div>
</div>
@@ -297,7 +312,7 @@
<th style="width:42%">Wert</th><th class="actions"></th>
</tr></thead>
<tbody>
{% for option in options %}
{% 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">
@@ -305,8 +320,16 @@
<div class="fs-xs faint">{{ option.description }}</div>
</td>
<td class="fs-xs mono">{{ option.type }}</td>
<td>{{ optionswert(option) }}</td>
<td class="actions">{{ optionsloeschen(option) }}</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>
@@ -321,6 +344,8 @@
<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>
@@ -328,7 +353,8 @@
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="card__body">
<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') }}
<label class="field">
<span class="field__label">Typ</span>
@@ -445,6 +471,23 @@
</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') }}">
+1 -1
View File
@@ -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 = [
@@ -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;