Add NPM endpoint verification

This commit is contained in:
Lyncoln
2026-09-20 00:40:12 -04:00
parent 2b4a6eea79
commit 939d51d661
2 changed files with 323 additions and 63 deletions
+101 -27
View File
@@ -6,9 +6,11 @@ from ...adapters import (
Capability, Capability,
CapabilityOperation, CapabilityOperation,
DiscoveredObject, DiscoveredObject,
NormalizedObject,
ObjectReference, ObjectReference,
) )
from ...npm_client import NpmClient from ...npm_client import NpmClient
from ...object_normalizer import normalize_discovered_object
_PROXY_HOST_FIELDS = ( _PROXY_HOST_FIELDS = (
@@ -36,10 +38,18 @@ class Plugin:
PROXY_HOSTS, PROXY_HOSTS,
CapabilityOperation.DISCOVER, CapabilityOperation.DISCOVER,
), ),
Capability(
PROXY_HOSTS,
CapabilityOperation.VERIFY,
),
Capability( Capability(
CERTIFICATES, CERTIFICATES,
CapabilityOperation.DISCOVER, CapabilityOperation.DISCOVER,
), ),
Capability(
CERTIFICATES,
CapabilityOperation.VERIFY,
),
} }
) )
@@ -74,14 +84,15 @@ class Plugin:
f"object type {object_type!r}" f"object type {object_type!r}"
) )
def _discover_proxy_hosts(self) -> list[DiscoveredObject]: def _proxy_host_from_row(
discovered: list[DiscoveredObject] = [] self,
row: dict[str, Any],
for row in self.client.proxy_hosts(): ) -> DiscoveredObject:
payload = { payload = {
field: row.get(field) field: row.get(field)
for field in _PROXY_HOST_FIELDS 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"): if certificate_id in (None, "", 0, "0"):
@@ -109,57 +120,120 @@ class Plugin:
), ),
) )
discovered.append( return DiscoveredObject(
DiscoveredObject(
object_type=PROXY_HOSTS, object_type=PROXY_HOSTS,
local_key=self._local_key(row, "proxy host"), local_key=self._local_key(
row,
"proxy host",
),
payload=payload, payload=payload,
references=references, references=references,
) )
)
return discovered def _certificate_from_row(
self,
def _discover_certificates(self) -> list[DiscoveredObject]: row: dict[str, Any],
discovered: list[DiscoveredObject] = [] ) -> DiscoveredObject:
for row in self.client.certificates():
meta = row.get("meta") meta = row.get("meta")
if not isinstance(meta, dict): if not isinstance(meta, dict):
meta = {} meta = {}
return DiscoveredObject(
object_type=CERTIFICATES,
local_key=self._local_key(
row,
"certificate",
),
payload={ payload={
"domain_names": list( "domain_names": list(
row.get("domain_names") or [] row.get("domain_names") or []
), ),
"provider": str(row.get("provider") or ""), "provider": str(
"nice_name": str(row.get("nice_name") or ""), row.get("provider") or ""
),
"nice_name": str(
row.get("nice_name") or ""
),
"valid_from": row.get("valid_from"), "valid_from": row.get("valid_from"),
"expires_on": row.get("expires_on"), "expires_on": row.get("expires_on"),
"cluster_managed": ( "cluster_managed": (
meta.get("cluster_managed") is True meta.get("cluster_managed") is True
), ),
"acme_renew": meta.get("acme_renew") is True, "acme_renew": (
meta.get("acme_renew") is True
),
"dns_challenge": ( "dns_challenge": (
meta.get("dns_challenge") is True 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): 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"
) )
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( raise RuntimeError(
f"{self.endpoint.identity} does not support verify yet" f"{self.endpoint.identity} cannot verify "
f"object type {object.object_type!r}"
) )
+189 -3
View File
@@ -7,9 +7,11 @@ from dns_sync.adapters import (
PROXY_HOSTS, PROXY_HOSTS,
Capability, Capability,
CapabilityOperation, CapabilityOperation,
NormalizedObject,
ObjectReference, ObjectReference,
) )
from dns_sync.config import AppConfig from dns_sync.config import AppConfig
from dns_sync.object_normalizer import normalize_discovered_object
from dns_sync.registry import load_adapter 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[0].client, first_client)
self.assertIs(adapters[1].client, second_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()) adapter = self.adapter(Mock())
self.assertEqual( self.assertEqual(
@@ -73,14 +75,37 @@ class NpmAdapterTests(unittest.TestCase):
PROXY_HOSTS, PROXY_HOSTS,
CapabilityOperation.DISCOVER, CapabilityOperation.DISCOVER,
), ),
Capability(
PROXY_HOSTS,
CapabilityOperation.VERIFY,
),
Capability( Capability(
CERTIFICATES, CERTIFICATES,
CapabilityOperation.DISCOVER, 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): def test_discovers_proxy_hosts_with_endpoint_local_key(self):
client = Mock() client = Mock()
client.proxy_hosts.return_value = [ client.proxy_hosts.return_value = [
@@ -179,6 +204,160 @@ class NpmAdapterTests(unittest.TestCase):
certificate.payload, 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): def test_unsupported_operations_fail_explicitly(self):
adapter = self.adapter(Mock()) adapter = self.adapter(Mock())
@@ -188,8 +367,15 @@ class NpmAdapterTests(unittest.TestCase):
with self.assertRaisesRegex(RuntimeError, "does not support apply"): with self.assertRaisesRegex(RuntimeError, "does not support apply"):
adapter.apply(Mock()) adapter.apply(Mock())
with self.assertRaisesRegex(RuntimeError, "does not support verify"): unsupported = NormalizedObject(
adapter.verify(Mock()) object_type="unsupported_objects",
logical_key="unsupported",
local_key="1",
payload={},
)
with self.assertRaisesRegex(RuntimeError, "cannot verify"):
adapter.verify(unsupported)
if __name__ == "__main__": if __name__ == "__main__":