Compare commits

...
2 Commits
9 changed files with 199 additions and 23 deletions

No files matched your search

+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "tesm" name = "tesm"
version = "2.8.41" version = "2.8.43"
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 = [
+37 -3
View File
@@ -112,9 +112,24 @@ def overview():
stil: kea.client_konfiguration(conn, stil) for stil, _, _ in kea.CLIENT_STILE 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
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@@ -148,7 +163,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"))
@@ -183,7 +204,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"))
@@ -608,7 +636,13 @@ 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"))
+84 -8
View File
@@ -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.")
@@ -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">
+33 -6
View File
@@ -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 &ndash; 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') %} &middot; {{ 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>
@@ -252,7 +280,7 @@
Rollbereich innerhalb der scrollenden Seite -- und das Gesuchte ging darin Rollbereich innerhalb der scrollenden Seite -- und das Gesuchte ging darin
unter. Jetzt liegt er hinter einem Klick, mit eigener Suche. #} unter. Jetzt liegt er hinter einem Klick, mit eigener Suche. #}
<details class="card" data-persist="dhcp-katalog"> <details class="card" data-persist="dhcp-katalog">
<summary class="card__header" style="cursor:pointer"> <summary class="card__header">
<div class="flex-1"> <div class="flex-1">
<div class="card__title">{{ icon('list', size=17) }} Optionskatalog</div> <div class="card__title">{{ icon('list', size=17) }} Optionskatalog</div>
<div class="card__sub"> <div class="card__sub">
@@ -403,7 +431,7 @@
{% for stil, bezeichnung, pfad in client_stile %} {% for stil, bezeichnung, pfad in client_stile %}
{% set konfig = client_konfig.get(stil) %} {% set konfig = client_konfig.get(stil) %}
<details class="card__body pt-0"> <details class="card__body pt-0">
<summary class="row row--tight" style="cursor:pointer"> <summary class="row row--tight">
<strong class="fs-sm">{{ bezeichnung }}</strong> <strong class="fs-sm">{{ bezeichnung }}</strong>
<span class="fs-xs faint mono">{{ pfad }}</span> <span class="fs-xs faint mono">{{ pfad }}</span>
</summary> </summary>
@@ -629,12 +657,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 &ndash; so würde die Datei geschrieben</div> <div class="card__sub">Vorschau &ndash; 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') %}
+4
View File
@@ -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/*)
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "tesm-core" name = "tesm-core"
version = "2.8.20" version = "2.8.21"
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 = [
@@ -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>