Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
65a7810cf2 | ||
|
|
a6d9a46d8f | ||
|
|
5cc34f2a1f | ||
|
|
e284d70855 | ||
|
|
b4e81959e7 | ||
|
|
29b62714cb | ||
|
|
f59ba42e55 | ||
|
|
b1275b2c71 | ||
|
|
1d8f92fd2d | ||
|
|
5324174f90 |
No files matched your search
@@ -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 = [
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
@@ -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():
|
||||||
|
|||||||
@@ -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,15 +484,40 @@ 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">
|
||||||
|
|||||||
@@ -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 – 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') %} · {{ 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,47 +191,128 @@
|
|||||||
{% 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 ---------------------------------------------------------- #}
|
||||||
|
|
||||||
|
{# 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') %}
|
||||||
|
<form method="post" action="{{ url_for('dhcp.set_option_value') }}"
|
||||||
|
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="option_id" value="{{ option.id }}">
|
||||||
|
{# 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. #}
|
||||||
|
<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>
|
||||||
|
{% else %}
|
||||||
|
<span class="mono fs-sm">{{ option.value or '–' | safe }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
{% macro optionsloeschen(option) %}
|
||||||
|
{% 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) }}"
|
||||||
|
data-no-guard>
|
||||||
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
|
<button class="btn btn--sm btn--ghost danger-text" type="submit"
|
||||||
|
data-confirm="Eigene Option {{ option.name }} löschen?"
|
||||||
|
data-confirm-title="Option löschen" data-confirm-ok="Löschen">
|
||||||
|
{{ icon('trash', size=14) }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% 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">
|
<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) }} DHCP-Optionen</div>
|
<div class="card__title">{{ icon('puzzle', size=17) }} Gesendete Optionen</div>
|
||||||
<div class="card__sub">
|
<div class="card__sub">
|
||||||
{{ options | length }} Optionen · Standardoptionen sind nicht löschbar, ihr Wert
|
{% if gesendet %}
|
||||||
lässt sich leeren
|
<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>
|
</div>
|
||||||
{{ search_box('options-table', 'Option suchen ...') }}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="table-wrap" style="max-height:420px;overflow:auto">
|
|
||||||
|
{% 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 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>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% 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">
|
<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>
|
<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>
|
<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">
|
||||||
@@ -182,43 +320,41 @@
|
|||||||
<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>
|
{% if option.reserviert %}
|
||||||
{% if current_user.has('dhcp_options.write') %}
|
{# Kein Eingabefeld: den Wert bestimmt der Katalog selbst. #}
|
||||||
<form method="post" action="{{ url_for('dhcp.set_option_value') }}" class="row row--tight"
|
<td class="fs-xs mono faint">
|
||||||
data-no-guard>
|
{{ option.value or '(noch sendet keine eigene Option einen Wert)' }}
|
||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
</td>
|
||||||
<input type="hidden" name="option_id" value="{{ option.id }}">
|
<td class="actions">{{ pill('belegt', '') }}</td>
|
||||||
<input class="input input--mono" type="text" name="value" value="{{ option.value or '' }}"
|
{% else %}
|
||||||
style="min-width:180px" placeholder="leer = nicht senden">
|
<td>{{ optionswert(option) }}</td>
|
||||||
<button class="btn btn--sm" type="submit">{{ icon('check', size=14) }}</button>
|
<td class="actions">{{ optionsloeschen(option) }}</td>
|
||||||
</form>
|
{% endif %}
|
||||||
{% else %}
|
|
||||||
<span class="mono fs-sm">{{ option.value or '–' | safe }}</span>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td class="actions">
|
|
||||||
{% 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) }}"
|
|
||||||
data-no-guard>
|
|
||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
|
||||||
<button class="btn btn--sm btn--ghost danger-text" type="submit"
|
|
||||||
data-confirm="Eigene Option {{ option.name }} löschen?"
|
|
||||||
data-confirm-title="Option löschen" data-confirm-ok="Löschen">
|
|
||||||
{{ icon('trash', size=14) }}</button>
|
|
||||||
</form>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
{% if current_user.has('dhcp_options.write') %}
|
</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 – so würde die Datei geschrieben</div>
|
<div class="card__sub">Vorschau – 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') %}
|
||||||
|
|||||||
@@ -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/*)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in new issue
Block a user