Move NPM configuration and client behind plugin boundary

This commit is contained in:
Lyncoln
2026-09-20 22:50:37 -04:00
parent 332f994c26
commit c9a3898055
8 changed files with 289 additions and 256 deletions
+12 -58
View File
@@ -79,15 +79,6 @@ def parse_endpoint_list(
return tuple(endpoints)
def endpoint_host(endpoint: Endpoint) -> str:
parsed = urlsplit(f"//{endpoint.address}")
if not parsed.hostname:
raise RuntimeError(
f"Invalid endpoint address: {endpoint.address!r}"
)
return parsed.hostname
def env_bool(name: str, default: bool) -> bool:
value = os.getenv(name)
if value is None:
@@ -97,64 +88,32 @@ def env_bool(name: str, default: bool) -> bool:
@dataclass(frozen=True)
class AppConfig:
npm_endpoints: tuple[Endpoint, ...]
proxy_ip: str
proxy_api_port: int
proxy_api_scheme: str
target_ip: str
proxy_verify_tls: bool
domain_matching: bool
domain: str | None
include_disabled: bool = False
@classmethod
def from_env(cls, include_disabled: bool = False) -> "AppConfig":
try:
port = int(os.getenv("PROXY_API_PORT", "81"))
except ValueError as exc:
raise RuntimeError("PROXY_API_PORT must be an integer") from exc
if not 1 <= port <= 65535:
raise RuntimeError("PROXY_API_PORT must be between 1 and 65535")
scheme = os.getenv("PROXY_API_SCHEME", "http").strip().lower()
if scheme not in {"http", "https"}:
raise RuntimeError("PROXY_API_SCHEME must be http or https")
raw_proxy_ip = os.getenv("PROXY_IP")
raw_npm_endpoints = os.getenv("NPM_ENDPOINTS")
if raw_npm_endpoints is None:
if not raw_proxy_ip:
raise RuntimeError(
"NPM_ENDPOINTS or legacy PROXY_IP is required"
)
proxy_ip = normalize_ipv4(raw_proxy_ip, "PROXY_IP")
npm_endpoints = parse_endpoint_list("npm", proxy_ip, port)
else:
npm_endpoints = parse_endpoint_list(
"npm",
raw_npm_endpoints,
port,
)
if raw_proxy_ip:
proxy_ip = normalize_ipv4(raw_proxy_ip, "PROXY_IP")
else:
proxy_ip = endpoint_host(npm_endpoints[0])
raw_target_ip = os.getenv("DNS_TARGET_IP")
raw_proxy_ip = os.getenv("PROXY_IP")
if raw_target_ip:
target_ip = normalize_ipv4(raw_target_ip, "DNS_TARGET_IP")
elif raw_proxy_ip:
target_ip = proxy_ip
elif len(npm_endpoints) == 1:
target_ip = normalize_ipv4(
endpoint_host(npm_endpoints[0]),
raw_target_ip,
"DNS_TARGET_IP",
)
elif raw_proxy_ip:
# Deprecated compatibility fallback. Core no longer treats
# PROXY_IP as an NPM endpoint.
target_ip = normalize_ipv4(
raw_proxy_ip,
"PROXY_IP",
)
else:
raise RuntimeError(
"DNS_TARGET_IP is required when multiple NPM_ENDPOINTS "
"are configured without legacy PROXY_IP"
"DNS_TARGET_IP is required "
"(legacy PROXY_IP is also accepted)"
)
domain_matching = env_bool("DOMAIN_MATCHING", False)
@@ -173,12 +132,7 @@ class AppConfig:
)
return cls(
npm_endpoints=npm_endpoints,
proxy_ip=proxy_ip,
proxy_api_port=port,
proxy_api_scheme=scheme,
target_ip=target_ip,
proxy_verify_tls=env_bool("PROXY_VERIFY_TLS", True),
domain_matching=domain_matching,
domain=domain,
include_disabled=include_disabled,
@@ -1,6 +1,7 @@
import os
from typing import Any
from ...adapters import (
from ....adapters import (
CERTIFICATES,
PROXY_HOSTS,
ApplyResult,
@@ -13,18 +14,49 @@ from ...adapters import (
PlanOperation,
RetireRequest,
)
from ...adapter_support import (
from ....adapter_support import (
require_action_endpoint,
require_desired,
require_endpoint_plugin,
)
from ...certificates.material import CertificateMaterial
from ...npm_client import NpmClient
from ...object_normalizer import normalize_discovered_object
from ....certificates.material import CertificateMaterial
from ....config import parse_endpoint_list
from ....normalizer import normalize_ipv4
from .client import NpmClient
from ....object_normalizer import normalize_discovered_object
def configured_endpoints(config):
return config.npm_endpoints
try:
port = int(os.getenv("PROXY_API_PORT", "81"))
except ValueError as exc:
raise RuntimeError(
"PROXY_API_PORT must be an integer"
) from exc
if not 1 <= port <= 65535:
raise RuntimeError(
"PROXY_API_PORT must be between 1 and 65535"
)
raw_endpoints = os.getenv("NPM_ENDPOINTS")
if raw_endpoints is None:
raw_proxy_ip = os.getenv("PROXY_IP")
if not raw_proxy_ip:
raise RuntimeError(
"NPM_ENDPOINTS or legacy PROXY_IP is required"
)
raw_endpoints = normalize_ipv4(
raw_proxy_ip,
"PROXY_IP",
)
return parse_endpoint_list(
"npm",
raw_endpoints,
port,
)
_PROXY_HOST_FIELDS = (
@@ -77,9 +109,9 @@ class Plugin:
def __init__(self, config, endpoint):
require_endpoint_plugin(endpoint, self.name)
self.config = config
self.endpoints = configured_endpoints(config)
self.endpoint = endpoint
self.client = NpmClient(config, endpoint)
self.client = NpmClient(endpoint)
@staticmethod
def _local_key(row: dict[str, Any], label: str) -> str:
@@ -246,16 +278,13 @@ class Plugin:
source_errors: list[str] = []
for endpoint in sorted(
self.config.npm_endpoints,
self.endpoints,
key=lambda item: item.identity,
):
if endpoint.identity == self.endpoint.identity:
continue
client = NpmClient(
self.config,
endpoint,
)
client = NpmClient(endpoint)
try:
rows = client.certificates()
@@ -7,8 +7,9 @@ from zipfile import BadZipFile, ZipFile
import requests
import urllib3
from .adapters import Endpoint
from .certificates.material import CertificateMaterial
from ....adapters import Endpoint
from ....certificates.material import CertificateMaterial
from ....config import env_bool
class NpmClient:
@@ -16,11 +17,9 @@ class NpmClient:
def __init__(
self,
config,
endpoint: Endpoint | None = None,
endpoint: Endpoint,
):
self.config = config
self.endpoint = endpoint or config.npm_endpoints[0]
self.endpoint = endpoint
if self.endpoint.plugin != "npm":
raise RuntimeError("NpmClient requires an npm endpoint")
@@ -34,10 +33,25 @@ class NpmClient:
if not self.secret:
raise RuntimeError("NPM_SECRET is required")
self.api_scheme = os.getenv(
"PROXY_API_SCHEME",
"http",
).strip().lower()
if self.api_scheme not in {"http", "https"}:
raise RuntimeError(
"PROXY_API_SCHEME must be http or https"
)
self.verify_tls = env_bool(
"PROXY_VERIFY_TLS",
True,
)
self.session = requests.Session()
self.token: str | None = None
if not config.proxy_verify_tls:
if not self.verify_tls:
urllib3.disable_warnings(
urllib3.exceptions.InsecureRequestWarning
)
@@ -45,7 +59,7 @@ class NpmClient:
@property
def api_url(self) -> str:
return (
f"{self.config.proxy_api_scheme}://"
f"{self.api_scheme}://"
f"{self.endpoint.address}/api"
)
@@ -57,7 +71,7 @@ class NpmClient:
"secret": self.secret,
},
timeout=15,
verify=self.config.proxy_verify_tls,
verify=self.verify_tls,
)
if response.status_code != 200:
@@ -107,7 +121,7 @@ class NpmClient:
method,
f"{self.api_url}{path}",
timeout=kwargs.pop("timeout", 15),
verify=self.config.proxy_verify_tls,
verify=self.verify_tls,
**kwargs,
)
+13 -33
View File
@@ -47,28 +47,27 @@ class EndpointParsingTests(unittest.TestCase):
)
class AppConfigEndpointTests(unittest.TestCase):
def test_legacy_proxy_ip_remains_supported(self):
class AppConfigPolicyTests(unittest.TestCase):
def test_explicit_dns_target_is_used(self):
with patch.dict(
os.environ,
{"PROXY_IP": "192.168.1.101"},
{
"DNS_TARGET_IP": "192.168.1.101",
"NPM_ENDPOINTS": "192.168.1.3,192.168.1.4",
},
clear=True,
):
config = AppConfig.from_env()
self.assertEqual(
tuple(endpoint.identity for endpoint in config.npm_endpoints),
("npm://192.168.1.101:81",),
config.target_ip,
"192.168.1.101",
)
self.assertEqual(config.target_ip, "192.168.1.101")
def test_endpoint_list_controls_api_and_proxy_ip_remains_target(self):
def test_legacy_proxy_ip_remains_target_fallback(self):
with patch.dict(
os.environ,
{
"NPM_ENDPOINTS": (
"192.168.1.3,192.168.1.4:8081,192.168.1.5"
),
"PROXY_IP": "192.168.1.101",
},
clear=True,
@@ -76,33 +75,15 @@ class AppConfigEndpointTests(unittest.TestCase):
config = AppConfig.from_env()
self.assertEqual(
tuple(endpoint.identity for endpoint in config.npm_endpoints),
(
"npm://192.168.1.3:81",
"npm://192.168.1.4:8081",
"npm://192.168.1.5:81",
),
config.target_ip,
"192.168.1.101",
)
self.assertEqual(config.target_ip, "192.168.1.101")
def test_explicit_dns_target_supports_multiple_endpoints(self):
def test_dns_target_is_required_without_legacy_fallback(self):
with patch.dict(
os.environ,
{
"NPM_ENDPOINTS": "192.168.1.3,192.168.1.4",
"DNS_TARGET_IP": "192.168.1.101",
},
clear=True,
):
config = AppConfig.from_env()
self.assertEqual(config.target_ip, "192.168.1.101")
def test_multiple_endpoints_require_target_without_proxy_ip(self):
with patch.dict(
os.environ,
{
"NPM_ENDPOINTS": "192.168.1.3,192.168.1.4",
"NPM_ENDPOINTS": "192.168.1.3",
},
clear=True,
):
@@ -113,6 +94,5 @@ class AppConfigEndpointTests(unittest.TestCase):
AppConfig.from_env()
if __name__ == "__main__":
unittest.main()
+73 -65
View File
@@ -16,23 +16,38 @@ from dns_sync.adapters import (
from dns_sync.certificates.material import CertificateMaterial
from dns_sync.config import AppConfig
from dns_sync.object_normalizer import normalize_discovered_object
from dns_sync.registry import load_adapter
from dns_sync.registry import (
configured_adapter_endpoints,
load_adapter,
)
class NpmAdapterTests(unittest.TestCase):
def config(self):
with patch.dict(
@staticmethod
def npm_env(
endpoints="192.168.1.3,192.168.1.4",
):
return patch.dict(
os.environ,
{
"NPM_ENDPOINTS": "192.168.1.3,192.168.1.4",
"NPM_ENDPOINTS": endpoints,
"DNS_TARGET_IP": "192.168.1.101",
},
clear=True,
):
)
def config(self):
with self.npm_env():
return AppConfig.from_env()
def adapter(self, client):
config = self.config()
with self.npm_env():
config = AppConfig.from_env()
endpoints = configured_adapter_endpoints(
"npm",
config,
)
with patch(
"dns_sync.plugins.adapters.npm.NpmClient",
return_value=client,
@@ -40,7 +55,7 @@ class NpmAdapterTests(unittest.TestCase):
return load_adapter(
"npm",
config,
config.npm_endpoints[0],
endpoints[0],
)
def target_adapter_with_sources(
@@ -48,7 +63,12 @@ class NpmAdapterTests(unittest.TestCase):
target_client,
source_clients,
):
config = self.config()
with self.npm_env():
config = AppConfig.from_env()
endpoints = configured_adapter_endpoints(
"npm",
config,
)
with patch(
"dns_sync.plugins.adapters.npm.NpmClient",
@@ -57,7 +77,7 @@ class NpmAdapterTests(unittest.TestCase):
adapter = load_adapter(
"npm",
config,
config.npm_endpoints[1],
endpoints[1],
)
client_by_identity = {
@@ -65,23 +85,33 @@ class NpmAdapterTests(unittest.TestCase):
for endpoint_id, client in source_clients.items()
}
def client_factory(config, endpoint):
def client_factory(endpoint):
return client_by_identity[endpoint.identity]
return adapter, client_factory
def test_registry_creates_one_adapter_per_endpoint(self):
config = self.config()
first_client = Mock()
second_client = Mock()
with self.npm_env():
config = AppConfig.from_env()
endpoints = configured_adapter_endpoints(
"npm",
config,
)
with patch(
"dns_sync.plugins.adapters.npm.NpmClient",
side_effect=[first_client, second_client],
) as client_class:
adapters = [
load_adapter("npm", config, endpoint)
for endpoint in config.npm_endpoints
load_adapter(
"npm",
config,
endpoint,
)
for endpoint in endpoints
]
self.assertEqual(
@@ -517,24 +547,13 @@ class NpmAdapterTests(unittest.TestCase):
source_client.certificate_material.assert_not_called()
def test_certificate_material_source_rejects_conflicting_material(self):
config = self.config()
# Add a third endpoint for this ambiguity test.
endpoint_type = type(config.npm_endpoints[0])
third_endpoint = endpoint_type(
with self.npm_env(
"192.168.1.3,192.168.1.4,192.168.1.5"
):
config = AppConfig.from_env()
endpoints = configured_adapter_endpoints(
"npm",
"192.168.1.5:81",
)
config = __import__(
"dataclasses"
).replace(
config,
npm_endpoints=(
config.npm_endpoints[0],
config.npm_endpoints[1],
third_endpoint,
),
)
target_client = Mock()
@@ -568,6 +587,9 @@ class NpmAdapterTests(unittest.TestCase):
)
)
with self.npm_env(
"192.168.1.3,192.168.1.4,192.168.1.5"
):
with patch(
"dns_sync.plugins.adapters.npm.NpmClient",
return_value=target_client,
@@ -575,7 +597,7 @@ class NpmAdapterTests(unittest.TestCase):
adapter = load_adapter(
"npm",
config,
config.npm_endpoints[1],
endpoints[1],
)
clients = {
@@ -583,7 +605,7 @@ class NpmAdapterTests(unittest.TestCase):
"npm://192.168.1.5:81": source_b,
}
def client_factory(config, endpoint):
def client_factory(endpoint):
return clients[endpoint.identity]
desired = NormalizedObject(
@@ -607,23 +629,13 @@ class NpmAdapterTests(unittest.TestCase):
adapter._certificate_material_source(desired)
def test_certificate_material_source_accepts_identical_replicas(self):
config = self.config()
endpoint_type = type(config.npm_endpoints[0])
third_endpoint = endpoint_type(
with self.npm_env(
"192.168.1.3,192.168.1.4,192.168.1.5"
):
config = AppConfig.from_env()
endpoints = configured_adapter_endpoints(
"npm",
"192.168.1.5:81",
)
config = __import__(
"dataclasses"
).replace(
config,
npm_endpoints=(
config.npm_endpoints[0],
config.npm_endpoints[1],
third_endpoint,
),
)
target_client = Mock()
@@ -660,6 +672,9 @@ class NpmAdapterTests(unittest.TestCase):
source_a.certificate_material.return_value = common_material
source_b.certificate_material.return_value = common_material
with self.npm_env(
"192.168.1.3,192.168.1.4,192.168.1.5"
):
with patch(
"dns_sync.plugins.adapters.npm.NpmClient",
return_value=target_client,
@@ -667,7 +682,7 @@ class NpmAdapterTests(unittest.TestCase):
adapter = load_adapter(
"npm",
config,
config.npm_endpoints[1],
endpoints[1],
)
clients = {
@@ -675,7 +690,7 @@ class NpmAdapterTests(unittest.TestCase):
"npm://192.168.1.5:81": source_b,
}
def client_factory(config, endpoint):
def client_factory(endpoint):
return clients[endpoint.identity]
desired = NormalizedObject(
@@ -699,23 +714,13 @@ class NpmAdapterTests(unittest.TestCase):
self.assertEqual(resolved, common_material)
def test_certificate_material_source_tolerates_unavailable_unrelated_source(self):
config = self.config()
endpoint_type = type(config.npm_endpoints[0])
third_endpoint = endpoint_type(
with self.npm_env(
"192.168.1.3,192.168.1.4,192.168.1.5"
):
config = AppConfig.from_env()
endpoints = configured_adapter_endpoints(
"npm",
"192.168.1.5:81",
)
config = __import__(
"dataclasses"
).replace(
config,
npm_endpoints=(
config.npm_endpoints[0],
config.npm_endpoints[1],
third_endpoint,
),
)
target_client = Mock()
@@ -743,6 +748,9 @@ class NpmAdapterTests(unittest.TestCase):
)
available.certificate_material.return_value = material
with self.npm_env(
"192.168.1.3,192.168.1.4,192.168.1.5"
):
with patch(
"dns_sync.plugins.adapters.npm.NpmClient",
return_value=target_client,
@@ -750,7 +758,7 @@ class NpmAdapterTests(unittest.TestCase):
adapter = load_adapter(
"npm",
config,
config.npm_endpoints[1],
endpoints[1],
)
clients = {
@@ -758,7 +766,7 @@ class NpmAdapterTests(unittest.TestCase):
"npm://192.168.1.5:81": available,
}
def client_factory(config, endpoint):
def client_factory(endpoint):
return clients[endpoint.identity]
desired = NormalizedObject(
+74 -27
View File
@@ -5,48 +5,49 @@ from zipfile import ZipFile
from unittest.mock import Mock, patch
from dns_sync.adapters import Endpoint
from dns_sync.config import AppConfig
from dns_sync.certificates.material import CertificateMaterial
from dns_sync.npm_client import NpmClient
from dns_sync.plugins.adapters.npm.client import NpmClient
class NpmClientEndpointTests(unittest.TestCase):
def config(self):
return AppConfig.from_env()
def test_default_client_uses_first_configured_endpoint(self):
def test_client_uses_explicit_endpoint(self):
with patch.dict(
os.environ,
{
"NPM_ENDPOINTS": "192.168.1.3,192.168.1.4:8081",
"DNS_TARGET_IP": "192.168.1.101",
"NPM_IDENTITY": "test@example.com",
"NPM_SECRET": "not-a-real-secret",
},
clear=True,
):
config = self.config()
endpoint = Endpoint(
"npm",
"192.168.1.3:81",
)
with patch(
"dns_sync.npm_client.requests.Session.request"
"dns_sync.plugins.adapters.npm.client.requests.Session.request"
) as request:
client = NpmClient(config)
client = NpmClient(endpoint)
request.assert_not_called()
self.assertEqual(client.api_url, "http://192.168.1.3:81/api")
self.assertEqual(
client.api_url,
"http://192.168.1.3:81/api",
)
def test_client_can_select_specific_configured_endpoint(self):
def test_client_can_select_specific_endpoint(self):
with patch.dict(
os.environ,
{
"NPM_ENDPOINTS": "192.168.1.3,192.168.1.4:8081",
"DNS_TARGET_IP": "192.168.1.101",
"NPM_IDENTITY": "test@example.com",
"NPM_SECRET": "not-a-real-secret",
},
clear=True,
):
config = self.config()
client = NpmClient(config, config.npm_endpoints[1])
endpoint = Endpoint(
"npm",
"192.168.1.4:8081",
)
client = NpmClient(endpoint)
self.assertEqual(
client.api_url,
@@ -61,19 +62,66 @@ class NpmClientEndpointTests(unittest.TestCase):
with patch.dict(
os.environ,
{
"PROXY_IP": "192.168.1.101",
"NPM_IDENTITY": "test@example.com",
"NPM_SECRET": "not-a-real-secret",
},
clear=True,
):
config = self.config()
endpoint = Endpoint("verizon", "192.168.1.1")
endpoint = Endpoint(
"verizon",
"192.168.1.1:443",
)
with self.assertRaisesRegex(RuntimeError, "npm endpoint"):
NpmClient(config, endpoint)
with self.assertRaisesRegex(
RuntimeError,
"npm endpoint",
):
NpmClient(endpoint)
def test_client_rejects_invalid_api_scheme(self):
with patch.dict(
os.environ,
{
"NPM_IDENTITY": "test@example.com",
"NPM_SECRET": "not-a-real-secret",
"PROXY_API_SCHEME": "ftp",
},
clear=True,
):
with self.assertRaisesRegex(
RuntimeError,
"PROXY_API_SCHEME",
):
NpmClient(
Endpoint(
"npm",
"192.168.1.3:81",
)
)
def test_client_owns_tls_verification_setting(self):
with patch.dict(
os.environ,
{
"NPM_IDENTITY": "test@example.com",
"NPM_SECRET": "not-a-real-secret",
"PROXY_API_SCHEME": "https",
"PROXY_VERIFY_TLS": "false",
},
clear=True,
):
client = NpmClient(
Endpoint(
"npm",
"192.168.1.3:81",
)
)
self.assertEqual(
client.api_url,
"https://192.168.1.3:81/api",
)
self.assertFalse(client.verify_tls)
class NpmClientMutationTests(unittest.TestCase):
@@ -81,17 +129,16 @@ class NpmClientMutationTests(unittest.TestCase):
with patch.dict(
os.environ,
{
"NPM_ENDPOINTS": "192.168.1.3",
"DNS_TARGET_IP": "192.168.1.101",
"NPM_IDENTITY": "test@example.com",
"NPM_SECRET": "not-a-real-secret",
},
clear=True,
):
config = AppConfig.from_env()
return NpmClient(
config,
config.npm_endpoints[0],
Endpoint(
"npm",
"192.168.1.3:81",
)
)
@staticmethod
+2 -3
View File
@@ -228,14 +228,13 @@ class PluginRegistryTests(unittest.TestCase):
"npm",
"192.168.1.3:81",
)
config = SimpleNamespace(
npm_endpoints=(endpoint,),
)
config = SimpleNamespace()
with patch.dict(
os.environ,
{
"ENABLED_PLUGINS": "npm",
"NPM_ENDPOINTS": "192.168.1.3",
},
clear=True,
):
+2
View File
@@ -24,6 +24,7 @@ class VerizonAdapterTests(unittest.TestCase):
with patch.dict(
os.environ,
{
"DNS_TARGET_IP": "192.168.1.101",
"NPM_ENDPOINTS": "192.168.1.3",
"VERIZON_ENDPOINTS": "192.168.1.1",
"ENABLED_PLUGINS": "npm,verizon",
@@ -141,6 +142,7 @@ class VerizonAdapterTests(unittest.TestCase):
with patch.dict(
os.environ,
{
"DNS_TARGET_IP": "192.168.1.101",
"NPM_ENDPOINTS": "192.168.1.3",
"VERIZON_ENDPOINTS": "192.168.1.1:8443",
"ENABLED_PLUGINS": "npm,verizon",