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).
External dependency gate
Section titled “External dependency gate”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 strategy
Section titled “PR strategy”- 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).
Working conventions for every task
Section titled “Working conventions for every task”- Work in the jj workspace
.worktrees/hl-hqyv-resolver(changewtmnpplzholds the spec). - Lint gates:
cd ansible && ansible-lint --config-file=.ansible-lint <changed files>for ansible;terraform fmt -checkfor tf;rumdl check <file>for markdown. - Commit after each task with
jj commit -m "<conventional message>"(jj repo — no git add/commit;--no-pageron 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 thelocal.host_entriesmap
Steps:
- Step 1: Promote the
k8s-clusterbare 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:rwDelete 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=oneshotEnvironment=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-reloadsudo 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 tasksand 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 afterCheck certificate validityand before the finalReport deployment statusdebug 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]"
Task 8: Docs (PR-B)
Section titled “Task 8: Docs (PR-B)”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):
for n in id mcp-gw grafana vault k8s-cluster; do echo -n "$n: "; dig $n.fzymgc.house A +short; donedig 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.155and 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-hostsauto-applies. If apply failsAlreadyExists, 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.0placeholder 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.house→192.168.20.140; TYPE65 empty; break-glass kubeconfig works without--serveroverrides:kubectl --kubeconfig ~/.kube/configs/fzymgc-house-admin.yml get --raw /readyz→ok. - Step 5: Watcher end-to-end check:
ssh router 'sudo touch /home/pi/.firewalla/config/unbound_local/20-controld-forward.conf'thenssh 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 closethe implementation beads; note completion onhl-hqyv; the hand-applied-config memory (engram8dec11b2) needs updating to “codified”.
Out of scope (tracked elsewhere)
Section titled “Out of scope (tracked elsewhere)”- 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.xconvention reconciliation, unbound monitoring, per-client query logging (spec Out-of-scope follow-ups).