From c8caa1417673bfea4679a1416eef26ae66973122 Mon Sep 17 00:00:00 2001 From: Christian Manivong Date: Wed, 15 Jul 2026 13:52:20 +0200 Subject: [PATCH] feat(dyndns): add get_ddns_status() for os-ddclient enabled/running state Verified against a live OPNsense 24.7 instance: the service id is "ddclient" but the REST module is "dyndns" (/api/ddclient/* all 404). Scoped to enabled/running only -- no ddclient/dyndns account was configured on the test device to verify a per-account "registered IP" shape against, so that comparison is deliberately left out rather than guessed. --- napalm_opnsense/opnsense.py | 27 +++++++++++++++++ tests/unit/test_driver.py | 60 +++++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+) diff --git a/napalm_opnsense/opnsense.py b/napalm_opnsense/opnsense.py index 5e50a66..a4f25cc 100644 --- a/napalm_opnsense/opnsense.py +++ b/napalm_opnsense/opnsense.py @@ -1091,6 +1091,33 @@ class OPNsenseDriver(FirewallDriver): }) return result + def get_ddns_status(self) -> dict[str, Any] | None: + """Return Dynamic DNS (os-ddclient) enabled/running state. + + Calls ``GET /api/dyndns/service/status`` and + ``GET /api/dyndns/settings/get`` -- note the API module is + "dyndns", not "ddclient" (the service id reported by + get_services() is "ddclient", but its REST controller lives under + a different, historical module name). + + Returns ``{"enabled": bool, "running": bool}``, or ``None`` if the + plugin isn't installed/reachable. Scoped to enabled/running only: + no per-account "registered IP" comparison, since that would need a + configured account to verify the response shape against. + """ + try: + status = self._get("/api/dyndns/service/status") + settings = self._get("/api/dyndns/settings/get") + except Exception as exc: + logger.warning("get_ddns_status() failed: %s", exc) + return None + + general = settings.get("ddclient", {}).get("general", {}) + return { + "enabled": general.get("enabled") == "1", + "running": status.get("status") == "running", + } + def get_dhcp_leases(self) -> list[dict[str, Any]]: """Return active DHCP leases from OPNsense. diff --git a/tests/unit/test_driver.py b/tests/unit/test_driver.py index 1fdc561..8d7f421 100644 --- a/tests/unit/test_driver.py +++ b/tests/unit/test_driver.py @@ -1060,6 +1060,66 @@ class TestGetCertificates: driver._post = lambda path, data=None: (_ for _ in ()).throw(Exception("404")) assert driver.get_certificates() == [] + +# --------------------------------------------------------------------------- +# get_ddns_status() +# +# Endpoints verified 2026-07-15 against a live OPNsense 24.7 instance: the +# service id is "ddclient" (get_services()) but the API module is "dyndns", +# not "ddclient" -- /api/ddclient/* all 404, /api/dyndns/service/status and +# /api/dyndns/settings/get are the real paths. Scoped to enabled/running +# only: no live ddclient/dyndns account was configured on the test device +# to verify a per-account "registered IP" shape against, so that comparison +# is deliberately not implemented here (would be guesswork). +# --------------------------------------------------------------------------- + +DYNDNS_STATUS_RESPONSE = {"status": "running"} +DYNDNS_SETTINGS_ENABLED = {"ddclient": {"general": {"enabled": "1"}}} +DYNDNS_SETTINGS_DISABLED = {"ddclient": {"general": {"enabled": "0"}}} + + +class TestGetDdnsStatus: + def _fake_get(self, status_response, settings_response): + def fake_get(path): + if "service/status" in path: + return status_response + return settings_response + + return fake_get + + def test_calls_expected_endpoints(self, driver): + calls = [] + + def fake_get(path): + calls.append(path) + if "service/status" in path: + return DYNDNS_STATUS_RESPONSE + return DYNDNS_SETTINGS_ENABLED + + driver._get = fake_get + driver.get_ddns_status() + assert calls == ["/api/dyndns/service/status", "/api/dyndns/settings/get"] + + def test_enabled_and_running(self, driver): + driver._get = self._fake_get(DYNDNS_STATUS_RESPONSE, DYNDNS_SETTINGS_ENABLED) + result = driver.get_ddns_status() + assert result == {"enabled": True, "running": True} + + def test_enabled_but_not_running(self, driver): + driver._get = self._fake_get({"status": "stopped"}, DYNDNS_SETTINGS_ENABLED) + result = driver.get_ddns_status() + assert result == {"enabled": True, "running": False} + + def test_disabled(self, driver): + driver._get = self._fake_get(DYNDNS_STATUS_RESPONSE, DYNDNS_SETTINGS_DISABLED) + result = driver.get_ddns_status() + assert result == {"enabled": False, "running": True} + + def test_api_error_returns_none(self, driver): + driver._get = lambda path: (_ for _ in ()).throw(Exception("404")) + assert driver.get_ddns_status() is None + + # --------------------------------------------------------------------------- # _post() # ---------------------------------------------------------------------------