Exclude expired disabled NPM certificate dependencies
This commit is contained in:
+133
-1
@@ -1,5 +1,5 @@
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, replace
|
||||
from datetime import datetime, timezone
|
||||
from typing import Protocol
|
||||
|
||||
@@ -20,6 +20,7 @@ from .object_normalizer import normalize_observations
|
||||
from .persistence import SQLiteStateStore
|
||||
from .projections import project_proxy_hosts_to_dns
|
||||
from .reconciliation import (
|
||||
ReconciliationConflict,
|
||||
build_endpoint_plans,
|
||||
merge_desired_with_previous,
|
||||
reconcile_observations,
|
||||
@@ -133,6 +134,132 @@ def _authoritative_observations(
|
||||
]
|
||||
|
||||
|
||||
def _certificate_expired(
|
||||
object: NormalizedObject,
|
||||
now: datetime,
|
||||
) -> bool:
|
||||
value = object.payload.get("expires_on")
|
||||
|
||||
if value is None:
|
||||
return False
|
||||
|
||||
if not isinstance(value, str):
|
||||
raise ReconciliationConflict(
|
||||
"Certificate expires_on must be normalized text"
|
||||
)
|
||||
|
||||
normalized = value.strip()
|
||||
|
||||
if normalized.endswith("Z"):
|
||||
normalized = normalized[:-1] + "+00:00"
|
||||
|
||||
try:
|
||||
expires_at = datetime.fromisoformat(normalized)
|
||||
except ValueError as exc:
|
||||
raise ReconciliationConflict(
|
||||
"Certificate expires_on is not a valid timestamp"
|
||||
) from exc
|
||||
|
||||
if expires_at.tzinfo is None:
|
||||
expires_at = expires_at.replace(tzinfo=timezone.utc)
|
||||
|
||||
return expires_at.astimezone(timezone.utc) <= now
|
||||
|
||||
|
||||
def _filter_expired_certificate_dependencies(
|
||||
observations: Iterable[Observation],
|
||||
now: datetime,
|
||||
) -> list[Observation]:
|
||||
rows = list(observations)
|
||||
|
||||
expired_local: set[tuple[str, str]] = set()
|
||||
|
||||
for observation in rows:
|
||||
object = observation.object
|
||||
|
||||
if (
|
||||
object.object_type != CERTIFICATES
|
||||
or not _certificate_expired(object, now)
|
||||
):
|
||||
continue
|
||||
|
||||
if object.local_key is None:
|
||||
raise ReconciliationConflict(
|
||||
"Expired observed certificate requires "
|
||||
"endpoint-local identity"
|
||||
)
|
||||
|
||||
expired_local.add(
|
||||
(
|
||||
observation.endpoint_id,
|
||||
object.local_key,
|
||||
)
|
||||
)
|
||||
|
||||
filtered: list[Observation] = []
|
||||
|
||||
for observation in rows:
|
||||
object = observation.object
|
||||
|
||||
if (
|
||||
object.object_type == CERTIFICATES
|
||||
and object.local_key is not None
|
||||
and (
|
||||
observation.endpoint_id,
|
||||
object.local_key,
|
||||
)
|
||||
in expired_local
|
||||
):
|
||||
continue
|
||||
|
||||
if object.object_type != PROXY_HOSTS:
|
||||
filtered.append(observation)
|
||||
continue
|
||||
|
||||
expired_references = tuple(
|
||||
reference
|
||||
for reference in object.references
|
||||
if (
|
||||
reference.object_type == CERTIFICATES
|
||||
and reference.local_key is not None
|
||||
and (
|
||||
observation.endpoint_id,
|
||||
reference.local_key,
|
||||
)
|
||||
in expired_local
|
||||
)
|
||||
)
|
||||
|
||||
if not expired_references:
|
||||
filtered.append(observation)
|
||||
continue
|
||||
|
||||
if object.payload.get("enabled") is True:
|
||||
raise ReconciliationConflict(
|
||||
"Enabled proxy host references an expired "
|
||||
f"certificate: {object.logical_key!r} "
|
||||
f"on {observation.endpoint_id}"
|
||||
)
|
||||
|
||||
retained_references = tuple(
|
||||
reference
|
||||
for reference in object.references
|
||||
if reference not in expired_references
|
||||
)
|
||||
|
||||
filtered.append(
|
||||
replace(
|
||||
observation,
|
||||
object=replace(
|
||||
object,
|
||||
references=retained_references,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
return filtered
|
||||
|
||||
|
||||
def _planning_adapter_views(
|
||||
adapters: Iterable[ReconciliationAdapter],
|
||||
live_pairs: frozenset[tuple[str, str]],
|
||||
@@ -310,6 +437,11 @@ def discover_reconcile_plan(
|
||||
),
|
||||
)
|
||||
|
||||
npm_observations = _filter_expired_certificate_dependencies(
|
||||
npm_observations,
|
||||
timestamp,
|
||||
)
|
||||
|
||||
npm_desired = (
|
||||
reconcile_observations(npm_observations)
|
||||
if npm_observations
|
||||
|
||||
@@ -11,9 +11,11 @@ from dns_sync.adapters import (
|
||||
DiscoveredObject,
|
||||
Endpoint,
|
||||
NormalizedObject,
|
||||
ObjectReference,
|
||||
Observation,
|
||||
)
|
||||
from dns_sync.orchestrator import discover_reconcile_plan
|
||||
from dns_sync.reconciliation import ReconciliationConflict
|
||||
from dns_sync.persistence import SQLiteStateStore
|
||||
|
||||
|
||||
@@ -343,6 +345,160 @@ class ObservationAuthorityTests(unittest.TestCase):
|
||||
persisted,
|
||||
)
|
||||
|
||||
def test_expired_certificate_on_disabled_proxy_is_removed_from_desired(
|
||||
self,
|
||||
):
|
||||
endpoint = Endpoint(
|
||||
"npm",
|
||||
"192.168.1.3:81",
|
||||
)
|
||||
|
||||
adapter = FakeAdapter(
|
||||
endpoint=endpoint,
|
||||
capabilities=capability_set(
|
||||
CERTIFICATES,
|
||||
PROXY_HOSTS,
|
||||
),
|
||||
discovered={
|
||||
CERTIFICATES: [
|
||||
DiscoveredObject(
|
||||
object_type=CERTIFICATES,
|
||||
local_key="56",
|
||||
payload={
|
||||
"domain_names": [
|
||||
"admin.trunavigate.org"
|
||||
],
|
||||
"provider": "letsencrypt",
|
||||
"valid_from": None,
|
||||
"expires_on": (
|
||||
"2026-06-22T03:40:57.000Z"
|
||||
),
|
||||
},
|
||||
)
|
||||
],
|
||||
PROXY_HOSTS: [
|
||||
DiscoveredObject(
|
||||
object_type=PROXY_HOSTS,
|
||||
local_key="29",
|
||||
payload={
|
||||
"domain_names": [
|
||||
"admin.trunavigate.org"
|
||||
],
|
||||
"forward_scheme": "http",
|
||||
"forward_host": "192.168.1.4",
|
||||
"forward_port": 3283,
|
||||
"enabled": False,
|
||||
},
|
||||
references=(
|
||||
ObjectReference(
|
||||
relation="certificate",
|
||||
object_type=CERTIFICATES,
|
||||
local_key="56",
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
with SQLiteStateStore(":memory:") as store:
|
||||
result = discover_reconcile_plan(
|
||||
[adapter],
|
||||
store,
|
||||
target_ip="192.168.1.101",
|
||||
run_at=T2,
|
||||
)
|
||||
|
||||
certificates = [
|
||||
object
|
||||
for object in result.desired
|
||||
if object.object_type == CERTIFICATES
|
||||
]
|
||||
proxies = [
|
||||
object
|
||||
for object in result.desired
|
||||
if object.object_type == PROXY_HOSTS
|
||||
]
|
||||
|
||||
self.assertEqual(certificates, [])
|
||||
self.assertEqual(len(proxies), 1)
|
||||
self.assertEqual(
|
||||
proxies[0].logical_key,
|
||||
"admin.trunavigate.org",
|
||||
)
|
||||
self.assertEqual(
|
||||
proxies[0].references,
|
||||
(),
|
||||
)
|
||||
|
||||
def test_expired_certificate_on_enabled_proxy_fails_closed(
|
||||
self,
|
||||
):
|
||||
endpoint = Endpoint(
|
||||
"npm",
|
||||
"192.168.1.3:81",
|
||||
)
|
||||
|
||||
adapter = FakeAdapter(
|
||||
endpoint=endpoint,
|
||||
capabilities=capability_set(
|
||||
CERTIFICATES,
|
||||
PROXY_HOSTS,
|
||||
),
|
||||
discovered={
|
||||
CERTIFICATES: [
|
||||
DiscoveredObject(
|
||||
object_type=CERTIFICATES,
|
||||
local_key="56",
|
||||
payload={
|
||||
"domain_names": [
|
||||
"active.example.com"
|
||||
],
|
||||
"provider": "letsencrypt",
|
||||
"valid_from": None,
|
||||
"expires_on": (
|
||||
"2026-06-22T03:40:57.000Z"
|
||||
),
|
||||
},
|
||||
)
|
||||
],
|
||||
PROXY_HOSTS: [
|
||||
DiscoveredObject(
|
||||
object_type=PROXY_HOSTS,
|
||||
local_key="29",
|
||||
payload={
|
||||
"domain_names": [
|
||||
"active.example.com"
|
||||
],
|
||||
"forward_scheme": "http",
|
||||
"forward_host": "192.168.1.4",
|
||||
"forward_port": 3283,
|
||||
"enabled": True,
|
||||
},
|
||||
references=(
|
||||
ObjectReference(
|
||||
relation="certificate",
|
||||
object_type=CERTIFICATES,
|
||||
local_key="56",
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
with SQLiteStateStore(":memory:") as store:
|
||||
with self.assertRaisesRegex(
|
||||
ReconciliationConflict,
|
||||
"Enabled proxy host references an expired certificate",
|
||||
):
|
||||
discover_reconcile_plan(
|
||||
[adapter],
|
||||
store,
|
||||
target_ip="192.168.1.101",
|
||||
run_at=T2,
|
||||
)
|
||||
|
||||
def test_all_failed_sources_preserve_persisted_desired_without_planning(self):
|
||||
npm_endpoint = Endpoint(
|
||||
"npm",
|
||||
|
||||
Reference in New Issue
Block a user