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": []})