From 23dbd3cf9146819c9abae9d785544ba2cdb77e7c Mon Sep 17 00:00:00 2001 From: Lyncoln Date: Sat, 19 Sep 2026 18:25:25 -0400 Subject: [PATCH] Project proxy hosts to virtual IP DNS records --- dns_sync/projections.py | 78 ++++++++++++++++ tests/test_projections.py | 188 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 266 insertions(+) create mode 100644 dns_sync/projections.py create mode 100644 tests/test_projections.py diff --git a/dns_sync/projections.py b/dns_sync/projections.py new file mode 100644 index 0000000..6571f01 --- /dev/null +++ b/dns_sync/projections.py @@ -0,0 +1,78 @@ +from collections.abc import Iterable + +from .adapters import ( + DNS_RECORDS, + PROXY_HOSTS, + NormalizedObject, + Observation, +) +from .normalizer import normalize_hostname, normalize_ipv4 + + +def project_proxy_hosts_to_dns( + observations: Iterable[Observation], + target_ip: str, + *, + include_disabled: bool = False, +) -> list[Observation]: + dns_target = normalize_ipv4(target_ip, "DNS target IP") + projected: list[Observation] = [] + + for observation in observations: + proxy_host = observation.object + if proxy_host.object_type != PROXY_HOSTS: + continue + if proxy_host.local_key is None: + raise RuntimeError( + "Proxy-host observations require an endpoint-local key" + ) + + enabled = proxy_host.payload.get("enabled") + if not isinstance(enabled, bool): + raise RuntimeError( + f"Proxy host {proxy_host.local_key} has invalid " + "normalized enabled state" + ) + if not enabled and not include_disabled: + continue + + domain_names = proxy_host.payload.get("domain_names") + if not isinstance(domain_names, (list, tuple)): + raise RuntimeError( + f"Proxy host {proxy_host.local_key} has invalid " + "normalized domain_names" + ) + + for raw_domain in domain_names: + hostname, wildcard = normalize_hostname( + str(raw_domain), + allow_wildcard=True, + ) + projected.append( + Observation( + endpoint_id=observation.endpoint_id, + object=NormalizedObject( + object_type=DNS_RECORDS, + logical_key=hostname, + local_key=( + f"{proxy_host.local_key}:{hostname}" + ), + payload={ + "hostname": hostname, + "ip": dns_target, + "record_type": "A", + "wildcard": wildcard, + }, + ), + observed_at=observation.observed_at, + ) + ) + + return sorted( + projected, + key=lambda item: ( + item.endpoint_id, + item.object.logical_key, + item.object.local_key or "", + ), + ) diff --git a/tests/test_projections.py b/tests/test_projections.py new file mode 100644 index 0000000..f198794 --- /dev/null +++ b/tests/test_projections.py @@ -0,0 +1,188 @@ +import unittest +from datetime import datetime, timezone + +from dns_sync.adapters import ( + CERTIFICATES, + PROXY_HOSTS, + NormalizedObject, + Observation, +) +from dns_sync.projections import project_proxy_hosts_to_dns +from dns_sync.reconciliation import reconcile_observations + + +NOW = datetime(2026, 9, 19, 23, 0, tzinfo=timezone.utc) + + +def proxy_host( + endpoint_id: str, + local_key: str, + domains: list[str], + forward_host: str, + *, + enabled: bool = True, +) -> Observation: + return Observation( + endpoint_id=endpoint_id, + object=NormalizedObject( + object_type=PROXY_HOSTS, + logical_key=",".join(sorted(domains)), + local_key=local_key, + payload={ + "domain_names": domains, + "forward_scheme": "http", + "forward_host": forward_host, + "forward_port": 8080, + "enabled": enabled, + }, + ), + observed_at=NOW, + ) + + +class ProxyHostDnsProjectionTests(unittest.TestCase): + def test_target_ip_replaces_npm_backend_address(self): + source = proxy_host( + "npm://192.168.1.3:81", + "12", + ["app.example.com"], + forward_host="192.168.1.50", + ) + + projected = project_proxy_hosts_to_dns( + [source], + target_ip="192.168.1.101", + ) + + self.assertEqual(len(projected), 1) + record = projected[0] + self.assertEqual(record.object.logical_key, "app.example.com") + self.assertEqual( + record.object.payload["ip"], + "192.168.1.101", + ) + self.assertNotEqual( + record.object.payload["ip"], + source.object.payload["forward_host"], + ) + self.assertEqual( + record.endpoint_id, + "npm://192.168.1.3:81", + ) + self.assertEqual( + record.object.local_key, + "12:app.example.com", + ) + self.assertEqual(record.observed_at, NOW) + + def test_domains_expand_to_individual_dns_records(self): + source = proxy_host( + "npm://192.168.1.3:81", + "12", + ["app.example.com", "*.wild.example.com"], + forward_host="backend.internal", + ) + + projected = project_proxy_hosts_to_dns( + [source], + target_ip="192.168.1.101", + ) + + self.assertEqual( + tuple(item.object.logical_key for item in projected), + ("*.wild.example.com", "app.example.com"), + ) + wildcard = next( + item + for item in projected + if item.object.logical_key == "*.wild.example.com" + ) + self.assertTrue(wildcard.object.payload["wildcard"]) + self.assertTrue( + all( + item.object.payload["ip"] == "192.168.1.101" + for item in projected + ) + ) + + def test_disabled_proxy_hosts_are_optional(self): + source = proxy_host( + "npm://192.168.1.3:81", + "12", + ["disabled.example.com"], + forward_host="192.168.1.50", + enabled=False, + ) + + excluded = project_proxy_hosts_to_dns( + [source], + target_ip="192.168.1.101", + ) + included = project_proxy_hosts_to_dns( + [source], + target_ip="192.168.1.101", + include_disabled=True, + ) + + self.assertEqual(excluded, []) + self.assertEqual(len(included), 1) + + def test_non_proxy_observations_are_not_projected(self): + certificate = Observation( + endpoint_id="npm://192.168.1.3:81", + object=NormalizedObject( + object_type=CERTIFICATES, + logical_key="app.example.com", + local_key="67", + payload={"domain_names": ["app.example.com"]}, + ), + observed_at=NOW, + ) + + projected = project_proxy_hosts_to_dns( + [certificate], + target_ip="192.168.1.101", + ) + + self.assertEqual(projected, []) + + def test_multiple_npm_nodes_form_one_dns_union(self): + observations = [ + proxy_host( + "npm://192.168.1.3:81", + "12", + ["one.example.com", "shared.example.com"], + forward_host="192.168.1.50", + ), + proxy_host( + "npm://192.168.1.4:81", + "22", + ["two.example.com", "shared.example.com"], + forward_host="192.168.1.60", + ), + ] + + projected = project_proxy_hosts_to_dns( + observations, + target_ip="192.168.1.101", + ) + desired = reconcile_observations(projected) + + self.assertEqual( + tuple(item.logical_key for item in desired), + ( + "one.example.com", + "shared.example.com", + "two.example.com", + ), + ) + self.assertTrue( + all( + item.payload["ip"] == "192.168.1.101" + for item in desired + ) + ) + + +if __name__ == "__main__": + unittest.main()