From 939d51d661b6493b93ab9e56d3da9a99becd6237 Mon Sep 17 00:00:00 2001 From: Lyncoln Date: Sun, 20 Sep 2026 00:40:12 -0400 Subject: [PATCH] Add NPM endpoint verification --- dns_sync/plugins/adapters/npm.py | 194 +++++++++++++++++++++---------- tests/test_npm_adapter.py | 192 +++++++++++++++++++++++++++++- 2 files changed, 323 insertions(+), 63 deletions(-) diff --git a/dns_sync/plugins/adapters/npm.py b/dns_sync/plugins/adapters/npm.py index 740a48f..fbbc3c1 100644 --- a/dns_sync/plugins/adapters/npm.py +++ b/dns_sync/plugins/adapters/npm.py @@ -6,9 +6,11 @@ from ...adapters import ( Capability, CapabilityOperation, DiscoveredObject, + NormalizedObject, ObjectReference, ) from ...npm_client import NpmClient +from ...object_normalizer import normalize_discovered_object _PROXY_HOST_FIELDS = ( @@ -36,10 +38,18 @@ class Plugin: PROXY_HOSTS, CapabilityOperation.DISCOVER, ), + Capability( + PROXY_HOSTS, + CapabilityOperation.VERIFY, + ), Capability( CERTIFICATES, CapabilityOperation.DISCOVER, ), + Capability( + CERTIFICATES, + CapabilityOperation.VERIFY, + ), } ) @@ -74,92 +84,156 @@ class Plugin: f"object type {object_type!r}" ) - def _discover_proxy_hosts(self) -> list[DiscoveredObject]: - discovered: list[DiscoveredObject] = [] + def _proxy_host_from_row( + self, + row: dict[str, Any], + ) -> DiscoveredObject: + payload = { + field: row.get(field) + for field in _PROXY_HOST_FIELDS + } - for row in self.client.proxy_hosts(): - payload = { - field: row.get(field) - for field in _PROXY_HOST_FIELDS - } - certificate_id = row.get("certificate_id") + certificate_id = row.get("certificate_id") - if certificate_id in (None, "", 0, "0"): - references = () - else: - if isinstance(certificate_id, bool): - raise RuntimeError( - "NPM proxy host has invalid certificate_id" - ) - - certificate_local_key = str( - certificate_id - ).strip() - - if not certificate_local_key: - raise RuntimeError( - "NPM proxy host has invalid certificate_id" - ) - - references = ( - ObjectReference( - relation="certificate", - object_type=CERTIFICATES, - local_key=certificate_local_key, - ), + if certificate_id in (None, "", 0, "0"): + references = () + else: + if isinstance(certificate_id, bool): + raise RuntimeError( + "NPM proxy host has invalid certificate_id" ) - discovered.append( - DiscoveredObject( - object_type=PROXY_HOSTS, - local_key=self._local_key(row, "proxy host"), - payload=payload, - references=references, + certificate_local_key = str( + certificate_id + ).strip() + + if not certificate_local_key: + raise RuntimeError( + "NPM proxy host has invalid certificate_id" ) + + references = ( + ObjectReference( + relation="certificate", + object_type=CERTIFICATES, + local_key=certificate_local_key, + ), ) - return discovered + return DiscoveredObject( + object_type=PROXY_HOSTS, + local_key=self._local_key( + row, + "proxy host", + ), + payload=payload, + references=references, + ) - def _discover_certificates(self) -> list[DiscoveredObject]: - discovered: list[DiscoveredObject] = [] + def _certificate_from_row( + self, + row: dict[str, Any], + ) -> DiscoveredObject: + meta = row.get("meta") + if not isinstance(meta, dict): + meta = {} - for row in self.client.certificates(): - meta = row.get("meta") - if not isinstance(meta, dict): - meta = {} - - payload = { + return DiscoveredObject( + object_type=CERTIFICATES, + local_key=self._local_key( + row, + "certificate", + ), + payload={ "domain_names": list( row.get("domain_names") or [] ), - "provider": str(row.get("provider") or ""), - "nice_name": str(row.get("nice_name") or ""), + "provider": str( + row.get("provider") or "" + ), + "nice_name": str( + row.get("nice_name") or "" + ), "valid_from": row.get("valid_from"), "expires_on": row.get("expires_on"), "cluster_managed": ( meta.get("cluster_managed") is True ), - "acme_renew": meta.get("acme_renew") is True, + "acme_renew": ( + meta.get("acme_renew") is True + ), "dns_challenge": ( meta.get("dns_challenge") is True ), - } - discovered.append( - DiscoveredObject( - object_type=CERTIFICATES, - local_key=self._local_key(row, "certificate"), - payload=payload, - ) - ) + }, + ) - return discovered + def _discover_proxy_hosts(self) -> list[DiscoveredObject]: + return [ + self._proxy_host_from_row(row) + for row in self.client.proxy_hosts() + ] + + def _discover_certificates(self) -> list[DiscoveredObject]: + return [ + self._certificate_from_row(row) + for row in self.client.certificates() + ] def apply(self, action): raise RuntimeError( f"{self.endpoint.identity} does not support apply yet" ) - def verify(self, object): + def verify(self, object: NormalizedObject) -> bool: + if object.local_key is None: + return False + + if object.object_type == PROXY_HOSTS: + row = self.client.proxy_host( + object.local_key + ) + actual = normalize_discovered_object( + self._proxy_host_from_row(row) + ) + + return actual == object + + if object.object_type == CERTIFICATES: + row = self.client.certificate( + object.local_key + ) + actual = normalize_discovered_object( + self._certificate_from_row(row) + ) + + if actual.local_key != object.local_key: + return False + + if actual.logical_key != object.logical_key: + return False + + # Provider, display name, and renewal flags describe how + # this NPM endpoint manages the certificate. They are not + # portable certificate identity. A copied Let's Encrypt + # certificate becomes provider="other" on the recipient. + # + # The portable verification state is the domain identity + # plus the certificate validity period exposed by NPM. + for field in ( + "domain_names", + "valid_from", + "expires_on", + ): + if ( + actual.payload.get(field) + != object.payload.get(field) + ): + return False + + return True + raise RuntimeError( - f"{self.endpoint.identity} does not support verify yet" + f"{self.endpoint.identity} cannot verify " + f"object type {object.object_type!r}" ) diff --git a/tests/test_npm_adapter.py b/tests/test_npm_adapter.py index fde337b..539c6f1 100644 --- a/tests/test_npm_adapter.py +++ b/tests/test_npm_adapter.py @@ -7,9 +7,11 @@ from dns_sync.adapters import ( PROXY_HOSTS, Capability, CapabilityOperation, + NormalizedObject, ObjectReference, ) from dns_sync.config import AppConfig +from dns_sync.object_normalizer import normalize_discovered_object from dns_sync.registry import load_adapter @@ -62,7 +64,7 @@ class NpmAdapterTests(unittest.TestCase): self.assertIs(adapters[0].client, first_client) self.assertIs(adapters[1].client, second_client) - def test_capabilities_are_discovery_only(self): + def test_capabilities_cover_discovery_and_verify_only(self): adapter = self.adapter(Mock()) self.assertEqual( @@ -73,14 +75,37 @@ class NpmAdapterTests(unittest.TestCase): PROXY_HOSTS, CapabilityOperation.DISCOVER, ), + Capability( + PROXY_HOSTS, + CapabilityOperation.VERIFY, + ), Capability( CERTIFICATES, CapabilityOperation.DISCOVER, ), + Capability( + CERTIFICATES, + CapabilityOperation.VERIFY, + ), } ), ) + self.assertNotIn( + Capability( + PROXY_HOSTS, + CapabilityOperation.APPLY, + ), + adapter.capabilities, + ) + self.assertNotIn( + Capability( + CERTIFICATES, + CapabilityOperation.APPLY, + ), + adapter.capabilities, + ) + def test_discovers_proxy_hosts_with_endpoint_local_key(self): client = Mock() client.proxy_hosts.return_value = [ @@ -179,6 +204,160 @@ class NpmAdapterTests(unittest.TestCase): certificate.payload, ) + def test_verifies_proxy_host_exact_normalized_state(self): + client = Mock() + client.proxy_host.return_value = { + "id": 12, + "domain_names": ["APP.EXAMPLE.COM."], + "forward_scheme": "HTTP", + "forward_host": "backend.internal", + "forward_port": 8080, + "certificate_id": 67, + "ssl_forced": True, + "caching_enabled": False, + "block_exploits": True, + "allow_websocket_upgrade": True, + "http2_support": True, + "hsts_enabled": True, + "hsts_subdomains": False, + "enabled": True, + } + adapter = self.adapter(client) + + expected = normalize_discovered_object( + adapter._proxy_host_from_row( + client.proxy_host.return_value + ) + ) + + self.assertTrue(adapter.verify(expected)) + client.proxy_host.assert_called_once_with("12") + + def test_proxy_host_verify_detects_reference_change(self): + client = Mock() + client.proxy_host.return_value = { + "id": 12, + "domain_names": ["app.example.com"], + "forward_scheme": "http", + "forward_host": "backend.internal", + "forward_port": 8080, + "certificate_id": 68, + "enabled": True, + } + adapter = self.adapter(client) + + expected = NormalizedObject( + object_type=PROXY_HOSTS, + logical_key="app.example.com", + local_key="12", + payload={ + "domain_names": ["app.example.com"], + "forward_scheme": "http", + "forward_host": "backend.internal", + "forward_port": 8080, + "ssl_forced": False, + "caching_enabled": False, + "block_exploits": False, + "allow_websocket_upgrade": False, + "http2_support": False, + "hsts_enabled": False, + "hsts_subdomains": False, + "enabled": True, + }, + references=( + ObjectReference( + relation="certificate", + object_type=CERTIFICATES, + local_key="67", + ), + ), + ) + + self.assertFalse(adapter.verify(expected)) + + def test_certificate_verify_uses_portable_certificate_state(self): + client = Mock() + client.certificate.return_value = { + "id": 84, + "domain_names": ["APP.EXAMPLE.COM."], + # Receiving NPM owns this as a custom/imported cert. + "provider": "other", + "nice_name": "local imported copy", + "valid_from": "2026-09-01T00:00:00Z", + "expires_on": "2026-12-01T00:00:00Z", + "meta": { + "cluster_managed": False, + "acme_renew": False, + "dns_challenge": False, + "certificate": "must-not-enter-normalized-state", + "certificate_key": "must-not-enter-normalized-state", + }, + } + adapter = self.adapter(client) + + expected = NormalizedObject( + object_type=CERTIFICATES, + logical_key="app.example.com", + local_key="84", + payload={ + "domain_names": ["app.example.com"], + # Source may have been Let's Encrypt. + "provider": "letsencrypt", + "nice_name": "source certificate", + "valid_from": "2026-09-01T00:00:00+00:00", + "expires_on": "2026-12-01T00:00:00+00:00", + "cluster_managed": True, + "acme_renew": True, + "dns_challenge": True, + }, + ) + + self.assertTrue(adapter.verify(expected)) + client.certificate.assert_called_once_with("84") + + def test_certificate_verify_detects_expiry_change(self): + client = Mock() + client.certificate.return_value = { + "id": 84, + "domain_names": ["app.example.com"], + "provider": "other", + "valid_from": "2026-09-01T00:00:00Z", + "expires_on": "2027-01-01T00:00:00Z", + "meta": {}, + } + adapter = self.adapter(client) + + expected = NormalizedObject( + object_type=CERTIFICATES, + logical_key="app.example.com", + local_key="84", + payload={ + "domain_names": ["app.example.com"], + "provider": "letsencrypt", + "nice_name": "", + "valid_from": "2026-09-01T00:00:00+00:00", + "expires_on": "2026-12-01T00:00:00+00:00", + "cluster_managed": False, + "acme_renew": False, + "dns_challenge": False, + }, + ) + + self.assertFalse(adapter.verify(expected)) + + def test_verify_requires_endpoint_local_key(self): + adapter = self.adapter(Mock()) + + object = NormalizedObject( + object_type=CERTIFICATES, + logical_key="app.example.com", + payload={ + "domain_names": ["app.example.com"], + }, + ) + + self.assertFalse(adapter.verify(object)) + def test_unsupported_operations_fail_explicitly(self): adapter = self.adapter(Mock()) @@ -188,8 +367,15 @@ class NpmAdapterTests(unittest.TestCase): with self.assertRaisesRegex(RuntimeError, "does not support apply"): adapter.apply(Mock()) - with self.assertRaisesRegex(RuntimeError, "does not support verify"): - adapter.verify(Mock()) + unsupported = NormalizedObject( + object_type="unsupported_objects", + logical_key="unsupported", + local_key="1", + payload={}, + ) + + with self.assertRaisesRegex(RuntimeError, "cannot verify"): + adapter.verify(unsupported) if __name__ == "__main__":