Resolve NPM certificate material sources

This commit is contained in:
Lyncoln
2026-09-20 00:43:51 -04:00
parent 939d51d661
commit 44489db3ff
2 changed files with 540 additions and 18 deletions
+136 -18
View File
@@ -9,6 +9,7 @@ from ...adapters import (
NormalizedObject, NormalizedObject,
ObjectReference, ObjectReference,
) )
from ...certificates.material import CertificateMaterial
from ...npm_client import NpmClient from ...npm_client import NpmClient
from ...object_normalizer import normalize_discovered_object from ...object_normalizer import normalize_discovered_object
@@ -180,6 +181,137 @@ class Plugin:
for row in self.client.certificates() 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): def apply(self, action):
raise RuntimeError( raise RuntimeError(
f"{self.endpoint.identity} does not support apply yet" f"{self.endpoint.identity} does not support apply yet"
@@ -210,28 +342,14 @@ class Plugin:
if actual.local_key != object.local_key: if actual.local_key != object.local_key:
return False return False
if actual.logical_key != object.logical_key:
return False
# Provider, display name, and renewal flags describe how # Provider, display name, and renewal flags describe how
# this NPM endpoint manages the certificate. They are not # this NPM endpoint manages the certificate. They are not
# portable certificate identity. A copied Let's Encrypt # portable certificate identity. A copied Let's Encrypt
# certificate becomes provider="other" on the recipient. # certificate becomes provider="other" on the recipient.
# return self._portable_certificate_matches(
# The portable verification state is the domain identity actual,
# plus the certificate validity period exposed by NPM. object,
for field in ( )
"domain_names",
"valid_from",
"expires_on",
):
if (
actual.payload.get(field)
!= object.payload.get(field)
):
return False
return True
raise RuntimeError( raise RuntimeError(
f"{self.endpoint.identity} cannot verify " f"{self.endpoint.identity} cannot verify "
+404
View File
@@ -10,6 +10,7 @@ from dns_sync.adapters import (
NormalizedObject, NormalizedObject,
ObjectReference, ObjectReference,
) )
from dns_sync.certificates.material import CertificateMaterial
from dns_sync.config import AppConfig from dns_sync.config import AppConfig
from dns_sync.object_normalizer import normalize_discovered_object from dns_sync.object_normalizer import normalize_discovered_object
from dns_sync.registry import load_adapter from dns_sync.registry import load_adapter
@@ -39,6 +40,33 @@ class NpmAdapterTests(unittest.TestCase):
config.npm_endpoints[0], 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): def test_registry_creates_one_adapter_per_endpoint(self):
config = self.config() config = self.config()
first_client = Mock() first_client = Mock()
@@ -358,6 +386,382 @@ class NpmAdapterTests(unittest.TestCase):
self.assertFalse(adapter.verify(object)) 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): def test_unsupported_operations_fail_explicitly(self):
adapter = self.adapter(Mock()) adapter = self.adapter(Mock())