Baseline modular DNS sync
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.swp
|
||||
*.tmp
|
||||
.env
|
||||
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
__version__ = "0.2.0"
|
||||
@@ -0,0 +1,3 @@
|
||||
from .cli import main
|
||||
|
||||
raise SystemExit(main())
|
||||
+109
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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]: ...
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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}")
|
||||
Executable
+44
@@ -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
|
||||
Reference in New Issue
Block a user