commit 47850e3ce5ecc207c83d56e9a482caa58cf279c9 Author: Lyncoln Date: Sat Sep 19 15:03:45 2026 -0400 Baseline modular DNS sync diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3c47cbc --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +__pycache__/ +*.py[cod] +*.swp +*.tmp +.env diff --git a/Containerfile b/Containerfile new file mode 100644 index 0000000..6ab35a0 --- /dev/null +++ b/Containerfile @@ -0,0 +1,14 @@ +FROM docker.io/library/python:3.13-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PYTHONPATH=/app + +RUN pip install --no-cache-dir requests PyYAML + +WORKDIR /app +COPY dns_sync /app/dns_sync +COPY entrypoint.sh /usr/local/bin/entrypoint.sh +RUN chmod 0755 /usr/local/bin/entrypoint.sh + +ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..c23fa4e --- /dev/null +++ b/README.md @@ -0,0 +1,84 @@ +# dns-sync modular + +A plugin-based DNS reconciliation service. + +## Plugin contract + +Source modules live under `dns_sync/plugins/sources/` and export a class named `Plugin` with: + +- `name` +- `discover() -> list[DesiredRecord]` + +Provider modules live under `dns_sync/plugins/providers/` and export `Plugin` with: + +- `name` +- `capabilities` +- `connect()` +- `list_records()` +- `add_record()` +- `update_record()` +- `delete_record()` +- `verify_record()` +- `capacity()` + +Plugins are convention-loaded from environment variables. No registry edit is needed: + +- `PROXY=npm` loads `dns_sync.plugins.sources.npm.Plugin` +- `DNS_PROVIDER=verizon` loads `dns_sync.plugins.providers.verizon.Plugin` + +## Current plugins + +Sources: `npm`, `file` + +Providers: `verizon` + +## Main environment + +```text +PROXY=npm +PROXY_IP=192.168.1.101 +PROXY_API_PORT=81 +PROXY_API_SCHEME=http +DNS_PROVIDER=verizon +DOMAIN_MATCHING=false +DOMAIN=example.com +SYNC_INTERVAL=1 +NPM_IDENTITY=... +NPM_SECRET=... +ROUTER_PASSWORD=... +``` + +`DNS_TARGET_IP` optionally overrides the default DNS destination, which is `PROXY_IP`. + + +## Domain filtering + +Filtering is performed by the central engine after source discovery and before +reconciliation, so all source plugins get identical behavior. + +```text +DOMAIN_MATCHING=true +DOMAIN=example.com +``` + +This includes the apex and every subdomain: + +```text +example.com +app.example.com +deep.app.example.com +*.example.com +``` + +It excludes unrelated names such as: + +```text +example.net +notexample.com +example.com.evil.net +``` + +When `DOMAIN_MATCHING=false`, `DOMAIN` is ignored and all discovered records are +eligible for reconciliation. + +When `DOMAIN_MATCHING=true`, `DOMAIN` is required. diff --git a/dns-sync.container b/dns-sync.container new file mode 100644 index 0000000..ea94e39 --- /dev/null +++ b/dns-sync.container @@ -0,0 +1,29 @@ +[Unit] +Description=Reverse Proxy DNS Sync +Wants=network-online.target +After=network-online.target + +[Container] +Image=localhost/dns-sync:latest +ContainerName=dns-sync + +Environment=PROXY=npm +Environment=PROXY_IP=192.168.1.101 +Environment=PROXY_API_PORT=81 +Environment=PROXY_API_SCHEME=http +Environment=DNS_PROVIDER=verizon +Environment=DOMAIN_MATCHING=false +Environment=DOMAIN=loxthelion.com +Environment=SYNC_INTERVAL=1 + +Environment=NPM_IDENTITY=davidfolkesmd@gmail.com + +Secret=dns-sync-npm-secret,type=env,target=NPM_SECRET +Secret=dns-sync-router-password,type=env,target=ROUTER_PASSWORD + +[Service] +Restart=on-failure +RestartSec=30 + +[Install] +WantedBy=default.target diff --git a/dns_sync/__init__.py b/dns_sync/__init__.py new file mode 100644 index 0000000..d3ec452 --- /dev/null +++ b/dns_sync/__init__.py @@ -0,0 +1 @@ +__version__ = "0.2.0" diff --git a/dns_sync/__main__.py b/dns_sync/__main__.py new file mode 100644 index 0000000..eb53e2f --- /dev/null +++ b/dns_sync/__main__.py @@ -0,0 +1,3 @@ +from .cli import main + +raise SystemExit(main()) diff --git a/dns_sync/cli.py b/dns_sync/cli.py new file mode 100644 index 0000000..fd55d23 --- /dev/null +++ b/dns_sync/cli.py @@ -0,0 +1,109 @@ +import argparse +import sys + +from .config import AppConfig +from .engine import ( + build_sync_plan, + execute_sync, + filter_desired_by_domain, + summarize_plan, + validate_desired, +) +from .registry import load_provider, load_source +from .reporting import print_plan + + +def build_parser(): + parser = argparse.ArgumentParser(prog="dns-sync", description="Synchronize reverse-proxy hostnames to a pluggable DNS provider") + sub = parser.add_subparsers(dest="command", required=True) + + sync = sub.add_parser("sync", help="Discover, normalize, reconcile, and apply DNS records") + sync.add_argument("--dry-run", action="store_true", help="Show the plan without modifying DNS") + sync.add_argument("--include-disabled", action="store_true", help="Include disabled source entries when supported") + + sub.add_parser("plugins", help="Show the configured source/provider plugins") + return parser + + +def run_sync(args): + config = AppConfig.from_env(include_disabled=args.include_disabled) + source = load_source(config.source_name, config) + provider = load_provider(config.provider_name, config) + + discovered = source.discover() + filtered, filtered_count = filter_desired_by_domain( + discovered, + domain=config.domain, + enabled=config.domain_matching, + ) + desired, warnings = validate_desired(filtered) + for warning in warnings: + print(f"WARNING: {warning}", file=sys.stderr) + + provider.connect() + current = provider.list_records() + plan = build_sync_plan(desired, current, provider.capabilities) + counts = summarize_plan(plan) + + used, free = provider.capacity() + print(f"Source: {source.name}:{config.proxy_url} -> {config.target_ip}") + print(f"Provider: {provider.name}") + if config.domain_matching: + print( + f"Domain matching: enabled ({config.domain} + subdomains)" + ) + print(f"Filtered out: {filtered_count}") + else: + print("Domain matching: disabled") + print(f"Desired records: {len(desired)}") + if free is None: + print(f"Provider capacity: {used} used, unlimited/unknown free") + else: + print(f"Provider capacity: {used} used, {free} free") + + if free is not None and counts["ADD"] > free: + raise RuntimeError(f"Sync requires {counts['ADD']} new records but provider has only {free} free slots") + + print("\nReconciliation plan:") + print_plan(plan) + print() + print( + "Plan summary: " + f"{counts['ADD']} add, {counts['UPDATE']} update, " + f"{counts['UNCHANGED']} unchanged, {counts['SKIP']} skipped" + ) + print("Deletion policy: disabled; provider records absent from desired state are untouched.") + + if args.dry_run: + print("DRY RUN: no changes were made.") + return 0 + + if counts["ADD"] == 0 and counts["UPDATE"] == 0: + print("Already synchronized. No changes required.") + return 0 + + print("\nApplying changes...") + execute_sync(provider, plan) + print( + "\nSync complete: " + f"{counts['ADD']} added, {counts['UPDATE']} updated, " + f"{counts['UNCHANGED']} unchanged, {counts['SKIP']} skipped" + ) + return 0 + + +def main(): + parser = build_parser() + args = parser.parse_args() + try: + if args.command == "sync": + return run_sync(args) + if args.command == "plugins": + config = AppConfig.from_env() + print(f"Source plugin: {config.source_name}") + print(f"Provider plugin: {config.provider_name}") + return 0 + parser.error("unknown command") + except Exception as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 1 diff --git a/dns_sync/config.py b/dns_sync/config.py new file mode 100644 index 0000000..4f3b3ed --- /dev/null +++ b/dns_sync/config.py @@ -0,0 +1,83 @@ +import os +from dataclasses import dataclass + +from .normalizer import normalize_hostname, normalize_ipv4 + + +def env_bool(name: str, default: bool) -> bool: + value = os.getenv(name) + if value is None: + return default + return value.strip().lower() not in {"0", "false", "no", "off"} + + +@dataclass(frozen=True) +class AppConfig: + source_name: str + provider_name: str + 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 + + @property + def proxy_url(self) -> str: + return f"{self.proxy_api_scheme}://{self.proxy_ip}:{self.proxy_api_port}" + + @classmethod + def from_env(cls, include_disabled: bool = False) -> "AppConfig": + source_name = os.getenv("PROXY", "npm").strip().lower() + provider_name = os.getenv("DNS_PROVIDER", "verizon").strip().lower() + + raw_proxy_ip = os.getenv("PROXY_IP") + if not raw_proxy_ip: + raise RuntimeError("PROXY_IP is required") + proxy_ip = normalize_ipv4(raw_proxy_ip, "PROXY_IP") + + 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") + + target_ip = normalize_ipv4( + os.getenv("DNS_TARGET_IP", proxy_ip), + "DNS_TARGET_IP", + ) + + domain_matching = env_bool("DOMAIN_MATCHING", False) + raw_domain = os.getenv("DOMAIN") + domain = None + + if raw_domain: + domain, _ = normalize_hostname( + raw_domain, + allow_wildcard=False, + ) + + if domain_matching and not domain: + raise RuntimeError( + "DOMAIN is required when DOMAIN_MATCHING=true" + ) + + return cls( + source_name=source_name, + provider_name=provider_name, + 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, + ) diff --git a/dns_sync/engine.py b/dns_sync/engine.py new file mode 100644 index 0000000..976ea8b --- /dev/null +++ b/dns_sync/engine.py @@ -0,0 +1,110 @@ +from .models import DesiredRecord, ProviderRecord, SyncAction + + +def hostname_matches_domain(hostname: str, domain: str) -> bool: + """Match a domain apex and any subdomain, without suffix false positives.""" + candidate = hostname.lower().rstrip(".") + if candidate.startswith("*."): + candidate = candidate[2:] + + base = domain.lower().rstrip(".") + return candidate == base or candidate.endswith("." + base) + + +def filter_desired_by_domain( + records: list[DesiredRecord], + domain: str | None, + enabled: bool, +) -> tuple[list[DesiredRecord], int]: + if not enabled: + return records, 0 + + if not domain: + raise RuntimeError( + "Domain filtering enabled without a configured DOMAIN" + ) + + matched = [ + record + for record in records + if hostname_matches_domain(record.hostname, domain) + ] + return matched, len(records) - len(matched) + + + + +def validate_desired(records: list[DesiredRecord]) -> tuple[dict[str, DesiredRecord], list[str]]: + desired: dict[str, DesiredRecord] = {} + warnings: list[str] = [] + + for record in records: + key = record.hostname.lower() + if key in desired: + warnings.append( + f"DUPLICATE {record.hostname} from {record.source}" + + (f" id={record.source_id}" if record.source_id else "") + + "; keeping first occurrence" + ) + continue + desired[key] = record + + return dict(sorted(desired.items())), warnings + + +def build_sync_plan(desired, current: dict[str, ProviderRecord], capabilities): + plan: list[SyncAction] = [] + + for hostname, record in desired.items(): + if record.record_type not in capabilities.record_types: + plan.append(SyncAction("SKIP", record, reason=f"record type {record.record_type} unsupported")) + continue + + if record.wildcard and not capabilities.wildcard_records: + plan.append(SyncAction("SKIP", record, reason="wildcard records unsupported")) + continue + + existing = current.get(hostname.lower()) + if existing is None: + if not capabilities.supports_create: + plan.append(SyncAction("SKIP", record, reason="provider cannot create records")) + else: + plan.append(SyncAction("ADD", record)) + elif existing.ip == record.ip and existing.record_type == record.record_type: + plan.append(SyncAction("UNCHANGED", record, current=existing)) + elif not capabilities.supports_update: + plan.append(SyncAction("SKIP", record, current=existing, reason="provider cannot update records")) + else: + plan.append(SyncAction("UPDATE", record, current=existing)) + + return plan + + +def summarize_plan(plan): + counts = {name: 0 for name in ("ADD", "UPDATE", "UNCHANGED", "SKIP")} + for item in plan: + counts[item.action] = counts.get(item.action, 0) + 1 + return counts + + +def execute_sync(provider, plan): + counts = summarize_plan(plan) + + for item in plan: + if item.action in {"UNCHANGED", "SKIP"}: + continue + + if item.action == "ADD": + detail = provider.add_record(item.desired) + elif item.action == "UPDATE": + detail = provider.update_record(item.desired) + else: + raise RuntimeError(f"Unknown sync action: {item.action}") + + if not provider.verify_record(item.desired): + raise RuntimeError(f"{item.desired.hostname}: {item.action.lower()} did not verify") + + suffix = f" ({detail})" if detail else "" + print(f"APPLIED {item.action:<6} {item.desired.hostname} -> {item.desired.ip}{suffix}") + + return counts diff --git a/dns_sync/interfaces.py b/dns_sync/interfaces.py new file mode 100644 index 0000000..7828da1 --- /dev/null +++ b/dns_sync/interfaces.py @@ -0,0 +1,22 @@ +from typing import Protocol + +from .models import DesiredRecord, ProviderCapabilities, ProviderRecord + + +class SourcePlugin(Protocol): + name: str + + def discover(self) -> list[DesiredRecord]: ... + + +class ProviderPlugin(Protocol): + name: str + capabilities: ProviderCapabilities + + def connect(self) -> None: ... + def list_records(self) -> dict[str, ProviderRecord]: ... + def add_record(self, record: DesiredRecord) -> str | None: ... + def update_record(self, record: DesiredRecord) -> str | None: ... + def delete_record(self, hostname: str) -> str | None: ... + def verify_record(self, record: DesiredRecord) -> bool: ... + def capacity(self) -> tuple[int, int | None]: ... diff --git a/dns_sync/models.py b/dns_sync/models.py new file mode 100644 index 0000000..ae999a7 --- /dev/null +++ b/dns_sync/models.py @@ -0,0 +1,38 @@ +from dataclasses import dataclass, field +from typing import Any + + +@dataclass(frozen=True) +class DesiredRecord: + hostname: str + ip: str + record_type: str = "A" + wildcard: bool = False + source: str = "" + source_id: str | None = None + + +@dataclass(frozen=True) +class ProviderRecord: + hostname: str + ip: str + record_type: str = "A" + metadata: dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class ProviderCapabilities: + record_types: frozenset[str] = frozenset({"A"}) + wildcard_records: bool = False + supports_create: bool = True + supports_update: bool = True + supports_delete: bool = False + max_records: int | None = None + + +@dataclass(frozen=True) +class SyncAction: + action: str + desired: DesiredRecord + current: ProviderRecord | None = None + reason: str | None = None diff --git a/dns_sync/normalizer.py b/dns_sync/normalizer.py new file mode 100644 index 0000000..73d9bf5 --- /dev/null +++ b/dns_sync/normalizer.py @@ -0,0 +1,35 @@ +import ipaddress +import re + +_LABEL_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$") + + +def normalize_hostname(value: str, allow_wildcard: bool = True) -> tuple[str, bool]: + hostname = value.strip().rstrip(".").lower() + wildcard = False + + if allow_wildcard and hostname.startswith("*."): + wildcard = True + hostname = hostname[2:] + + if not hostname or len(hostname) > 253: + raise RuntimeError(f"Invalid hostname: {value!r}") + + for label in hostname.split("."): + if not _LABEL_RE.match(label): + raise RuntimeError(f"Invalid hostname: {value!r}") + + normalized = f"*.{hostname}" if wildcard else hostname + return normalized, wildcard + + +def normalize_ipv4(value: str, label: str = "IP") -> str: + try: + addr = ipaddress.ip_address(value.strip()) + except ValueError as exc: + raise RuntimeError(f"{label}: {value!r} is not a valid IP address") from exc + + if addr.version != 4: + raise RuntimeError(f"{label}: only IPv4 is currently supported") + + return str(addr) diff --git a/dns_sync/plugins/__init__.py b/dns_sync/plugins/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/dns_sync/plugins/providers/__init__.py b/dns_sync/plugins/providers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/dns_sync/plugins/providers/verizon.py b/dns_sync/plugins/providers/verizon.py new file mode 100644 index 0000000..338c4d2 --- /dev/null +++ b/dns_sync/plugins/providers/verizon.py @@ -0,0 +1,214 @@ +import hashlib +import os +import re +import time + +import requests +import urllib3 + +from ...models import ProviderCapabilities, ProviderRecord +from ...normalizer import normalize_hostname, normalize_ipv4 + +LUCI_USERNAME = ( + "edbd881f1ee2f76ba0bd70fd184f87711be991a0401fd07ccd4b199665f00761" + "afc91731d8d8ba6cbb188b2ed5bfb465b9f3d30231eb0430b9f90fe91d136648" +) +CFG_RE = re.compile(r'addCfg\("dns_(ip|name)(\d+)",\s*"([^"]*)",\s*"([^"]*)"\);') + + +class _Slot: + def __init__(self, slot): + self.slot = slot + self.hostname = "" + self.ip = "" + self.name_token = "" + self.ip_token = "" + + +class Plugin: + name = "verizon" + capabilities = ProviderCapabilities( + record_types=frozenset({"A"}), + wildcard_records=False, + supports_create=True, + supports_update=True, + supports_delete=True, + max_records=64, + ) + + def __init__(self, config): + self.config = config + self.router_url = os.getenv("ROUTER_URL", "https://192.168.1.1").rstrip("/") + self.password = os.getenv("ROUTER_PASSWORD") + if not self.password: + raise RuntimeError("ROUTER_PASSWORD is required when DNS_PROVIDER=verizon") + + self.timeout = int(os.getenv("ROUTER_TIMEOUT", "10")) + self.session = requests.Session() + self.session.verify = False + urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + self.auth_token = None + self._last_slot_count = 0 + + def _url(self, path): + return f"{self.router_url}{path}" + + def connect(self): + response = self.session.get(self._url("/loginStatus.cgi"), timeout=self.timeout) + response.raise_for_status() + login_token = response.json().get("loginToken") + if not login_token: + raise RuntimeError("Router did not return loginToken") + + md5_password = hashlib.md5(self.password.encode("utf-8")).hexdigest() + sha512_md5 = hashlib.sha512(md5_password.encode("ascii")).hexdigest() + luci_password = hashlib.sha512((login_token + sha512_md5).encode("ascii")).hexdigest() + + response = self.session.post( + self._url("/login.cgi"), + data={ + "luci_username": LUCI_USERNAME, + "luci_password": luci_password, + "luci_view": "Desktop", + "luci_token": login_token, + "luci_keep_login": "0", + }, + allow_redirects=False, + timeout=self.timeout, + ) + if response.status_code not in (200, 302): + raise RuntimeError(f"Router login failed: HTTP {response.status_code}") + + response = self.session.get(self._url("/loginStatus.cgi"), timeout=self.timeout) + response.raise_for_status() + self.auth_token = response.json().get("token") + if not self.auth_token: + raise RuntimeError("Router login succeeded but authenticated token was missing") + if "sysauth" not in self.session.cookies: + raise RuntimeError("Router login succeeded but sysauth cookie was missing") + + def _fetch_slots(self): + if not self.auth_token: + self.connect() + + response = self.session.get( + self._url("/cgi/cgi_dns_server.js"), + timeout=self.timeout, + headers={"Cache-Control": "no-cache"}, + ) + response.raise_for_status() + + slots = {} + for kind, slot_s, token, value in CFG_RE.findall(response.text): + slot_num = int(slot_s) + slot = slots.setdefault(slot_num, _Slot(slot_num)) + if kind == "ip": + slot.ip = value + slot.ip_token = token + else: + slot.hostname = value + slot.name_token = token + + if not slots: + raise RuntimeError( + "No Verizon dns_ip/dns_name slots were found; authentication may have expired or the router format changed" + ) + self._last_slot_count = len(slots) + return slots + + @staticmethod + def _find(slots, hostname): + target = hostname.rstrip(".").lower() + for slot in slots.values(): + if slot.hostname.rstrip(".").lower() == target: + return slot + return None + + @staticmethod + def _first_empty(slots): + for slot_num in sorted(slots): + slot = slots[slot_num] + if not slot.hostname and not slot.ip: + return slot + return None + + def list_records(self): + slots = self._fetch_slots() + result = {} + for slot in slots.values(): + if not slot.hostname: + continue + hostname, wildcard = normalize_hostname(slot.hostname, allow_wildcard=True) + if wildcard: + # The firmware UI has no wildcard support, but normalize defensively. + pass + ip = normalize_ipv4(slot.ip, hostname) + result[hostname.lower()] = ProviderRecord( + hostname=hostname, + ip=ip, + record_type="A", + metadata={"slot": slot.slot}, + ) + return result + + def capacity(self): + slots = self._fetch_slots() + used = sum(1 for slot in slots.values() if slot.hostname or slot.ip) + return used, len(slots) - used + + def _write_slot(self, slot, hostname, ip): + if not self.auth_token: + raise RuntimeError("Verizon provider is not authenticated") + if not slot.ip_token or not slot.name_token: + raise RuntimeError(f"Slot {slot.slot} is missing dynamic field tokens") + + response = self.session.post( + self._url("/apply_abstract.cgi"), + data={ + "token": self.auth_token, + "action": "dnsmasq_reload", + slot.ip_token: ip, + slot.name_token: hostname, + }, + headers={"Origin": self.router_url, "Referer": f"{self.router_url}/"}, + timeout=self.timeout, + ) + response.raise_for_status() + + def add_record(self, record): + slots = self._fetch_slots() # fresh opaque tokens before every mutation + existing = self._find(slots, record.hostname) + if existing: + raise RuntimeError(f"{record.hostname} already exists in Verizon slot {existing.slot}") + slot = self._first_empty(slots) + if slot is None: + raise RuntimeError("No empty Verizon IPv4 DNS slots are available") + self._write_slot(slot, record.hostname, record.ip) + return f"slot {slot.slot}" + + def update_record(self, record): + slots = self._fetch_slots() # fresh opaque tokens before every mutation + slot = self._find(slots, record.hostname) + if slot is None: + raise RuntimeError(f"{record.hostname} does not exist in Verizon DNS") + self._write_slot(slot, record.hostname, record.ip) + return f"slot {slot.slot}" + + def delete_record(self, hostname): + slots = self._fetch_slots() + slot = self._find(slots, hostname) + if slot is None: + return None + self._write_slot(slot, "", "") + return f"slot {slot.slot}" + + def verify_record(self, record): + attempts = int(os.getenv("VERIFY_ATTEMPTS", "5")) + delay = float(os.getenv("VERIFY_DELAY", "1")) + for _ in range(attempts): + slots = self._fetch_slots() + slot = self._find(slots, record.hostname) + if slot is not None and slot.ip == record.ip: + return True + time.sleep(delay) + return False diff --git a/dns_sync/plugins/sources/__init__.py b/dns_sync/plugins/sources/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/dns_sync/plugins/sources/file.py b/dns_sync/plugins/sources/file.py new file mode 100644 index 0000000..c30a85d --- /dev/null +++ b/dns_sync/plugins/sources/file.py @@ -0,0 +1,39 @@ +import os + +import yaml + +from ...models import DesiredRecord +from ...normalizer import normalize_hostname, normalize_ipv4 + + +class Plugin: + name = "file" + + def __init__(self, config): + self.config = config + self.path = os.getenv("DNS_SYNC_FILE") + if not self.path: + raise RuntimeError("DNS_SYNC_FILE is required when PROXY=file") + + def discover(self): + try: + with open(self.path, "r", encoding="utf-8") as fh: + data = yaml.safe_load(fh) + except (OSError, yaml.YAMLError) as exc: + raise RuntimeError(f"Could not read desired-state file {self.path}: {exc}") from exc + + if not isinstance(data, dict) or not isinstance(data.get("records"), dict): + raise RuntimeError("Desired-state YAML must contain a top-level records mapping") + + result = [] + for raw_hostname, raw_ip in data["records"].items(): + hostname, wildcard = normalize_hostname(str(raw_hostname), allow_wildcard=True) + result.append( + DesiredRecord( + hostname=hostname, + ip=normalize_ipv4(str(raw_ip), hostname), + wildcard=wildcard, + source=self.name, + ) + ) + return result diff --git a/dns_sync/plugins/sources/npm.py b/dns_sync/plugins/sources/npm.py new file mode 100644 index 0000000..78803bb --- /dev/null +++ b/dns_sync/plugins/sources/npm.py @@ -0,0 +1,82 @@ +import os + +import requests +import urllib3 + +from ...models import DesiredRecord +from ...normalizer import normalize_hostname + + +class Plugin: + name = "npm" + + def __init__(self, config): + self.config = config + self.identity = os.getenv("NPM_IDENTITY") + self.secret = os.getenv("NPM_SECRET") + if not self.identity: + raise RuntimeError("NPM_IDENTITY is required when PROXY=npm") + if not self.secret: + raise RuntimeError("NPM_SECRET is required when PROXY=npm") + + self.session = requests.Session() + self.token = None + if not config.proxy_verify_tls: + urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + + @property + def api_url(self): + return f"{self.config.proxy_url}/api" + + def _login(self): + response = self.session.post( + f"{self.api_url}/tokens", + json={"identity": self.identity, "secret": self.secret}, + timeout=15, + verify=self.config.proxy_verify_tls, + ) + if response.status_code != 200: + raise RuntimeError(f"NPM login failed: HTTP {response.status_code}: {response.text[:300]}") + data = response.json() + token = data.get("token") + if not token: + raise RuntimeError("NPM login succeeded but no token was returned") + self.token = token + self.session.headers.update({"Authorization": f"Bearer {token}", "Accept": "application/json"}) + + def _proxy_hosts(self): + if not self.token: + self._login() + response = self.session.get( + f"{self.api_url}/nginx/proxy-hosts", + timeout=15, + verify=self.config.proxy_verify_tls, + ) + if response.status_code != 200: + raise RuntimeError(f"Unable to retrieve NPM proxy hosts: HTTP {response.status_code}: {response.text[:300]}") + data = response.json() + if not isinstance(data, list): + raise RuntimeError("NPM proxy-host response was not a list") + return data + + def discover(self): + records: list[DesiredRecord] = [] + for host in self._proxy_hosts(): + enabled = bool(host.get("enabled", False)) + if not enabled and not self.config.include_disabled: + continue + + source_id = str(host.get("id", "")) or None + for raw_domain in host.get("domain_names", []): + hostname, wildcard = normalize_hostname(str(raw_domain), allow_wildcard=True) + records.append( + DesiredRecord( + hostname=hostname, + ip=self.config.target_ip, + record_type="A", + wildcard=wildcard, + source=self.name, + source_id=source_id, + ) + ) + return records diff --git a/dns_sync/registry.py b/dns_sync/registry.py new file mode 100644 index 0000000..578dba2 --- /dev/null +++ b/dns_sync/registry.py @@ -0,0 +1,30 @@ +import importlib + + +def _load(kind: str, name: str, config): + if not name or not name.replace("_", "").replace("-", "").isalnum(): + raise RuntimeError(f"Invalid {kind} plugin name: {name!r}") + + module_name = name.replace("-", "_") + path = f"dns_sync.plugins.{kind}.{module_name}" + + try: + module = importlib.import_module(path) + except ModuleNotFoundError as exc: + if exc.name == path: + raise RuntimeError(f"Unknown {kind[:-1]} plugin: {name}") from exc + raise + + plugin_class = getattr(module, "Plugin", None) + if plugin_class is None: + raise RuntimeError(f"Plugin module {path} does not export Plugin") + + return plugin_class(config) + + +def load_source(name: str, config): + return _load("sources", name, config) + + +def load_provider(name: str, config): + return _load("providers", name, config) diff --git a/dns_sync/reporting.py b/dns_sync/reporting.py new file mode 100644 index 0000000..36bcf1a --- /dev/null +++ b/dns_sync/reporting.py @@ -0,0 +1,15 @@ +def print_plan(plan): + if not plan: + print("Desired state contains no records.") + return + + for item in plan: + h = item.desired.hostname + if item.action == "ADD": + print(f"{'ADD':<10} {h:<45} -> {item.desired.ip}") + elif item.action == "UPDATE": + print(f"{'UPDATE':<10} {h:<45} {item.current.ip} -> {item.desired.ip}") + elif item.action == "SKIP": + print(f"{'SKIP':<10} {h:<45} {item.reason}") + else: + print(f"{'UNCHANGED':<10} {h:<45} {item.desired.ip}") diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100755 index 0000000..0a94b59 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,44 @@ +#!/bin/sh +set -u + +INTERVAL_MINUTES="${SYNC_INTERVAL:-1}" +case "$INTERVAL_MINUTES" in + ''|*[!0-9]*) + echo "ERROR: SYNC_INTERVAL must be an integer number of minutes" >&2 + exit 1 + ;; +esac +if [ "$INTERVAL_MINUTES" -lt 1 ]; then + echo "ERROR: SYNC_INTERVAL must be at least 1 minute" >&2 + exit 1 +fi + +INTERVAL_SECONDS=$((INTERVAL_MINUTES * 60)) +sleep_pid="" + +shutdown() { + echo "Stopping DNS sync service..." + [ -z "$sleep_pid" ] || kill "$sleep_pid" 2>/dev/null || true + exit 0 +} +trap shutdown INT TERM + +echo "DNS sync service started" +echo "Sync interval: ${INTERVAL_MINUTES} minute(s)" +echo "Source plugin: ${PROXY:-npm}" +echo "DNS provider: ${DNS_PROVIDER:-verizon}" + +while :; do + echo + echo "=== DNS sync: $(date -Iseconds) ===" + if python -m dns_sync sync; then + echo "Sync run completed successfully" + else + rc=$? + echo "ERROR: Sync run failed with exit code ${rc}" >&2 + fi + sleep "$INTERVAL_SECONDS" & + sleep_pid=$! + wait "$sleep_pid" || true + sleep_pid="" +done