diff --git a/dns_sync/plugins/adapters/npm.py b/dns_sync/plugins/adapters/npm.py index fbbc3c1..4a81b57 100644 --- a/dns_sync/plugins/adapters/npm.py +++ b/dns_sync/plugins/adapters/npm.py @@ -9,6 +9,7 @@ from ...adapters import ( NormalizedObject, ObjectReference, ) +from ...certificates.material import CertificateMaterial from ...npm_client import NpmClient from ...object_normalizer import normalize_discovered_object @@ -180,6 +181,137 @@ class Plugin: for row in self.client.certificates() ] + @staticmethod + def _portable_certificate_matches( + actual: NormalizedObject, + desired: NormalizedObject, + ) -> bool: + if actual.object_type != CERTIFICATES: + return False + + if desired.object_type != CERTIFICATES: + return False + + if actual.logical_key != desired.logical_key: + return False + + for field in ( + "domain_names", + "valid_from", + "expires_on", + ): + if ( + actual.payload.get(field) + != desired.payload.get(field) + ): + return False + + return True + + def _certificate_material_source( + self, + desired: NormalizedObject, + ) -> CertificateMaterial: + if desired.object_type != CERTIFICATES: + raise RuntimeError( + "Certificate material source requires " + "a certificate object" + ) + + matches: list[ + tuple[str, str, CertificateMaterial] + ] = [] + source_errors: list[str] = [] + + for endpoint in sorted( + self.config.npm_endpoints, + key=lambda item: item.identity, + ): + if endpoint.identity == self.endpoint.identity: + continue + + client = NpmClient( + self.config, + endpoint, + ) + + try: + rows = client.certificates() + except Exception as exc: + source_errors.append( + f"{endpoint.identity}: " + f"{type(exc).__name__}" + ) + continue + + for row in rows: + candidate = normalize_discovered_object( + self._certificate_from_row(row) + ) + + if not self._portable_certificate_matches( + candidate, + desired, + ): + continue + + if candidate.local_key is None: + raise RuntimeError( + "Matching NPM certificate has no " + "endpoint-local key" + ) + + material = client.certificate_material( + candidate.local_key + ) + + matches.append( + ( + endpoint.identity, + candidate.local_key, + material, + ) + ) + + if not matches: + detail = "" + + if source_errors: + detail = ( + "; unavailable sources: " + + ", ".join(source_errors) + ) + + raise RuntimeError( + "No NPM source certificate material found for " + f"{desired.logical_key!r}" + f"{detail}" + ) + + baseline = matches[0][2] + + for _, _, material in matches[1:]: + if material != baseline: + endpoints = ", ".join( + sorted( + { + endpoint_id + for ( + endpoint_id, + _, + _, + ) in matches + } + ) + ) + raise RuntimeError( + "Conflicting NPM certificate material " + f"sources for {desired.logical_key!r}: " + f"{endpoints}" + ) + + return baseline + def apply(self, action): raise RuntimeError( f"{self.endpoint.identity} does not support apply yet" @@ -210,28 +342,14 @@ class Plugin: 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 + return self._portable_certificate_matches( + actual, + object, + ) raise RuntimeError( f"{self.endpoint.identity} cannot verify " diff --git a/tests/test_npm_adapter.py b/tests/test_npm_adapter.py index 539c6f1..462285c 100644 --- a/tests/test_npm_adapter.py +++ b/tests/test_npm_adapter.py @@ -10,6 +10,7 @@ from dns_sync.adapters import ( NormalizedObject, ObjectReference, ) +from dns_sync.certificates.material import CertificateMaterial from dns_sync.config import AppConfig from dns_sync.object_normalizer import normalize_discovered_object from dns_sync.registry import load_adapter @@ -39,6 +40,33 @@ class NpmAdapterTests(unittest.TestCase): config.npm_endpoints[0], ) + def target_adapter_with_sources( + self, + target_client, + source_clients, + ): + config = self.config() + + with patch( + "dns_sync.plugins.adapters.npm.NpmClient", + return_value=target_client, + ): + adapter = load_adapter( + "npm", + config, + config.npm_endpoints[1], + ) + + client_by_identity = { + endpoint_id: client + for endpoint_id, client in source_clients.items() + } + + def client_factory(config, endpoint): + return client_by_identity[endpoint.identity] + + return adapter, client_factory + def test_registry_creates_one_adapter_per_endpoint(self): config = self.config() first_client = Mock() @@ -358,6 +386,382 @@ class NpmAdapterTests(unittest.TestCase): self.assertFalse(adapter.verify(object)) + def test_resolves_certificate_material_from_other_npm_endpoint(self): + target_client = Mock() + source_client = Mock() + + source_client.certificates.return_value = [ + { + "id": 10, + "domain_names": ["APP.EXAMPLE.COM."], + "provider": "letsencrypt", + "nice_name": "source", + "valid_from": "2026-09-01T00:00:00Z", + "expires_on": "2026-12-01T00:00:00Z", + "meta": { + "cluster_managed": True, + "acme_renew": True, + }, + } + ] + + material = CertificateMaterial( + certificate=b"CERT", + certificate_key=b"KEY", + intermediate_certificate=b"CHAIN", + ) + source_client.certificate_material.return_value = material + + adapter, client_factory = ( + self.target_adapter_with_sources( + target_client, + { + "npm://192.168.1.3:81": source_client, + }, + ) + ) + + desired = NormalizedObject( + object_type=CERTIFICATES, + logical_key="app.example.com", + payload={ + "domain_names": ["app.example.com"], + "provider": "letsencrypt", + "nice_name": "source", + "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": False, + }, + ) + + with patch( + "dns_sync.plugins.adapters.npm.NpmClient", + side_effect=client_factory, + ): + resolved = adapter._certificate_material_source( + desired + ) + + self.assertEqual(resolved, material) + source_client.certificate_material.assert_called_once_with( + "10" + ) + target_client.certificates.assert_not_called() + target_client.certificate_material.assert_not_called() + + def test_certificate_material_source_ignores_wrong_expiry(self): + target_client = Mock() + source_client = Mock() + + source_client.certificates.return_value = [ + { + "id": 10, + "domain_names": ["app.example.com"], + "provider": "letsencrypt", + "valid_from": "2026-09-01T00:00:00Z", + "expires_on": "2027-01-01T00:00:00Z", + "meta": {}, + } + ] + + adapter, client_factory = ( + self.target_adapter_with_sources( + target_client, + { + "npm://192.168.1.3:81": source_client, + }, + ) + ) + + desired = NormalizedObject( + object_type=CERTIFICATES, + logical_key="app.example.com", + payload={ + "domain_names": ["app.example.com"], + "valid_from": "2026-09-01T00:00:00+00:00", + "expires_on": "2026-12-01T00:00:00+00:00", + }, + ) + + with patch( + "dns_sync.plugins.adapters.npm.NpmClient", + side_effect=client_factory, + ): + with self.assertRaisesRegex( + RuntimeError, + "No NPM source certificate material found", + ): + adapter._certificate_material_source(desired) + + source_client.certificate_material.assert_not_called() + + def test_certificate_material_source_rejects_conflicting_material(self): + config = self.config() + + # Add a third endpoint for this ambiguity test. + endpoint_type = type(config.npm_endpoints[0]) + third_endpoint = endpoint_type( + "npm", + "192.168.1.5:81", + ) + + config = __import__( + "dataclasses" + ).replace( + config, + npm_endpoints=( + config.npm_endpoints[0], + config.npm_endpoints[1], + third_endpoint, + ), + ) + + target_client = Mock() + source_a = Mock() + source_b = Mock() + + row_a = { + "id": 10, + "domain_names": ["app.example.com"], + "provider": "letsencrypt", + "valid_from": "2026-09-01T00:00:00Z", + "expires_on": "2026-12-01T00:00:00Z", + "meta": {}, + } + row_b = dict(row_a) + row_b["id"] = 44 + + source_a.certificates.return_value = [row_a] + source_b.certificates.return_value = [row_b] + + source_a.certificate_material.return_value = ( + CertificateMaterial( + certificate=b"CERT-A", + certificate_key=b"KEY-A", + ) + ) + source_b.certificate_material.return_value = ( + CertificateMaterial( + certificate=b"CERT-B", + certificate_key=b"KEY-B", + ) + ) + + with patch( + "dns_sync.plugins.adapters.npm.NpmClient", + return_value=target_client, + ): + adapter = load_adapter( + "npm", + config, + config.npm_endpoints[1], + ) + + clients = { + "npm://192.168.1.3:81": source_a, + "npm://192.168.1.5:81": source_b, + } + + def client_factory(config, endpoint): + return clients[endpoint.identity] + + desired = NormalizedObject( + object_type=CERTIFICATES, + logical_key="app.example.com", + payload={ + "domain_names": ["app.example.com"], + "valid_from": "2026-09-01T00:00:00+00:00", + "expires_on": "2026-12-01T00:00:00+00:00", + }, + ) + + with patch( + "dns_sync.plugins.adapters.npm.NpmClient", + side_effect=client_factory, + ): + with self.assertRaisesRegex( + RuntimeError, + "Conflicting NPM certificate material", + ): + adapter._certificate_material_source(desired) + + def test_certificate_material_source_accepts_identical_replicas(self): + config = self.config() + + endpoint_type = type(config.npm_endpoints[0]) + third_endpoint = endpoint_type( + "npm", + "192.168.1.5:81", + ) + + config = __import__( + "dataclasses" + ).replace( + config, + npm_endpoints=( + config.npm_endpoints[0], + config.npm_endpoints[1], + third_endpoint, + ), + ) + + target_client = Mock() + source_a = Mock() + source_b = Mock() + + common_material = CertificateMaterial( + certificate=b"CERT", + certificate_key=b"KEY", + intermediate_certificate=b"CHAIN", + ) + + source_a.certificates.return_value = [ + { + "id": 10, + "domain_names": ["app.example.com"], + "provider": "letsencrypt", + "valid_from": "2026-09-01T00:00:00Z", + "expires_on": "2026-12-01T00:00:00Z", + "meta": {}, + } + ] + source_b.certificates.return_value = [ + { + "id": 44, + "domain_names": ["app.example.com"], + "provider": "other", + "valid_from": "2026-09-01T00:00:00Z", + "expires_on": "2026-12-01T00:00:00Z", + "meta": {}, + } + ] + + source_a.certificate_material.return_value = common_material + source_b.certificate_material.return_value = common_material + + with patch( + "dns_sync.plugins.adapters.npm.NpmClient", + return_value=target_client, + ): + adapter = load_adapter( + "npm", + config, + config.npm_endpoints[1], + ) + + clients = { + "npm://192.168.1.3:81": source_a, + "npm://192.168.1.5:81": source_b, + } + + def client_factory(config, endpoint): + return clients[endpoint.identity] + + desired = NormalizedObject( + object_type=CERTIFICATES, + logical_key="app.example.com", + payload={ + "domain_names": ["app.example.com"], + "valid_from": "2026-09-01T00:00:00+00:00", + "expires_on": "2026-12-01T00:00:00+00:00", + }, + ) + + with patch( + "dns_sync.plugins.adapters.npm.NpmClient", + side_effect=client_factory, + ): + resolved = adapter._certificate_material_source( + desired + ) + + self.assertEqual(resolved, common_material) + + def test_certificate_material_source_tolerates_unavailable_unrelated_source(self): + config = self.config() + + endpoint_type = type(config.npm_endpoints[0]) + third_endpoint = endpoint_type( + "npm", + "192.168.1.5:81", + ) + + config = __import__( + "dataclasses" + ).replace( + config, + npm_endpoints=( + config.npm_endpoints[0], + config.npm_endpoints[1], + third_endpoint, + ), + ) + + target_client = Mock() + unavailable = Mock() + available = Mock() + + unavailable.certificates.side_effect = RuntimeError( + "offline" + ) + + available.certificates.return_value = [ + { + "id": 44, + "domain_names": ["app.example.com"], + "provider": "other", + "valid_from": "2026-09-01T00:00:00Z", + "expires_on": "2026-12-01T00:00:00Z", + "meta": {}, + } + ] + + material = CertificateMaterial( + certificate=b"CERT", + certificate_key=b"KEY", + ) + available.certificate_material.return_value = material + + with patch( + "dns_sync.plugins.adapters.npm.NpmClient", + return_value=target_client, + ): + adapter = load_adapter( + "npm", + config, + config.npm_endpoints[1], + ) + + clients = { + "npm://192.168.1.3:81": unavailable, + "npm://192.168.1.5:81": available, + } + + def client_factory(config, endpoint): + return clients[endpoint.identity] + + desired = NormalizedObject( + object_type=CERTIFICATES, + logical_key="app.example.com", + payload={ + "domain_names": ["app.example.com"], + "valid_from": "2026-09-01T00:00:00+00:00", + "expires_on": "2026-12-01T00:00:00+00:00", + }, + ) + + with patch( + "dns_sync.plugins.adapters.npm.NpmClient", + side_effect=client_factory, + ): + resolved = adapter._certificate_material_source( + desired + ) + + self.assertEqual(resolved, material) + def test_unsupported_operations_fail_explicitly(self): adapter = self.adapter(Mock())