From b8a68fc3a82bba53744b482b10274dc5374db4c4 Mon Sep 17 00:00:00 2001 From: Christian Manivong Date: Thu, 9 Jul 2026 10:31:05 +0200 Subject: [PATCH] =?UTF-8?q?fix(opnsense):=20correct=20Kea=20leases4=20del?= =?UTF-8?q?=5Flease=20endpoint=20=E2=80=94=20path=20param,=20not=20body?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- napalm_opnsense/opnsense.py | 23 +++++++++++++++-------- tests/unit/test_driver.py | 10 ++++++++-- 2 files changed, 23 insertions(+), 10 deletions(-) diff --git a/napalm_opnsense/opnsense.py b/napalm_opnsense/opnsense.py index bf02d3a..aa105d3 100644 --- a/napalm_opnsense/opnsense.py +++ b/napalm_opnsense/opnsense.py @@ -1221,13 +1221,20 @@ class OPNsenseDriver(FirewallDriver): 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). + Lease removal is best-effort and non-fatal — 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). Endpoint verified live against a real OPNsense instance: + ``LeasesController`` is documented as "Abstract [non-callable]" with + 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 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 [] 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}) + del_lease = self._post(f"/api/kea/leases4/del_lease/{ip}") result["lease_deleted"] = bool( del_lease.get("result") == "deleted" or del_lease.get("status") == "ok" ) diff --git a/tests/unit/test_driver.py b/tests/unit/test_driver.py index fe40859..9addd9f 100644 --- a/tests/unit/test_driver.py +++ b/tests/unit/test_driver.py @@ -1379,8 +1379,14 @@ class TestDeleteDhcpReservationAndLease: reconfigure_call = driver.session.post.call_args_list[2] assert reconfigure_call.args[0] == "https://opnsense.example.com/api/kea/service/reconfigure" del_lease_call = driver.session.post.call_args_list[3] - assert del_lease_call.args[0] == "https://opnsense.example.com/api/kea/leases4/delLease" - assert del_lease_call.kwargs["json"] == {"ip-address": "172.22.8.253"} + # IP is a URL path segment, not a JSON body field — verified live + # 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): driver.session.post.return_value = _make_json_response({"rows": []})