Scope note: this does not fix the operator's reported flap. Their workstation resolves via 8.8.8.8/1.1.1.1 and never consults PiHole, so this change is invisible to it. The Cloudflare-record flap is a separate issue (external-dns policy=sync with no RFC1918 target guard, plus two HTTPRoutes claiming the apex) and is not addressed here. This PR fixes a real but different defect affecting clients that forward to PiHole — principally the SOHO router, which does.
The defect: our authoritative answer is uncacheable, the wrong one caches for 5 minutes
dnsmasq answers address= entries with TTL 0 unless told otherwise. The router forwards LAN queries to PiHole and caches the result — the decrementing TTL above is proof it caches rather than forwarding live. So:
the correct answer is uncacheable — the router must re-query PiHole on every lookup, maximum exposure to any momentary gap;
the wrong answer is cacheable for 300s — the instant the router falls back to upstream (standard forwarder behaviour when the target does not answer) it latches the public WAN address and serves it to every forwarding client for five minutes.
One sub-second PiHole gap becomes five minutes of wrong answers for those clients. Then it expires, the router re-queries PiHole, gets TTL 0 again, and is immediately re-exposed. That asymmetry, not the gap, is what makes it visible.
Fix
local-ttl=60 prepended to FTLCONF_misc_dnsmasq_lines. dnsmasq applies local-ttl to replies made from configuration, so one directive covers all five address= names.
60s is a deliberate floor: long enough to ride out a pod restart and an L2 lease re-election (both seconds), short enough that a genuine VIP change propagates inside a minute. These VIPs are pinned by lbipam.cilium.io/ips and effectively static, so the propagation cost is theoretical while the flap it prevents is measured.
apps/wave-0/pihole.yaml regenerated with the canonical command (--all catalog -o apps --wave-layout --no-image-automation, deliberately not--emit-waves); exactly two files changed and the rendered diff is the single local-ttl=60; prefix.
Not verified
UNAPPLIED. Verify after merge with dig home.keithjackson.dev @192.168.2.23 — expect TTL=60, not 0.
I did not confirm FTL accepts local-ttl via FTLCONF_misc_dnsmasq_lines. The directive is valid dnsmasq and the field is a passthrough, but catalog/pihole.yaml's own header records FTL previously writing a bogus line into dnsmasq.conf and reporting "0 invalid" — a malformed passthrough fails silently. If the TTL is still 0 after merge, check the pod's /etc/pihole/dnsmasq.conf; that is the cause, not this reasoning.
It does not remove the trigger.192.168.2.23 is served by two LoadBalancer Services with two independently-elected L2 announcement leases, so two nodes can ARP for it simultaneously — they sat on different nodes earlier the same day. Same pattern on crafty (.22) and unifi (.21). Merging each pair into one mixed-protocol Service would remove the class; that decision is parked.
No help for an outage longer than the TTL — the cache expires and the public answer latches again. That is an availability problem, not a caching one.
No alert fires if the split-horizon answer regresses to the public address. Nothing probes the name from a LAN vantage point and compares against ${GATEWAY_VIP}.
Local gate: rc 3, zero failures; only gitea-settings and L2/L3 conformance did not run.
Full detail: decisions/2026-10-18-pihole-local-ttl.md
> **Scope note:** this does **not** fix the operator's reported flap. Their workstation resolves via 8.8.8.8/1.1.1.1 and never consults PiHole, so this change is invisible to it. The Cloudflare-record flap is a separate issue (external-dns `policy=sync` with no RFC1918 target guard, plus two HTTPRoutes claiming the apex) and is **not** addressed here. This PR fixes a real but *different* defect affecting clients that forward to PiHole — principally the SOHO router, which does.
## The defect: our authoritative answer is uncacheable, the wrong one caches for 5 minutes
Measured on prod 2026-10-18:
```
dig home.keithjackson.dev @192.168.2.23 (pihole) -> 192.168.2.20 TTL=0
dig home.keithjackson.dev @1.1.1.1 (public) -> wan.home.keithjackson.dev TTL=300
dig home.keithjackson.dev @192.168.2.1 (router) -> wan.... TTL=153 -> 148 -> 143
```
dnsmasq answers `address=` entries with **TTL 0** unless told otherwise. The router forwards LAN queries to PiHole and **caches** the result — the decrementing TTL above is proof it caches rather than forwarding live. So:
- the **correct** answer is **uncacheable** — the router must re-query PiHole on *every* lookup, maximum exposure to any momentary gap;
- the **wrong** answer is **cacheable for 300s** — the instant the router falls back to upstream (standard forwarder behaviour when the target does not answer) it latches the public WAN address and serves it to **every forwarding client** for five minutes.
**One sub-second PiHole gap becomes five minutes of wrong answers** for those clients. Then it expires, the router re-queries PiHole, gets TTL 0 again, and is immediately re-exposed. That asymmetry, not the gap, is what makes it visible.
## Fix
`local-ttl=60` prepended to `FTLCONF_misc_dnsmasq_lines`. dnsmasq applies `local-ttl` to replies made from configuration, so one directive covers all five `address=` names.
60s is a deliberate floor: long enough to ride out a pod restart and an L2 lease re-election (both seconds), short enough that a genuine VIP change propagates inside a minute. These VIPs are pinned by `lbipam.cilium.io/ips` and effectively static, so the propagation cost is theoretical while the flap it prevents is measured.
`apps/wave-0/pihole.yaml` regenerated with the canonical command (`--all catalog -o apps --wave-layout --no-image-automation`, deliberately **not** `--emit-waves`); exactly two files changed and the rendered diff is the single `local-ttl=60;` prefix.
## Not verified
- **UNAPPLIED.** Verify after merge with `dig home.keithjackson.dev @192.168.2.23` — expect `TTL=60`, not 0.
- **I did not confirm FTL accepts `local-ttl` via `FTLCONF_misc_dnsmasq_lines`.** The directive is valid dnsmasq and the field is a passthrough, but `catalog/pihole.yaml`'s own header records FTL previously writing a bogus line into `dnsmasq.conf` and reporting "0 invalid" — a malformed passthrough fails **silently**. If the TTL is still 0 after merge, check the pod's `/etc/pihole/dnsmasq.conf`; that is the cause, not this reasoning.
- **It does not remove the trigger.** `192.168.2.23` is served by **two** LoadBalancer Services with **two independently-elected L2 announcement leases**, so two nodes can ARP for it simultaneously — they sat on different nodes earlier the same day. Same pattern on crafty (`.22`) and unifi (`.21`). Merging each pair into one mixed-protocol Service would remove the class; that decision is parked.
- **No help for an outage longer than the TTL** — the cache expires and the public answer latches again. That is an availability problem, not a caching one.
- **No alert fires if the split-horizon answer regresses** to the public address. Nothing probes the name from a LAN vantage point and compares against `${GATEWAY_VIP}`.
Local gate: **rc 3, zero failures**; only `gitea-settings` and L2/L3 `conformance` did not run.
Full detail: `decisions/2026-10-18-pihole-local-ttl.md`
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Operator reported DNS flapping between the cluster ingress and the router's
public IP. PiHole is not at fault -- both replicas answer 192.168.2.20
consistently (6/6 via the VIP, 8/8 from a client). The bug is an asymmetry in
CACHEABILITY that we created. Measured:
pihole 192.168.2.23 -> 192.168.2.20 TTL=0
public 1.1.1.1 -> wan.home.keithjackson.dev TTL=300
router 192.168.2.1 -> wan.... TTL=153 -> 148 -> 143 (decrementing = caching)
dnsmasq answers `address=` entries with TTL 0 unless told otherwise. The router
forwards LAN queries to PiHole and caches the result, so the CORRECT answer is
uncacheable -- re-queried on every lookup, maximum exposure to any gap -- while
the WRONG answer caches for 300s. The instant the router falls back to upstream
(standard forwarder behaviour when the target does not answer) it latches the
WAN address for the whole LAN for five minutes, then expires, re-queries PiHole,
gets TTL 0, and is immediately re-exposed.
So ONE sub-second PiHole gap -- pod restart, L2 lease move, ARP flap, kured
reboot -- becomes FIVE MINUTES of the house resolving to the WAN IP. That
asymmetry, not the gap, is why it reads as "flapping".
Fix: local-ttl=60 prepended to FTLCONF_misc_dnsmasq_lines. dnsmasq applies
local-ttl to replies made from configuration, so one directive covers all five
address= names. 60s rides out a pod restart and a lease re-election while a real
VIP change still propagates inside a minute; these VIPs are pinned and static.
apps/wave-0/pihole.yaml regenerated with the canonical render command
(--all catalog -o apps --wave-layout --no-image-automation, NOT --emit-waves);
exactly two files changed and the rendered diff is the single local-ttl prefix.
UNAPPLIED. Does NOT remove the trigger: .23 still has two independently-elected
L2 leases (as do crafty .22 and unifi .21), which the operator has parked. If
the TTL is still 0 after merge, suspect FTL silently rejecting the passthrough
line -- see the handoff.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
The defect: our authoritative answer is uncacheable, the wrong one caches for 5 minutes
Measured on prod 2026-10-18:
dnsmasq answers
address=entries with TTL 0 unless told otherwise. The router forwards LAN queries to PiHole and caches the result — the decrementing TTL above is proof it caches rather than forwarding live. So:One sub-second PiHole gap becomes five minutes of wrong answers for those clients. Then it expires, the router re-queries PiHole, gets TTL 0 again, and is immediately re-exposed. That asymmetry, not the gap, is what makes it visible.
Fix
local-ttl=60prepended toFTLCONF_misc_dnsmasq_lines. dnsmasq applieslocal-ttlto replies made from configuration, so one directive covers all fiveaddress=names.60s is a deliberate floor: long enough to ride out a pod restart and an L2 lease re-election (both seconds), short enough that a genuine VIP change propagates inside a minute. These VIPs are pinned by
lbipam.cilium.io/ipsand effectively static, so the propagation cost is theoretical while the flap it prevents is measured.apps/wave-0/pihole.yamlregenerated with the canonical command (--all catalog -o apps --wave-layout --no-image-automation, deliberately not--emit-waves); exactly two files changed and the rendered diff is the singlelocal-ttl=60;prefix.Not verified
dig home.keithjackson.dev @192.168.2.23— expectTTL=60, not 0.local-ttlviaFTLCONF_misc_dnsmasq_lines. The directive is valid dnsmasq and the field is a passthrough, butcatalog/pihole.yaml's own header records FTL previously writing a bogus line intodnsmasq.confand reporting "0 invalid" — a malformed passthrough fails silently. If the TTL is still 0 after merge, check the pod's/etc/pihole/dnsmasq.conf; that is the cause, not this reasoning.192.168.2.23is served by two LoadBalancer Services with two independently-elected L2 announcement leases, so two nodes can ARP for it simultaneously — they sat on different nodes earlier the same day. Same pattern on crafty (.22) and unifi (.21). Merging each pair into one mixed-protocol Service would remove the class; that decision is parked.${GATEWAY_VIP}.Local gate: rc 3, zero failures; only
gitea-settingsand L2/L3conformancedid not run.Full detail:
decisions/2026-10-18-pihole-local-ttl.md🤖 Generated with Claude Code