diff --git a/apps/tesm/pyproject.toml b/apps/tesm/pyproject.toml index e97d328..fb02bfd 100644 --- a/apps/tesm/pyproject.toml +++ b/apps/tesm/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "tesm" -version = "2.8.42" +version = "2.8.43" description = "TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten und Aruba-Switchen" requires-python = ">=3.11" dependencies = [ diff --git a/apps/tesm/src/tesm/blueprints/dhcp.py b/apps/tesm/src/tesm/blueprints/dhcp.py index 2324adb..42646c2 100644 --- a/apps/tesm/src/tesm/blueprints/dhcp.py +++ b/apps/tesm/src/tesm/blueprints/dhcp.py @@ -112,9 +112,24 @@ def overview(): stil: kea.client_konfiguration(conn, stil) for stil, _, _ in kea.CLIENT_STILE }, 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 # --------------------------------------------------------------------------- # @@ -148,7 +163,13 @@ def add_subnet(): start = (request.form.get("range_start") 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: flash(problem, "danger") return redirect(url_for("dhcp.overview")) @@ -183,7 +204,14 @@ def update_subnet(subnet_id: int): interface = (request.form.get("interface") or "").strip() start = (request.form.get("range_start") 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: flash(problem, "danger") return redirect(url_for("dhcp.overview")) @@ -608,7 +636,13 @@ def _write_and_redirect(): for warning in result.get("warnings", []): flash(warning, "warning") 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"]: flash(result["message"], "info") return redirect(url_for("dhcp.overview")) diff --git a/apps/tesm/src/tesm/services/kea.py b/apps/tesm/src/tesm/services/kea.py index a2fc2ae..50d976b 100644 --- a/apps/tesm/src/tesm/services/kea.py +++ b/apps/tesm/src/tesm/services/kea.py @@ -186,9 +186,20 @@ def matching_network( 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: - """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: return "Es ist kein Interface ausgewählt." try: @@ -203,9 +214,41 @@ def validate_subnet( f"Der Bereich {range_start}-{range_end} passt in kein Netz von {interface}. " "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 "" +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 # --------------------------------------------------------------------------- # @@ -393,8 +436,16 @@ def render_config( return entries 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() - for index, subnet in enumerate(subnets, start=1): + for subnet in subnets: interface = str(subnet["interface"]) match = matching_network( networks, interface, str(subnet["range_start"]), str(subnet["range_end"]) @@ -433,15 +484,40 @@ def render_config( entry["option-data"] = option_data(overrides) subnet_reservations.append(entry) - subnet_entries.append( - { - "id": index, - "subnet": str(network), - "pools": [{"pool": f"{subnet['range_start']} - {subnet['range_end']}"}], + schluessel = str(network) + eintrag = nach_netz.get(schluessel) + if eintrag is None: + eintrag = { + # 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 {}), "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: warnings.append("Es ist kein gültiges Subnetz konfiguriert | Kea würde nicht starten.") diff --git a/apps/tesm/src/tesm/templates/tesm/dhcp.html b/apps/tesm/src/tesm/templates/tesm/dhcp.html index 8b2034f..a6aa82e 100644 --- a/apps/tesm/src/tesm/templates/tesm/dhcp.html +++ b/apps/tesm/src/tesm/templates/tesm/dhcp.html @@ -7,6 +7,29 @@ {% endblock %} {% 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' %} +