Skip to content

#126 answer systemd-resolved synthetic names in the DNS proxy - #128

Merged
matthewdevenny merged 4 commits into
mainfrom
matt/126-synthetic-records
Sep 15, 2026
Merged

matthewdevenny merged 4 commits into
mainfrom
matt/126-synthetic-records

Conversation

@matthewdevenny

@matthewdevenny matthewdevenny commented Sep 15, 2026 •

Copy link
Copy Markdown
Contributor

Closes #126

Problem

systemd-resolved answers _gateway, _outbound, _localdnsstub and _localdnsproxy from 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. localhost survives only because nsswitch consults /etc/hosts first.

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 _gateway loses 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/hosts carries only the exact name localhost). 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/resolve outlives 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 _gateway was 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 own resolv.conf search list qualify; _gateway.example.com stays 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.go gains one serveSynthetic early return; everything else lives in the new file. pkg/network is untouched.

Verification

Lima VM (CI's 6.17-azure kernel), live under enforce with query filtering and search lan, through the real DNAT:

Client Before After
c-ares (pycares) _gateway error=6 DNS server refused query 192.168.5.2
glibc getent hosts _gateway / _outbound NXDOMAIN 192.168.5.2 / 192.168.5.15
Go pure / cgo resolver NXDOMAIN resolves
dig @127.0.0.53 _gateway (stub, DNAT path) NXDOMAIN resolved's own answer: aa ra ad, TTL 0
_gateway.lan on the wire REFUSED + block record per lookup NXDOMAIN aa, no record
api.localhost via the stub REFUSED 127.0.0.1
resolved stopped mid-run (systemctl stop) — _gateway fails quietly on the ordinary path, no WARN; works again on start with no cargowall restart
example.com control REFUSED REFUSED

Every 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); full go test ./... green in Lima; build, vet, staticcheck, gofumpt, goimports-reviser clean.

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 asks myhost.lan first). Same fix shape; kept separate because it touches gate semantics rather than resolved fidelity.

🤖 Generated with Claude Code

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>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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. and lan is 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.

Comment thread pkg/dns/server.go Outdated
Comment thread pkg/dns/synthetic.go Outdated
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>
@matthewdevenny

Copy link
Copy Markdown
Contributor Author

Two of Copilot's suppressed findings, for the record (no thread to reply in):

  • Qclass — taken in de27956: serveSynthetic intercepts IN class only; a CHAOS _gateway query takes the ordinary path (pinned by TestServeSynthetic_NonINClassIsOrdinary).
  • Suffix-only detection could shadow a real _gateway.<search> upstream — declined. Under enforce that record is unreachable either way: no rule can carry an underscore label, so the gate REFUSES it, and this change only swaps that REFUSED for the NXDOMAIN c-ares needs in order to move on to the bare name. Under audit it is a deviation only for a zone that actually defines _gateway.<your search domain>. Relaying the expanded form to the stub is not an option — resolved would send it upstream unmarked and the DNAT would bring it straight back into the proxy. Documented in design.md.

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>
Comment thread pkg/dns/synthetic.go Outdated
Comment thread pkg/dns/synthetic.go
Comment thread pkg/dns/synthetic.go Outdated
Comment thread pkg/dns/synthetic_test.go Outdated
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>
@matthewdevenny
matthewdevenny merged commit 88108f0 into main Sep 15, 2026
25 checks passed
@matthewdevenny
matthewdevenny deleted the matt/126-synthetic-records branch September 15, 2026 17:59
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Resolver synthetic records die at the stub DNAT: _gateway stops resolving, silently in audit mode

3 participants