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:
@@ -241,10 +241,31 @@ eshpfi-management/
|
||||
│ └── README.md # what this stack does, how to deploy
|
||||
├── stacks-mirror/ # gitignored snapshot of live host state (drift detection)
|
||||
│ └── <host>/<stack>/ # populated by sync-stacks.sh, NOT a deploy source
|
||||
├── dns/ # fleet internal DNS — *.internal names
|
||||
│ ├── internal.yaml # source of truth (hosts, sites, aliases)
|
||||
│ └── README.md # workflow, naming, IPv6 caveat
|
||||
└── docs/
|
||||
└── pfi/ # general PFI infrastructure reference
|
||||
```
|
||||
|
||||
## Internal DNS (`*.internal`)
|
||||
|
||||
Fleet hosts have names: `<host>.<site>.internal`, sites `ana` / `esh` / `nh3`.
|
||||
`dns/internal.yaml` is the source of truth; the AdGuard resolvers are derived
|
||||
state.
|
||||
|
||||
```bash
|
||||
$EDITOR dns/internal.yaml
|
||||
scripts/dns-sync.py --dry-run # diff
|
||||
scripts/dns-sync.py # apply
|
||||
```
|
||||
|
||||
The sync is authoritative **within `.internal` only** — names added by hand in
|
||||
the AdGuard UI get deleted, but rewrites in other zones (ESH's `esteban.net`
|
||||
entries) are left alone. See `dns/README.md`, especially the IPv6 note: v6
|
||||
addresses only go in the file once they are pinned statically on the host,
|
||||
because SLAAC addresses rotate and a stale record is worse than none.
|
||||
|
||||
## Working rules
|
||||
|
||||
- **Copies, not symlinks.** Files here reflect what's on the server at the time of the last sync. When you edit here, the server doesn't change until you deploy.
|
||||
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
# Fleet internal DNS — `*.internal`
|
||||
|
||||
Names for fleet hosts so nobody has to remember addresses. Built 2026-08-19
|
||||
because IPv6 makes memorising them hopeless — and, more to the point, because
|
||||
v6 addresses are *derived* rather than assigned, so they cannot be reliably
|
||||
memorised **or** written down once and trusted.
|
||||
|
||||
```
|
||||
dns/internal.yaml the source of truth — hosts, sites, aliases
|
||||
scripts/dns-sync.py reconciles the resolvers against it
|
||||
```
|
||||
|
||||
## Adding a name
|
||||
|
||||
Edit `dns/internal.yaml`, then:
|
||||
|
||||
```bash
|
||||
scripts/dns-sync.py --dry-run # see the diff
|
||||
scripts/dns-sync.py # apply, with a prompt
|
||||
```
|
||||
|
||||
That is the whole workflow. It is deliberately the same shape as
|
||||
`deploy-stack.sh`: a file in git is the intent, the running system is derived
|
||||
state, and you see a diff before anything changes.
|
||||
|
||||
## Naming
|
||||
|
||||
`<host>.<site>.internal`, sites **`ana`** (Anaheim colo), **`esh`** (home lab),
|
||||
**`nh3`** (office).
|
||||
|
||||
`.internal` is ICANN-reserved for private use, which is why it is used here
|
||||
rather than `.local` (reserved for mDNS — the old `searxng.pfi.local` was a
|
||||
standards collision that happened to work) or an invented TLD that could later
|
||||
collide with a real one.
|
||||
|
||||
**Every name is published to every resolver.** The site label says where a host
|
||||
*is*, not which resolver knows about it — `ana-docker.ana.internal` resolves
|
||||
from ESH and NH3 too.
|
||||
|
||||
Irvine is not a fourth zone: `irv-ml1` is reachable only through NH3's
|
||||
WireGuard tunnel and numbered out of NH3's `10.100.79.0/24`, so it lives under
|
||||
`nh3`. Worth revisiting if Irvine ever becomes a site in its own right.
|
||||
|
||||
## The resolvers
|
||||
|
||||
| site | resolver | API port |
|
||||
|---|---|---|
|
||||
| ana | ana-docker `10.250.50.70` | **8053** |
|
||||
| esh | esh-docker-vm `10.0.50.45` | 8080 |
|
||||
| nh3 | nh3-docker `10.100.50.40` | 8080 |
|
||||
|
||||
ana is the odd one out — `:8080` and `:3000` were already taken on that host —
|
||||
so the port is carried per-site in `internal.yaml` rather than assumed by the
|
||||
script.
|
||||
|
||||
The colo resolver (`stacks/adguard-ana/`) was stood up as part of this work;
|
||||
before it, colo hosts resolved straight against `1.1.1.1` and the site had no
|
||||
way to answer for internal names. ESH and NH3 run older, unmanaged compose
|
||||
files, left alone on purpose — adopting three live resolvers into this repo
|
||||
while also introducing a new naming system is two risky changes at once.
|
||||
|
||||
## Two properties worth not breaking
|
||||
|
||||
**Authority is scoped to the zone, not the resolver.** Only rewrites ending in
|
||||
`.internal` are managed. The ESH resolver carries hand-made `esteban.net`
|
||||
entries that predate this system; the sync reads them, ignores them, and leaves
|
||||
them alone. If this ever grows to manage another zone, that scoping is the
|
||||
thing to be careful with — resolver-wide authority would silently delete
|
||||
somebody else's work.
|
||||
|
||||
**Within the zone it is authoritative.** Names added by hand in the AdGuard UI
|
||||
*will* be deleted by the next sync. That is the point: one place to look.
|
||||
|
||||
## Credential
|
||||
|
||||
`scripts/dns-sync.py` authenticates as a dedicated **`infra-ops`** AdGuard user,
|
||||
not as the operator's account, and pulls the password from the vault:
|
||||
|
||||
```bash
|
||||
secret get nh3-dev/adguard-infra-ops-password
|
||||
```
|
||||
|
||||
⚠️ The vault appends a trailing newline on read. The script strips it, because
|
||||
a password carrying a stray `\n` fails auth in a way that looks exactly like a
|
||||
wrong password.
|
||||
|
||||
The existing `lkraven` AdGuard user was left untouched. Config backups from
|
||||
before the user was added are on each resolver as
|
||||
`AdGuardHome.yaml.bak-preinfraops-*`.
|
||||
|
||||
## ⚠️ IPv6 — the reason this exists, and still the unfinished half
|
||||
|
||||
The `v6:` column is empty and that is correct as of 2026-08-19: **no fleet host
|
||||
has a global IPv6 address yet.** ESH's `/56` is live only on `esh-cameras`,
|
||||
NH3's LANs are back to `ipv6_interface_type: none`, the colo has no v6 at all.
|
||||
|
||||
When v6 arrives, **do not paste in whatever `ip -6 addr` shows.** SLAAC gives
|
||||
hosts either EUI-64 addresses (MAC-coupled) or privacy-extension ones (which
|
||||
rotate), and UniFi has no v6 equivalent of a DHCP reservation. An address only
|
||||
belongs in this file once it has been pinned **statically on the host itself**.
|
||||
A record that silently stops matching reality is worse than no record — the
|
||||
name keeps resolving and starts lying.
|
||||
|
||||
The suggested convention when that happens: give each server a static address
|
||||
out of its site's `/64` whose low-order bits echo the v4 host octet
|
||||
(`esh-docker-vm` at `…::45`), so the addresses are both declarable and
|
||||
semi-memorable.
|
||||
|
||||
## Not migrated: `matrix.pfi.local`
|
||||
|
||||
`searxng.pfi.local` moved to `searxng.ana.internal` (both names still route,
|
||||
so nothing breaks mid-migration; drop the fallback `Host()` in
|
||||
`stacks/searxng/compose.yaml` once the Traefik log shows the old one unused).
|
||||
|
||||
**`matrix.pfi.local` was deliberately left alone.** A Matrix `server_name` is
|
||||
baked into every user ID, room ID and signing key, and federation identity is
|
||||
derived from it — renaming it is not a DNS change, it is rebuilding the
|
||||
homeserver's identity and invalidating its history. It stays on `.local`.
|
||||
|
||||
## Still open
|
||||
|
||||
Colo hosts still point at `1.1.1.1`, so they do not yet *use* the new resolver
|
||||
— they only get answers if something asks it directly. Repointing a whole
|
||||
site's DNS is a bigger change than standing the service up, so it is a separate
|
||||
operator-approved step.
|
||||
@@ -0,0 +1,110 @@
|
||||
# Fleet internal DNS — the source of truth for *.internal names.
|
||||
#
|
||||
# THIS FILE IS AUTHORITATIVE. `scripts/dns-sync.sh` reconciles every resolver
|
||||
# against it: names here are created, names removed here are deleted, and
|
||||
# names edited here are updated. Do NOT add .internal names in the AdGuard UI
|
||||
# — the next sync will delete them.
|
||||
#
|
||||
# WHAT THE SYNC WILL NOT TOUCH: any rewrite outside the `.internal` zone. The
|
||||
# ESH resolver carries hand-made `esteban.net` entries that predate this file
|
||||
# and are deliberately left alone. Authority is scoped to the zone, not to the
|
||||
# resolver's whole table.
|
||||
#
|
||||
# NAMING: <host>.<site>.internal, sites `ana` / `esh` / `nh3` (operator,
|
||||
# 2026-08-19). `.internal` is ICANN-reserved for exactly this use since 2024,
|
||||
# which is why it is used here rather than `.local` (reserved for mDNS) or a
|
||||
# made-up TLD that could later collide with a real one.
|
||||
#
|
||||
# EVERY name is published to EVERY resolver, so `ana-docker.ana.internal`
|
||||
# resolves from ESH and NH3 too. The site label says where a host IS, not
|
||||
# which resolver knows about it.
|
||||
#
|
||||
# ⚠️ THE v6 COLUMN IS EMPTY ON PURPOSE, AND MUST STAY DECLARATIVE.
|
||||
# No fleet host has a global IPv6 address today (verified 2026-08-19: ESH's
|
||||
# /56 is live only on esh-cameras, NH3's LANs are back to ipv6_interface_type
|
||||
# none, the colo has no v6 at all). When v6 lands, do NOT paste in whatever
|
||||
# `ip -6 addr` happens to show: SLAAC addresses are either EUI-64 (MAC-coupled)
|
||||
# or privacy-extension (they rotate), and UniFi has no v6 equivalent of a DHCP
|
||||
# reservation. A v6 address only belongs in this file once it has been pinned
|
||||
# STATICALLY on the host itself — otherwise the record rots silently and the
|
||||
# name starts lying, which is worse than having no record.
|
||||
|
||||
zone: internal
|
||||
|
||||
sites:
|
||||
ana:
|
||||
subnet: 10.250.0.0/16
|
||||
resolver: 10.250.50.70 # ana-docker — AdGuard #3, stood up for this
|
||||
# ⚠️ NOT 8080. ana-docker already has :8080 and :3000 taken, so this
|
||||
# AdGuard's API is on 8053. The port lives here rather than in the script
|
||||
# precisely so the odd one out cannot be forgotten.
|
||||
api_port: 8053
|
||||
description: Anaheim colo
|
||||
esh:
|
||||
subnet: 10.0.0.0/16
|
||||
resolver: 10.0.50.45 # esh-docker-vm
|
||||
api_port: 8080
|
||||
description: ESH home lab (esteban.net)
|
||||
nh3:
|
||||
subnet: 10.100.0.0/16
|
||||
resolver: 10.100.50.40 # nh3-docker
|
||||
api_port: 8080
|
||||
description: NH3 office
|
||||
|
||||
hosts:
|
||||
# ---- ana: Anaheim colo ----
|
||||
- {name: ana-docker, site: ana, v4: 10.250.50.70, note: general-purpose docker host}
|
||||
- {name: ana-ml2, site: ana, v4: 10.250.50.54, note: GPU inference, dual RTX PRO 6000}
|
||||
- {name: ana-nas, site: ana, v4: 10.250.50.50, note: CT109 on pfi-pve — NFS/SMB}
|
||||
- {name: ana-filebot, site: ana, v4: 10.250.50.53, note: file-task automation}
|
||||
- {name: ana-wg, site: ana, v4: 10.250.50.252, note: WireGuard host}
|
||||
- {name: corviduo-dev, site: ana, v4: 10.250.50.152, note: Worldtree-team dev VM (PFI-hosted)}
|
||||
- {name: pbs-ana, site: ana, v4: 10.250.50.90, note: Proxmox Backup Server — fleet primary}
|
||||
- {name: pfi-ana-webhost, site: ana, v4: 10.250.50.52, note: web workload}
|
||||
- {name: pfi-postgres, site: ana, v4: 10.250.50.80, note: shared Postgres}
|
||||
- {name: pfi-pteradactyl, site: ana, v4: 10.250.50.55, note: game panel}
|
||||
- {name: pfi-tacticalrmm, site: ana, v4: 10.250.50.57, note: TacticalRMM}
|
||||
- {name: pfi-pve, site: ana, v4: 10.250.250.31, note: Proxmox hypervisor}
|
||||
- {name: ana-gw, site: ana, v4: 10.250.0.1, note: FortiGate-80F edge}
|
||||
- {name: pfi-pve-idrac, site: ana, v4: 10.250.250.30, note: iDRAC — OOB for pfi-pve}
|
||||
- {name: ana-ml2-bmc, site: ana, v4: 10.250.250.50, note: BMC for ana-ml2}
|
||||
# SureFire tenant hardware — PFI-managed under the hosting agreement.
|
||||
- {name: sfsrv-ana, site: ana, v4: 10.250.250.115, note: SureFire tenant hypervisor}
|
||||
- {name: sf-ana-container, site: ana, v4: 10.250.150.100, note: SureFire tenant container host}
|
||||
- {name: sf-r630-idrac, site: ana, v4: 10.250.250.110, note: SureFire tenant R630 iDRAC}
|
||||
|
||||
# ---- nh3: NH3 office ----
|
||||
- {name: nh3-docker, site: nh3, v4: 10.100.50.40, note: general-purpose docker host + AdGuard}
|
||||
- {name: nh3-dev, site: nh3, v4: 10.100.10.50, note: dev box, fleet sidecars, Claude sessions}
|
||||
- {name: nh3-extdev, site: nh3, v4: 10.100.50.42, note: manager / external-dev box}
|
||||
- {name: nh3-nas, site: nh3, v4: 10.100.50.50, note: Synology RS2418+}
|
||||
- {name: nh3-pve, site: nh3, v4: 10.100.250.60, note: Proxmox hypervisor}
|
||||
- {name: pbs-nh3, site: nh3, v4: 10.100.50.90, note: Proxmox Backup Server — DR mirror}
|
||||
- {name: nh3-gw, site: nh3, v4: 10.100.0.1, note: UniFi UDM Pro SE — gateway + controller}
|
||||
# Irvine is not its own zone: irv-ml1 is reachable only through NH3's
|
||||
# WireGuard tunnel and is numbered out of NH3's 10.100.79.0/24, so it is
|
||||
# named under nh3. Revisit if Irvine ever becomes a site in its own right.
|
||||
- {name: irv-ml1, site: nh3, v4: 10.100.79.3, note: GPU host (Irvine, via WG) — 3090 + A6000}
|
||||
|
||||
# ---- esh: ESH home lab ----
|
||||
- {name: esh-docker-vm, site: esh, v4: 10.0.50.45, note: general-purpose docker host + AdGuard}
|
||||
- {name: esh-nas, site: esh, v4: 10.0.50.50, note: NAS}
|
||||
- {name: esh-pve, site: esh, v4: 10.0.250.35, note: Proxmox hypervisor}
|
||||
- {name: esh-pve-nas, site: esh, v4: 10.0.50.55, note: Proxmox hypervisor — storage/media}
|
||||
- {name: esh-vm-db, site: esh, v4: 10.0.50.60, note: PostgreSQL + MongoDB}
|
||||
- {name: vm-esh-nas, site: esh, v4: 10.0.50.154, note: NAS-adjacent docker host}
|
||||
- {name: esh-filebot, site: esh, v4: 10.0.50.70, note: restic / file-sync VM}
|
||||
- {name: esh-gw, site: esh, v4: 10.0.250.1, note: esh-gw}
|
||||
- {name: esh-udm, site: esh, v4: 10.0.0.1, note: UniFi UDM Pro Max — gateway + controller}
|
||||
- {name: plex, site: esh, v4: 10.0.50.56, note: media server}
|
||||
- {name: jellyfin, site: esh, v4: 10.0.50.57, note: media server}
|
||||
- {name: brother, site: esh, v4: 10.0.90.125, note: Brother printer}
|
||||
|
||||
# Service aliases — a name that points at whatever host currently runs it, so
|
||||
# consumers reference the SERVICE rather than the box. Changing where something
|
||||
# runs becomes a one-line edit here instead of a hunt through configs.
|
||||
aliases:
|
||||
- {name: searxng, site: ana, target: ana-docker, note: replaces searxng.pfi.local (.local is mDNS-reserved)}
|
||||
- {name: gateway, site: ana, target: ana-docker, note: LiteLLM gateway :4000}
|
||||
- {name: booth, site: nh3, target: nh3-dev, note: The Booth :8090}
|
||||
- {name: homepage, site: esh, target: esh-docker-vm, note: fleet dashboard :5100}
|
||||
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()
|
||||
@@ -0,0 +1,56 @@
|
||||
# adguard-ana — the Anaheim colo's resolver.
|
||||
#
|
||||
# Stood up 2026-08-19 to close the gap found while designing the .internal
|
||||
# naming scheme: ESH and NH3 each had an AdGuard, but colo hosts were
|
||||
# resolving straight against 1.1.1.1, so the colo had no way to answer for
|
||||
# internal names at all.
|
||||
#
|
||||
# ⚠️ PORTS DIFFER FROM THE OTHER TWO SITES. ana-docker is a busy host: :8080
|
||||
# and :3000 are already taken (langfuse and friends), so AdGuard's web UI and
|
||||
# API live on :8053 here, not :8080. Anything that talks to this API — notably
|
||||
# scripts/dns-sync.py — reads the port from dns/internal.yaml rather than
|
||||
# assuming the ESH/NH3 value.
|
||||
#
|
||||
# ⚠️ THIS IS NOT YET THE COLO'S RESOLVER-OF-RECORD. Standing the service up and
|
||||
# repointing a whole site's DNS are two different changes with two different
|
||||
# blast radii. Colo hosts still use 1.1.1.1; the cutover is a separate,
|
||||
# operator-approved step. Until then this answers only what asks it directly.
|
||||
#
|
||||
# ESH and NH3 run their own, older, unmanaged compose files. They were left
|
||||
# alone deliberately — adopting three live resolvers into this repo while also
|
||||
# introducing a new naming system is two risky changes at once. Worth doing
|
||||
# later, as its own piece of work.
|
||||
|
||||
name: adguard-ana
|
||||
|
||||
services:
|
||||
adguardhome:
|
||||
image: adguard/adguardhome:latest
|
||||
container_name: ${AG_CONTAINER:-adguardhome}
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${AG_DNS_PORT:-53}:53/tcp"
|
||||
- "${AG_DNS_PORT:-53}:53/udp"
|
||||
# Web UI + REST API. 8053, not 8080 — see the note above.
|
||||
- "${AG_WEB_PORT:-8053}:80/tcp"
|
||||
volumes:
|
||||
- adguard_work:/opt/adguardhome/work
|
||||
- adguard_conf:/opt/adguardhome/conf
|
||||
networks:
|
||||
- tnet
|
||||
labels:
|
||||
- homepage.group=Service Networking
|
||||
- homepage.name=AdGuard (ANA)
|
||||
- homepage.icon=mdi-dns
|
||||
- homepage.description=DNS resolver + .internal zone (colo)
|
||||
- homepage.href=http://10.250.50.70:${AG_WEB_PORT:-8053}
|
||||
- homepage.siteMonitor=http://10.250.50.70:${AG_WEB_PORT:-8053}
|
||||
|
||||
volumes:
|
||||
adguard_work: {}
|
||||
adguard_conf: {}
|
||||
|
||||
networks:
|
||||
tnet:
|
||||
name: traefik-net
|
||||
external: true
|
||||
@@ -0,0 +1,42 @@
|
||||
# FIRST-BOOT SEED ONLY — AdGuard rewrites this file at runtime.
|
||||
#
|
||||
# It exists so the container comes up already configured instead of sitting in
|
||||
# the setup wizard waiting for a human. After first boot the live copy is in
|
||||
# the adguard-ana_adguard_conf volume and diverges from this file; treat this
|
||||
# as the bootstrap, not as ongoing config. Notably the `rewrites:` list stays
|
||||
# EMPTY here — scripts/dns-sync.py owns it from dns/internal.yaml.
|
||||
#
|
||||
# ⚠️ NO BLOCKLISTS, DELIBERATELY. The other two AdGuards filter ads for human
|
||||
# browsing; this one resolves for a rack of servers. A blocklist false-positive
|
||||
# here breaks service-to-service calls at 3am for no upside, so filtering is
|
||||
# off and this instance is a plain recursive resolver plus the .internal zone.
|
||||
http:
|
||||
address: 0.0.0.0:80
|
||||
users:
|
||||
- name: infra-ops
|
||||
password: $2a$05$v7d1ASBg9qprZNx3Y50YqempdiL.Lo8.jMYxyVr6MroaGF0w5tQje
|
||||
auth_attempts: 5
|
||||
block_auth_min: 15
|
||||
language: en
|
||||
theme: auto
|
||||
dns:
|
||||
bind_hosts:
|
||||
- 0.0.0.0
|
||||
port: 53
|
||||
upstream_dns:
|
||||
- 1.1.1.1
|
||||
- 1.0.0.1
|
||||
bootstrap_dns:
|
||||
- 1.1.1.1
|
||||
- 8.8.8.8
|
||||
protection_enabled: true
|
||||
filtering_enabled: false
|
||||
safebrowsing_enabled: false
|
||||
parental_enabled: false
|
||||
rewrites: []
|
||||
filters: []
|
||||
whitelist_filters: []
|
||||
user_rules: []
|
||||
log:
|
||||
verbose: false
|
||||
schema_version: 29
|
||||
@@ -23,7 +23,12 @@ services:
|
||||
# ------------------------------------------------------------------
|
||||
environment:
|
||||
- SEARXNG_SECRET=${SEARXNG_SECRET}
|
||||
- BASE_URL=https://searxng.pfi.local/
|
||||
# searxng.ana.internal, not the old searxng.pfi.local (migrated
|
||||
# 2026-08-19). `.local` is reserved for mDNS, so the old name was a
|
||||
# standards collision that happened to work; `.internal` is ICANN-
|
||||
# reserved for exactly this. The name is served by the fleet's AdGuard
|
||||
# resolvers from dns/internal.yaml — see scripts/dns-sync.py.
|
||||
- BASE_URL=https://searxng.ana.internal/
|
||||
- INSTANCE_NAME=SearXNG
|
||||
# ------------------------------------------------------------------
|
||||
# Resource limits — tune for VM 102's available RAM/CPU
|
||||
@@ -72,7 +77,12 @@ services:
|
||||
labels:
|
||||
# Traefik configuration — auto-discovery via Docker provider
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.searxng.rule=Host(`searxng.pfi.local`)
|
||||
# Both names during the migration: `.internal` is the real one now, and
|
||||
# the old `.pfi.local` is kept as a fallback so anything still pointing
|
||||
# at it (a bookmark, a hardcoded config elsewhere) does not break the
|
||||
# day the name changes. Drop the second Host() once nothing uses it —
|
||||
# the Traefik access log will tell you when that is.
|
||||
- traefik.http.routers.searxng.rule=Host(`searxng.ana.internal`) || Host(`searxng.pfi.local`)
|
||||
- traefik.http.routers.searxng.entrypoints=websecure
|
||||
- traefik.http.routers.searxng.tls=true
|
||||
- traefik.http.routers.searxng.service=searxng
|
||||
|
||||
Reference in New Issue
Block a user