Repository navigation
feat: run dns checks over DoH, DoT, UDP and TCP - #45
Merged
Merged
Conversation
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.
1 of 3 tasks
…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.
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.
Summary
Design 32 S5a, the DNS check method, agent part.
internal/prober/dns.go: adnscheck asks one resolver one question.https://postsapplication/dns-message(RFC 8484, ID 0),tls://frames the message behind a two-byte length inside TLS (RFC 7858, default 853),udp://andtcp://are plain DNS (default 53). Onlygolang.org/x/net/dns/dnsmessageand the standard library.resolveTarget), so a resolver resolving to a private address readsdestination_refusedand nothing is sent.cert_not_after. Latency runs from the dial to the decoded answer.errorstarts withdns_rcode,dns_mismatch,dns_no_answerordns_transport; the result carriesdns_rcodeand at most 16dns_answersof at most 255 bytes.dnsis inprobe_methods(prober.Facts); a question change restarts the check (monitorChanged).dialJudgedtakes 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:
application/dns-messagefails naming the type, which is what a wrong path serving HTML looks like.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/scripts/check-release-workflow.sh,scripts/test-install-integrity.shinternal/prober/dns_test.goagainst 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