Add NPM endpoint verification
This commit is contained in:
@@ -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,92 +84,156 @@ 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],
|
||||||
|
) -> DiscoveredObject:
|
||||||
|
payload = {
|
||||||
|
field: row.get(field)
|
||||||
|
for field in _PROXY_HOST_FIELDS
|
||||||
|
}
|
||||||
|
|
||||||
for row in self.client.proxy_hosts():
|
certificate_id = row.get("certificate_id")
|
||||||
payload = {
|
|
||||||
field: row.get(field)
|
|
||||||
for field in _PROXY_HOST_FIELDS
|
|
||||||
}
|
|
||||||
certificate_id = row.get("certificate_id")
|
|
||||||
|
|
||||||
if certificate_id in (None, "", 0, "0"):
|
if certificate_id in (None, "", 0, "0"):
|
||||||
references = ()
|
references = ()
|
||||||
else:
|
else:
|
||||||
if isinstance(certificate_id, bool):
|
if isinstance(certificate_id, bool):
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
"NPM proxy host has invalid certificate_id"
|
"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,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
discovered.append(
|
certificate_local_key = str(
|
||||||
DiscoveredObject(
|
certificate_id
|
||||||
object_type=PROXY_HOSTS,
|
).strip()
|
||||||
local_key=self._local_key(row, "proxy host"),
|
|
||||||
payload=payload,
|
if not certificate_local_key:
|
||||||
references=references,
|
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]:
|
def _certificate_from_row(
|
||||||
discovered: list[DiscoveredObject] = []
|
self,
|
||||||
|
row: dict[str, Any],
|
||||||
|
) -> DiscoveredObject:
|
||||||
|
meta = row.get("meta")
|
||||||
|
if not isinstance(meta, dict):
|
||||||
|
meta = {}
|
||||||
|
|
||||||
for row in self.client.certificates():
|
return DiscoveredObject(
|
||||||
meta = row.get("meta")
|
object_type=CERTIFICATES,
|
||||||
if not isinstance(meta, dict):
|
local_key=self._local_key(
|
||||||
meta = {}
|
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
@@ -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__":
|
||||||
|
|||||||
Reference in New Issue
Block a user