Skip to content

Authoritative Internal .house Resolver — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use dev-flow:subagent-driven-development (recommended) or dev-flow:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Move the hand-applied unbound authoritative .house zone into Git: router-hosts (≥ the rh#349 release) writes the zone, a host-side systemd path unit reloads unbound, the tf inventory becomes complete, and the redundant dnsmasq per-name overlay retires.

Architecture: tf/router-hosts (complete inventory) → router-hosts daemon writes /unbound/10-fzymgc-authoritative.conf (new unbound_conf_path output) → systemd .path unit on the Firewalla watches unbound_local/ → oneshot unbound-control reload → unbound serves .house authoritatively (NODATA for unlisted RR types) and forwards . to Control D over DoT. Spec: docs/engineering/specs/2026-07-07-authoritative-house-resolver-design.md (decisions D1–D9).

Tech Stack: Terraform (routerhosts provider, HCP TFC), Ansible (role router-hosts, target = Firewalla over SSH as pi), unbound 1.19.1, systemd path units, Docker Compose.

Model hints (for bead materialization, Rule 5): Task 2 = model:haiku (mechanical vars edit with exact content given). Tasks 1, 3, 4, 5, 6, 7, 8 = model:sonnet (inventory correctness, template/handler semantics, systemd unit wiring, docs judgment). Task 9 = operator-driven runbook (bead tracks it; no agent dispatch).


Tasks 2 onward MUST NOT be applied to the router until router-hosts#349 ships in a tagged release (expected 0.11.0): Task 2’s version bump references an image tag that will not exist until then, and an old image given unbound_conf_path in server.toml will fail config validation and crash-loop (the image is distroless). Only Task 1 (PR-A) is safe to merge and apply immediately; Tasks 2–8 (PR-B) may be authored and reviewed at any time but are applied strictly via the Task 9 runbook, after the release exists.

Wherever this plan writes 0.11.0, substitute the actual release version carrying rh#349.

Current live drift to be aware of (from the 2026-07-07 extdata outage, trail on hl-hqyv): the on-box compose file was hot-patched to image 0.10.11 (ghcr retention deleted 0.10.7 — bead about retention policy exists) while ansible/roles/router-hosts/defaults/main.yml still says 0.10.7. Task 2 reconciles this. The on-box server.toml/compose otherwise match the role templates.

  • PR-A (mergeable immediately): Task 1 (tf inventory). TFC auto-applies on merge.
  • PR-B (mergeable after rh#349 release): Tasks 2–8 (role + docs). Applied manually via the runbook in Task 9.
  • The spec + this plan + ADRs ship in a docs-only PR first (per the writing-plans docs-first pattern).
  • Work in the jj workspace .worktrees/hl-hqyv-resolver (change wtmnpplz holds the spec).
  • Lint gates: cd ansible && ansible-lint --config-file=.ansible-lint <changed files> for ansible; terraform fmt -check for tf; rumdl check <file> for markdown.
  • Commit after each task with jj commit -m "<conventional message>" (jj repo — no git add/commit; --no-pager on every jj command).

Task 1: Complete the tf/router-hosts inventory (PR-A)

Section titled “Task 1: Complete the tf/router-hosts inventory (PR-A)”

Files:

  • Modify: tf/router-hosts/hosts.tf:78-85 (k8s_api entry) and the local.host_entries map

Steps:

  • Step 1: Promote the k8s-cluster bare alias to FQDN

In tf/router-hosts/hosts.tf, change the k8s_api entry’s aliases (bare aliases emit no zone record — router-hosts writes names verbatim; spec D9 fixes the IP question: it stays 192.168.20.140):

# Kubernetes API VIP
k8s_api = {
ip_address = "192.168.20.140"
hostname = "k8s-api.fzymgc.house"
comment = "Kubernetes API VIP (kube-vip); k8s-cluster FQDN = break-glass kubeconfig endpoint (hl-hqyv D9)"
tags = ["terraform", "infrastructure", "kubernetes"]
aliases = ["k8s-api", "k8s-cluster.fzymgc.house"]
}

(Matches the spec’s literal diff: the bare k8s-cluster alias is replaced by the FQDN — bare aliases emit no zone record, and post-retirement nothing client-facing consumes hosts-file short names.)

  • Step 2: Add the two Firewalla Custom-DNS imports

These are the only two zone names not in the router-hosts inventory (live diff 2026-07-07: zone had 60 names, hosts file 58). Add to local.host_entries following the existing entry shape:

# Imported from Firewalla-app Custom DNS (hl-hqyv D3) — the app entries are
# deleted manually after this applies (see rollout runbook).
nas_ingress = {
ip_address = "192.168.21.80"
hostname = "nas-ingress.fzymgc.house"
comment = "NAS ingress VIP (imported from Firewalla Custom DNS)"
tags = ["terraform", "infrastructure", "nas"]
aliases = ["nas-ingress"]
}
router = {
ip_address = "192.168.20.1"
hostname = "router.fzymgc.house"
comment = "Firewalla router (imported from Firewalla Custom DNS)"
tags = ["terraform", "infrastructure", "network"]
aliases = ["router"]
}
  • Step 3: Lint

Run: cd tf/router-hosts && terraform fmt -check && terraform init -backend=false -input=false >/dev/null && terraform validate Expected: Success! The configuration is valid.

  • Step 4: Commit

jj --no-pager commit -m "feat(router-hosts): complete .house inventory — k8s-cluster FQDN alias + Custom-DNS imports [hl-hqyv]"

AlreadyExists trap (apply time, runbook): if TFC apply fails with AlreadyExists, the names pre-exist in the router-hosts store as manual entries — delete them first with router-hosts host delete <ulid> (precedent: hl-uegs.1, PR #1601), then re-run the TFC apply.


Task 2: Role defaults + version gate (PR-B)

Section titled “Task 2: Role defaults + version gate (PR-B)”

Files:

  • Modify: ansible/roles/router-hosts/defaults/main.yml
  • Modify: ansible/roles/router-hosts/tasks/preflight.yml

Steps:

  • Step 1: Bump version and add unbound vars, remove the cutover var

In defaults/main.yml: set router_hosts_version: "0.11.0" (the rh#349 release), append a comment line to the existing version-history comment block: # 0.11.0 (rh#349): unbound_conf_path output — per-name local-zone static + local-data. Delete the router_hosts_enable_dnsmasq_cutover: true line (and its comment). Add:

# Firewalla unbound include dir (unbound.template.conf includes unbound_local/*)
router_hosts_unbound_local_dir: "{{ router_hosts_firewalla_config_dir }}/unbound_local"
# Firewalla unbound runtime dir (binaries, unbound.conf, libhiredis — LD_LIBRARY_PATH target)
router_hosts_unbound_run_dir: "/home/pi/.firewalla/run/unbound"
# Control D DoT upstream (Home-Firewalla profile). Rendered into 20-controld-forward.conf.
router_hosts_controld_resolver_id: "mt3vg25yld"
router_hosts_controld_forward_ips:
- "76.76.2.22"
- "2606:1a40::22"
# Directory holding the watcher unit files on flash (survives reboot; post_main.d re-installs)
router_hosts_units_dir: "{{ router_hosts_firewalla_config_dir }}/router-hosts-units"
  • Step 2: Add the version assert to preflight

Append to ansible/roles/router-hosts/tasks/preflight.yml:

- name: Assert router-hosts version supports unbound_conf_path (rh#349)
ansible.builtin.assert:
that:
- router_hosts_version is version('0.11.0', '>=')
fail_msg: >-
router_hosts_version={{ router_hosts_version }} predates unbound_conf_path
(needs the rh#349 release). Deploying server.toml with unbound_conf_path
against an older image will crash-loop the daemon.
tags:
- router-hosts
- router-hosts-preflight
  • Step 3: Lint

Run: cd ansible && ansible-lint --config-file=.ansible-lint roles/router-hosts/defaults/main.yml roles/router-hosts/tasks/preflight.yml Expected: no new violations.

  • Step 4: Commit

jj --no-pager commit -m "feat(router-hosts): 0.11.0 version gate + unbound/controld role vars [hl-hqyv]"


Task 3: server.toml + docker-compose templates (PR-B)

Section titled “Task 3: server.toml + docker-compose templates (PR-B)”

Files:

  • Modify: ansible/roles/router-hosts/templates/server.toml.j2
  • Modify: ansible/roles/router-hosts/templates/docker-compose.yml.j2

Steps:

  • Step 1: Swap dnsmasq output for unbound output in server.toml.j2

Replace the dnsmasq_conf_path line and its comment block with:

# Authoritative per-name unbound zone (>=0.11.0, rh#349): one `local-zone
# "<name>." static` + `local-data` pair per FQDN. static answers listed types
# and returns NODATA for the rest — closes the split-horizon RR-type leak
# class (AAAA hl-zh3w, HTTPS/type-65 ECH hl-hqyv). Written into Firewalla's
# unbound_local include dir; a host-side systemd path unit reloads unbound.
unbound_conf_path = "/unbound/10-fzymgc-authoritative.conf"

hosts_file_path = "/hosts/main" stays. Everything else in the file stays.

  • Step 2: Update the compose template

In docker-compose.yml.j2, in the router-hosts service:

Replace the dnsmasq bind mount and its comment:

# Emit the authoritative unbound zone straight into Firewalla's unbound_local include dir
- {{ router_hosts_unbound_local_dir }}:/unbound:rw

Delete the healthcheck: block entirely and leave this comment in its place (the ≥0.10.8 image is distroless/static — no shell, no test; the old check reported a false “unhealthy” forever; nothing depends_on this service’s health):

# No healthcheck: image is distroless (no shell/test binary). Health is
# asserted by verify.yml (gRPC port + regenerated outputs) instead.
  • Step 3: Lint

Run: cd ansible && ansible-lint --config-file=.ansible-lint roles/router-hosts/templates/docker-compose.yml.j2 || true (templates aren’t linted by ansible-lint; instead) yamllint -c ../.yamllint.yaml roles/router-hosts/templates/docker-compose.yml.j2 || true — j2 templates may not parse as YAML; visual review is the gate here. Confirm Jinja renders: python3 -c "import jinja2,sys; jinja2.Environment().parse(open(sys.argv[1]).read())" ansible/roles/router-hosts/templates/docker-compose.yml.j2 (repeat for server.toml.j2). Expected: no Jinja syntax errors.

  • Step 4: Commit

jj --no-pager commit -m "feat(router-hosts): unbound_conf_path output, drop dnsmasq output + broken distroless healthcheck [hl-hqyv]"


Task 4: Control D DoT forward template (PR-B)

Section titled “Task 4: Control D DoT forward template (PR-B)”

Files:

  • Create: ansible/roles/router-hosts/templates/unbound-controld-forward.conf.j2

Steps:

  • Step 1: Create the template

Content mirrors the live-proven hand-applied file byte-for-byte modulo templating (fetched 2026-07-07):

# {{ ansible_managed }}
# Forward all non-local queries to Control D ({{ router_hosts_controld_resolver_id }} profile) over DoT.
# Replaces unbound's root recursion; keeps Control D filtering, encrypted, no local ctrld daemon.
# Design: docs/engineering/specs/2026-07-07-authoritative-house-resolver-design.md (hl-hqyv, D7/D8).
server:
tls-cert-bundle: "/etc/ssl/certs/ca-certificates.crt"
forward-zone:
name: "."
forward-tls-upstream: yes
{% for ip in router_hosts_controld_forward_ips %}
forward-addr: {{ ip }}@853#{{ router_hosts_controld_resolver_id }}.dns.controld.com
{% endfor %}
  • Step 2: Verify rendered output matches the live file

Run: ssh router 'sudo cat /home/pi/.firewalla/config/unbound_local/20-controld-forward.conf' and compare the directive lines (comments may differ) against a local render. The server:/forward-zone: structure and the two forward-addr: lines must match exactly.

  • Step 3: Commit

jj --no-pager commit -m "feat(router-hosts): template Control D DoT forward-zone for unbound [hl-hqyv]"


Task 5: systemd reload watcher + boot persistence + handlers (PR-B)

Section titled “Task 5: systemd reload watcher + boot persistence + handlers (PR-B)”

Files:

  • Create: ansible/roles/router-hosts/templates/router-hosts-unbound-reload.path.j2
  • Create: ansible/roles/router-hosts/templates/router-hosts-unbound-reload.service.j2
  • Create: ansible/roles/router-hosts/templates/z0152-install-unbound-watch.sh.j2
  • Modify: ansible/roles/router-hosts/handlers/main.yml

Steps:

  • Step 1: Create the path unit template

router-hosts-unbound-reload.path.j2 (verified against systemd source: PathChanged= on a directory includes IN_MOVED_TO, so router-hosts’ atomic temp+rename write triggers it; pre-existing state at unit start does not):

# {{ ansible_managed }}
[Unit]
Description=Watch unbound_local for zone/config changes (router-hosts daemon + ansible writes)
[Path]
PathChanged={{ router_hosts_unbound_local_dir }}
Unit=router-hosts-unbound-reload.service
[Install]
WantedBy=multi-user.target
  • Step 2: Create the service unit template

router-hosts-unbound-reload.service.j2 (invocation live-verified 2026-07-07: unbound-control needs LD_LIBRARY_PATH for libhiredis.so):

# {{ ansible_managed }}
[Unit]
Description=Reload unbound after unbound_local change
[Service]
Type=oneshot
Environment=LD_LIBRARY_PATH={{ router_hosts_unbound_run_dir }}
ExecStart={{ router_hosts_unbound_run_dir }}/unbound-control -c {{ router_hosts_unbound_run_dir }}/unbound.conf reload
  • Step 3: Create the boot re-install script template

z0152-install-unbound-watch.sh.j2 — deployed to post_main.d/0152-install-unbound-watch.sh (mirrors the 0151 pattern; re-asserts units after firmware updates reset /etc/systemd/system; sources units from flash, NOT extdata — extdata mount is unreliable at boot, bead hl-tl2k):

#!/usr/bin/env bash
# {{ ansible_managed }}
# Reinstall + start the unbound_local reload watcher after Firewalla boot.
# Unit sources live on internal flash ({{ router_hosts_units_dir }}) so this
# works even if /extdata failed to mount (hl-tl2k).
sudo cp {{ router_hosts_units_dir }}/router-hosts-unbound-reload.path \
{{ router_hosts_units_dir }}/router-hosts-unbound-reload.service \
/etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now router-hosts-unbound-reload.path
  • Step 4: Replace the dnsmasq handler with unbound + daemon-reload handlers

handlers/main.yml — keep Restart router-hosts; replace the Reload dnsmasq handler with:

- name: Reload dnsmasq
ansible.builtin.systemd:
name: firerouter_dns # Firewalla's dnsmasq service name
state: reloaded
listen: reload dnsmasq
- name: Reload unbound
ansible.builtin.command:
cmd: "{{ router_hosts_unbound_run_dir }}/unbound-control -c {{ router_hosts_unbound_run_dir }}/unbound.conf reload"
environment:
LD_LIBRARY_PATH: "{{ router_hosts_unbound_run_dir }}"
changed_when: true
listen: reload unbound

(Reload dnsmasq survives for one consumer: the Task 6 overlay-cleanup task.)

  • Step 5: Lint + Jinja parse check

Run: cd ansible && ansible-lint --config-file=.ansible-lint roles/router-hosts/handlers/main.yml and the jinja2.Environment().parse one-liner from Task 3 Step 3 for each new template. Expected: clean.

  • Step 6: Commit

jj --no-pager commit -m "feat(router-hosts): systemd unbound-reload watcher units + boot reinstall + handlers [hl-hqyv]"


Task 6: configure.yml — deploy unbound config, retire the dnsmasq overlay (PR-B)

Section titled “Task 6: configure.yml — deploy unbound config, retire the dnsmasq overlay (PR-B)”

Files:

  • Modify: ansible/roles/router-hosts/tasks/configure.yml (the cutover block starts at the comment # DNS cutover tasks and runs to end of file)

Steps:

  • Step 1: Delete the dnsmasq cutover block

Remove the entire cutover block — every task from the # DNS cutover tasks comment to end of file, including the existing Apply pending router-hosts restart before waiting for its output (meta: flush_handlers) task. Step 2’s appended block carries its own flush at the correct position. Ordering is load-bearing: the Ensure unbound_local directory exists task MUST execute before any flushed container restart — the new compose file bind-mounts {{ router_hosts_unbound_local_dir }}:/unbound, and if the host directory doesn’t exist at container start, dockerd auto-creates it root-owned (MkdirAllAndChown), the pi-uid daemon then gets EACCES writing the zone, and the wait task below times out. (The dir pre-exists on the live box from the hand-apply, but a fresh deploy would hit this.) Also delete the now-unreferenced template ansible/roles/router-hosts/templates/local-hosts.conf.j2.

  • Step 2: Append the unbound configuration block
# --- unbound authoritative zone (hl-hqyv; replaces the retired dnsmasq overlay) ---
- name: Ensure unbound_local directory exists
ansible.builtin.file:
path: "{{ router_hosts_unbound_local_dir }}"
state: directory
owner: "{{ router_hosts_user }}"
group: "{{ router_hosts_user }}"
mode: "0755"
tags:
- router-hosts
- router-hosts-configure
- router-hosts-unbound
- name: Deploy Control D DoT forward-zone
ansible.builtin.template:
src: unbound-controld-forward.conf.j2
dest: "{{ router_hosts_unbound_local_dir }}/20-controld-forward.conf"
owner: "{{ router_hosts_user }}"
group: "{{ router_hosts_user }}"
mode: "0644"
notify: reload unbound
tags:
- router-hosts
- router-hosts-configure
- router-hosts-unbound
- name: Ensure watcher-unit flash directory exists
ansible.builtin.file:
path: "{{ router_hosts_units_dir }}"
state: directory
owner: "{{ router_hosts_user }}"
group: "{{ router_hosts_user }}"
mode: "0755"
tags:
- router-hosts
- router-hosts-configure
- router-hosts-unbound
- name: Deploy unbound-reload watcher units to flash
ansible.builtin.template:
src: "{{ item }}.j2"
dest: "{{ router_hosts_units_dir }}/{{ item }}"
owner: "{{ router_hosts_user }}"
group: "{{ router_hosts_user }}"
mode: "0644"
loop:
- router-hosts-unbound-reload.path
- router-hosts-unbound-reload.service
register: router_hosts_unit_sources
tags:
- router-hosts
- router-hosts-configure
- router-hosts-unbound
- name: Install unbound-reload watcher units into systemd
ansible.builtin.copy:
src: "{{ router_hosts_units_dir }}/{{ item }}"
dest: "/etc/systemd/system/{{ item }}"
remote_src: true
owner: root
group: root
mode: "0644"
loop:
- router-hosts-unbound-reload.path
- router-hosts-unbound-reload.service
tags:
- router-hosts
- router-hosts-configure
- router-hosts-unbound
- name: Enable and start the unbound-reload path watcher
ansible.builtin.systemd:
name: router-hosts-unbound-reload.path
enabled: true
state: started
daemon_reload: true
tags:
- router-hosts
- router-hosts-configure
- router-hosts-unbound
- name: Deploy watcher boot re-install script
ansible.builtin.template:
src: z0152-install-unbound-watch.sh.j2
dest: "{{ router_hosts_post_main_dir }}/0152-install-unbound-watch.sh"
owner: "{{ router_hosts_user }}"
group: "{{ router_hosts_user }}"
mode: "0755"
tags:
- router-hosts
- router-hosts-configure
- router-hosts-unbound
# Deterministic completion (same pattern as the retired cutover block): flush the
# pending restart, then wait for the daemon's startup regeneration to write the
# zone. Fails loudly if the zone never appears (hl-xy4w class of silent gap).
- name: Apply pending router-hosts restart before waiting for its output
ansible.builtin.meta: flush_handlers
- name: Wait for the daemon to write the unbound zone (regenerated on startup)
ansible.builtin.wait_for:
path: "{{ router_hosts_unbound_local_dir }}/10-fzymgc-authoritative.conf"
search_regex: "local-zone"
timeout: 60
tags:
- router-hosts
- router-hosts-configure
- router-hosts-unbound
# --- one-time retirement of the dnsmasq per-name overlay (hl-hqyv D4) ---
- name: Remove retired dnsmasq overlay files
ansible.builtin.file:
path: "{{ router_hosts_firewalla_config_dir }}/dnsmasq_local/{{ item }}"
state: absent
loop:
- router-hosts.conf
- local-hosts.conf
notify: reload dnsmasq
tags:
- router-hosts
- router-hosts-configure
- router-hosts-unbound
  • Step 3: Lint

Run: cd ansible && ansible-lint --config-file=.ansible-lint roles/router-hosts/tasks/configure.yml Expected: clean.

  • Step 4: Syntax-check the playbook

Run: cd ansible && ansible-playbook -i inventory/hosts.yml router-playbook.yml --tags router-hosts --syntax-check Expected: playbook: router-playbook.yml (no errors).

  • Step 5: Commit

jj --no-pager commit -m "feat(router-hosts): deploy unbound zone pipeline + watcher, retire dnsmasq overlay [hl-hqyv]"


Task 7: verify.yml — unbound assertions (PR-B)

Section titled “Task 7: verify.yml — unbound assertions (PR-B)”

Files:

  • Modify: ansible/roles/router-hosts/tasks/verify.yml (insert after Check certificate validity and before the final Report deployment status debug task, so the success message stays last)

Steps:

  • Step 1: Append unbound verification tasks
- name: Verify unbound zone file exists and is non-empty
ansible.builtin.stat:
path: "{{ router_hosts_unbound_local_dir }}/10-fzymgc-authoritative.conf"
register: router_hosts_unbound_zone
failed_when: not router_hosts_unbound_zone.stat.exists or router_hosts_unbound_zone.stat.size == 0
check_mode: false
tags:
- router-hosts
- router-hosts-verify
- name: Verify unbound is running and reloadable
ansible.builtin.command:
cmd: "{{ router_hosts_unbound_run_dir }}/unbound-control -c {{ router_hosts_unbound_run_dir }}/unbound.conf status"
environment:
LD_LIBRARY_PATH: "{{ router_hosts_unbound_run_dir }}"
register: router_hosts_unbound_status
changed_when: false
failed_when: "'version:' not in router_hosts_unbound_status.stdout"
check_mode: false
tags:
- router-hosts
- router-hosts-verify
- name: Verify a managed name answers internally (A)
ansible.builtin.command:
cmd: dig @127.0.0.1 -p 8953 id.fzymgc.house A +short
register: router_hosts_dig_a
changed_when: false
failed_when: "'192.168.20.145' not in router_hosts_dig_a.stdout"
check_mode: false
tags:
- router-hosts
- router-hosts-verify
- name: Verify type-65 returns NODATA for a managed name (ECH leak closed)
ansible.builtin.command:
cmd: dig @127.0.0.1 -p 8953 id.fzymgc.house TYPE65 +short
register: router_hosts_dig_65
changed_when: false
failed_when: router_hosts_dig_65.stdout | trim | length > 0
check_mode: false
tags:
- router-hosts
- router-hosts-verify
- name: Verify external resolution works (Control D DoT forward)
ansible.builtin.command:
cmd: dig @127.0.0.1 -p 8953 example.com A +short
register: router_hosts_dig_ext
changed_when: false
failed_when: router_hosts_dig_ext.stdout | trim | length == 0
check_mode: false
tags:
- router-hosts
- router-hosts-verify
  • Step 2: Lint

Run: cd ansible && ansible-lint --config-file=.ansible-lint roles/router-hosts/tasks/verify.yml Expected: clean.

  • Step 3: Commit

jj --no-pager commit -m "test(router-hosts): verify unbound zone, reloadability, A/TYPE65/external resolution [hl-hqyv]"


Files:

  • Modify: docs/reference/network.md (DNS/resolver description; MetalLB namespace correction)
  • Create: docs/operations/dns.md

Steps:

  • Step 1: Update network.md

Find the DNS/resolver description (search for dnsmasq) and: (a) describe the resolver chain as client → dnsmasq (interceptor) → unbound (authoritative .house, all RR types, NODATA unlisted; DoT forward to Control D), replacing any per-name-dnsmasq-overlay description; (b) in the MetalLB section (~lines 172–199), which currently names no namespace at all, add one sentence stating MetalLB runs in namespace metallb (source: argocd/app-configs/metallb/kustomization.yaml).

  • Step 2: Create docs/operations/dns.md

With Starlight frontmatter (title, description), covering: the resolver architecture (link the spec); unbound operational commands (unbound-control ... status|reload with the LD_LIBRARY_PATH={{ run dir }} requirement; no unbound-checkconf on the box — validate with a throwaway instance on port 18953); how the zone regenerates (router-hosts startup + host-change, watcher reload); the client-side verification runbook (run from a real client, not the box):

Terminal window
for n in id mcp-gw grafana vault k8s-cluster; do echo -n "$n: "; dig $n.fzymgc.house A +short; done
dig id.fzymgc.house TYPE65 +short # want: empty (NODATA)
curl -sS -o/dev/null -w"%{http_code} %{remote_ip}\n" https://mcp-gw.fzymgc.house/mcp/engram # want: 401 via 192.168.20.155

and the manual Firewalla Custom-DNS cleanup (post-rollout): delete the nas-ingress.fzymgc.house and router.fzymgc.house entries in the Firewalla app (router-hosts/CLI cannot touch Firewalla-native entries — hl-uegs precedent), then re-verify both names still resolve via the zone.

  • Step 3: Lint

Run: rumdl check docs/reference/network.md docs/operations/dns.md Expected: clean.

  • Step 4: Commit

jj --no-pager commit -m "docs(dns): unbound authoritative resolver ops + runbook; fix MetalLB namespace [hl-hqyv]"


Task 9: Rollout runbook (operator-driven; no code)

Section titled “Task 9: Rollout runbook (operator-driven; no code)”

Sequencing enforces the external gate. Steps 3+ happen only after rh#349’s release exists on ghcr.

  • Step 1: Merge PR-A (tf inventory). TFC workspace main-cluster-router-hosts auto-applies. If apply fails AlreadyExists, see Task 1’s trap note. Verify: ssh router 'sudo grep -c "k8s-cluster.fzymgc.house\|nas-ingress\|router.fzymgc.house" /extdata/router-hosts/hosts/main' → expect ≥3 lines matched (daemon regenerates outputs on the change).
  • Step 2: Confirm rh#349 released; note the tag; update Task 2’s 0.11.0 placeholder in PR-B if the actual version differs; confirm the tag exists: gh api /orgs/fzymgc-house/packages/container/router-hosts/versions --jq '.[].metadata.container.tags[]' | head -5.
  • Step 3: Merge PR-B, then apply: cd ansible && ansible-playbook -i inventory/hosts.yml router-playbook.yml --tags router-hosts. The role’s flush/wait/verify chain asserts the zone was written, unbound reloads, and A/TYPE65/external checks pass on-router.
  • Step 4: Client-side verification (from a workstation): run the dig/curl block from docs/operations/dns.md. All internal names → internal IPs; k8s-cluster.fzymgc.house192.168.20.140; TYPE65 empty; break-glass kubeconfig works without --server overrides: kubectl --kubeconfig ~/.kube/configs/fzymgc-house-admin.yml get --raw /readyzok.
  • Step 5: Watcher end-to-end check: ssh router 'sudo touch /home/pi/.firewalla/config/unbound_local/20-controld-forward.conf' then ssh router 'journalctl -u router-hosts-unbound-reload.service --no-pager -n 3' → a fresh reload run.
  • Step 6: Manual Firewalla Custom-DNS cleanup (app UI): delete nas-ingress.fzymgc.house + router.fzymgc.house; re-verify both resolve.
  • Step 7: Close out: bd close the implementation beads; note completion on hl-hqyv; the hand-applied-config memory (engram 8dec11b2) needs updating to “codified”.

  • rh#349 implementation itself (router-hosts repo).
  • Boot-reliability of the extdata mount (bead hl-tl2k) and ghcr retention-vs-pinned-tags (bead filed 2026-07-07).
  • 10.255.254.x convention reconciliation, unbound monitoring, per-client query logging (spec Out-of-scope follow-ups).