Exclude expired disabled NPM certificate dependencies
This commit is contained in:
+133
-1
@@ -1,5 +1,5 @@
|
|||||||
from collections.abc import Iterable
|
from collections.abc import Iterable
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, replace
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Protocol
|
from typing import Protocol
|
||||||
|
|
||||||
@@ -20,6 +20,7 @@ from .object_normalizer import normalize_observations
|
|||||||
from .persistence import SQLiteStateStore
|
from .persistence import SQLiteStateStore
|
||||||
from .projections import project_proxy_hosts_to_dns
|
from .projections import project_proxy_hosts_to_dns
|
||||||
from .reconciliation import (
|
from .reconciliation import (
|
||||||
|
ReconciliationConflict,
|
||||||
build_endpoint_plans,
|
build_endpoint_plans,
|
||||||
merge_desired_with_previous,
|
merge_desired_with_previous,
|
||||||
reconcile_observations,
|
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(
|
def _planning_adapter_views(
|
||||||
adapters: Iterable[ReconciliationAdapter],
|
adapters: Iterable[ReconciliationAdapter],
|
||||||
live_pairs: frozenset[tuple[str, str]],
|
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 = (
|
npm_desired = (
|
||||||
reconcile_observations(npm_observations)
|
reconcile_observations(npm_observations)
|
||||||
if npm_observations
|
if npm_observations
|
||||||
|
|||||||
@@ -11,9 +11,11 @@ from dns_sync.adapters import (
|
|||||||
DiscoveredObject,
|
DiscoveredObject,
|
||||||
Endpoint,
|
Endpoint,
|
||||||
NormalizedObject,
|
NormalizedObject,
|
||||||
|
ObjectReference,
|
||||||
Observation,
|
Observation,
|
||||||
)
|
)
|
||||||
from dns_sync.orchestrator import discover_reconcile_plan
|
from dns_sync.orchestrator import discover_reconcile_plan
|
||||||
|
from dns_sync.reconciliation import ReconciliationConflict
|
||||||
from dns_sync.persistence import SQLiteStateStore
|
from dns_sync.persistence import SQLiteStateStore
|
||||||
|
|
||||||
|
|
||||||
@@ -343,6 +345,160 @@ class ObservationAuthorityTests(unittest.TestCase):
|
|||||||
persisted,
|
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):
|
def test_all_failed_sources_preserve_persisted_desired_without_planning(self):
|
||||||
npm_endpoint = Endpoint(
|
npm_endpoint = Endpoint(
|
||||||
"npm",
|
"npm",
|
||||||
|
|||||||
Reference in New Issue
Block a user