From a93e067bfdd6b1d88c695c42dc52852141278feb Mon Sep 17 00:00:00 2001 From: Lyncoln Date: Sun, 20 Sep 2026 10:53:26 -0400 Subject: [PATCH] Exclude expired disabled NPM certificate dependencies --- dns_sync/orchestrator.py | 134 ++++++++++++++++++++++- tests/test_orchestrator_authority.py | 156 +++++++++++++++++++++++++++ 2 files changed, 289 insertions(+), 1 deletion(-) diff --git a/dns_sync/orchestrator.py b/dns_sync/orchestrator.py index 8c929be..9d19fc6 100644 --- a/dns_sync/orchestrator.py +++ b/dns_sync/orchestrator.py @@ -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 diff --git a/tests/test_orchestrator_authority.py b/tests/test_orchestrator_authority.py index 057dfa4..0efcf31 100644 --- a/tests/test_orchestrator_authority.py +++ b/tests/test_orchestrator_authority.py @@ -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",