feat(dns): fleet .internal naming — git-sourced, agent-managed, three resolvers
Names for fleet hosts so addresses stop needing to be memorised. Built because IPv6 makes that hopeless — and, more to the point, because v6 addresses are derived rather than assigned, so they cannot reliably be written down once and trusted either. dns/internal.yaml source of truth: 38 hosts + 4 service aliases scripts/dns-sync.py reconciles AdGuard resolvers against it stacks/adguard-ana/ the colo's resolver, which did not exist Naming is <host>.<site>.internal with sites ana/esh/nh3 (operator's call). .internal is ICANN-reserved for this; .local is reserved for mDNS, which is why searxng.pfi.local was a collision that merely happened to work. Same posture as deploy-stack.sh: file is intent, resolvers are derived state, you see a diff before anything changes. Every name is published to every resolver, so the site label says where a host IS, not who knows about it. Two properties that matter: - Authority is scoped to the ZONE, not the resolver. ESH carries hand-made esteban.net rewrites predating this; they are read, ignored and preserved. Resolver-wide authority would have silently deleted them. - Within .internal it IS authoritative, so UI-added names get removed. That is the point — one place to look. Colo gap closed: ana-docker had no resolver at all (hosts went straight to 1.1.1.1). Its AdGuard runs API on 8053 because 8080/3000 were taken, so the port is carried per-site in the yaml rather than assumed by the script. It ships with no blocklists — a false positive on a server network breaks service-to-service calls for no upside. Auth is a dedicated infra-ops AdGuard user, not the operator's account, password vaulted at nh3-dev/adguard-infra-ops-password. Pre-change configs backed up on each resolver. Both resolvers stayed answering across the restart. searxng.pfi.local -> searxng.ana.internal, with the old Host() kept alongside so nothing breaks mid-migration. matrix.pfi.local deliberately NOT migrated: a Matrix server_name is baked into every user id, room id and signing key, so renaming it rebuilds the homeserver's identity rather than changing a DNS name. The v6 column is empty and correct — no fleet host has a global v6 address yet. The file documents why addresses must be pinned statically before they go in, since a record that silently stops matching is worse than no record.
This commit is contained in:
Executable
+193
@@ -0,0 +1,193 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user