DNS resolver: RFC 1035 message codec + UDP/TCP/DoH transports, forward (A/AAAA) and reverse (PTR) lookups.
- The /etc/hosts + UDP-PTR core, the general codec, TCP, EDNS(0) and DoH fill a
real std gap (
std.Io.net.HostName.lookupis forward-only and opaque — no PTR, no server/transport control, no DoH). - Model after: Go
netdnsclient +miekg/dns(message codec) / c-ares; RFC 1035 (wire format), RFC 8484 (DoH). - Deps:
netaddr(address parse/format, reverse-name construction, RFC 6724 result ordering),http(DoH transport),std.json(DoH-JSON),std.Io.net(UDP/TCP).
Provenance: clean-room from RFC 1035 (wire), RFC 8484 (DoH), RFC 2782 (SRV)
and RFC 8659 (CAA) throughout, including the /etc/hosts + UDP-PTR core;
design references miekg/dns (BSD-3-Clause) and c-ares (MIT) — behavior only,
no source copied. Recorded, together with the live-capture reasoning for
src/goldens.zig, in NOTICE beside this file. No fping code is
involved — fping has no resolver at all, and an earlier note claiming
derivation was a bookkeeping error.
| File | Role |
|---|---|
src/message.zig |
Pure wire codec: encode query / decode response — header, question, RR sections, name-compression pointers (strictly-backwards rule + jump budget: loops impossible), A/AAAA/PTR/CNAME/NS/MX/TXT/SOA, EDNS(0) OPT. No I/O; golden-byte tested; fuzzed. |
src/config.zig |
/etc/resolv.conf + /etc/hosts parsing, Go-nameList search expansion. Pure string logic, fixture-tested. |
src/Resolver.zig |
Blocking client: UDP (TC bit → TCP retry), TCP (2-byte length prefix), DoH POST/GET (application/dns-message), DoH-JSON (application/dns-json). |
src/root.zig |
Vocabulary re-exports + netaddr bridges (reverseName, recordIp). |
src/testdata/reply_*.bin |
Three replies captured off a real loopback socket by tools/interop.zig: one lying about the question, one with no question section, one honest. Replayed hermetically by Resolver.zig. |
tools/interop.zig |
The live half of that anchor: a hostile UDP server on 127.0.0.1 answering a real query() from a real second thread. zig build interop-dns (no peer, no network); -- --capture re-takes the frames. Never compiled into test-dns. |
const dns = @import("dns");
var resolver = dns.Resolver.init(io, gpa, .{});
defer resolver.deinit();
// getaddrinfo-like: /etc/hosts first, then A + AAAA with the search list,
// RFC 6724-ordered on Linux.
const ips = try resolver.lookupIp("example.com");
defer gpa.free(ips);
// Any record type; caller inspects rcode/answers, message owns its memory.
var msg = try resolver.resolve("example.com", .mx);
defer msg.deinit();
for (msg.answers) |rec| switch (rec.data) {
.mx => |mx| std.debug.print("{d} {s}\n", .{ mx.preference, mx.exchange }),
else => {},
};
// Reverse (PTR): hosts file first, then in-addr.arpa / ip6.arpa via netaddr.
const names = try resolver.reverse(netaddr.parseIp("8.8.8.8").?);
defer resolver.freeNames(names);
// DNS-over-HTTPS (RFC 8484) — same API, different transport:
var doh = dns.Resolver.init(io, gpa, .{ .doh_url = "https://dns.google/dns-query" });
defer doh.deinit();
var m2 = try doh.query("example.com", .aaaa);
defer m2.deinit();
// Low-level codec, transport-agnostic — a codec-only consumer never needs
// to name `dns.message` (which would pull in `Resolver`, and with it `http`):
var buf: [dns.max_query_len]u8 = undefined;
const packet = try dns.encodeQuery(&buf, "example.com", .a, .{ .id = 1 });
var decoded = try dns.decode(gpa, response_bytes);
defer decoded.deinit();- Decoded names are dotted text without the trailing root dot (root = "");
no
\DDDescape handling — labels are raw bytes. A consumer that needs the true label boundaries (not text dot-counting) readsRecord.labels(audit F7) — slices intoRecord.name, populated for every decoded record; empty on a hand-builtRecordthat never set it. resolvereturns the last response even on NXDOMAIN/empty — inspectMessage.rcode();lookupIpreturns an empty slice when nothing resolves.- EDNS(0) advertises a 1232-byte UDP payload by default (DNS flag day 2020);
set
edns_udp_size = nullfor plain RFC 1035 queries. - DoH uses query id 0 (RFC 8484 §4.1 cache friendliness).
- A reply is accepted only if it comes from the server queried, carries the
(per-datagram fresh) transaction id, has QR set and echoes exactly our
question — name (case-insensitive), type, class IN.
lookupIp/reverseadditionally return only records owned by the queried name or by a CNAME target chained from it in the same answer;resolve/queryhand back the whole message for the caller to judge. - Compression pointers must point strictly backwards (Go dnsmessage rule); combined with the 253-char name cap and a 16-jump budget, adversarial pointer loops always fail fast with an error — the fuzz test starts from the six live captures and hammers this.
timeout_msbounds each UDP attempt and each TCP attempt end to end (connect + write + reads, via a canceled task — std 0.16.0 has no stream read deadline). It is per attempt per server: aquerymay taketimeout_ms × attempts × servers, andlookupIpruns one per search candidate and address family on top. Set it small, shortenservers, or pass a rooted name (trailing dot) when a call must be bounded.- DoH-JSON (
queryJson) validatesnamelike the wire path and percent-encodes it into the URL.
zig build test-dns — offline: golden query bytes, canned responses
(compression, CNAME chain, MX/TXT/SOA/OPT, PTR), adversarial packets
(truncations at every offset, pointer loops, bad rdata lengths, hostile
counts under a memory limit), fuzzed decode seeded with the live captures,
resolv.conf/hosts fixtures, search-list order, reverse-name goldens (incl. the
RFC 3596 example), response correlation, the bailiwick rule, DoH-JSON URL
encoding, and loopback stubs (a lying UDP server, a silent TCP server). Live
tests (UDP, TCP, DoH POST/GET, DoH-JSON, PTR of 8.8.8.8) run against public
resolvers when the network is up and skip via error.SkipZigTest when it is
not.