Project proxy hosts to virtual IP DNS records
This commit is contained in:
@@ -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 "",
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user