diff --git a/apps/tesm/pyproject.toml b/apps/tesm/pyproject.toml index d394130..b5c463c 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.27" +version = "2.8.28" 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 fec558d..016ee11 100644 --- a/apps/tesm/src/tesm/blueprints/dhcp.py +++ b/apps/tesm/src/tesm/blueprints/dhcp.py @@ -20,7 +20,7 @@ from tesm_core.auth.service import client_ip from tesm_core.db import Database from tesm_core.extension import core, db, transaction -from ..services import kea +from ..services import inventory, kea from ..permissions import DHCP_PAGE bp = Blueprint("dhcp", __name__, url_prefix="/dhcp") @@ -73,6 +73,12 @@ def overview(): # Zustand aus den laufenden Leases -- wie im Windows-DHCP. reservations = kea.annotate_reservations(reservations, leases) reserved_ips = {str(entry["ip"]) for entry in reservations} + # Wozu es schon einen Client gibt, ist nicht mehr zu uebernehmen -- auch + # wenn dessen Adresse (noch) keine Reservierung hat. + bekannte_macs = { + inventory.normalize_mac(str(zeile["mac"])) + for zeile in Database.all(conn, "SELECT mac FROM devices WHERE deleted_at IS NULL") + } return render_template( "tesm/dhcp.html", @@ -90,6 +96,7 @@ def overview(): manual_reservations=Database.all(conn, "SELECT * FROM dhcp_reservations ORDER BY ip"), leases=leases, reserved_ips=reserved_ips, + bekannte_macs=bekannte_macs, networks=networks, default_gateway=kea.default_gateway(sysops) if sysops else "", service=kea.service_state(sysops) if sysops else {}, @@ -447,6 +454,91 @@ def add_reservation(): return _write_and_redirect() +@bp.post("/lease/adopt") +# Zwei Rechte, mit Absicht: die Uebernahme ist eine eigene Funktion, das +# Ergebnis aber ein Client. Wer keine Clients anlegen darf, legt auch hier +# keine an -- sonst waere diese Schaltflaeche eine Hintertuer an der +# Geraeteverwaltung vorbei. +@permission_required("dhcp_lease_adopt.execute", "devices.create") +def adopt_lease(): + """Macht aus einer dynamisch vergebenen Adresse einen ueberwachten Client. + + Die Angaben kommen **nicht** aus dem Formular, sondern aus der Lease-Datei: + geschickt wird nur die MAC, alles Weitere liest TESM frisch nach. Damit + heisst "Uebernahme" auch Uebernahme -- und eine Lease, die zwischen + Seitenaufbau und Klick abgelaufen oder umgezogen ist, faellt auf, statt + stillschweigend einen falschen Datensatz anzulegen. + """ + extension = core() + conn = db() + sysops = extension.sysops + gesucht = inventory.normalize_mac(request.form.get("mac") or "") + leases = kea.read_leases(sysops) if sysops else [] + lease = next((eintrag for eintrag in leases if eintrag.mac == gesucht), None) + if lease is None: + flash( + "Zu dieser MAC liegt keine Lease mehr vor. Die Liste war nicht mehr aktuell.", + "warning", + ) + return redirect(url_for("dhcp.overview")) + + # Nur dynamische. Eine Adresse, die schon reserviert ist, gehoert bereits + # zu einem gepflegten Datensatz -- da gibt es nichts zu uebernehmen. + networks = kea.detect_networks(sysops) if sysops else [] + reserviert = { + str(eintrag["ip"]) + for eintrag in kea.collect_reservations( + conn, only_networks=kea.configured_networks(conn, networks) + ) + } + if lease.address in reserviert: + flash( + f"{lease.address} ist bereits reserviert – diese Lease ist nicht dynamisch.", + "warning", + ) + return redirect(url_for("dhcp.overview")) + + # Der Hostname aus der Lease ist der Name, den das Geraet selbst nennt. + # Fehlt er, steht die Adresse da: ein Name wie "Gerät 7" sagt weniger. + name = (lease.hostname or "").strip() or f"Client {lease.address}" + try: + with transaction(): + device_id = inventory.create_device( + conn, + name=name, + mac=lease.mac, + ip=lease.address, + switch_id=None, + port="", + credential_id=None, + ssh_port=None, + # Ohne Switch und Port gibt es keinen PoE-Neustart. Mit + # eingeschaltetem Automatismus schriebe die Ueberwachung bei + # jedem Ausfall einen Fehlschlag ins Protokoll, den niemand + # abstellen kann. Einschalten kann man ihn, sobald der Port + # eingetragen ist. + auto_restart=False, + actor=current_user.username, + ) + except inventory.ValidationError as exc: + flash(str(exc), "danger") + return redirect(url_for("dhcp.overview")) + + _audit( + "device.created", + name, + f"aus dynamischer Lease {lease.address} ({lease.mac}) übernommen", + severity="notice", + ) + flash( + f"{name} wurde als Client übernommen. Switch, Port und Zugangsdaten fehlen noch – " + "ohne sie gibt es keinen PoE-Neustart und keine Wartung. Die feste Adresse entsteht " + "beim nächsten Schreiben der DHCP-Konfiguration.", + "success", + ) + return redirect(url_for("devices.detail", device_id=device_id)) + + @bp.post("/reservation//delete") @permission_required("dhcp_reservations.delete") def delete_reservation(reservation_id: int): diff --git a/apps/tesm/src/tesm/permissions.py b/apps/tesm/src/tesm/permissions.py index c2d91ff..0d88deb 100644 --- a/apps/tesm/src/tesm/permissions.py +++ b/apps/tesm/src/tesm/permissions.py @@ -264,6 +264,31 @@ DHCP_SERVICE = Resource( action_hints={A.EXECUTE: "Dienstzustand ändern."}, ) +#: Eine dynamische Lease in einen Client verwandeln. +#: +#: Eigene Zeile, weil es eine eigene Entscheidung ist: hier wird aus einer +#: Beobachtung ("dieses Geraet hat sich eine Adresse geholt") ein gepflegter +#: Datensatz, den die Ueberwachung ab sofort anpingt und der beim naechsten +#: Schreiben der Konfiguration eine feste Adresse bekommt. Wer Leases sehen +#: darf, soll deshalb nicht automatisch welche uebernehmen duerfen. +#: +#: **Und es reicht allein nicht.** Die Uebernahme legt einen Client an, also +#: verlangt die Route zusaetzlich "Clients: Anlegen". Sonst waere dies eine +#: Hintertuer an der Geraeteverwaltung vorbei -- dieselbe Wirkung, anderes +#: Formular. Beide Haken zusammen, oder die Schaltflaeche erscheint nicht. +DHCP_LEASE_ADOPT = Resource( + key="dhcp_lease_adopt", + parent="dhcp_subnets", + label="DHCP: Lease als Client übernehmen", + actions=(A.READ, A.EXECUTE), + hint="Aus einer dynamisch vergebenen Adresse einen überwachten Client machen", + action_hints={ + A.READ: "Die Schaltfläche an dynamischen Leases sehen.", + A.EXECUTE: "Client aus der Lease anlegen. Verlangt zusätzlich " + "'Clients: Anlegen' – es entsteht ein Gerätedatensatz.", + }, +) + #: Die DHCP-Seite zeigt alle Abschnitte; jeder Abschnitt haengt an seiner #: eigenen Zeile. Geoeffnet wird sie, wer einen davon sehen darf. DHCP_PAGE: tuple[str, ...] = ( @@ -380,7 +405,14 @@ NETWORK_AREA = Area( key="network", label="Netzwerkdienste", icon="network", - resources=(DHCP_SUBNETS, DHCP_OPTIONS, DHCP_RESERVATIONS, DHCP_CONFIG, DHCP_SERVICE), + resources=( + DHCP_SUBNETS, + DHCP_OPTIONS, + DHCP_RESERVATIONS, + DHCP_LEASE_ADOPT, + DHCP_CONFIG, + DHCP_SERVICE, + ), ) FILES_AREA = Area( diff --git a/apps/tesm/src/tesm/templates/tesm/dhcp.html b/apps/tesm/src/tesm/templates/tesm/dhcp.html index 727b452..88664e7 100644 --- a/apps/tesm/src/tesm/templates/tesm/dhcp.html +++ b/apps/tesm/src/tesm/templates/tesm/dhcp.html @@ -377,6 +377,10 @@
{{ icon('clock', size=17) }} Leases
Direkt aus {{ lease_file }} gelesen – je Adresse gilt der letzte Eintrag. + {% if current_user.has('dhcp_lease_adopt.read') and current_user.has('devices.create') %} + Eine dynamische Lease lässt sich als Client übernehmen; sie bekommt + damit eine feste Adresse und wird überwacht. + {% endif %}
@@ -394,7 +398,7 @@
- + {% for lease in leases %} @@ -413,6 +417,31 @@ + {% endfor %}
IPMACHostnameArtZustandLäuft ab
Läuft ab
{{ lease.expires_text }}
{{ lease.remaining_text }}
+ {# Übernehmen gibt es nur für dynamische Leases: was reserviert + ist, gehört schon zu einem gepflegten Datensatz. Und nur, + wenn beide Rechte stehen -- hier entsteht ein Client. #} + {% if lease.address not in reserved_ips + and current_user.has('dhcp_lease_adopt.read') + and current_user.has('devices.create') %} + {% if lease.mac in bekannte_macs %} + + bereits Client + + {% else %} +
+ + + +
+ {% endif %} + {% endif %} +