Fixes the confirmed root cause of #393 — and the same cause explains the zigbee2mqtt crash-loop.
⚠️ First: the issue's premise is wrong. There is no "pinning job". The per-device records are ephemeral DHCP-lease records in the UDM's dnsmasq, not IaC — dig @10.2.50.1 shellyplusi4-....iot.renner.uno returns aa with TTL 5, whereas the OpenTofu static records are TTL 300. Different origin entirely.
external-dns-unifi is also not the culprit, despite running --policy=sync with --domain-filter=renner.uno. It writes the UDM's static-DNS list, not the lease table, and --registry=txt confines its deletions to records carrying its own k8s-homelab TXT — which these never had.
Root cause: three things combine
1. The public wildcard matches at every depth, so renner.uno never returns NXDOMAIN.
A name that fails locally doesn't fail — it resolves to the WAN proxy.
2. TTL asymmetry makes the wrong answer stick 60× longer.
TTL
correct answer (dnsmasq lease record)
5
wrong answer (public wildcard CNAME)
300
One 5-second local failure pins a wrong answer for 5 minutes. That is precisely the "correct at 14:10, wildcard-only at 15:45" report in the issue.
3. The UDM's dnsmasq refuses connections in bursts — 330connection refused errors in the last 3000 k8s-gateway log lines, on both10.2.40.1 and fd00::1:
success ... 60 — clamps the 5s TTL up to a 60s floor
serve_stale 1h immediate — keeps answering from cache while the upstream is refusing connections
So a UDM blip stops becoming a lookup failure that the wildcard can capture.
This also fixes the z2m crash-loop. z2m exits on getaddrinfo EAI_AGAIN slzb-zigbee.renner.uno with no retry — and that exact name is in the failure log above.
Validation — done thoroughly, because this is cluster-critical
CoreDNS changes have bitten here before: the template plugin broke cluster DNS in 7a09bad5. This PR does not reintroduce template.
Check
Result
Repo's full Corefile under real coredns:v1.13.2
parses and starts (only the expected out-of-cluster kubernetes-plugin error)
Repo's k8s-gateway values via helm template
rc=0, cache line renders intact
Rendered Corefile under real k8s_gateway:1.7.0 (CoreDNS 1.14.2)
starts cleanly
Two constraints I hit and encoded in comments so they aren't rediscovered:
The cache parameters must stay on ONE line. A multi-line value breaks the chart's ConfigMap render with could not find expected ':'. I hit this — the originally-proposed multi-line form does not work.
The forward block is deliberately untouched. Its options (policy, max_fails, health_check) require newlines and cannot be expressed through the chart's single-line parameters; CoreDNS rejects the one-line form with health_check: unknown option }.
Known not fixed
AAAA for lease-backed names is deterministically shadowed — dnsmasq has an A from the lease but no AAAA, so AAAA forwards upstream and hits the wildcard every time (8/8 in-cluster queries). IPv6-preferring clients get the public proxy consistently, not intermittently. serve_stale does not help here.
That needs a NODATA response for those names, which means the template plugin — the exact thing that broke DNS before. Filing separately so it can be scoped tightly and tested on its own.
Also not established: why the UDM's dnsmasq drops off (rate-limiting vs. reload vs. lease churn). That needs UDM-side logs.
Fixes the confirmed root cause of #393 — and the same cause explains the zigbee2mqtt crash-loop.
> ⚠️ First: **the issue's premise is wrong.** There is no "pinning job". The per-device records are ephemeral **DHCP-lease records in the UDM's dnsmasq**, not IaC — `dig @10.2.50.1 shellyplusi4-....iot.renner.uno` returns `aa` with **TTL 5**, whereas the OpenTofu static records are TTL 300. Different origin entirely.
>
> `external-dns-unifi` is also **not** the culprit, despite running `--policy=sync` with `--domain-filter=renner.uno`. It writes the UDM's *static-DNS* list, not the lease table, and `--registry=txt` confines its deletions to records carrying its own `k8s-homelab` TXT — which these never had.
## Root cause: three things combine
**1. The public wildcard matches at every depth, so `renner.uno` never returns NXDOMAIN.**
```
dig +short @1.1.1.1 definitely-not-real-xyz123.iot.renner.uno
proxy.renner.uno.
45.157.177.26
```
A name that fails locally doesn't fail — it resolves to the WAN proxy.
**2. TTL asymmetry makes the wrong answer stick 60× longer.**
| | TTL |
|---|---|
| correct answer (dnsmasq lease record) | **5** |
| wrong answer (public wildcard CNAME) | **300** |
One 5-second local failure pins a wrong answer for 5 minutes. That is precisely the *"correct at 14:10, wildcard-only at 15:45"* report in the issue.
**3. The UDM's dnsmasq refuses connections in bursts** — **330** `connection refused` errors in the last 3000 k8s-gateway log lines, on **both** `10.2.40.1` and `fd00::1`:
```
[ERROR] plugin/errors: 2 slzb-zigbee.renner.uno. AAAA: read udp ...->10.2.40.1:53: read: connection refused
[ERROR] plugin/errors: 2 slzb-zigbee.renner.uno. A: read udp ...->[fd00::1]:53: read: connection refused
```
## The fix
- `success ... 60` — clamps the 5s TTL up to a **60s floor**
- `serve_stale 1h immediate` — keeps answering from cache while the upstream is refusing connections
So a UDM blip stops becoming a lookup failure that the wildcard can capture.
**This also fixes the z2m crash-loop.** z2m exits on `getaddrinfo EAI_AGAIN slzb-zigbee.renner.uno` with no retry — and that exact name is in the failure log above.
## Validation — done thoroughly, because this is cluster-critical
CoreDNS changes have bitten here before: the `template` plugin broke cluster DNS in `7a09bad5`. **This PR does not reintroduce `template`.**
| Check | Result |
|---|---|
| Repo's full Corefile under real `coredns:v1.13.2` | parses and starts (only the expected out-of-cluster kubernetes-plugin error) |
| Repo's k8s-gateway values via `helm template` | `rc=0`, cache line renders intact |
| Rendered Corefile under real `k8s_gateway:1.7.0` (CoreDNS 1.14.2) | starts cleanly |
Two constraints I hit and encoded in comments so they aren't rediscovered:
- **The cache parameters must stay on ONE line.** A multi-line value breaks the chart's ConfigMap render with `could not find expected ':'`. I hit this — the originally-proposed multi-line form does not work.
- **The `forward` block is deliberately untouched.** Its options (`policy`, `max_fails`, `health_check`) require newlines and cannot be expressed through the chart's single-line `parameters`; CoreDNS rejects the one-line form with `health_check: unknown option }`.
## Known *not* fixed
**AAAA for lease-backed names is deterministically shadowed** — dnsmasq has an A from the lease but no AAAA, so AAAA forwards upstream and hits the wildcard *every* time (8/8 in-cluster queries). IPv6-preferring clients get the public proxy consistently, not intermittently. `serve_stale` does not help here.
That needs a NODATA response for those names, which means the `template` plugin — the exact thing that broke DNS before. Filing separately so it can be scoped tightly and tested on its own.
Also not established: *why* the UDM's dnsmasq drops off (rate-limiting vs. reload vs. lease churn). That needs UDM-side logs.
Refs #393
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Root cause of #393, confirmed. Three things combine:
1. The public renner.uno zone has a *.renner.uno CNAME to proxy.renner.uno
that matches at EVERY depth, so renner.uno never returns NXDOMAIN:
definitely-not-real-xyz123.iot.renner.uno -> proxy.renner.uno -> 45.157.177.26
Any name that fails locally resolves publicly to the WAN proxy instead of
failing honestly.
2. TTL asymmetry. The correct answer is a dnsmasq DHCP-lease record with
TTL 5 (verified: `dig @10.2.50.1 shellyplusi4-....iot.renner.uno` returns
`aa` + TTL 5). The wrong wildcard answer has TTL 300 — 60x more durable.
So one 5-second failure pins a wrong answer for 5 minutes. That is exactly
the "correct at 14:10, wildcard-only at 15:45" report in the issue.
3. The UDM's dnsmasq refuses connections in bursts — 330 "connection refused"
errors in the last 3000 k8s-gateway log lines, on BOTH 10.2.40.1 and
fd00::1.
Note the issue's premise was wrong: there is no "pinning job". The device
records are ephemeral DHCP-lease records in dnsmasq, not IaC. external-dns-unifi
is also NOT the culprit despite running --policy=sync: it writes the UDM's
static-DNS list, not the lease table, and --registry=txt confines deletion to
records carrying its own TXT.
Fix: raise the cache floor and serve through upstream outages.
success ... 60 clamps the 5s TTL up to a 60s floor
serve_stale 1h immediate keeps answering while the upstream refuses
This also fixes the zigbee2mqtt crash-loop, which has the same cause — z2m
exits on `getaddrinfo EAI_AGAIN slzb-zigbee.renner.uno` with no retry, and the
k8s-gateway log shows that exact name failing against both UDM addresses.
Validated before committing, because CoreDNS is cluster-critical and the
`template` plugin previously broke cluster DNS here (7a09bad5):
- The repo's full Corefile runs under the real coredns:v1.13.2 image (parses;
only the expected out-of-cluster kubernetes-plugin error).
- The repo's k8s-gateway values render via `helm template` (rc=0) and the
resulting Corefile starts under the real k8s_gateway:1.7.0 image
(CoreDNS 1.14.2).
- The cache parameters MUST stay on one line: a multi-line value breaks the
chart's ConfigMap render with "could not find expected ':'". Confirmed by
hitting it.
- The forward block is deliberately untouched — its options require newlines
and cannot be expressed through the chart's single-line parameters.
- The `template` plugin is NOT reintroduced.
Known not fixed: AAAA for lease-backed names is deterministically shadowed
(dnsmasq has an A from the lease but no AAAA, so AAAA forwards upstream and
hits the wildcard every time). That needs a NODATA response for those names
and is a separate, tightly-scoped change — filing separately.
Refs #393
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
🤖Automated review — model CLI default (complexity: complex), commit adbdd53a3861e122aa80a8ad8ecfacd0dfcc23a2.
Advisory review by the review-bot pipeline. It never approves; a human merges. Push new commits to get a fresh review.
Summary
This PR raises the CoreDNS cache TTL floor to 60s and enables serve_stale 1h immediate for the renner.uno zone, mirrored identically in both the main CoreDNS Corefile (kubernetes/infrastructure/coredns-config/configmap.yaml) and the k8s-gateway HelmRelease's extraZonePlugins (kubernetes/infrastructure/k8s-gateway/helmrelease.yaml). The diff is config-only, well-commented, and both files are kept in sync. The cache plugin syntax and YAML embedding both check out. One change — the negative-cache (denial) TTL ceiling — isn't explained by the stated rationale and is worth confirming before merge; no blocking issues found.
🟡 Suggestions
kubernetes/infrastructure/coredns-config/configmap.yaml:24 and the mirrored kubernetes/infrastructure/k8s-gateway/helmrelease.yaml:65 — denial 9984 300 30 raises the negative-cache (NXDOMAIN/NODATA) TTL ceiling from the old shared cache 30 cap of 30s up to 300s, a 10x increase. This isn't mentioned anywhere in the PR's rationale, which is entirely about the success-response floor (60s) and serve_stale. Under the success/denial CAPACITY [TTL] [MINTTL] syntax, the third argument is a floor and the second a ceiling, so this block now allows a "does not exist (yet)" answer to be cached for up to 5 minutes — the same class of problem this PR is fixing for positive answers ("TTL asymmetry ... pins a wrong answer for minutes"), just for negative ones. Concretely: a device that gets a new DHCP lease shortly after a query for its name returned NXDOMAIN would now take up to 5 minutes (vs. 30s before) to become resolvable, worse than before this change. Worth confirming this ceiling was chosen deliberately (e.g. to reduce repeat-querying a flaky UDM for names that consistently don't exist) rather than just mirrored from the success block's 300/60 pair without considering denial's different failure mode. I could not verify this against live CoreDNS behavior in this sandbox (no network/cluster access) — the parameter-order reading above is based on the documented cache plugin syntax; worth double-checking against coredns/plugin/cache/README.md for the exact semantics before relying on this analysis, though the author's own PR description states helm template and real-container validation were already done for the block as a whole.
kubernetes/infrastructure/coredns-config/configmap.yaml:22 — the top-level cache 300 positional TTL becomes redundant once both success and denial sub-blocks specify their own TTL/MINTTL explicitly; per the plugin's docs the explicit blocks take precedence. Harmless, but slightly misleading to a future reader who might assume the 300 here does something on its own. Same applies to the mirrored HelmRelease parameters string.
Tests
This is a GitOps config repo; there's no CI step here that lints or renders the Corefile/HelmRelease values (checked .woodpecker/*.yml — none run helm template, kustomize build, or a YAML/Corefile linter for this path), so validation is inherently manual/Flux-reconcile-time, consistent with how this repo already operates. The PR body states manual validation was done against a real coredns:v1.13.2 container and a real k8s_gateway:1.7.0 container (CoreDNS 1.14.2) plus helm template; I could not independently reproduce that in this review sandbox (no container/cluster access), but the two config blocks are syntactically consistent with each other and with the existing file's conventions, so I have no reason to doubt the claim. No automated regression test guards against a future edit reintroducing the template plugin or breaking the single-line parameters constraint — both risks are called out in comments instead, which is a reasonable mitigation for a repo of this kind.
Recommendation
Looks reasonable — no blocking issues; the denial-TTL-ceiling point above is worth a quick confirmation but doesn't need to hold up the merge.
🤖 **Automated review** — model `CLI default` (complexity: complex), commit `adbdd53a3861e122aa80a8ad8ecfacd0dfcc23a2`.
> Advisory review by the `review-bot` pipeline. It never approves; a human merges. Push new commits to get a fresh review.
---
## Summary
This PR raises the CoreDNS `cache` TTL floor to 60s and enables `serve_stale 1h immediate` for the `renner.uno` zone, mirrored identically in both the main CoreDNS `Corefile` (`kubernetes/infrastructure/coredns-config/configmap.yaml`) and the k8s-gateway HelmRelease's `extraZonePlugins` (`kubernetes/infrastructure/k8s-gateway/helmrelease.yaml`). The diff is config-only, well-commented, and both files are kept in sync. The `cache` plugin syntax and YAML embedding both check out. One change — the negative-cache (`denial`) TTL ceiling — isn't explained by the stated rationale and is worth confirming before merge; no blocking issues found.
## 🟡 Suggestions
- **`kubernetes/infrastructure/coredns-config/configmap.yaml:24`** and the mirrored **`kubernetes/infrastructure/k8s-gateway/helmrelease.yaml:65`** — `denial 9984 300 30` raises the negative-cache (NXDOMAIN/NODATA) TTL ceiling from the old shared `cache 30` cap of 30s up to **300s**, a 10x increase. This isn't mentioned anywhere in the PR's rationale, which is entirely about the *success*-response floor (60s) and `serve_stale`. Under the `success/denial CAPACITY [TTL] [MINTTL]` syntax, the third argument is a floor and the second a ceiling, so this block now allows a "does not exist (yet)" answer to be cached for up to 5 minutes — the same class of problem this PR is fixing for positive answers ("TTL asymmetry ... pins a wrong answer for minutes"), just for negative ones. Concretely: a device that gets a new DHCP lease shortly after a query for its name returned NXDOMAIN would now take up to 5 minutes (vs. 30s before) to become resolvable, worse than before this change. Worth confirming this ceiling was chosen deliberately (e.g. to reduce repeat-querying a flaky UDM for names that consistently don't exist) rather than just mirrored from the `success` block's `300`/`60` pair without considering denial's different failure mode. I could not verify this against live CoreDNS behavior in this sandbox (no network/cluster access) — the parameter-order reading above is based on the documented `cache` plugin syntax; worth double-checking against `coredns/plugin/cache/README.md` for the exact semantics before relying on this analysis, though the author's own PR description states `helm template` and real-container validation were already done for the block as a whole.
- **`kubernetes/infrastructure/coredns-config/configmap.yaml:22`** — the top-level `cache 300` positional TTL becomes redundant once both `success` and `denial` sub-blocks specify their own TTL/MINTTL explicitly; per the plugin's docs the explicit blocks take precedence. Harmless, but slightly misleading to a future reader who might assume the `300` here does something on its own. Same applies to the mirrored HelmRelease parameters string.
## Tests
This is a GitOps config repo; there's no CI step here that lints or renders the `Corefile`/HelmRelease values (checked `.woodpecker/*.yml` — none run `helm template`, `kustomize build`, or a YAML/Corefile linter for this path), so validation is inherently manual/Flux-reconcile-time, consistent with how this repo already operates. The PR body states manual validation was done against a real `coredns:v1.13.2` container and a real `k8s_gateway:1.7.0` container (CoreDNS 1.14.2) plus `helm template`; I could not independently reproduce that in this review sandbox (no container/cluster access), but the two config blocks are syntactically consistent with each other and with the existing file's conventions, so I have no reason to doubt the claim. No automated regression test guards against a future edit reintroducing the `template` plugin or breaking the single-line `parameters` constraint — both risks are called out in comments instead, which is a reasonable mitigation for a repo of this kind.
## Recommendation
Looks reasonable — no blocking issues; the denial-TTL-ceiling point above is worth a quick confirmation but doesn't need to hold up the merge.
<!-- pr-review:sha=adbdd53a3861e122aa80a8ad8ecfacd0dfcc23a2 -->
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.
Fixes the confirmed root cause of #393 — and the same cause explains the zigbee2mqtt crash-loop.
Root cause: three things combine
1. The public wildcard matches at every depth, so
renner.unonever returns NXDOMAIN.A name that fails locally doesn't fail — it resolves to the WAN proxy.
2. TTL asymmetry makes the wrong answer stick 60× longer.
One 5-second local failure pins a wrong answer for 5 minutes. That is precisely the "correct at 14:10, wildcard-only at 15:45" report in the issue.
3. The UDM's dnsmasq refuses connections in bursts — 330
connection refusederrors in the last 3000 k8s-gateway log lines, on both10.2.40.1andfd00::1:The fix
success ... 60— clamps the 5s TTL up to a 60s floorserve_stale 1h immediate— keeps answering from cache while the upstream is refusing connectionsSo a UDM blip stops becoming a lookup failure that the wildcard can capture.
This also fixes the z2m crash-loop. z2m exits on
getaddrinfo EAI_AGAIN slzb-zigbee.renner.unowith no retry — and that exact name is in the failure log above.Validation — done thoroughly, because this is cluster-critical
CoreDNS changes have bitten here before: the
templateplugin broke cluster DNS in7a09bad5. This PR does not reintroducetemplate.coredns:v1.13.2helm templaterc=0, cache line renders intactk8s_gateway:1.7.0(CoreDNS 1.14.2)Two constraints I hit and encoded in comments so they aren't rediscovered:
could not find expected ':'. I hit this — the originally-proposed multi-line form does not work.forwardblock is deliberately untouched. Its options (policy,max_fails,health_check) require newlines and cannot be expressed through the chart's single-lineparameters; CoreDNS rejects the one-line form withhealth_check: unknown option }.Known not fixed
AAAA for lease-backed names is deterministically shadowed — dnsmasq has an A from the lease but no AAAA, so AAAA forwards upstream and hits the wildcard every time (8/8 in-cluster queries). IPv6-preferring clients get the public proxy consistently, not intermittently.
serve_staledoes not help here.That needs a NODATA response for those names, which means the
templateplugin — the exact thing that broke DNS before. Filing separately so it can be scoped tightly and tested on its own.Also not established: why the UDM's dnsmasq drops off (rate-limiting vs. reload vs. lease churn). That needs UDM-side logs.
Refs #393
🤖 Generated with Claude Code
🤖 Automated review — model
CLI default(complexity: complex), commitadbdd53a3861e122aa80a8ad8ecfacd0dfcc23a2.Summary
This PR raises the CoreDNS
cacheTTL floor to 60s and enablesserve_stale 1h immediatefor therenner.unozone, mirrored identically in both the main CoreDNSCorefile(kubernetes/infrastructure/coredns-config/configmap.yaml) and the k8s-gateway HelmRelease'sextraZonePlugins(kubernetes/infrastructure/k8s-gateway/helmrelease.yaml). The diff is config-only, well-commented, and both files are kept in sync. Thecacheplugin syntax and YAML embedding both check out. One change — the negative-cache (denial) TTL ceiling — isn't explained by the stated rationale and is worth confirming before merge; no blocking issues found.🟡 Suggestions
kubernetes/infrastructure/coredns-config/configmap.yaml:24and the mirroredkubernetes/infrastructure/k8s-gateway/helmrelease.yaml:65—denial 9984 300 30raises the negative-cache (NXDOMAIN/NODATA) TTL ceiling from the old sharedcache 30cap of 30s up to 300s, a 10x increase. This isn't mentioned anywhere in the PR's rationale, which is entirely about the success-response floor (60s) andserve_stale. Under thesuccess/denial CAPACITY [TTL] [MINTTL]syntax, the third argument is a floor and the second a ceiling, so this block now allows a "does not exist (yet)" answer to be cached for up to 5 minutes — the same class of problem this PR is fixing for positive answers ("TTL asymmetry ... pins a wrong answer for minutes"), just for negative ones. Concretely: a device that gets a new DHCP lease shortly after a query for its name returned NXDOMAIN would now take up to 5 minutes (vs. 30s before) to become resolvable, worse than before this change. Worth confirming this ceiling was chosen deliberately (e.g. to reduce repeat-querying a flaky UDM for names that consistently don't exist) rather than just mirrored from thesuccessblock's300/60pair without considering denial's different failure mode. I could not verify this against live CoreDNS behavior in this sandbox (no network/cluster access) — the parameter-order reading above is based on the documentedcacheplugin syntax; worth double-checking againstcoredns/plugin/cache/README.mdfor the exact semantics before relying on this analysis, though the author's own PR description stateshelm templateand real-container validation were already done for the block as a whole.kubernetes/infrastructure/coredns-config/configmap.yaml:22— the top-levelcache 300positional TTL becomes redundant once bothsuccessanddenialsub-blocks specify their own TTL/MINTTL explicitly; per the plugin's docs the explicit blocks take precedence. Harmless, but slightly misleading to a future reader who might assume the300here does something on its own. Same applies to the mirrored HelmRelease parameters string.Tests
This is a GitOps config repo; there's no CI step here that lints or renders the
Corefile/HelmRelease values (checked.woodpecker/*.yml— none runhelm template,kustomize build, or a YAML/Corefile linter for this path), so validation is inherently manual/Flux-reconcile-time, consistent with how this repo already operates. The PR body states manual validation was done against a realcoredns:v1.13.2container and a realk8s_gateway:1.7.0container (CoreDNS 1.14.2) plushelm template; I could not independently reproduce that in this review sandbox (no container/cluster access), but the two config blocks are syntactically consistent with each other and with the existing file's conventions, so I have no reason to doubt the claim. No automated regression test guards against a future edit reintroducing thetemplateplugin or breaking the single-lineparametersconstraint — both risks are called out in comments instead, which is a reasonable mitigation for a repo of this kind.Recommendation
Looks reasonable — no blocking issues; the denial-TTL-ceiling point above is worth a quick confirmation but doesn't need to hold up the merge.