fix(opnsense): correct Kea leases4 del_lease endpoint — path param, not body
CI / test (3.10) (push) Failing after 7s
CI / test (3.11) (push) Failing after 7s
CI / test (3.12) (push) Failing after 8s
CI / test (3.9) (push) Failing after 7s

The lease-delete call never actually worked: it posted {"ip-address": ip}
to /api/kea/leases4/delLease, both wrong. Verified live against a real
OPNsense instance while cleaning up stale leases left by failed NetOrk VM
provisioning attempts — every call returned {"status": "error", "message":
"Missing lease IP parameter"} despite three different body-parameter
guesses (ips as list, ips as string, ip singular). The official API docs
(docs.opnsense.org/development/api/core/kea.html) show LeasesController as
"Abstract [non-callable]" with a del_lease($ips=null) action; despite that
signature looking like a body field, the concrete leases4 route only
accepts the IP as a URL path segment: POST /api/kea/leases4/del_lease/{ip}
confirmed {"status": "ok"} and the lease actually gone from a follow-up
search.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Christian Manivong
2026-07-09 10:31:05 +02:00
co-authored by Claude Sonnet 5
parent b8dac1db63
commit b8a68fc3a8
2 changed files with 23 additions and 10 deletions
+15 -8
View File
@@ -1221,13 +1221,20 @@ class OPNsenseDriver(FirewallDriver):
method's "never silently no-op on the thing the caller explicitly method's "never silently no-op on the thing the caller explicitly
asked for" contract. asked for" contract.
Lease removal is best-effort and non-fatal: the exact Kea Lease removal is best-effort and non-fatal — a failure here just
lease-delete endpoint shape is unverified in this codebase (unlike means a stale lease record lingers in Kea until its own natural
reservations, ``get_dhcp_leases()`` only ever implemented the read cleanup, which is cosmetic, not a functional problem (a deleted
path) — a failure here just means a stale lease record lingers in reservation already prevents the client from getting the same IP
Kea until its own natural cleanup, which is cosmetic, not a back). Endpoint verified live against a real OPNsense instance:
functional problem (a deleted reservation already prevents the ``LeasesController`` is documented as "Abstract [non-callable]" with
client from getting the same IP back). a ``del_lease($ips=null)`` action
(https://docs.opnsense.org/development/api/core/kea.html) — the
concrete, callable route is the ``leases4`` controller (matching
``search``, used above), and despite the ``$ips`` parameter name the
IP is passed as a URL path segment, not a JSON body field — a POST
body of ``{"ips": [ip]}`` (the natural reading of the signature)
returns ``{"status": "error", "message": "Missing lease IP
parameter"}``; only ``POST /api/kea/leases4/del_lease/{ip}`` works.
:param mac: NIC MAC address of the reservation to remove. :param mac: NIC MAC address of the reservation to remove.
:param ip: IP address of the reservation/lease to remove. :param ip: IP address of the reservation/lease to remove.
@@ -1272,7 +1279,7 @@ class OPNsenseDriver(FirewallDriver):
rows = leases.get("rows") or leases.get("leases") or [] rows = leases.get("rows") or leases.get("leases") or []
if any((row.get("address") or row.get("ip-address")) == ip for row in rows): if any((row.get("address") or row.get("ip-address")) == ip for row in rows):
result["lease_found"] = True result["lease_found"] = True
del_lease = self._post("/api/kea/leases4/delLease", {"ip-address": ip}) del_lease = self._post(f"/api/kea/leases4/del_lease/{ip}")
result["lease_deleted"] = bool( result["lease_deleted"] = bool(
del_lease.get("result") == "deleted" or del_lease.get("status") == "ok" del_lease.get("result") == "deleted" or del_lease.get("status") == "ok"
) )
+8 -2
View File
@@ -1379,8 +1379,14 @@ class TestDeleteDhcpReservationAndLease:
reconfigure_call = driver.session.post.call_args_list[2] reconfigure_call = driver.session.post.call_args_list[2]
assert reconfigure_call.args[0] == "https://opnsense.example.com/api/kea/service/reconfigure" assert reconfigure_call.args[0] == "https://opnsense.example.com/api/kea/service/reconfigure"
del_lease_call = driver.session.post.call_args_list[3] del_lease_call = driver.session.post.call_args_list[3]
assert del_lease_call.args[0] == "https://opnsense.example.com/api/kea/leases4/delLease" # IP is a URL path segment, not a JSON body field — verified live
assert del_lease_call.kwargs["json"] == {"ip-address": "172.22.8.253"} # against a real OPNsense instance; a {"ips": [ip]} body (the
# natural reading of the documented del_lease($ips=null) signature)
# returns {"status": "error", "message": "Missing lease IP parameter"}.
assert (
del_lease_call.args[0]
== "https://opnsense.example.com/api/kea/leases4/del_lease/172.22.8.253"
)
def test_noop_when_no_reservation_and_no_lease_found(self, driver): def test_noop_when_no_reservation_and_no_lease_found(self, driver):
driver.session.post.return_value = _make_json_response({"rows": []}) driver.session.post.return_value = _make_json_response({"rows": []})