#!/usr/bin/env python3 """dns-sync.py — reconcile the fleet's AdGuard resolvers against dns/internal.yaml. Source of truth is the file; the resolvers are derived state. Same posture as deploy-stack.sh: show a diff, ask, then apply. scripts/dns-sync.py # diff every resolver, prompt before applying scripts/dns-sync.py --dry-run # diff only, never write scripts/dns-sync.py --yes # skip the prompt scripts/dns-sync.py --site esh # one resolver AUTHORITY IS SCOPED TO THE ZONE, NOT THE RESOLVER. Only rewrites ending in `.internal` are considered. The ESH resolver carries hand-made `esteban.net` entries that predate this system; they are read, ignored, and left alone. If this ever grows to manage other zones, that scoping is the thing to be careful with — a resolver-wide authority would silently delete a colleague's work. CREDENTIAL: pulled from the vault, never hardcoded. secret get nh3-dev/adguard-infra-ops-password The vault appends a trailing newline on read; it is stripped here, because a password with a stray \\n fails auth in a way that looks like a wrong password. """ from __future__ import annotations import argparse import base64 import json import pathlib import subprocess import sys import urllib.error import urllib.request REPO = pathlib.Path(__file__).resolve().parent.parent SPEC = REPO / "dns" / "internal.yaml" SECRET_CLI = REPO / "services" / "secrets-broker" / "secret" SECRET_NAME = "nh3-dev/adguard-infra-ops-password" DEFAULT_API_PORT = 8080 USER = "infra-ops" TIMEOUT = 10 def load_spec() -> dict: import yaml # local import so --help works without the dep return yaml.safe_load(SPEC.read_text()) def get_password() -> str: try: out = subprocess.run([str(SECRET_CLI), "get", SECRET_NAME], capture_output=True, text=True, timeout=60) except FileNotFoundError: sys.exit(f"secret CLI not found at {SECRET_CLI}") if out.returncode != 0: sys.exit(f"could not read {SECRET_NAME} from the vault:\n{out.stderr.strip()}") pw = out.stdout.strip("\n") if not pw: sys.exit(f"{SECRET_NAME} came back empty") return pw def desired_pairs(spec: dict) -> set[tuple[str, str]]: """The (domain, answer) pairs the zone should contain. A pair IS AdGuard's identity for a rewrite, which is why this is a set of tuples rather than a name->address map: a dual-stack host is two rewrites that share one name, and a map would silently drop one of them. Every name is published to every resolver — the site label says where a host IS, not which resolver knows about it. """ zone = spec["zone"] hosts = spec.get("hosts") or [] by_name = {h["name"]: h for h in hosts} pairs: set[tuple[str, str]] = set() def emit(fqdn: str, host: dict) -> None: for key in ("v4", "v6"): if host.get(key): pairs.add((fqdn, str(host[key]))) seen: set[str] = set() for h in hosts: fqdn = f"{h['name']}.{h['site']}.{zone}" if fqdn in seen: sys.exit(f"duplicate name in dns/internal.yaml: {fqdn}") seen.add(fqdn) emit(fqdn, h) for a in spec.get("aliases") or []: target = by_name.get(a["target"]) if target is None: sys.exit(f"alias {a['name']} points at unknown host {a['target']!r}") fqdn = f"{a['name']}.{a['site']}.{zone}" if fqdn in seen: sys.exit(f"alias {fqdn} collides with a host of the same name") seen.add(fqdn) emit(fqdn, target) return pairs def api(host: str, path: str, pw: str, payload: dict | None = None, port: int = DEFAULT_API_PORT): url = f"http://{host}:{port}/control/{path}" data = json.dumps(payload).encode() if payload is not None else None req = urllib.request.Request(url, data=data, method="POST" if data else "GET") token = base64.b64encode(f"{USER}:{pw}".encode()).decode() req.add_header("Authorization", f"Basic {token}") if data: req.add_header("Content-Type", "application/json") try: with urllib.request.urlopen(req, timeout=TIMEOUT) as r: body = r.read().decode().strip() return json.loads(body) if body else None except urllib.error.HTTPError as e: sys.exit(f"{host}: {path} -> HTTP {e.code} {e.reason}\n{e.read().decode()[:200]}") except urllib.error.URLError as e: sys.exit(f"{host}: unreachable ({e.reason}). Tried port {port}; " f"run this from a host that can reach it.") def main() -> None: ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("--site", help="only this site's resolver") ap.add_argument("--dry-run", action="store_true", help="diff only, never write") ap.add_argument("--yes", action="store_true", help="skip the confirmation prompt") args = ap.parse_args() spec = load_spec() zone_suffix = "." + spec["zone"] want = desired_pairs(spec) sites = spec["sites"] if args.site: if args.site not in sites: sys.exit(f"unknown site {args.site!r}; known: {', '.join(sites)}") sites = {args.site: sites[args.site]} pw = get_password() plans = {} for site, cfg in sites.items(): host = cfg["resolver"] port = int(cfg.get("api_port", DEFAULT_API_PORT)) current_all = api(host, "rewrite/list", pw, port=port) or [] # SCOPE: only our zone. Everything else on this resolver is somebody # else's and stays untouched. current = {(r["domain"], r["answer"]) for r in current_all if r["domain"].endswith(zone_suffix)} foreign = len(current_all) - len(current) add = sorted(want - current) remove = sorted(current - want) plans[site] = (host, port, add, remove, foreign) print(f"\n=== {site} ({host}:{port}) ===") print(f" in zone: {len(current)} outside zone (left alone): {foreign}") for d, a in add: print(f" + {d:<44} {a}") for d, a in remove: print(f" - {d:<44} {a}") if not add and not remove: print(" in sync") total = sum(len(a) + len(r) for _, _, a, r, _ in plans.values()) if total == 0: print("\nnothing to do.") return if args.dry_run: print(f"\n--dry-run: {total} change(s) NOT applied.") return if not args.yes: if input(f"\napply {total} change(s)? [y/N] ").strip().lower() not in ("y", "yes"): sys.exit("aborted.") for site, (host, port, add, remove, _) in plans.items(): # Delete first: AdGuard tolerates duplicate (domain, answer) pairs, so # removing before adding keeps a re-pointed name from briefly resolving # to BOTH its old and new address. for d, a in remove: api(host, "rewrite/delete", pw, {"domain": d, "answer": a}, port=port) for d, a in add: api(host, "rewrite/add", pw, {"domain": d, "answer": a}, port=port) print(f"{site}: -{len(remove)} +{len(add)}") print("done.") if __name__ == "__main__": main()