Skip to content

fix(resolver): send the original name to the next resolver for rewritten queries - #2232

Merged
0xERR0R merged 4 commits into
mainfrom
fix/revert-rewritten-request-before-delegating
Sep 5, 2026
Merged

fix(resolver): send the original name to the next resolver for rewritten queries#2232
0xERR0R merged 4 commits into
mainfrom
fix/revert-rewritten-request-before-delegating

Conversation

@0xERR0R

@0xERR0R 0xERR0R commented Aug 23, 2026

Copy link
Copy Markdown
Owner

Follow-up to #2223, which restored one half of what #1884 dropped. This restores the other half.

#1884 removed RewriterResolver and moved rewriting into CustomDNSResolver and ConditionalUpstreamResolver. The old rewriter wired its inner resolver to a NoOpResolver and continued the chain itself, after putting the original request back:

// Revert the request: must be done before calling r.next
request.Req = original

Both new resolvers instead continue the chain from inside their own resolution, 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

blog.example.com is rewritten to blog.printer.lan, misses the mapping, and blog.printer.lan is 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:

If not found and if fallbackUpstream was set to true, the original query "blog.example.com" will be sent upstream.

What changed

Both resolvers revert the request before any exit, and delegate to the next resolver themselves instead of from inside processRequest.

For ConditionalUpstreamResolver that is a move of request.Req = original above the delegation. For CustomDNSResolver, processRequest now reports whether the mapping handled the query at all — the same shape the conditional resolver's processRequest already had — rather than continuing the chain on its own. processCNAME keeps resolving out-of-mapping CNAME targets (#1867) by delegating explicitly.

Three things fall out of that:

  • fallbackUpstream now works with filterUnmappedTypes: false. Previously the fallback could only fire for the empty CUSTOM DNS response that filterUnmappedTypes: true produces; with it off, the unmapped-type case breaks to the next resolver and never reached the check at all.
  • ConditionalUpstreamResolver no longer leaks the rewritten request on error. When the next resolver failed it returned before the revert, leaving the caller's model.Request pointing at the rewritten message — so query logging, metrics and server.go's SERVFAIL reply all reported the internal name instead of what the client asked for.
  • shouldFallbackUpstream loses its answered parameter. Callers now hand it only queries they answered themselves, so it no longer has to be told. That also means an error from CustomDNSResolver is 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 main before the change:

spec on main
conditional: rewritten name matches no mapping next resolver saw www.nomatch.example.
conditional: next resolver fails request.Req left at www.nomatch.example.
customDNS: rewritten domain not in mapping next resolver saw www.nomatch.example.
customDNS: unmapped type, filterUnmappedTypes: false next resolver saw www.custom.domain.
customDNS: mapping errors, fallbackUpstream: true CNAME loop detected surfaced instead of falling back

Two 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.exampleexample.com specs cover the delegation processCNAME now does itself.

Test cleanup

Also in here, since it is the same specs:

  • The two fallbackUpstream error specs from fix(resolver): restore fallbackUpstream for rewritten queries #2223 built their dead upstream with Start() then Close(), which hands the ephemeral port back to the OS while the config still points at it. Under ginkgo -p, or if anything else claims that port, the "dead" upstream comes back to life and the spec asserts the wrong branch. Replaced with NewBrokenUDPUpstreamServer(), which keeps the port bound and answers with data the client cannot parse — MockUDPUpstreamServer already had that mode.
  • The identical nine-line mock wiring repeated across four specs is now newRecordingResolver.
  • Dropped a dead sut = NewCustomDNSResolver(cfg) in a BeforeEach — the outer JustBeforeEach rebuilds sut and re-wires Next afterwards, so it was discarded immediately.

Verification

go test ./... green over 20 packages (e2e excluded locally), -race green, gofmt clean, 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) == 0 is false and fallbackUpstream does not fire. That predates #1884 — the old rewriter tested Answer == nil and behaved identically — so it is a limitation of the feature rather than part of this regression.

https://claude.ai/code/session_01NueDwL4oyg972RkyQezMJL

Copilot AI lite review requested due to automatic review settings August 23, 2026 20:36

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 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.Req before delegating to the next resolver in both CustomDNSResolver and ConditionalUpstreamResolver, so downstream resolvers see the client’s original name.
  • Refactor CustomDNSResolver.processRequest to report whether the mapping handled the query, moving delegation responsibility to Resolve (and explicitly in processCNAME) 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.

Comment thread docs/configuration.md
Comment on lines 648 to +649
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.
@0xERR0R 0xERR0R added the 🐞 bug Something isn't working label Sep 5, 2026
@0xERR0R 0xERR0R added this to the v0.35.0 milestone Sep 5, 2026
@codecov

codecov Bot commented Sep 5, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 88.08%. Comparing base (77b0fe7) to head (1d29a38).

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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

…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
@0xERR0R
0xERR0R force-pushed the fix/revert-rewritten-request-before-delegating branch from 55be48d to 1d29a38 Compare September 5, 2026 16:08
@0xERR0R
0xERR0R enabled auto-merge (squash) September 5, 2026 16:09
@0xERR0R
0xERR0R merged commit e2b40db into main Sep 5, 2026
23 checks passed
@0xERR0R
0xERR0R deleted the fix/revert-rewritten-request-before-delegating branch September 5, 2026 16:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

🐞 bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants