#126 answer systemd-resolved synthetic names in the DNS proxy - #128
Merged
Merged
Conversation
systemd-resolved answers _gateway, _outbound, _localdnsstub and _localdnsproxy from local state and never sends them upstream. The redirect DNATs the stub to the proxy, and the proxy's upstream is deliberately the resolver behind resolved, so every one of them NXDOMAINed for the length of a run: REFUSED under enforce, silently under audit, where no connection is ever attempted and nothing is logged. localhost survives only because nsswitch consults /etc/hosts first. The proxy now answers them itself (pkg/dns/synthetic.go), ahead of the filter gate and the cache — resolved's own precedence — on host listeners only, and only while resolved is running, via a new network.SystemdResolvedRunning shared with the cache flush. Answer shape matches resolved: authoritative, TTL 0, A/AAAA by family, NODATA for other types, SERVFAIL when the route table cannot be read. _gateway comes from /proc/net/route and /proc/net/ipv6_route ordered by metric; _outbound is the address on the route's interface sharing the gateway's subnet. Resolution only, never egress: the answer feeds neither hostname tracking nor the firewall. Reaching the gateway stays an explicit CIDR rule. The search-expanded form gets the same treatment. With a search list in resolv.conf every stub resolver asks _gateway.<search> before _gateway, and the filter gate REFUSED it. glibc and Go treat REFUSED as "try the next form"; c-ares ends its search on a REFUSED multi-label attempt, so _gateway was unresolvable for c-ares clients under enforce (pycares: error 6 "DNS server refused query", while getent succeeded in the same second). The proxy now answers that form NXDOMAIN itself — what the upstream says natively, since resolved does not synthesize it — with no upstream round trip and no block record for a name the client is about to abandon. Only suffixes on the host's own resolv.conf search list qualify; _gateway.example.com stays an ordinary query. Verified live in the Lima VM under enforce with query filtering and "search lan": glibc (getent), Go's pure and cgo resolvers and c-ares (pycares) all resolve _gateway and _outbound through the real DNAT; _gateway.lan answers NXDOMAIN; example.com is REFUSED alongside; the proxy records no block for either name. Signed-off-by: Matthew DeVenny <matt@codecargo.com>
There was a problem hiding this comment.
Pull request overview
Adds systemd-resolved synthetic DNS name handling to the DNS proxy, with routing, search-domain, listener integration, tests, and documentation.
Changes:
- Shares systemd-resolved runtime detection with cache flushing.
- Adds synthetic gateway, outbound, and local listener responses.
- Integrates synthetic answers into the DNS server and documents the behavior.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Review |
|---|---|
pkg/network/dns_redirect.go |
Shared resolved runtime probe. |
pkg/network/dns_redirect_test.go |
Tests runtime detection and failures. |
pkg/dns/synthetic.go |
Implements synthetic resolution; moderate findings concern source-address selection and suffix-only handling. |
pkg/dns/synthetic_test.go |
Tests synthetic names, routes, and listeners. |
pkg/dns/server.go |
Integrates synthetic handling; moderate findings concern stale runtime state and missing IN-class restriction. |
design.md |
Documents synthetic-name behavior. |
Suppressed comments (2)
pkg/dns/server.go:545
- The synthetic fast path does not check Qclass, so a CHAOS or other non-IN A query receives IN-class records (and an expanded query gets a synthetic NXDOMAIN) instead of following normal DNS handling. Restrict this precedence path to
dns.ClassINET.
if s.synthetic && len(r.Question) > 0 {
pkg/dns/synthetic.go:121
- This suffix-only test cannot distinguish a resolver-generated search expansion from an explicit query for a real multi-label name. If the upstream owns
_gateway.lan.andlanis in the host search list, this branch returns local NXDOMAIN and makes that record unreachable, whereas systemd-resolved would forward the multi-label query normally. Do not synthesize this form based solely on the suffix, or otherwise preserve the ordinary upstream path; the DNS wire query carries no provenance that would make the c-ares workaround safe.
if slices.Contains(hostSearchDomains(resolvConfPath), rest) {
return first, true, true
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Review on #128: the proxy already reaches the stub on a marked socket (the startup cache peek), and the redirect's RETURN rules pass that mark through the stub DNAT. So the bare names go to 127.0.0.53 on s.client and resolved's own answer comes back — exact rcode, flags and TTL, including NXDOMAIN with no default route and NODATA for the other family, which the hand-rolled version got wrong. The /proc route parsers and the _outbound source heuristic are gone (402 → 151 lines). Also from review: the probe runs per hit rather than as a Start-time snapshot, so a resolved restart mid-run is honoured; the intercept is IN class only, so a CHAOS query takes the ordinary path; the three-way branch in handleDNSQuery is one serveSynthetic early return, keyed off a hostListener helper rather than the attribution enum; and the comments name the constraints instead of retelling the ticket. The search-expanded form stays a local NXDOMAIN: relaying it to the stub would send it upstream unmarked and DNAT it straight back. Same live verification in the Lima VM under enforce + filtering with "search lan": c-ares, glibc, Go pure and Go cgo all resolve _gateway and _outbound through the real DNAT; the relayed answer carries resolved's own flags (aa ra ad, EDNS); no loop, no stub failure, no block record; example.com REFUSED alongside; rules torn down. Signed-off-by: Matthew DeVenny <matt@codecargo.com>
Contributor
Author
|
Two of Copilot's suppressed findings, for the record (no thread to reply in):
|
Second review pass: the comments still restated design.md around an ~80-line implementation. Kept one-line WHYs where the code would otherwise look wrong — the expanded form is NXDOMAIN because REFUSED on a multi-label attempt aborts c-ares' search before the bare name, and it is not relayed because the stub's unmarked upstream query would DNAT straight back — and dropped the rest. No code change. Signed-off-by: Matthew DeVenny <matt@codecargo.com>
Review on #128: - /run/systemd/resolve outlives a stopped resolved (RuntimeDirectoryPreserve=yes, verified on the Lima box), so the "resolved running" probe only ever detected that it had started once. A job that stops resolved would have had every _gateway lookup relayed to a dead stub: SERVFAIL plus a WARN per attempt, surfacing as ::warning:: annotations. The probe is gone — pkg/network is back to main — and a stub exchange error now sends the query down the ordinary path with a Debug line, which is where it went before this PR. Verified live: stop resolved mid-run → _gateway REFUSED at the gate, no WARN; start it → answers again with no cargowall restart. - resolved also synthesizes the RFC 6761 localhost family — localhost.localdomain and anything beneath localhost or localhost.localdomain — and /etc/hosts carries only the exact name localhost, so api.localhost hit the same REFUSED as _gateway. Relayed like the rest; verified live to 127.0.0.1 under enforce. - The fake stub in tests is started before use and its conn closed on cleanup: miekg's Shutdown before ActivateAndServe is a "server not started" no-op that leaves the socket and goroutine behind. - The general search-expansion problem (#127) needs the opposite fix from the synthetic case, not a shared one: for a real host the expanded form is the name that resolves, so the general answer is to strip host search suffixes for matching, not to NXDOMAIN them. #127's proposal is corrected; the NXDOMAIN here stays specific to the synthetic set. Signed-off-by: Matthew DeVenny <matt@codecargo.com>
jordanjennings
approved these changes
Sep 15, 2026
This was referenced Sep 15, 2026
matthewdevenny
added a commit
that referenced
this pull request
Sep 15, 2026
…acked Review on #131: syntheticQuery accepts any label under .localhost, and hostnameIPs has no eviction, so routing every relayed answer through enforcement let a job grow tracking without bound by issuing unique localhost aliases — no allow rule needed. Enforcement now applies to the four underscore names only, a fixed set. The localhost family is relayed as in #128 and never tracked: loopback is auto-allowed, and attributing 127.0.0.1 to an alias buys nothing. Signed-off-by: Matthew DeVenny <matt@codecargo.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #126
Problem
systemd-resolved answers
_gateway,_outbound,_localdnsstuband_localdnsproxyfrom local state and never sends them upstream. The DNS redirect DNATs the resolved stub to the proxy, and the proxy's upstream is deliberately the resolver behind resolved, so every one of those names NXDOMAINed for the length of a run — REFUSED under enforce, silently under audit, where no connection is ever attempted and nothing is logged.localhostsurvives only because nsswitch consults/etc/hostsfirst.Found via Blacksmith runners, whose host-side agent (sticky disks, cache, metadata) is reached at the VM's default gateway; a runner generation that names it
_gatewayloses the agent entirely, and the Blacksmith actions fall back silently while CargoWall reports nothing.Change
pkg/dns/synthetic.go— the proxy relays the names to the resolved stub on its already-marked client socket (s.client; the redirect's RETURN rules pass the mark through the stub DNAT, exactly as they do for the startup cache peek) and writes resolved's own answer back: exact rcode, flags and TTL, uncached, unenforced. Covers the underscore set and the RFC 6761 localhost family (localhost,localhost.localdomain, anything beneath either —/etc/hostscarries only the exact namelocalhost). No route-table parsing, no source-address heuristic, no new dependencies.Gates, in order: IN class; host listener (a container's native path never resolved these, and the host's gateway is the wrong answer in a container netns); name matches. Sits ahead of the filter gate and the cache — resolved's own precedence. There is deliberately no "is resolved running" probe:
/run/systemd/resolveoutlives a stopped resolved (RuntimeDirectoryPreserve=yes), so directory presence proves nothing; a stub that does not answer sends the query down the ordinary path at the cost of one connection-refused round trip on loopback, and a resolved stop or restart mid-run is honoured with no state.Search-expanded form. With a search list in
resolv.conf, every stub resolver asks_gateway.<search>before_gateway, and the gate REFUSED it. glibc and Go treat REFUSED as "try the next form"; c-ares ends its search on a REFUSED multi-label attempt (its #852 carve-out is single-label only), so_gatewaywas unresolvable for c-ares clients under enforce even with the bare name answered. That form is answered NXDOMAIN locally — what the upstream says natively — with no round trip and no block record. Only suffixes on the host's ownresolv.confsearch list qualify;_gateway.example.comstays an ordinary query. It is never relayed to the stub, which would send it upstream unmarked and DNAT it straight back.Resolution only, never egress. The answer feeds neither hostname tracking nor the firewall; the test server carries a mock firewall with zero expectations, so any enforcement side effect fails the suite. Reaching the gateway stays an explicit CIDR rule, per the issue's out-of-scope.
pkg/dns/server.gogains oneserveSyntheticearly return; everything else lives in the new file.pkg/networkis untouched.Verification
Lima VM (CI's 6.17-azure kernel), live under enforce with query filtering and
search lan, through the real DNAT:_gatewayerror=6 DNS server refused query192.168.5.2getent hosts _gateway/_outbound192.168.5.2/192.168.5.15dig @127.0.0.53 _gateway(stub, DNAT path)aa ra ad, TTL 0_gateway.lanon the wireaa, no recordapi.localhostvia the stub127.0.0.1systemctl stop)_gatewayfails quietly on the ordinary path, no WARN; works again on start with no cargowall restartexample.comcontrolEvery run tears the iptables rules down cleanly. 10 new unit tests (stub faked with a local
dns.Server, started before use and closed on cleanup); fullgo test ./...green in Lima;build,vet,staticcheck,gofumpt,goimports-reviserclean.Follow-up
#127 — the same REFUSED-vs-c-ares failure hits any allowed single-label hostname on a host with a search domain (rule
myhost, client asksmyhost.lanfirst). Same fix shape; kept separate because it touches gate semantics rather than resolved fidelity.🤖 Generated with Claude Code