Files
esh-pfi-infrastructure/dns
vh a95717e810 feat(gx10): rack networking — VLAN 50 via DHCP reservation, wired only
pfi-gx10 moved from desk Wi-Fi (10.100.10.226, VLAN 10) to the rack on
10.100.50.60 (nh3-servers, VLAN 50), reachable as pfi-gx10.nh3.internal.

The address is a DHCP RESERVATION on the UDM, not a host static. Operator
ruling during the move, and the better design: a host-side static works
until the box moves and is then a stale netplan file on a machine whose
address you no longer know. The pre-written playbook wrote a host static;
it is kept for its safety ordering and annotated as retired.

The port arrived on the native VLAN, not the server VLAN, so switch port 22
was repointed first. port_overrides is a whole-array PUT — two unrelated
overrides on ports 21 and 23 were read, preserved and written back, with the
original array backed up to a file before the change.

Wi-Fi stayed up as the escape hatch until the wired path was proven from
outside, and was downed last as its own step. The step worth keeping: while
Wi-Fi was up, traffic to nh3-dev still preferred wlP9s9 because that
interface sits directly on the userland subnet, so reachability proved
nothing about the wired path. `ping -I enP7s7` across the VLAN boundary is
what actually settled it before the hatch came down.
2026-09-03 15:59:09 -07:00
..

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:

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:

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.