Skip to content

feat: run dns checks over DoH, DoT, UDP and TCP - #45

Merged
lr00rl merged 7 commits into
integrationfrom
feature/20261025_s5a-dns-check
Oct 25, 2026
Merged

lr00rl merged 7 commits into
integrationfrom
feature/20261025_s5a-dns-check

Conversation

@lr00rl

@lr00rl lr00rl commented Oct 25, 2026

Copy link
Copy Markdown
Contributor

Summary

Design 32 S5a, the DNS check method, agent part.

  • internal/prober/dns.go: a dns check asks one resolver one question. https:// posts application/dns-message (RFC 8484, ID 0), tls:// frames the message behind a two-byte length inside TLS (RFC 7858, default 853), udp:// and tcp:// are plain DNS (default 53). Only golang.org/x/net/dns/dnsmessage and the standard library.
  • The resolver host is resolved once and every address judged by the rule http and tcp checks use (resolveTarget), so a resolver resolving to a private address reads destination_refused and nothing is sent.
  • One query per run (recursion desired, EDNS(0) at 1232 bytes); a truncated UDP answer is retried once over TCP. The timeout bounds the whole exchange; a DoH body over 64 KiB is refused unread; DoH and DoT verify the certificate against the host and record cert_not_after. Latency runs from the dial to the decoded answer.
  • Judgement per the spec; a failure's error starts with dns_rcode, dns_mismatch, dns_no_answer or dns_transport; the result carries dns_rcode and at most 16 dns_answers of at most 255 bytes.
  • dns is in probe_methods (prober.Facts); a question change restarts the check (monitorChanged). dialJudged takes the network so UDP shares it.

SDK pin: v0.2.24-0.20261025003326-6485e4abe7b9, the pushed branch commit of LatticeNet/lattice-sdk#45. Re-pin after that merges.

