fix(resolver): send the original name to the next resolver for rewritten queries - #2232
Conversation
There was a problem hiding this comment.
🟢 Approval recommended
The resolver-chain semantics are corrected with clear, targeted refactoring and are backed by new/expanded tests that assert both downstream qname correctness and single-delegation behavior.
Pull request overview
This PR fixes resolver-chain behavior for rewritten DNS queries by ensuring the original query name (not the rewritten/internal one) is what gets passed to downstream resolvers and used for fallback logic, aligning runtime behavior with the documented fallbackUpstream semantics and preventing leaked rewritten names in errors/metrics/logging.
Changes:
- Restore original
request.Reqbefore delegating to the next resolver in bothCustomDNSResolverandConditionalUpstreamResolver, so downstream resolvers see the client’s original name. - Refactor
CustomDNSResolver.processRequestto report whether the mapping handled the query, moving delegation responsibility toResolve(and explicitly inprocessCNAME) so request restoration always happens before exiting. - Strengthen/expand specs and add test helpers (
newRecordingResolver,NewBrokenUDPUpstreamServer) to verify downstream is called exactly once and sees the original name under multiple scenarios.
File summaries
| File | Description |
|---|---|
| resolver/rewrite_helper.go | Simplifies shouldFallbackUpstream API and adds context-cancellation exclusions for error-triggered fallback. |
| resolver/mocks_test.go | Adds reusable test helpers to record delegated qname and to simulate a reliably broken UDP upstream. |
| resolver/custom_dns_resolver.go | Ensures request reversion happens before any exit and prevents rewritten names from leaking downstream; refactors mapping resolution to return “handled”. |
| resolver/custom_dns_resolver_test.go | Updates/extends specs to assert downstream sees original qname and is called exactly once across mapping-miss, unmapped-type, and error cases. |
| resolver/conditional_upstream_resolver.go | Restores original request before delegation/fallback decisions so rewritten names don’t leak to next resolver or callers on error. |
| resolver/conditional_upstream_resolver_test.go | Adds specs for rewrite+no-mapping and next-resolver failure; uses broken-upstream helper to avoid flaky port reuse. |
| docs/configuration.md | Clarifies that fallbackUpstream is ignored when no rewrite rules are configured. |
| config/rewriter.go | Documents that FallbackUpstream only applies when rewrite rules exist. |
Review details
- Files reviewed: 8/8 changed files
- Comments generated: 1
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| The optional parameter `fallbackUpstream`, if false (default), return empty result if after rewrite, the mapped resolver returned an empty answer. If true, the original query will be sent to the upstream resolver. | ||
| It only has an effect together with `rewrite`; without any rewrite rules it is ignored. |
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #2232 +/- ##
==========================================
+ Coverage 88.05% 88.08% +0.02%
==========================================
Files 126 126
Lines 9958 9961 +3
==========================================
+ Hits 8769 8774 +5
+ Misses 924 923 -1
+ Partials 265 264 -1 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
…ten queries and `ConditionalUpstreamResolver`. #2223 restored the `fallbackUpstream` branch of the resolver it deleted; this restores the other one. The old rewriter wired its inner resolver to a `NoOpResolver` and reverted the request before continuing the chain itself, so a rewritten query the inner resolver could not answer went on under its *original* name. Both new resolvers instead continue the chain while `request.Req` still holds the rewritten message, so the rewrite target is what leaves the resolver: customDNS: rewrite: {example.com: printer.lan} mapping: {printer.lan: 192.168.178.3} A query for `blog.example.com` is rewritten to `blog.printer.lan`, misses the mapping, and `blog.printer.lan` is what goes upstream — an internal name sent to a public resolver, and an NXDOMAIN for a name the upstream could have answered. The documentation promises the opposite: "the original query 'blog.example.com' will be sent upstream". Both resolvers now revert the request before any exit, and delegate to the next resolver themselves rather than from inside `processRequest`. For `CustomDNSResolver` that means `processRequest` reports whether the mapping handled the query at all instead of continuing the chain on its own; `processCNAME` keeps resolving out-of-mapping CNAME targets by delegating explicitly. Three things fall out of that: - `fallbackUpstream` now also works with `filterUnmappedTypes: false`, where the unmapped-type case takes the delegation path rather than producing an empty CUSTOM DNS response. - `ConditionalUpstreamResolver` no longer leaves `request.Req` pointing at the rewritten message when the next resolver fails, which had the query logging, metrics and the SERVFAIL reply report the internal name. - `shouldFallbackUpstream` loses its `answered` parameter: callers hand it only queries they answered themselves, so it no longer needs to be told. An error from `CustomDNSResolver` is now always its own, so the error case is honoured for it too — except for a cancelled context, which would only fail again. Claude-Session: https://claude.ai/code/session_01NueDwL4oyg972RkyQezMJL
`shouldFallbackUpstream` returns early when the rewrite map is empty, matching `NewRewriterResolver`, which returned the inner resolver unwrapped in that case. Neither the configuration reference nor the field's own comment mentioned the precondition, so `fallbackUpstream: true` next to a bare mapping loads, logs and does nothing. Claude-Session: https://claude.ai/code/session_01NueDwL4oyg972RkyQezMJL
f3bb2b2 extended the `FallbackUpstream` doc comment in config/rewriter.go without re-running `make generate`, so docs/config.schema.json still carried the old description and `make generate-check` failed. Claude-Session: https://claude.ai/code/session_01QyEFFhrY3j9QtGzR2wUJr9
…target Reverting the request before delegating changes which name the rebinding protection inspects. `customDNS` sits above `RebindingProtectionResolver` in the chain, and used to hand the *rewritten* request down when the rewritten name missed the mapping, so the allowlist matched the rewrite target. It now receives the original name. #2111 documented the old behaviour ("allowlist the rewritten form"), which is now inverted: an operator following it has their split-horizon answers dropped as REBIND after this change. Correct the paragraph and pin both directions with specs, which nothing covered before. Claude-Session: https://claude.ai/code/session_01QyEFFhrY3j9QtGzR2wUJr9
55be48d to
1d29a38
Compare
Follow-up to #2223, which restored one half of what #1884 dropped. This restores the other half.
#1884 removed
RewriterResolverand moved rewriting intoCustomDNSResolverandConditionalUpstreamResolver. The old rewriter wired its inner resolver to aNoOpResolverand continued the chain itself, after putting the original request back:Both new resolvers instead continue the chain from inside their own resolution, while
request.Reqstill holds the rewritten message. So the rewrite target is what leaves the resolver:blog.example.comis rewritten toblog.printer.lan, misses the mapping, andblog.printer.lanis what goes upstream — an internal name handed to a public resolver, and an NXDOMAIN for a name the upstream could have answered. The documentation promises the opposite:What changed
Both resolvers revert the request before any exit, and delegate to the next resolver themselves instead of from inside
processRequest.For
ConditionalUpstreamResolverthat is a move ofrequest.Req = originalabove the delegation. ForCustomDNSResolver,processRequestnow reports whether the mapping handled the query at all — the same shape the conditional resolver'sprocessRequestalready had — rather than continuing the chain on its own.processCNAMEkeeps resolving out-of-mapping CNAME targets (#1867) by delegating explicitly.Three things fall out of that:
fallbackUpstreamnow works withfilterUnmappedTypes: false. Previously the fallback could only fire for the emptyCUSTOM DNSresponse thatfilterUnmappedTypes: trueproduces; with it off, the unmapped-type casebreaks to the next resolver and never reached the check at all.ConditionalUpstreamResolverno longer leaks the rewritten request on error. When the next resolver failed it returned before the revert, leaving the caller'smodel.Requestpointing at the rewritten message — so query logging, metrics andserver.go's SERVFAIL reply all reported the internal name instead of what the client asked for.shouldFallbackUpstreamloses itsansweredparameter. Callers now hand it only queries they answered themselves, so it no longer has to be told. That also means an error fromCustomDNSResolveris always its own, so the error case is honoured for it too — a CNAME loop or an unsupported RR type in the mapping now falls back instead of returning SERVFAIL. A cancelled context is excluded: it would only fail again on the next resolver, and its own error is the useful one.Tests
Five specs, each failing on
mainbefore the change:mainwww.nomatch.example.request.Reqleft atwww.nomatch.example.www.nomatch.example.filterUnmappedTypes: falsewww.custom.domain.fallbackUpstream: trueCNAME loop detectedsurfaced instead of falling backTwo of them also assert the next resolver is called exactly once, pinning the invariant that a query which never hit the mapping is not resolved twice.
The existing
cname.example→example.comspecs cover the delegationprocessCNAMEnow does itself.Test cleanup
Also in here, since it is the same specs:
fallbackUpstreamerror specs from fix(resolver): restore fallbackUpstream for rewritten queries #2223 built their dead upstream withStart()thenClose(), which hands the ephemeral port back to the OS while the config still points at it. Underginkgo -p, or if anything else claims that port, the "dead" upstream comes back to life and the spec asserts the wrong branch. Replaced withNewBrokenUDPUpstreamServer(), which keeps the port bound and answers with data the client cannot parse —MockUDPUpstreamServeralready had that mode.newRecordingResolver.sut = NewCustomDNSResolver(cfg)in aBeforeEach— the outerJustBeforeEachrebuildssutand re-wiresNextafterwards, so it was discarded immediately.Verification
go test ./...green over 20 packages (e2e excluded locally),-racegreen,gofmtclean,golangci-lint v2.12.2— the version pinned in the Makefile — reports 0 issues.Not in scope
A CNAME whose target has no record of the queried type still yields a CNAME-only answer, so
len(Answer) == 0is false andfallbackUpstreamdoes not fire. That predates #1884 — the old rewriter testedAnswer == niland behaved identically — so it is a limitation of the feature rather than part of this regression.https://claude.ai/code/session_01NueDwL4oyg972RkyQezMJL