feat(opnsense): add delete_dhcp_reservation_and_lease() for Kea DHCPv4
CI / test (3.10) (push) Failing after 7s
CI / test (3.11) (push) Failing after 7s
CI / test (3.12) (push) Failing after 7s
CI / test (3.9) (push) Failing after 7s

Combined removal of a static reservation and its active lease, needed by
NetOrk's VM-deletion cleanup flow. Reservation deletion follows the same
search-then-del<X>/{uuid} + reconfigure pattern as create_dhcp_reservation
and raises on failure; lease deletion is best-effort/non-fatal since the
Kea lease-delete endpoint shape is unverified against a real box.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Christian Manivong
2026-07-08 19:24:04 +02:00
co-authored by Claude Sonnet 5
parent c12065c114
commit b8dac1db63
2 changed files with 170 additions and 0 deletions
+73
View File
@@ -1208,6 +1208,79 @@ class OPNsenseDriver(FirewallDriver):
self._post("/api/kea/service/reconfigure")
def delete_dhcp_reservation_and_lease(self, mac: str, ip: str) -> dict[str, Any]:
"""Remove a Kea DHCPv4 static reservation and any active lease for (mac, ip).
Combined per NetOrk's VM-deletion cleanup flow — reservation and
lease removal are always requested together, so a single driver
call keeps callers from having to sequence two calls themselves.
Reservation removal follows the same search-then-act pattern as
``create_dhcp_reservation`` (``searchReservation`` -> ``del<X>/{uuid}``
-> ``service/reconfigure``) and raises on failure, mirroring that
method's "never silently no-op on the thing the caller explicitly
asked for" contract.
Lease removal is best-effort and non-fatal: the exact Kea
lease-delete endpoint shape is unverified in this codebase (unlike
reservations, ``get_dhcp_leases()`` only ever implemented the read
path) — a failure here just means a stale lease record lingers in
Kea until its own natural cleanup, which is cosmetic, not a
functional problem (a deleted reservation already prevents the
client from getting the same IP back).
:param mac: NIC MAC address of the reservation to remove.
:param ip: IP address of the reservation/lease to remove.
:returns: {"reservation_found", "reservation_deleted", "lease_found",
"lease_deleted"} — all bool.
:raises RuntimeError: Kea plugin unavailable, or Kea rejects
deletion of a reservation that does exist.
"""
result: dict[str, Any] = {
"reservation_found": False,
"reservation_deleted": False,
"lease_found": False,
"lease_deleted": False,
}
# --- Reservation ---
try:
existing = self._post(
"/api/kea/dhcpv4/searchReservation",
{"current": 1, "rowCount": -1, "searchPhrase": ip},
)
except Exception as exc:
raise RuntimeError(f"Kea DHCPv4 plugin unavailable: {exc}") from exc
reservation_uuid = None
for row in existing.get("rows") or []:
if row.get("ip_address") == ip:
reservation_uuid = row.get("uuid")
break
if reservation_uuid:
result["reservation_found"] = True
del_result = self._post(f"/api/kea/dhcpv4/delReservation/{reservation_uuid}")
if del_result.get("result") != "deleted":
raise RuntimeError(f"Kea rejected reservation delete for {ip}: {del_result}")
result["reservation_deleted"] = True
self._post("/api/kea/service/reconfigure")
# --- Lease (best-effort) ---
try:
leases = self._get("/api/kea/leases4/search")
rows = leases.get("rows") or leases.get("leases") or []
if any((row.get("address") or row.get("ip-address")) == ip for row in rows):
result["lease_found"] = True
del_lease = self._post("/api/kea/leases4/delLease", {"ip-address": ip})
result["lease_deleted"] = bool(
del_lease.get("result") == "deleted" or del_lease.get("status") == "ok"
)
except Exception as exc:
logger.warning("DHCP lease delete for %s failed (non-fatal): %s", ip, exc)
return result
def get_services(self) -> list[dict[str, Any]]:
"""Return running services from OPNsense.