Decisions a reviewer may question:

  • A UDP query goes to the first judged address only, since a UDP dial cannot tell an address that does not answer; TCP, DoT and DoH fall through the addresses as tcp checks do.
  • Datagrams that do not echo the query ID are ignored until the timeout, so a stray answer cannot pass the check.
  • A response code above 15 (BADVERS, BADCOOKIE) is read through the EDNS record's upper bits.
  • A DoH 200 whose content type is not application/dns-message fails naming the type, which is what a wrong path serving HTML looks like.
  • An invalid check (a stale server's) fails with the field's sentence before anything is resolved, mirroring the SDK rules the server validates with.

Left out: DNSSEC validation, DoH GET, DoQ, and node targets (they wait for the dnsproxy engine, per the spec).

Test plan

  • go vet ./..., go test ./..., gofmt -l .
  • go test -race ./internal/prober/ ./cmd/lattice-agent/
  • gosec on the prober (one G115 annotated), govulncheck, scripts/check-release-workflow.sh, scripts/test-install-integrity.sh
  • internal/prober/dns_test.go against stub resolvers per transport: pass and mismatch over UDP, TCP, DoT and DoH; NXDOMAIN expected and received; SERVFAIL; extended rcode; no answer of the asked type; AAAA, MX, TXT and CNAME value forms; truncated UDP to TCP; DoH 500; a certificate for another name (DoT and DoH, no query sent); a resolver slower than the timeout; a body over 64 KiB with and without Content-Length; a wrong content type; a private resolver refused before any dial; a UDP answer with another ID; answer bounds; query shape; invalid checks

A dns check (design 32 S5a) asks one resolver one question and judges the
answer. The target's scheme picks the transport: https posts
application/dns-message (RFC 8484, ID 0), tls frames the message behind a
two-byte length inside TLS (RFC 7858), and udp and tcp are plain DNS. The
resolver's host is resolved once and every address judged by the rule
http and tcp checks follow, so a resolver that resolves to a private
address is refused with destination_refused before anything is sent.

One query per run, with recursion desired and an EDNS(0) record
advertising 1232 bytes; a truncated UDP answer is asked again once over
TCP. The check's timeout bounds the whole exchange, a DoH body over
64 KiB is refused unread, DoH and DoT verify the certificate against the
host and record cert_not_after, and latency runs from the dial to the
decoded answer. A response code above 15 is read through the EDNS
record.

The judgement follows the spec: NOERROR with an answer of the asked type,
every expected value in the answer set in the SDK's canonical form, or
NXDOMAIN when the check expects it. A failure's error starts with
dns_rcode, dns_mismatch, dns_no_answer or dns_transport, and the result
carries the response code and at most 16 answers of at most 255 bytes.
The agent advertises dns in probe_methods, and a change to the question
restarts the check.

The SDK pin moves to the S5a branch commit (lattice-sdk #45); it is
re-pinned after that merges.

Tested: stub resolvers per transport covering pass, mismatch, NXDOMAIN
expected and received, SERVFAIL, truncated UDP to TCP, a DoH 500, a
certificate for another name, a slow resolver, a body over 64 KiB and a
private resolver; go test -race on prober and cmd; gosec and govulncheck.
…t failure

A review found that only the UDP path acted on the truncation bit: a
truncated answer over TCP, DoT or DoH, or a second one after the TCP
retry, reached the judgement with an empty response code and failed as
dns_rcode with no code. It now fails as dns_transport, "the response is
truncated".

The same pass tightens three smaller things. A response that echoes a
question must echo the one asked, so an answer carrying the right ID for
another name is refused. A DoH answer's ID is not judged, since HTTP
binds it to its request and RFC 8484 sends 0. The TCP retry goes to the
address the truncated UDP answer came from. A reported answer drops
invalid UTF-8 before it is cut, at a character boundary, so it never
grows past 255 bytes.

Tested: the new cases fail before the change; go test -race ./internal/prober/; gosec.
dialJudged's share of the deadline per address becomes addrContext, so
the UDP exchange can try addresses on the same terms, and exchangeDoH
builds its client through dohClient, the seam the DNS tests use to pin
that a DoH request dials only its target. No behavior changes.
The DoH and resolver protections were covered only through the http
check's tests, which would not notice the DNS probe stepping around the
shared transport. Four tests now pin them on the DNS path: a DoH 3xx to
loopback, a private range or the metadata service is reported and not
followed; a proxy in the environment is ignored (the transport is
checked too, since net/http reads the environment once per process); a
DoH request for any host but the target is refused before a dial; and a
resolver name with one private address among public ones is refused
before any query, in either order and over every transport.

Tested: each test fails with its protection removed (CheckRedirect
dropped, Proxy set to ProxyFromEnvironment, the host check disabled,
only the first address judged) and passes with it in place.
A DoH answer with the wrong content type echoed the whole header, so a
resolver could put up to 64 KiB of its own text into the result's error,
which every node ships to the control plane on each run and the server
stores. The error now names only the parsed media type, cut to 64 bytes.

The other places a resolver's text reached the error are bounded the
same way: a TLS failure names the host asked for and at most three of
the certificate's names, 64 bytes each, instead of the x509 error's full
list; a transport error, which can quote the resolver's bytes (a garbled
HTTP status line, HTTP/2 GOAWAY debug data), is cut to 160 bytes; and
the whole error is cut to 512 bytes, the server's bound on a stored
error, so a node never sends text the server would cut. Reason only ever
carries constants, decode errors are fixed strings, and answers already
pass through dnsReported's bounds.

Tested: TestDNSResolverTextInErrorsIsBounded failed before the fix (a
61513-byte error) and passes after.
…r silent

exchangeUDP dialed only the first judged address, so a healthy resolver
whose first address is in a family the node cannot reach, or simply
does not answer, read as a timeout. It now asks the addresses in turn,
each for its share of the run's time on the same terms as dialJudged
(addrContext), as TCP, DoT and DoH already fall through. An answer,
right or wrong, ends the exchange at that address; a wrong answer is
not a reason to ask another. A truncated answer is asked again over TCP
of the address that sent it, which is no longer always the first.

Tested: TestDNSUDPTriesTheNextAddress failed before the fix on the
unreachable, silent and truncated cases and passes after; the wrong
answer case pins that the second address is never asked.
The UDP, TCP and DoT exchanges set the connection's deadline from the
context but did not watch its cancellation, so a run cancelled at
shutdown sat in its read until the check's timeout, up to the full
timeout per check. bindConn replaces setDeadline: it keeps the deadline
and closes the connection from context.AfterFunc when the context is
cancelled. A deadline that passes is still left to the connection's own
timeout, so a slow resolver reads as a timeout rather than a closed
connection, and a cancelled run now says it was cancelled instead of
timed out. DoH already followed its request's context.

Tested: TestDNSCancelEndsTheRun failed before the fix (a TCP run
cancelled after 200 ms took 20 s) and passes after over UDP, TCP, DoT
and DoH; go test -race on internal/prober and cmd/lattice-agent, gosec
(67 files, 0 issues) and govulncheck pass.
lr00rl added a commit that referenced this pull request Oct 25, 2026
…error bounds

The alpha.10 entry said one query per run and nothing about error size or
cancellation; agent #45's review fixes changed all three.
@lr00rl
lr00rl merged commit 3d1cd22 into integration Oct 25, 2026
1 check passed
@lr00rl
lr00rl deleted the feature/20261025_s5a-dns-check branch October 25, 2026 01:58
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.

1 participant