diff --git a/napalm_opnsense/opnsense.py b/napalm_opnsense/opnsense.py index 4e75d2a..084860f 100644 --- a/napalm_opnsense/opnsense.py +++ b/napalm_opnsense/opnsense.py @@ -2126,10 +2126,30 @@ class OPNsenseDriver(OPNsensePingMixin, FirewallDriver): """Remove an OPNsense plugin by name. Calls ``POST /api/core/firmware/remove/{name}``. + + **Plugins only, and it says so.** ``firmware/remove`` acts on the + plugin set, and ``get_packages`` reads the same list — so software + installed as a plain FreeBSD package is invisible to the one and + unreachable by the other. The Wazuh agent is exactly that, on a driver + the agent plugin lists as supported, and during a fleet-wide rollback + the request posted for it could never have succeeded. + + Raising beats posting. A request that cannot work reports failure for + the wrong reason and sends whoever reads it looking in the wrong place; + netOrk turns ``NotImplementedError`` into a 501, which is the accurate + answer. Reaching plain packages would need shell access, and the + credentials stored for these devices are frequently API-key only — + a decision of its own rather than a detail of this one. """ import re as _re if not _re.match(r'^[a-zA-Z0-9_\-\.]+$', name): raise ValueError(f"Invalid package name: {name!r}") + if not name.startswith("os-"): + raise NotImplementedError( + f"{name!r} is not an OPNsense plugin. This driver can remove plugins " + "(os-*) through the firmware API; a FreeBSD package needs shell access, " + "which is not configured for OPNsense devices." + ) try: result = self._post(f"/api/core/firmware/remove/{name}") return {"success": True, "output": str(result)} diff --git a/tests/unit/test_driver.py b/tests/unit/test_driver.py index 3098c6c..1729961 100644 --- a/tests/unit/test_driver.py +++ b/tests/unit/test_driver.py @@ -2544,3 +2544,53 @@ class TestSyncDnsZone: added = [d for p, d in calls if p.endswith("addhostoverride")] assert len(added) == 1 + + +# --------------------------------------------------------------------------- +# uninstall_package – says what it cannot do +# --------------------------------------------------------------------------- + + +class TestUninstallPackage: + """`firmware/remove` reaches OPNsense plugins and nothing else. + + `get_packages` reads `/api/core/firmware/info` filtered to the plugin list, + and `firmware/remove` acts on the same set. Software installed as a plain + FreeBSD package is invisible to the first and unreachable by the second — + the Wazuh agent being exactly that, on a driver the agent plugin lists as + supported. + + Observed during a fleet-wide rollback on 2026-09-19: the `gw` device could + not be handled through netOrk at all, and the request that was posted for + it could never have succeeded. + + So it refuses instead of posting. A request that cannot work is worse than + an honest no: the caller stops looking for the real problem. The API turns + NotImplementedError into 501, which is the accurate answer. + """ + + def test_a_plugin_is_removed(self, driver): + driver._post = MagicMock(return_value={"status": "ok"}) + + result = driver.uninstall_package("os-wazuh-agent") + + assert result["success"] is True + driver._post.assert_called_once() + + def test_a_freebsd_package_is_refused(self, driver): + driver._post = MagicMock() + + with pytest.raises(NotImplementedError, match="plugin"): + driver.uninstall_package("wazuh-agent") + + driver._post.assert_not_called() + + def test_the_refusal_names_the_package(self, driver): + """Whoever reads the 501 needs to know which name was rejected.""" + with pytest.raises(NotImplementedError, match="wazuh-agent"): + driver.uninstall_package("wazuh-agent") + + def test_a_malformed_name_is_still_a_ValueError(self, driver): + """Refusing non-plugins must not swallow the injection guard.""" + with pytest.raises(ValueError): + driver.uninstall_package("os-thing; rm -rf /")