A DNS stub resolver for Zig: seven transports, resolv.conf, AXFR/IXFR zone transfers and RFC 2136 dynamic updates, signed.
  • Zig 91.7%
  • Nix 7.7%
  • Python 0.6%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Jeffrey C. Ollie dd10b5b328
All checks were successful
test / test (push) Successful in 15m50s
test / docs (push) Successful in 6m26s
Move zig-uri to 0.3.0 (edaf631) and zig-http to b6a5c6b
zig-uri 0.3.0 has no `unicode` build option -- its IDNA support is a module
of its own -- so `uri` is one module however it is reached, and a program
that also uses zig-mime no longer gets two copies of its files. zig-http
moves to the commit that pins the same zig-uri, so the graph still holds one
of each.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012g9sa6tkGMWgpDJd9YDetP
2026-10-04 14:29:12 -05:00
.forgejo/workflows CI: jobs that boot virtual machines run on the m tier 2026-09-26 21:31:13 -05:00
LICENSES Ask a DNS server, and decide whether the answer is one 2026-09-13 17:42:37 -05:00
src Build with Zig 0.17.0, and release 0.2.0 2026-10-04 11:59:05 -05:00
tests Build with Zig 0.17.0, and release 0.2.0 2026-10-04 11:59:05 -05:00
tools Build with Zig 0.17.0, and release 0.2.0 2026-10-04 11:59:05 -05:00
.gitignore Ask a DNS server, and decide whether the answer is one 2026-09-13 17:42:37 -05:00
build.zig Build with Zig 0.17.0, and release 0.2.0 2026-10-04 11:59:05 -05:00
build.zig.zon Move zig-uri to 0.3.0 (edaf631) and zig-http to b6a5c6b 2026-10-04 14:29:12 -05:00
build.zig.zon.nix Move zig-uri to 0.3.0 (edaf631) and zig-http to b6a5c6b 2026-10-04 14:29:12 -05:00
flake.lock Build with Zig 0.17.0, and release 0.2.0 2026-10-04 11:59:05 -05:00
flake.nix Build with Zig 0.17.0, and release 0.2.0 2026-10-04 11:59:05 -05:00
package.nix Build with Zig 0.17.0, and release 0.2.0 2026-10-04 11:59:05 -05:00
README.md Build with Zig 0.17.0, and release 0.2.0 2026-10-04 11:59:05 -05:00
REUSE.toml Ask dnsproxy, in a virtual machine, over all five transports 2026-09-13 21:19:17 -05:00

zig-dns-client

A DNS stub resolver for Zig: the part that asks.

Requires Zig 0.17.0; the zig-0.16 branch and the v0.1.0 tag build with Zig 0.16.0 (see Adding it to a project).

zig-dns turns bytes into a message and a message into bytes, and does nothing else — no sockets, no configuration, no clock. This is the rest of a client: the socket, the /etc/resolv.conf, the identifier that has to be unguessable, the retry when a server does not answer, and the rules that decide a response is an answer to the question that was asked before a byte of it is believed.

The API documentation is generated from the doc comments, which are most of the explanation of why a stub resolver is the shape it is.

Queries go over UDP, TCP, TLS, HTTPS, HTTP/3, QUIC and DNSCrypt — all seven. A truncated UDP answer is asked again over TCP without the caller being told, and the five encrypted transports authenticate the server before believing a word of the answer — four of them through the web PKI, by checking a certificate against a name: DNS over TLS (RFC 7858), DNS over HTTPS (RFC 8484) over HTTP/2, HTTP/1.1 or HTTP/3, and DNS over QUIC (RFC 9250).

And it resolves localhost without asking anybody. RFC 6761 §6.3 says a name resolution library should answer those names itself and should not send queries for them to a server, so it does — and it reads /etc/hosts, which is the other thing here that is not DNS. A resolver that cannot answer localhost is not usable as the thing a program resolves names with.

And it writes. A dynamic update (RFC 2136) is how a zone is changed by asking its primary to: the records to add or delete, the conditions that must hold first, and a signature — TSIG or SIG(0) (RFC 3007) — that makes it allowed. zupdate beside zdig is nsupdate small enough to read.

And it transfers zones. A query asks one question and gets one message back; a zone transfer is the other half of the wire protocol — a sequence of messages on one connection carrying a whole zone (AXFR, RFC 5936) or the differences between two versions of one (IXFR, RFC 1995), over TCP or over TLS (XoT, RFC 9103), signed with a shared key (TSIG, RFC 8945) or not. What comes back is a zig-dns-zone Zone, which can be written out as a zone file or brought up to date again later.

DNSCrypt is the odd one, and that is why it is here. There is no authority and no name to check: the resolver publishes a long-term Ed25519 provider key out of band — in a DNS stamp, say — and signs short-lived X25519 keys with it, so the 32 bytes a caller was given in advance are the whole of what is trusted. It is also the only encrypted transport here that needs no allocator at all.

None of the four certificate transports implements what it runs on. TLS 1.3 is tls.zig, QUIC is zig-quic, and both versions of the HTTP are zig-http's client — so there is no packet protection and no HTTP parser in this repository, and what is here for each transport is the handful of decisions that turn a connection into a DNS query.

Where this lives

The repository lives in three places that carry the same history. The Forgejo instance at https://git.jcollie.dev/jeff/zig-dns-client is the web-visible one:

$ git clone https://git.jcollie.dev/jeff/zig-dns-client.git

it is mirrored on Tangled at https://tangled.org/jcollie.dev/zig-dns-client, and it is also on the Radicle network, where the repository's identifier is

rad:zG1sMXgSjSSoAZEjRqRuGSAKvYgH

and rad clone rad:zG1sMXgSjSSoAZEjRqRuGSAKvYgH fetches it from any node that seeds it. Any of the three is the whole project.

Adding it to a project

$ zig fetch --save git+https://git.jcollie.dev/jeff/zig-dns-client.git

That is main, which needs Zig 0.17.0. For Zig 0.16.0, take the zig-0.16 branch, which holds the last of zig-dns-client to build with it, or the v0.1.0 tag, which is the release it was cut from:

$ zig fetch --save git+https://git.jcollie.dev/jeff/zig-dns-client.git#zig-0.16
$ zig fetch --save git+https://git.jcollie.dev/jeff/zig-dns-client.git#v0.1.0

Either way it is saved under the package's own name, dns_client — the name it is imported by, so the dependency and the module read the same — and then, in build.zig:

const dns_client = b.dependency("dns_client", .{ .target = target, .optimize = optimize });
exe.root_module.addImport("dns_client", dns_client.module("dns_client"));

zig-dns and z46 come with it and are re-exported as dns_client.dns and dns_client.z46, so one dependency is enough to reach dns.Type, dns.Record and the addresses a reply hands back. The certificate transports come with three more libraries, and DNSCrypt with one small one; none of them costs anything to a program that does not reach for one — Zig compiles only what is referenced, so a program that speaks UDP links neither a certificate parser nor a congestion controller:

tls.zig TLS 1.3, for DNS over TLS and DNS over HTTPS. A fork of ianic/tls.zig with RFC 9001's handshake interface beside it, which is what lets one library serve all four
zig-quic QUIC (RFC 9000, 9001, 9002), for DNS over QUIC and DNS over HTTP/3
zig-http HTTP, from the end that asks: RFC 9112 over the TLS session and RFC 9114 over the QUIC one
zig-dns-zone Zone files, for the transfers and for zupdate: a Zone is what an AXFR fills and what an IXFR brings up to date, and its presentation-form reader is what turns 10 mx1.example.com. on a command line into record data. Re-exported as dns_client.zone, and pinned to a commit whose own dns pin is this project's — src/root.zig asserts at compile time that zone.dns.Record and dns.Record are the same type, because two copies of that package would be two types that print identically and cannot be handed to each other
zig-std-crypto-ext One construction, for DNSCrypt: libsodium's XChaCha20 box, which is the NaCl secretbox layout rather than the RFC 8439 AEAD of the same name that std.crypto ships. Everything else DNSCrypt needs — X25519, Ed25519, the XSalsa20 box — is already in std

They have to agree with each other and the manifest says how: zig-http is pinned to a commit whose own quic pin is this project's, because quic.stream.Id is a struct and a second copy of that package in the graph would be a second type of the same name — at which point the session this library opens could not be handed to zig-http's HTTP/3 driver at all. All three take tls the same way, with no options, for the same reason.

Quick start

Asking whoever the machine is configured to ask:

const dns_client = @import("dns_client");

var file: [dns_client.resolv_conf.max_file_len]u8 = undefined;
const conf = try dns_client.resolv_conf.read(io, &file);
const resolver = conf.resolver();

var buffer: [4096]u8 = undefined;
const reply = try resolver.lookup(io, "example.com", .a, &buffer);

var it = reply.addresses();
while (try it.next()) |address| std.debug.print("{f}\n", .{address});

Asking somebody in particular:

const resolver: dns_client.Resolver = .{
    .servers = &.{
        .init(try .parse("1.1.1.1"), .udp),
        .init(try .parse("2606:4700:4700::1111"), .udp),
    },
    .options = .{ .attempts = 3, .dnssec_ok = true },
};

const reply = try resolver.queryText(io, "example.com", .mx, &buffer);
std.debug.print("{f}", .{reply}); // prints it the way dig does

lookup applies the search list — the rules that turn www into www.example.com and know when not to. query asks exactly what it is given.

Asking over DNS over TLS, where the server has to prove who it is:

var client: dns_client.tls.Client = try .initSystem(gpa, io);
defer client.deinit();

const resolver: dns_client.Resolver = .{
    .servers = &.{.{
        .endpoint = .init(try .parse("1.1.1.1"), 853),
        .protocol = .tls,
        .name = "cloudflare-dns.com",   // what the certificate must say
    }},
    .transport = client.transport(),
};

A certificate is checked against a name, and an address is not one — so an encrypted server without name is error.ServerNameRequired rather than a connection that quietly verifies nothing. The trust store is read once, when the client is built, and the allocator it came out of is kept: DNS over TLS asks nothing more of it, and DNS over HTTPS borrows it for the head and body buffers its HTTP client wants. Client.transport() is a superset of the default, so a list holding an encrypted resolver and a plain one for names that only exist on this network works as it reads.

The same client reaches DNS over HTTPS, which is written down as fields rather than as a URL:

.{
    .endpoint = .init(try .parse("1.1.1.1"), 443),  // where to connect
    .protocol = .https,
    .name = "cloudflare-dns.com",                   // Host, and the certificate
    .path = "/dns-query",                           // the resource
}

— that being https://cloudflare-dns.com/dns-query reached at 1.1.1.1. The split is deliberate: a URL alone would have to have its host resolved before the resolver could resolve anything, and the address is exactly what a bootstrap has to supply.

The HTTP is zig-http's client rather than anything written here, over the TLS session above: this library decides the method, the resource, the media type at both ends and where the answer goes, and nothing in it parses a status line, a header field or a chunked body. .h3 is the same request through the same client over HTTP/3, so the two DoH transports differ in what carries them and in nothing else.

Which version carries it is the server's choice. The session offers h2 and http/1.1 in ALPN and the transport drives whichever came back, which is the whole of the negotiation — §3.1 of RFC 9113 makes ALPN the only way into HTTP/2 over TLS, and a server that selects nothing is answered in HTTP/1.1. A Server says nothing about the version, and neither does a caller.

That is what reaches Quad9, which answers HTTP/1.1 with 505 HTTP Version Not Supported and was for a while the one public resolver this transport had to be told to skip: offer h2 and dns.quad9.net answers. Cloudflare, Google and AdGuard answer either version and now get HTTP/2, which for one query per connection buys little in itself. What it will buy is multiplexing, once a transport keeps its connection in userdata: zig-http's HTTP/2 client begins and awaits a request in two steps precisely so that several can be outstanding at once, and it needs no task of its own to do it.

DNS over QUIC has a client of its own, because QUIC carries its own TLS rather than running on top of somebody else's:

var client: dns_client.quic.Client = try .initSystem(gpa, io);
defer client.deinit();
// .initFromFile(gpa, io, "ca.pem") for a private authority, and
// .initInsecure(gpa) for a server whose certificate is not meant to verify.

const resolver: dns_client.Resolver = .{
    .servers = &.{.{
        .endpoint = .init(try .parse("94.140.14.14"), 853),
        .protocol = .quic,
        .name = "dns.adguard-dns.com",
    }},
    .transport = client.transport(),
};

quic.Client serves .h3 as well, since HTTP/3 is QUIC underneath: the same trust store, the same connection, an HTTP request on the stream rather than a bare DNS message. Chain the two clients — quic_client.fallback = tls_client.transport() — and, with dnscrypt_client.fallback = quic_client.transport() on top, one resolver speaks all seven. The trust store can be one bundle rather than two, because QUIC's handshake is the same TLS 1.3 and quic.Client.root_ca is the same type as tls.Client.root_ca: zdig reads it once and hands it to both, and the client that read it is the one that frees it.

DNSCrypt is the one that trusts a key rather than a certificate authority:

var client: dns_client.dnscrypt.Client = .init(&.{.{
    .name = "2.dnscrypt.default.ns1.adguard.com",
    .public_key = provider_key, // 32 bytes, out of the resolver's stamp
}});

const resolver: dns_client.Resolver = .{
    .servers = &.{.{
        .endpoint = .init(try .parse("94.140.14.14"), 5443),
        .protocol = .dnscrypt,
        .name = "2.dnscrypt.default.ns1.adguard.com", // which key to check
    }},
    .transport = client.transport(),
};

No allocator, no trust store, no deinit: a Certificate is a fixed-size value with no slices in it, so the client owns nothing. Server.name is the provider name here, which is the same field for the same reason — it is who the server is claiming to be.

What it costs instead is a round trip. A session opens with a plain DNS TXT query to 2.dnscrypt-cert.<zone>, at the resolver's own address and DNSCrypt port, whose answer is a certificate signed by the provider key; that is fetched per query, as the other four fetch a handshake, and it goes out through Client.fallback so a caller who routes plain queries through something of its own gets this routed the same way. Client.refresh fetches one in advance and then a query costs one round trip rather than two.

Among the certificates that verify and are in date, selection takes the first encryption system in Options.es_versions that has any, and the highest serial within it — so a resolver cannot move a client onto its weaker cipher by numbering that certificate higher. Both systems are spoken: es-version 2, X25519 with XChaCha20-Poly1305, which everything current prefers, and es-version 1, the XSalsa20 box, which the draft no longer lists and resolvers still serve.

Two error tags are DNSCrypt's own: error.ProviderKeyRequired, for a server this client holds no key for, and error.DnscryptFailed, the analogue of error.TlsFailed — an authenticated response whose padding will not strip or whose plaintext is not a message. Everything about a certificate folds into error.CertificateVerificationFailed, exactly as expired, misnamed and untrusted do for TLS, because the caller's move is the same. And a datagram that fails the source address, the length, the resolver magic, the echoed nonce or the tag is discarded and the wait goes on, as it is over plain UDP: anybody can send one.

There is no insecure_skip_verify and there will not be. Unlike a certificate chain there is nothing here a test cannot produce — signing a certificate of one's own takes six lines, which is what tests/dnscrypt.zig does — so the flag would exist only to be misused.

RFC 9250 §4.2.1 requires the DNS message identifier to be zero over QUIC, since the stream is what tells one exchange from another; the transport zeroes it on the way out and writes the requestor's own back before returning, so Resolver never has to know.

QUIC comes from zig-quic, which does its I/O through the Io it is handed — so every transport here reads and writes through the caller's Io, and an implementation of somebody's own sees these packets like any other. What the two QUIC transports need that the other four do not is a task: something has to read datagrams and fire timers while the task that sent the query is blocked waiting for the answer, and a client doing both on one task would be waiting for itself. They ask the Io for it with Io.Group.concurrent, which Io.Threaded — what std.process.Init hands a program — grants by default; an Io that cannot run two things at once gets error.ConcurrencyUnavailable rather than a hang.

What a failed handshake says. All four encrypted transports make the one distinction worth making — a certificate that did not verify (error.CertificateVerificationFailed) against a session that failed for some other reason (error.TlsFailed) — and the QUIC pair reads it off the same fact the TLS pair reads off its handshake: RFC 9001 §4.8 puts a failed TLS handshake on the wire as §20.1's CRYPTO_ERROR with the alert inside it, and zig-quic's client now reports that code rather than one error for every ending.

Two things none of the four will tell you, and both are deliberate. Which kind of certificate failure it was: §4.4 of RFC 9001 leaves authentication to TLS, and tls.zig raises one alert for an expired certificate, a wrong name and an untrusted chain alike, because none of the three is recoverable. And a server that presents no certificate at all is error.TlsFailed, because what went wrong is the handshake rather than the certificate — there was not one. A caller wanting the close code itself, the reason phrase, or whether the peer sent it wants quic_io.Client.Options.diagnosis and a connection of its own; nothing in this library logs, so there is nowhere here for it to go.

tests/nixos/dnsproxy.nix checks all of that against real certificates: a wrong name and an untrusted authority, over each of the four, each having to come back naming the certificate rather than merely failing.

What the DoH transports send. The same request in both versions: a POST of the message with application/dns-message at both ends, and a Content-Length. HTTP/3 does not need that last one -- §4.1.2 of RFC 9114 delimits the body with the stream -- but §8.6 of RFC 9110 asks for it and Cloudflare insists: POST to cloudflare-dns.com/dns-query over HTTP/3 without it is answered 400, which is a query that failed for a reason nothing in the answer explains. zig-http's client sends it on both versions now; it was found here, ten queries failing without it and ten succeeding with it.

What it does not send is an Accept-Encoding. zig-http's clients can ask for gzip and undo it, and accept_encodings is left empty here on purpose: a DNS message is a few hundred bytes of mostly incompressible names, so a coding buys nothing and costs a decoder on the path that reads an answer from a stranger. A server that applies one unbidden is error.HttpMalformedResponse, because what came back is then not a message this can read.

What answers what. Checked against the public resolvers, and against kdig and curl on the same machine wherever one of them speaks the same protocol: AdGuard and Quad9 answer over all four; Cloudflare and Google answer over .tls, .https and .h3 and serve no DNS over QUIC at all, which kdig confirms by failing against them too. Nothing refuses anything any more: Quad9's .https came good when the session started offering h2, and offering only http/1.1 brings its 505 straight back, which is how that path is checked.

Quad9 is also where two faults in this library's QUIC came from, and both are worth knowing about because neither could be seen from here. Its server demands a Retry where no other public resolver does, and zig-quic's own Retry path sealed its token against uninitialised memory, so a handshake behind require_retry stalled until the idle timeout. And it answers a query whose FIN arrives with it and silently ignores one whose FIN arrives behind it -- which is what zig-quic used to send, since a write was flushed before the end of the stream was set. Both were invisible from this end: the packets are acknowledged either way, nothing is closed, and the query simply goes unanswered. zig build test in either of those repositories now fails if either comes back.

The two answers that are not DNS

A resolver that cannot answer localhost is not usable as the thing a program resolves names with, and localhost is very often not in the DNS at all. Two things answer it here, in this order, before any server is asked.

localhost, from the protocol

RFC 6761 §6.3 reserves localhost. and everything under it: a name resolution library "SHOULD always return the IP loopback address for address queries and negative responses for all other query types", and "SHOULD NOT send queries for localhost names to their configured caching DNS server(s)". That is Options.localhost, and it is on:

$ zdig localhost +short
127.0.0.1
$ zdig api.localhost AAAA +short      # §6.3 covers anything under it
::1
$ zdig localhost MX                   # a name that exists with no MX
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, ...; ANSWER: 0
;; SERVER: localhost (RFC 6761 §6.3)

It needs no file and no configuration, which is the point: a container built from nothing has no /etc/hosts, and on a machine like that a library that only reads files cannot resolve localhost at all — a famous annoyance with musl, and the reason this is here as well as the file rather than instead of it. The reverse direction is covered for the two loopback addresses, so zdig -x 127.0.0.1 needs no network either; everything else under 127.in-addr.arpa is left to the servers, because RFC 6303 §4.1 makes that a server's zone to serve and a stub inventing a denial is worse than one declining to ask.

+nolocalhost turns it off, which is how to ask a server what it thinks localhost is — a question about the server rather than about the name.

invalid. (§6.4) is deliberately not done: answering nxdomain here would be correct, but the network answers the same nxdomain, so nothing is fixed by it.

/etc/hosts, from the machine

var file: [dns_client.hosts.max_file_len]u8 = undefined;
resolver.hosts = dns_client.hosts.read(io, &file) catch null;

and from then on the file is consulted before any server. It is off until that line is written, because the file has to be read into a buffer somebody owns and nothing here allocates.

The answer is a DNS message. hosts.lookup synthesises a response into the caller's buffer — the question echoed, one record per address, aa set — so Reply stays one type whatever answered: reply.addresses() walks a file's answer and a server's the same way, and {f} prints either the way dig does. What changes is Reply.from, which is .hosts or the server, and prints as ;; SERVER: /etc/hosts. The TTL is zero: the file can be edited between one query and the next and nothing here watches it.

The order is the point. lookup asks the file — and then RFC 6761 — about the name as written, before the search list is applied to anything. With a search domain configured and the usual ndots, the candidates for localhost begin with localhost.example.com — so a file consulted per candidate would send that to a server first, which is exactly the query this exists to prevent. And the file is consulted before the check for servers at all: a machine with no resolv.conf can still answer for its own names.

Reverse lookups work, in the in-addr.arpa and ip6.arpa trees, so zdig +hosts -x 127.0.0.1 is answered without a packet being sent. The answer is the canonical name — the first on the line, which is what RFC 952 put there and what a ptr is for.

The file wins over the protocol, where they disagree about a localhost name. §6.3 point 6 says the effective data for these names "cannot be modified by local configuration", but that is addressed to people publishing zones: a machine's own /etc/hosts is the administrator of that machine saying what it calls itself, and overriding it would be this library deciding it knows better. In practice they agree, every hosts file in the world mapping localhost to loopback — and where the file is silent about a family, or missing altogether, §6.3 answers. The promise that matters holds either way: the query is not sent.

One decision is debatable and is written down where it is made: a name that is in the file with an a record and is asked for as aaaa falls through rather than being answered with an empty noerror. The alternative loses answers — a file pinning one address of a name that has both is common — and falling through is what files dns does in glibc.

In place of HostName.lookup

HostLookup.lookup has exactly the signature of std.Io.net.HostName.lookup and fills the same queue with the same LookupResults, so it goes wherever that function goes — including a field that holds a pointer to it:

var client: http_io.Client = .{
    .io = io,
    .gpa = gpa,
    .resolver = dns_client.HostLookup.lookup,
};

It reads /etc/resolv.conf and /etc/hosts on every call, as the standard library does, with both buffers on the stack. A missing resolv.conf means no servers rather than an implied one, and localhost and the hosts file still answer; a file that exists and cannot be read is error.DetectingNetworkConfigurationFailed.

A program with a resolver of its own — other servers, an encrypted transport, a hosts file read once — wraps it in a HostLookup and calls resolve, which takes the same arguments after the receiver:

var names: dns_client.HostLookup = .{ .resolver = conf.resolver() };
names.resolver.hosts = dns_client.hosts.read(io, &hosts_text) catch null;

var results: [16]std.Io.net.HostName.LookupResult = undefined;
var queue: std.Io.Queue(std.Io.net.HostName.LookupResult) = .init(&results);
try names.resolve(try .init("example.com"), io, &queue, .{ .port = 443 });

A method cannot stand in for the function directly, having a receiver the function does not, so a caller that needs both has one line of its own that supplies it — a container-level HostLookup and a function calling resolve.

It works in the same order as the standard library: an address literal is its own answer, then /etc/hosts and localhost for the name as written, then the servers, one search-list candidate at a time. Every return closes the queue, the family option is respected, and a canonical_name is queued when a buffer is given. It queues at most sixteen results, the queue size the standard library promises not to block on, so a caller that reads the queue only after the lookup returns cannot be left waiting; an answer with more addresses keeps the first. The errors are HostName.LookupError and nothing else: every Resolver.Error short of cancelation becomes error.NameServerFailure.

Where it differs, it does so on purpose:

  • Both families come from one candidate. The first candidate with any address ends the search, so www is never answered with an IPv6 address for www.example.com and an IPv4 one for www itself.
  • IPv6 comes first, the order RFC 6724's default policy prefers. The standard library puts a before aaaa for a name from the DNS and the other way round for localhost.
  • The canonical name is the owner of the addresses rather than the target of the last cname seen, which is the same name when the chain is in order and still the right one when it is not.
  • A name that does not exist is error.UnknownHostName, and one that exists with no address is error.NoAddressReturned. The standard library reports NoAddressReturned for both, since it never reads the response code.

Against the standard library on the same machine, the addresses and canonical names agree for ordinary names, localhost, and a cname chain (www.github.com is github.com).

HTTPS records

ServiceLookup does RFC 9460's "SVCB resolution" for an https origin: the HTTPS records (type 65) that say, before anything connects, which protocols an origin speaks, on which port, at which name, with which addresses to start from. That is how a client can send its first request over HTTP/3, where Alt-Svc could only say so on a response. It has HostLookup's shape — a free lookup answering from this machine's configuration, a resolve over any Resolver, a queue closed on return and never more than sixteen in it — and produces a Service of its own for each endpoint:

var results: [16]dns_client.ServiceLookup.Service = undefined;
var queue: std.Io.Queue(dns_client.ServiceLookup.Service) = .init(&results);
try dns_client.ServiceLookup.lookup(try .init("example.com"), io, &queue, .{ .port = 443 });
while (queue.getOne(io)) |service| {
    // service.priority, service.target(), service.port, service.alpn,
    // service.no_default_alpn, service.hints()
} else |err| switch (err) {
    error.Closed => {},
    else => |e| return e,
}

What it does is the resolver's half of RFC 9460, in §3's order:

  • The query name is the host for port 443 and _<port>._https.<host> for any other (§9.1), always asked as HTTPS and never as SVCB.
  • Aliases are followed: AliasMode records up to eight deep, and CNAMEs as the server returned them. An alias to . means the service does not exist (§2.5.1), and a chain that loops or runs past the limit counts as no record (§3.1). After an alias, §3's extra endpoint is appended last — the name the chain reached, the authority's port, no parameters — at priority 65535.
  • Only compatible, self-consistent records are kept. Every key named in mandatory has to be one this implements (alpn, no-default-alpn, port, ipv4hint, ipv6hint); ech is not, since neither TLS library here has Encrypted ClientHello, so a record that requires it is dropped and one that merely offers it is kept with it ignored. no-default-alpn without alpn, a malformed value, or an ALPN set with nothing HTTP speaks is dropped too.
  • A target of . is the record's owner (§2.5.2), which after an alias or a CNAME is not the origin. target() is empty only when the endpoint really is the name asked about. On a port-prefixed owner the _<port>._https labels are taken off first: RFC 9460 does not say to, but the name with them on has no addresses, and the host under them is what the owner means.
  • Sorted by priority, with equal priorities shuffled (§2.4.1).

Failure depends on what the answers travelled over, which is §3.1's point. When every server is reached over an encrypted protocol — DNS over TLS, HTTPS, QUIC or HTTP/3, or DNSCrypt — a servfail, a transport error or a timeout is error.ProtectedResolutionFailed, and the caller should give up on the connection: otherwise anybody who can drop the query can choose it. Over UDP or TCP the same failure is no records. nxdomain, and a name with no HTTPS records, are no records either way. A name /etc/hosts or RFC 6761 answers has no HTTPS records, and no query is sent for it.

What to do with each endpoint — which transports its ALPN set allows, which addresses to connect to, when to fall back to the origin — is the HTTP client's decision, not this library's.

Zone transfers

A query asks one question and gets one message back, which is what Transport's vtable says and the whole of what it can say. A zone transfer is the other shape: one connection, a sequence of messages, and a whole zone or the differences between two versions of one. So transfer sits beside Resolver rather than under it, and hands back a zig-dns-zone Zone — which can be written out as a zone file, asked what it holds, or brought up to date again later.

var message: [dns.max_message_len]u8 = undefined;

var zone = try dns_client.transfer.axfr(gpa, io, .{
    .server = .init(try .parse("192.0.2.53"), .tcp),
    .zone = try .parse("example.com", &name_buffer),
    .buffers = .{ .message = &message },
});
defer zone.deinit();

std.debug.print("{f}", .{zone});   // a zone file

and, later, the same zone refreshed rather than fetched again:

switch (try dns_client.transfer.ixfr(io, request, &zone)) {
    .up_to_date => |serial| {},  // nothing newer; the zone was not touched
    .updated => |serial| {},     // the differences were applied
    .replaced => |serial| {},    // the server sent the whole zone instead
}

The serial to ask from comes out of the zone itself, which is the only honest answer to "what do you already have". Up to date is an outcome and never an error: it is the ordinary successful answer to a refresh, and the one result meaning nothing is wrong must not have to be sorted out of a catch beside ConnectionRefused.

Nothing is applied until the transfer has ended. RFC 1995 §2 says a client may only replace the older version once every difference has been processed, so a transfer that fails at message forty leaves the zone exactly as it was. What does that is a journal rather than a clone — one re-encoded image per changed record, which is the size of the difference and not of the zone, since cloning a zone to apply five changes throws away the point of IXFR. A whole zone in answer to an ixfr is built beside the old one and swapped in at the end.

Underneath both is a Stream, for a caller that wants the differences themselves rather than their result:

var stream: dns_client.transfer.Stream = undefined;
try stream.open(io, request);
defer stream.close(io);

while (try stream.next(io)) |change| switch (change) {
    .add => |record| {},
    .delete => |record| {},
}

Change has no variant for a delta boundary, and that is deliberate: the first record of every difference is the old soa and the first record after the removals is the new one, so a caller applying .delete and .add in arrival order bumps the serial correctly without ever knowing where one difference ended. stream.delta says which one is arriving for a caller that wants to watch, which is what zdig +deltas prints — and the only way to tell a correct difference from a lucky one, since RFC 1995's condensed and uncondensed forms converge on the same zone.

A record handed out by next borrows the message buffer and does not survive the next call; Zone.add re-encodes, which is why axfr can fill a zone and reuse one 64 KB buffer for the whole transfer.

Signed, with a shared key. dns_client.tsig is RFC 8945: the HMACs §6 registers, a Key, and a Session that signs the query and then checks the answer's chain — the first envelope's digest covers the request's MAC, later ones may be signed every hundredth message at most, and the last one MUST be signed. Hand a Key to Request.key and the transfer does all of it.

.key = .{
    .name = try .parse("transfer.example.com", &key_name_buffer),
    .algorithm = .hmac_sha256,
    .secret = try dns_client.tsig.secretFromBase64(&secret, "aGVsbG8..."),
},

Two things worth knowing about that. A record is handed to the caller before the envelope carrying it has been authenticated — it cannot be otherwise, since the signature may be up to 99 messages away — so a caller keeping records as they arrive must throw them away if the transfer fails; the axfr/ixfr functions do exactly that. And tsig.Hasher is an Io.Writer, which is the seam: zig-dns says which bytes RFC 8945 §4.3.3 covers and writes them, this library keeps the running HMAC, and a test can point the same serialisation at Writer.fixed and read the digest input byte for byte.

Over TLS, which RFC 9103 calls XoT, is the same transfer with .tls on the server and a trust store on the request:

.server = .{
    .endpoint = .init(try .parse("192.0.2.53"), 853),
    .protocol = .tls,
    .name = "ns1.example.com",
},
.tls = &tls_client,
.buffers = .{ .message = &message, .tls_input = &in, .tls_output = &out },

§7.1 makes the ALPN token dot mandatory where RFC 7858 merely registered it, and a .tls server with no trust store is error.ProtocolUnsupported rather than a session that verifies nothing. There is no downgrade: asking for XoT and reaching a cleartext port fails.

The caller has asked for an unbounded amount of somebody else's data, so Options bounds it: 30 seconds for one message, ten minutes for the whole transfer — without the second a server holds a connection open for ever by sending one message every twenty-nine seconds — a million records and 128 MiB. accept_full refuses a whole zone where a difference was budgeted for, and benevolent decides whether a removal that finds nothing is tolerated; it is off, because the two ends then disagree about what the zone contains, and Knot ships the same switch.

Dynamic updates

Everything above asks questions. An update writes: a message saying which zone, what must already be true, and what to change, sent to the server that holds the zone, which either does all of it or none of it (RFC 2136).

It is one message out and one back, so unlike a transfer it goes through Transport like any query — over any of the seven, whichever the Server names. A primary that accepts updates over DNS over TLS costs nothing here.

try dns_client.update.send(io, .{
    .server = primary,
    .zone = try .fromText(&names, "example.com"),
    .prerequisites = &.{.{ .name_not_in_use = www }},
    .changes = &.{
        .{ .add = .{ .name = www, .ttl = 3600, .data = .{ .a = address } } },
        .{ .delete_rrset = .{ .name = old, .type = .a } },
    },
    .key = key,
    .buffers = .{ .message = &message, .reply = &reply },
});

The nine shapes are types. RFC 2136 §2.4 and §2.5 do not describe nine operations so much as nine encodings: "delete this RRset" is a record with the class set to any, a zero TTL and no data at all, and "this name must not exist" is the same shape with the class none and the type any. Written by hand that is a table in a specification open in another window, and a class picked wrongly from it deletes something. dns.update.Prerequisite and dns.update.Change are that table, one variant per subsection, in zig-dns where the wire format lives.

A prerequisite is what makes an update conditional, and the interesting thing about one is that it fails: error.PrerequisiteNameExists means somebody took the name between looking and asking. The response codes become errors that say which condition did not hold rather than the number they arrived as — nxrrset is error.PrerequisiteRrsetMissing.

Nothing is retried. A query asked twice is answered twice; an update sent twice is applied twice, and while most changes are idempotent a prerequisite-guarded one is not — the second copy of "add this name if nobody has it" fails, having already succeeded. So a transport error comes back rather than being retried, and whether asking again is safe is something only the caller knows.

Finding the primary is the two lookups RFC 2136 implies and nsupdate performs: the zone's soa names its primary in mname, and that name has an address.

const primary = try dns_client.update.primaryFor(io, &resolver, "example.com", buffers);

It speaks TCP, because a write that may be lost is worse than one that costs a handshake. The mname is a hint and nothing more — a zone whose mname names a hidden primary is a real configuration, and then this finds a server that answers notauth, which is as much as anything outside can know.

Signing one

No primary worth the name accepts an unsigned update, and RFC 3007 names the two ways to sign one. Both are here, and a request carrying both is refused, because RFC 2931 §3.1 allows one or the other and not both.

TSIG (.key) is the same tsig.Key the transfers use: a secret both ends hold.

SIG(0) (.signing_key) is a key pair, which is the difference that matters — nothing has to be copied from one machine to the other but the public half, and the server finds that in the DNS as a key record. dns_client.sig0 is the signing half, with dns.sig0 saying which bytes RFC 2931 §3.1 covers, exactly as tsig and dns.tsig divide the same work.

const key = try dns_client.sig0.Key.fromBind(&names, private_file, key_file);

which is the pair of files dnssec-keygen -a ED25519 -T KEY -n HOST writes, because that is what anybody who has such a key has. The public file is not redundant: the key tag in every signature is a checksum over the record the server will look up, so a tag computed from invented flags names a key nobody has. The two files are checked against each other, so a private key that does not produce that public key is error.Sig0KeyMismatch here rather than a signature the server silently refuses.

Three algorithms, the ones std.crypto can sign with incrementally: Ed25519 (15), ECDSA P-256 with SHA-256 (13) and P-384 with SHA-384 (14). RSA is not among them — std.crypto verifies RSA for certificates and cannot make a signature — so an RSA key is error.Sig0AlgorithmUnsupported rather than a signature that will not verify.

Multicast DNS

mdns finds what is on the local network — the printers, speakers and servers nobody configured — the way avahi-browse does: DNS-Based Service Discovery (RFC 6763) over one-shot Multicast DNS queries (RFC 6762).

var found = try dns_client.mdns.browse(io, gpa, "_mass._tcp", .{});
defer found.deinit();
for (found.services) |s| {
    // s.instance, s.host, s.port, s.addresses, s.txtValue("server_version")
}

browse asks for the service's PTR records and reads every section of every response, since a responder usually sends the SRV, TXT and address records with them; it asks again only for what did not come.

It listens on port 5353, shared. A query sent from any other port is answered by unicast, straight back to that port — and a desktop's firewall, which lets 5353 in for the system's own responder, drops those answers, since they match no connection it knows. So on Linux mdns binds 5353 alongside the system's responder, with SO_REUSEADDR and SO_REUSEPORT, joins the group on every interface that is up and can multicast, sends the question out of each, and hears the answers every member of the group is sent. Only where that cannot be done does it fall back to an ephemeral port.

It asks once and collects the answers for as long as it is told to, and will not hear a service that arrives afterwards. It is IPv4 only, though an IPv6 address that comes with an answer is kept.

Watching: Monitor

mdns.Monitor keeps watching one service type: each instance is reported to a handler once it is whole -- host, port, addresses and TXT -- and again if that changes, and reported lost when it says goodbye or its records run out. It asks at once, then after 1, 2, 4 seconds and on, doubling to an hour (RFC 6762 §5.2), each query listing the instances already known with more than half their lifetime to go so that they do not answer again (§7.1); what has not come with a PTR is asked for by name. It reads everything heard on 5353, announcements and answers to anyone's questions included.

Publishing: Responder

mdns.Responder answers for a program's own services, for a machine with no system responder, or one that will not publish for it -- NixOS's Avahi, unless publish.userServices is on. It holds a host name, <host>.local, whose A records are the host's IPv4 addresses, on each link only that link's (§6.2), read from the kernel over netlink; and for each service the type's PTR, the instance's SRV and TXT, a PTR for service-type enumeration (RFC 6763 §9), and NSEC records saying what else there is not (§6.1).

  • A name is probed for, three times 250 ms apart, before it is used, and announced twice (§8). A name someone else has -- found by probing, or later from an answer that disagrees (§9) -- becomes Kitchen (2), or host-2 for the host. Two probing for one name at once are told apart by their records (§8.2).
  • A PTR answer brings the SRV, TXT and address with it (RFC 6763 §12); an answer the query lists as known is left out (§7.1); a querier on a port other than 5353 gets a legacy unicast answer (§6.7).
  • Removing a service, or stopping, sends goodbyes (§10.1).
  • Everything else goes by multicast, answers to QU questions (which §5.4 allows) and probes included. Port 5353 is shared, and Linux hands a unicast datagram to a shared port to one of the sockets on it: on a host with two responders, the wrong one half the time.

Both are a state machine -- mdns.responder.Core, mdns.monitor.Core, packets and the time in, packets and events out, no socket -- and a Linux loop around it.

zmdns

zmdns is all three on the command line:

$ zmdns _mass._tcp
c5cb638ad5234ef4b346f16f7632d625  mass.local:8095  192.168.4.251:8095
    server_version=2.8.7
    schema_version=29
    ...
$ zmdns --watch _sendspin._tcp
found Kitchen kitchen-player.local:8928 192.168.1.2 path=/sendspin
lost Kitchen
$ zmdns --publish kitchen-player Kitchen _sendspin._tcp 8928 path=/sendspin
published Kitchen on kitchen-player.local

A publisher stopped with SIGINT or SIGTERM says goodbye first.

zdig

A dig small enough to read, which is how the library is exercised against the internet rather than against itself. It is built by zig build and can be run straight out of the build:

$ zig build run -- example.com AAAA +short
2606:4700:10::ac42:93f3
2606:4700:10::6814:179a

$ zdig @1.1.1.1 cloudflare.com DNSKEY +dnssec
$ zdig -x 2606:4700:4700::1111 +short
$ zdig cloud04 +search
$ zdig localhost +short                           # RFC 6761, no server asked
$ zdig +hosts localhost +short                    # /etc/hosts, before any server
$ zdig +hosts -x 127.0.0.1 +short                 # and the reverse direction
$ zdig @1.1.1.1 org DNSKEY +dnssec +bufsize=512   # truncates, retries over TCP
$ zdig @1.1.1.1 example.com +tcp                  # TCP from the start
$ zdig @1.1.1.1 example.com +tls-hostname=cloudflare-dns.com   # DNS over TLS
$ zdig @9.9.9.9 example.com +tls-hostname=dns.quad9.net +short
$ zdig @1.1.1.1 example.com +https +tls-hostname=cloudflare-dns.com  # DoH
$ zdig @8.8.8.8 example.com +https +tls-hostname=dns.google +short
$ zdig @94.140.14.14 example.com +quic +tls-hostname=dns.adguard-dns.com  # DoQ
$ zdig @8.8.8.8 example.com +h3 +tls-hostname=dns.google +short           # DoH3
$ zdig example.com +stamp=sdns://AQMAAAAAAAAAETk0...  +short              # DNSCrypt
$ zdig +stamp=sdns://AQMAAAAAAAAAETk0... +dnscrypt-cert   # what it is offering

$ zdig @192.0.2.53 example.com AXFR               # the whole zone, as a zone file
$ zdig @192.0.2.53 example.com AXFR +canonical    # the same, sorted
$ zdig @192.0.2.53 example.com +ixfr=2026010101   # the changes since that serial
$ zdig @192.0.2.53 example.com +ixfr=2026010101 +deltas   # as the differences
$ zdig @192.0.2.53 example.com +apply=example.com.zone    # what a secondary does
$ zdig @192.0.2.53 example.com AXFR +tsig=hmac-sha256:key.example.com:aGVsbG8=
$ zdig @192.0.2.53 example.com AXFR +xot +tls-hostname=ns1.example.com

The transfer switches are worth a word each. AXFR is written as a type rather than as a switch, the way dig takes it, and forces TCP. +deltas prints an incremental transfer as the differences it is rather than as the records going past, which is the only way to see that a server condensed them. +apply=FILE reads a zone file, asks for the changes since its serial, applies them and prints what the zone became — a secondary's whole job in one command, and the only way to see that a difference was applied rather than merely read. Every transfer ends with dig's own summary line:

;; XFR size: 37 records (messages 13, bytes 4096)

+stamp= is four arguments in one, which is how a resolver is actually published: the address, the protocol, the provider name and the provider key all come out of the sdns:// string, parsed by zig-uri's uri.stamp. The long form is +dnscrypt +provider-name=NAME +provider-key=KEY, the key being 64 hex characters in either case, with or without the : grouping providers print it in. +dnscrypt-cert prints the certificates a resolver is offering and stops, which is the comparison worth making against another DNSCrypt client — matching addresses for a name prove little, and the same serial, window and resolver key prove both clients performed the same handshake.

It takes dig's argument grammar, or the part of it worth having: a server with an @ in front, a name, a type, and +option switches in any order. With no server it reads /etc/resolv.conf, which is the whole point of having read it.

zupdate

nsupdate small enough to read. BIND's reads a little language on standard input, which is excellent for scripts and awkward for everything else — it cannot be seen in a shell history and it cannot be checked without being run. Here the whole update is arguments:

$ zupdate @192.0.2.53 example.com add www.example.com 3600 A 192.0.2.1
$ zupdate @192.0.2.53 example.com delete old.example.com A
$ zupdate @192.0.2.53 example.com delete gone.example.com        # everything it owns
$ zupdate @192.0.2.53 example.com delete www.example.com A 192.0.2.1  # one record
$ zupdate @ns1 example.com --prereq=not-in-use:new.example.com     add new.example.com 3600 A 192.0.2.9
$ zupdate @ns1 example.com --tsig=hmac-sha256:key.example.com:aGVsbG8=     add www.example.com 300 TXT "hello"
$ zupdate @ns1 example.com --sig0=Kupdater.example.com.+015+12345     delete www.example.com
$ zupdate example.com add www.example.com 60 A 192.0.2.1   # the primary from the soa
$ zupdate --dry-run example.com delete www.example.com A   # print it and stop

With no @server it finds the primary the way nsupdate does, out of the zone's soa. --dry-run prints the message that would be sent and stops, which is how the shapes of RFC 2136 §2.4 and §2.5 can be looked at without a server — and how the guest checks them:

;; ->>HEADER<<- opcode: UPDATE, status: NOERROR, id: 0, flags:; QUERY: 1, ANSWER: 1, AUTHORITY: 2, ADDITIONAL: 0

;; QUESTION SECTION:
;example.com.	IN	SOA

;; ANSWER SECTION:
new.example.com.	0	NONE	ANY	\# 0

;; AUTHORITY SECTION:
new.example.com.	3600	IN	A	192.0.2.9
old.example.com.	0	ANY	A	\# 0

The record data is read by zig-dns-zone, which is the half of the presentation format dns deliberately does not have — so 192.0.2.1 and 10 mx1.example.com. are parsed by the same code a zone file goes through, and a type this library knows is a type zupdate can write.

How it is put together

Resolver is the policy and Transport is the wire, and the line between them is that nothing in Resolver knows what a socket is.

Resolver which server to ask next, how long to give it, how many times to go round, and what a response has to look like before it is believed
Transport a vtable with one method: get this message to that server and bring one back
udp one datagram out, one back, and three things a forger has to get right (RFC 1035 §4.2.1)
tcp the same messages behind a two-byte length prefix, on a connection (RFC 1035 §4.2.2, RFC 7766)
tls that framing inside a TLS session, with the server's certificate checked (RFC 7858), on tls.zig
https the message in the body of a POST, so that it looks like any other HTTPS request (RFC 8484), on zig-http's client, in the version ALPN settled on
h3 the same POST as https, made by the same client, carried on a QUIC stream instead of a TCP connection (RFC 9114)
quic one query, one bidirectional stream, over TLS 1.3 on UDP (RFC 9250), on zig-quic
transfer a zone, or the differences to one: many messages on one connection, which is the one shape Transport cannot express, so it sits beside Resolver rather than under it
tsig a shared secret, a keyed hash, and the rules that chain one message's signature to the next (RFC 8945). Used by transfer, by update, and by anything else here that ever signs a query
sig0 the other way to authenticate a request (RFC 2931): a key pair, the three algorithms std.crypto can sign with, and the two files dnssec-keygen writes
update changing a zone rather than reading it (RFC 2136): the conditions, the changes, the response codes that say which condition failed, and finding the primary from the soa
dnscrypt a query sealed to a short-term key that a provider key signed, with no certificate authority anywhere in it. dnscrypt.packet is the wire format as pure functions over buffers — std and one primitive, no socket, no clock
Server an address, a port, a protocol, and the name the encrypted transports will authenticate against
resolv_conf nameserver, domain, search, and the options that decide how a query is sent
hosts /etc/hosts, which is not DNS: the names a machine answers for itself, as a synthesised response so that everything above stays one shape
localhost RFC 6761 §6.3's reserved names, answered from the protocol rather than from a file or a server — so localhost resolves on a machine that has neither
HostLookup std.Io.net.HostName.lookup itself, answered from this machine's configuration, and the same over any Resolver with resolve
ServiceLookup RFC 9460's SVCB resolution for an https origin: its HTTPS records, aliases followed, incompatible ones dropped, sorted by priority, in HostLookup's shape
zdig a dig small enough to read, built by zig build

What follows from that line: a test can drive the whole resolver with a dozen lines that hand back a canned message — which is what the tests beside Resolver do — and a program that speaks only UDP links only UDP.

Adding a transport

Write a function and hand back a vtable:

fn exchange(_: ?*anyopaque, io: Io, request: Transport.Request) Transport.Error![]u8 { ... }
pub const transport: Transport = .{ .vtable = &.{ .exchange = exchange } };

Transport.automatic picks by server.protocol, so a new one is a file next to src/transport/udp.zig and a line in that switch. Nothing in Resolver, Server or resolv_conf changes, and neither does anybody's configuration — which is not a claim, it is what happened when TCP was added: two files and one line, and options use-vc in a resolv.conf started working without being touched.

Three things worth knowing

Everything borrows. A response is decoded in the buffer it was received into, so a Reply lives exactly as long as that buffer holds that response; the search domains out of a resolv.conf are slices of the text it was read from; a Resolver built from one borrows it in turn. The UDP, TCP and DNSCrypt paths allocate nothing at all, so a program that speaks only those needs no allocator anywhere; the other four need one for the trust store, for a QUIC connection's windows and for an HTTP exchange's head and body buffers, and each of their clients holds the allocator that comes out of.

An answer has to earn it. Over UDP there is nothing underneath to keep a response honest — no connection, no handshake, no certificate. What a requestor has instead is three things a forger has to get right at once, and all three are insisted on: the datagram came from the address and port the query went to, it carries the identifier the query was sent with, and it echoes the question that was asked. A datagram failing any of them is discarded and the wait goes on, because one stray packet from anybody must not be able to make a lookup fail. The identifier is drawn fresh for every transmission and every transmission gets its own ephemeral port, so a retry does not give a guesser a second go at the same pair.

A truncated answer is chased, not returned. A response with the header's truncated bit set means the answer did not fit in a datagram, so the same question goes to the same server over TCP — same port, since that is what TCP DNS uses — and that answer is the one handed back. A caller that never looks at the flag is right not to. Options.retry_truncated turns it off for anything with its own idea about when TCP is worth the round trips; then the truncated response arrives as it stands, with an answer section that is empty for a reason nothing else in the message explains.

What it will not do

No cache. No negative cache, no TTL accounting, no shared state. Every query is a query.

No cname chasing. A recursive server puts the whole chain in one answer, which is what Reply.addresses walks; a resolver being used to talk to an authoritative server wants to see the referral rather than have it chased.

No DNSSEC validation. Options.dnssec_ok asks for the records — zig-dns decodes every one of them — but nothing here verifies a signature, and the authentic_data bit in a reply is the server's claim about its own validation, worth exactly as much as the path to that server.

No NSS. /etc/hosts is read and localhost is answered from RFC 6761 — the two things here that are not DNS — but there is no nsswitch.conf, no NIS and no systemd-resolved bus. What a name service switch is for is deciding between sources, and this has three in a fixed order: the file, the protocol's reserved names, then the servers. Multicast DNS is here, in mdns, but as its own thing that a program asks when it means to — the resolver never sends a .local name there by itself.

No SIG(0) on the way back, and no RSA. A response's SIG(0) is signed with the server's key rather than with the requestor's (RFC 2931 §3), and nothing here has that key — fetching and validating it is a DNSSEC lookup, with the deadly embrace §2.4 describes waiting at the end of it — so a signature on an acknowledgement is left alone rather than checked against the wrong thing. A TSIG on one is checked, because there the key is already in hand. And the RSA algorithms are refused rather than attempted: std.crypto verifies RSA signatures for certificates and cannot make one.

No NOTIFY, and no refresh loop. The transfers are here; what drives them is not. A secondary asks again when RFC 1996's NOTIFY arrives or when the soa's refresh timer runs out, and both are policy above a client rather than anything the wire protocol decides. Nor is a transfer served, which is a different program, and nor is one carried over QUIC — RFC 9250 §4.2 allows several responses on one stream, and transfer.Link grows a variant the day somebody wants it.

No connection reuse, and no session resumption. One exchange is one connection, where RFC 7766 §6.2.1 would rather a busy client kept one open and pipelined down it — and for TLS that means a fresh handshake, three round trips, every query. QUIC would hand back the most: its handshake is one round trip and §4.6 of RFC 9001 would make a resumed one none. A connection that outlives a query is state, and state is what a Transport has userdata for: a pooling transport goes next to src/transport/tcp.zig without anything above it changing.

No bounded TCP handshake, and not by choice. connect takes a timeout and Zig 0.17.0's threaded backend panics outright on being given one, so none is passed: a host that swallows the SYN is waited on for as long as the operating system waits, about two minutes on Linux, against a timeout that is usually five seconds. Everything after the handshake is bounded properly, and the one line to change is marked in src/transport/tcp.zig.

Building

zig build test runs everything; zig build check compiles the parts no test runs.

$ zig build test --summary all
$ zig build docs && zig build docs-serve   # http://127.0.0.1:8000/

Under Nix, nix develop -c zig build test uses the toolchain in flake.nix rather than whatever is on the path.

Testing

The tests beside each file drive the resolver through a transport that answers out of a table, which is the right way to test the rules and proves nothing at all about the socket. tests/loopback.zig goes the other way: a server bound to the loopback interface on a port the kernel picks, with UDP and TCP on the same port as a real DNS server has them, and the resolver asking it. That is where the checks that exist only in a transport are tested — a reply from the wrong source and a reply carrying the wrong identifier, each by sending only the bad datagram and requiring the query to time out, so that what is being proved does not depend on which packet won a race; and, for TCP, a message split across segments, a connection that ends in the middle of one, and a length prefix promising more than the buffer can hold.

tests/dnscrypt.zig is the same idea for DNSCrypt, which needs a harness of its own because it is two exchanges rather than one: a server that mints its own certificates, answers the TXT query and then the encrypted one, and can be told to get either of them wrong — no certificate, one signed by somebody else, one that has expired, three with different serials, an answer with the wrong resolver magic, the wrong echoed nonce, a forged tag, no padding, or no encryption at all. It also asserts the three things only a server can see: that the certificate query really is TXT for the provider name, that the client magic is the one the chosen certificate carries, and that the padded plaintext is a multiple of 64 with the whole packet at or above the target.

tests/transfer.zig is a third such harness, for the transfers: a TCP server that can be told how to answer badly. Its zone is a function of a record's index rather than a file, so a five-thousand-record transfer costs no fixture, and its one lever the other harnesses do not need is how many records go in an envelope — three by default, which turns any zone into a stream of messages. That is what lets the suite say the thing that matters most about a transfer: envelope boundaries carry no meaning. One test fixes an eight-record sequence and enumerates all 128 groupings with a bitmask over the seven gaps, requiring byte-identical canonical text from each. Beside it are the behaviours only a server can produce — a stream that stops mid-transfer, one that never ends, a coalesced write putting several messages in one TCP segment, a dribbled one putting one message across several, a non-zero RCODE arriving half way — and the TSIG cadences: every envelope, every hundredth, and the last one left unsigned, which must be refused.

tests/update.zig is the same idea for the one thing here that changes somebody's zone, and what it tests is what goes out: a copy of the message the server was sent, read back the way a primary would read it — the opcode, the sections in their RFC 2136 meanings, and the class and empty rdata that tell a deletion from an addition. Both signatures are then checked by arithmetic other than the arithmetic that made them: the TSIG is recomputed with a one-shot std.crypto HMAC over bytes written into a plain buffer, and the SIG(0) with a bare Ed25519.verify. A signing bug that is symmetrical — the wrong bytes covered the same way at both ends — passes every test that verifies with its own signer, and fails these.

tests/rfc1995.zig is RFC 1995 §7 itself: the worked example's three generations and its four replies — whole zone, incremental, condensed and up to date — transcribed as presentation text and encoded with dns.Builder rather than hand-packed, then read back through the same code a socket feeds. The condensed-versus-uncondensed pair is the vector that proves the differences are observed rather than inferred, since both converge on the same zone.

Nothing in the suite reaches the network.

Against something that is not this

Both of those prove the client agrees with itself. nix flake check boots a NixOS guest running dnsproxy, which serves plain DNS, DoT, DoH, DoH3 and DoQ from one process off one certificate, and asks it the same question over all six transports.

What that buys over querying a public resolver is a certificate we control, and so the ability to test the half that matters: that verification refuses. Two servers run, identical but for who signed their certificate — one by an authority in the guest's trust store, one by an authority nothing has heard of. Each encrypted transport has to answer through the first, refuse a name the certificate does not carry, refuse the second outright, and reach the second when +tls-insecure says to. A public resolver cannot test any of that: point a client at 1.1.1.1 with a name that ought to fail and Cloudflare's edge may serve a certificate that legitimately covers it — *.example.com, as it happens — so the test would be measuring the provider rather than the client.

A second guest does the same for DNSCrypt, against the same dnsproxy speaking that protocol instead. Its fixture is deterministic — every key comes from a seed written into tests/nixos/dnscrypt-keys.py — which has a pleasant consequence: the keys it derives with libsodium are the ones in Appendix 2 of the DNSCrypt draft, so the Go server, libsodium and this client are all agreeing about the same numbers. Two servers run, differing only in their encryption system, since es-version 1 is the one construction here that otherwise never meets a foreign implementation. The weight is again on the refusal: a rogue provider key against the real server must come back naming the certificate, with the correct key against the same port immediately afterwards so that a refusal cannot be a port that was never reachable. DNSCrypt has no +tls-insecure equivalent and should not gain one, so that is the only way round.

A third guest serves transfers, which needs real primaries rather than a fixture, and runs two of them: Knot DNS on the usual ports and BIND on one of its own.

The headline assertion needs no reference client and no expected answer: a zone obtained by applying an IXFR difference equals a zone obtained by a fresh AXFR, canonically written — whatever the server sent and however it chose to express it, the two routes to the same serial have to agree. It is asked of both servers, because one implementation is not a second opinion.

Knot is there for its journal: knotc zone-begin / zone-set / zone-commit writes a changeset straight into it, so a second serial costs no file write and no reload race. Three zones — an open one, one behind a TSIG ACL, and one with provide-ixfr off, which is how the "whole zone in answer to a request for the differences" reply is obtained from a server's own decision rather than from a test pretending. Beside the headline property: the difference compared against kdig -t IXFR=…, TSIG required and then supplied, a wrong key and an unknown key each naming itself, and XoT on the same certificate fixture the dnsproxy guest uses — including +xot pointed at the cleartext port, which must fail rather than downgrade.

BIND is there because it is never told what changed: it works the difference out. One of its zones is changed by nsupdate, twice, so that the reply is a series of differences the client must apply oldest first; another is changed by replacing the zone file and reloading, where ixfr-from-differences makes BIND diff the version it was serving against the one it has just been handed — a delta produced by an algorithm nothing here has seen. Its third zone is transferable only with the key, which checks the TSIG chain against the implementation TSIG was written for.

One thing BIND taught this guest, and it is the sort of thing only a real server does: it weighs the difference against the zone and sends the whole zone when the difference is not smaller. Its fixtures therefore carry sixty filler records, because an eight-record zone can never have a smaller difference and BIND would have answered every +ixfr= with an AXFR — a test that passes while proving nothing.

A fourth guest is the updates, against both servers again and for the same reason: Knot takes the unsigned and TSIG cases and the prerequisites, and BIND takes SIG(0), which Knot does not implement. Every change is checked with kdig rather than by believing the acknowledgement — an update that claims to have worked and a zone that says otherwise is exactly the failure worth catching. The prerequisite subtest is the one to read: the same update twice, where the second must fail because the first one worked, and the zone must show that nothing of it was applied.

The SIG(0) subtest is the strongest thing in the suite. BIND's update-policy grants a named key rights over a zone and finds the public half as a key record published in it, so the update is accepted only if an independent implementation agrees about every byte of RFC 2931 §3.1's signed data and about the key tag, which is a checksum over the published record. Its key pair comes from a seed written into tests/nixos/sig0-key.py, so the tag, the file names and every signature are the same on every run.

A fifth set of guests is Multicast DNS, against Avahi. One publishes a service; another is a desktop as NixOS makes one — its own Avahi holding port 5353, the firewall on and open for that and nothing else — and zmdns there has to find the service, its port, its TXT record and its host's addresses. That firewall is the point: it is what decided that mdns shares 5353 rather than listening on a port of its own, and nothing short of a real one would show the decision was right. A third guest has no Avahi at all, and between them they check that:

  • a service zmdns --publish announces, beside Avahi or with none, is seen by Avahi's avahi-browse, and its host resolves through avahi-resolve;
  • an instance name already taken becomes Kitchen (2), on a machine with a responder on 5353 already;
  • zmdns --watch sees services arrive -- ours and Avahi's -- and leave, by their goodbyes.
$ nix flake check --print-build-logs

It needs nested virtualisation (/dev/kvm), so in CI it runs on an ephemeral DigitalOcean runner rather than the always-on one.

Fuzzing

zig-dns already fuzzes the decoder, so tests/fuzz.zig is about what this library added on top — and all of it is reached from somewhere untrusted: a datagram from whoever felt like sending one, and an /etc/resolv.conf written by DHCP, by a VPN client, or by a hand that slipped. The property that matters is one sentence: the resolver never returns a reply that is not an answer to the question it asked.

The Multicast DNS responder is fed anything heard on 5353, from the group and from a legacy querier: it returns, frees what it allocated, and answers only for names it holds. The monitor is fed any response after a whole instance: it reports only instances of its type, whole, and every query it sends is a message.

The transfer target's property is the same sentence in another shape: a stream of envelopes either fails or yields a zone whose first and last records are the same soa with none between, and whose record count is what arrived less that duplicate. Two of the targets are DNSCrypt's, and the property there is sharper: a certificate this library accepted really was signed by the provider key it was checked against, checked with a second std.crypto call rather than by asking the function under test again — so a parse path that forgot to verify fails instead of passing. Because no mutation will ever produce a signature that verifies, the same target also signs whatever the fuzzer produced as the signed region and requires the fields to read back, which is what reaches the decoding with arbitrary values in it.

zig build fuzz --fuzz runs them under Zig's own fuzzer, steered by coverage: the test binaries are compiled with LLVM, since the self-hosted backend Debug otherwise uses emits no coverage instrumentation. zig build fuzz-run is a loop of our own over the same targets, with a corpus in place of coverage feedback, a fixed count of iterations for CI to run, and a leak check after every input:

$ zig build fuzz --fuzz                               # Zig's fuzzer, until interrupted
$ zig build fuzz-run                                  # a minute of each target
$ zig build fuzz-run -- --seconds 300 --target responses
$ zig build fuzz-run -- --input fuzz-findings/x.bin --target responses

Without --fuzz, each target also runs as an ordinary test over the seeds beside it, so zig build test covers the same properties on the input that has already been interesting once.

Packaging

nix build produces the library and zdig. The Zig dependencies come from build.zig.zon.nix, generated by zon2nix and committed, so the build fetches nothing:

$ nix develop -c zon2nix --17 --nix=build.zig.zon.nix build.zig.zon

Regenerating that file is the whole of adding, removing or updating a dependency.

References cited

Kept in the Zotero collection named after this project, and every one of them is cited by section somewhere in src/ — the doc comments are where the reasoning lives, and each of these is what one of them is reasoning about.

Andrews, M. 2011. Locally Served DNS Zones. RFC 6303. RFC Editor. https://www.rfc-editor.org/info/rfc6303. §4.1, which lists the reverse zones a resolver has no business asking the internet about — 127.in-addr.arpa among them. It is why src/localhost.zig answers the reverse of the two loopback addresses, and equally why it leaves the rest of that zone alone: serving it is a server's job, and a stub inventing a denial would be worse than one declining to ask.

Arciszewski, S. 2020. XChaCha: eXtended-nonce ChaCha and AEAD_XChaCha20_Poly1305. Internet-Draft draft-irtf-cfrg-xchacha-03. IETF. https://datatracker.ietf.org/doc/html/draft-irtf-cfrg-xchacha-03. §2.2's HChaCha20, which DNSCrypt's es-version 2 needs twice: once inside its AEAD and once to derive the shared key. It is zig-std-crypto-ext's, because std.crypto implements it and keeps it private.

Arkko, J., M. Cotton, and L. Vegoda. 2010. IPv4 Address Blocks Reserved for Documentation. RFC 5737. RFC Editor. https://www.rfc-editor.org/info/rfc5737. Where the addresses in the tests come from: 192.0.2.0/24 is reserved, so a query sent there has nothing to reach and nothing routes to it.

Bishop, M., ed. 2022. HTTP/3. RFC 9114. RFC Editor. https://www.rfc-editor.org/info/rfc9114. What carries src/transport/h3.zig's request. §4.1 puts each request on its own stream and §4.3.1 fixes the pseudo-header fields; the driver is zig-http's rather than this library's.

Cheshire, S., and M. Krochmal. 2013. DNS-Based Service Discovery. RFC 6763. RFC Editor. https://www.rfc-editor.org/info/rfc6763. How src/mdns.zig's browse finds instances of a service: §4 for the PTR query that names them, §5 and §6 for the SRV and TXT records that say where each is and what it says of itself, §6.4 for why a TXT key is compared without regard to case, and §12 for the additional records a responder sends with its answer, which is why most browses need no second question.

Cheshire, S., and M. Krochmal. 2013. Multicast DNS. RFC 6762. RFC Editor. https://www.rfc-editor.org/info/rfc6762. §5.1 for the one-shot query mdns makes, §6.7 for the legacy unicast answer it falls back to where port 5353 cannot be shared, §10.1 for the goodbye a zero TTL is, §10.2 for the cache-flush bit masked off every class, and §18.1 for the zero identifier a query from 5353 carries.

Cheshire, S., and M. Krochmal. 2013. Special-Use Domain Names. RFC 6761. RFC Editor. https://www.rfc-editor.org/info/rfc6761. src/localhost.zig is §6.3 entire: point 3's obligation on a name resolution library to answer address queries for localhost. and anything under it with the loopback address, to answer every other type negatively, and not to send the query to a server at all; point 6's "cannot be modified by local configuration", which is where the argument about /etc/hosts taking precedence is had and answered. §6.4's invalid. is deliberately left out, which that file explains.

Damas, J., M. Graff, and P. Vixie. 2013. Extension Mechanisms for DNS (EDNS(0)). RFC 6891. RFC Editor. https://www.rfc-editor.org/info/rfc6891. The opt record a query carries to say how large an answer it can take, and §6.2.2's rule that a server answering formerr may never have seen one — which is what Options.edns_fallback retries without.

Denis, F. 2026. DNSCrypt Protocol Specification. Internet-Draft draft-denis-dprive-dnscrypt. IETF. https://dnscrypt.github.io/dnscrypt-protocol/. src/transport/dnscrypt.zig and src/transport/dnscrypt/packet.zig entire, and Appendix 2 is where packet.zig's test vectors come from — a whole es-version 2 exchange with every value pinned, cross-checked by its authors against dnscrypt-proxy. Appendix 1 is the one thing a reader has to take seriously before writing any of it: the XChaCha20_DJB-Poly1305 construction is the NaCl secretbox layout and not the AEAD of RFC 8439, and the two are not interchangeable.

Denis, F. 2024. DNS Stamps Specification. https://dnscrypt.info/stamps-specifications/. What sdns:// says, which is how a DNSCrypt resolver's address, provider name and provider key travel as one string. Parsed by zig-uri's uri.stamp rather than here.

Denis, F. libsodium. https://github.com/jedisct1/libsodium. The XChaCha20 box DNSCrypt's es-version 2 is: its crypto_box_curve25519xchacha20poly1305 and the beforenm that src/transport/dnscrypt/packet.zig derives a shared key with. The draft's Appendix 2 vectors are what the two are checked to agree on.

Dickinson, J., S. Dickinson, R. Bellis, A. Mankin, and D. Wessels. 2016. DNS Transport over TCP — Implementation Requirements. RFC 7766. RFC Editor. https://www.rfc-editor.org/info/rfc7766. src/transport/tcp.zig. §6.2.1 wants a busy client to keep a connection open and pipeline down it, which this does not do and says so.

Dupont, F., S. Morris, P. Vixie, D. Eastlake 3rd, O. Gudmundsson, and B. Wellington. 2021. Secret Key Transaction Authentication for DNS (TSIG). RFC 8945. RFC Editor. https://www.rfc-editor.org/info/rfc8945. src/tsig.zig entire. §4.3.3 says which bytes the digest covers — the message with the TSIG record removed, ARCOUNT decremented and the identifier put back — §5.3.1 is the chain that makes a multi-message transfer one signed thing, and §5.2 fixes the order the checks run in, which is why a badkey reply is believed rather than verified: it arrives with an empty MAC. §4.2's error field lives in the TSIG registry and not the DNS one, where 16 is badsig rather than badvers.

Eastlake 3rd, D. 2000. DNS Request and Transaction Signatures ( SIG(0)s ). RFC 2931. RFC Editor. https://www.rfc-editor.org/info/rfc2931. src/sig0.zig and zig-dns's dns.sig0. §3 fixes most of the record to constants — type covered zero, the root as the owner, class any — and §3.1 is the two-part data a signature covers: the record's own rdata with the signature omitted rather than zeroed, then the message as it stood before the signature was added to it. §2.4 is also the honest account of why nothing here verifies a SIG(0) on a response.

Eastlake 3rd, D., and A. Panitz. 1999. Reserved Top Level DNS Names. RFC 2606. RFC Editor. https://www.rfc-editor.org/info/rfc2606. Why the tests ask about names under .test and .example rather than about somebody's.

Elz, R., and R. Bush. 1996. Serial Number Arithmetic. RFC 1982. RFC Editor. https://www.rfc-editor.org/info/rfc1982. How to tell which of two zone serials is newer when the numbers wrap, which is the arithmetic a refresh turns on: serial 1 is newer than 0xFFFFFFFF. Implemented in zig-dns as dns.serial, beside the soa it is about, including §3.2's pair that the arithmetic deliberately leaves unordered.

Elz, R., and R. Bush. 1997. Clarifications to the DNS Specification. RFC 2181. RFC Editor. https://www.rfc-editor.org/info/rfc2181. §5.2, which is why an IXFR removal matches on owner, class, type and rdata and ignores the TTL: a TTL difference is not a difference.

Fielding, R., M. Nottingham, and J. Reschke, eds. 2022. HTTP Semantics. RFC 9110. RFC Editor. https://www.rfc-editor.org/info/rfc9110. What a method, a status and a media type mean, independently of the version that writes them down — including §5.1's rule that the parameters after a media type are not part of it, which is what checkMediaType trims before comparing.

Fielding, R., M. Nottingham, and J. Reschke, eds. 2022. HTTP/1.1. RFC 9112. RFC Editor. https://www.rfc-editor.org/info/rfc9112. The version src/transport/https.zig speaks. §6.3's framing rules are the reason that file no longer has a parser of its own: a client that reads a body's end in a different place from the server that sent it is a client splitting responses on itself.

Hoffman, P., and P. McManus. 2018. DNS Queries over HTTPS (DoH). RFC 8484. RFC Editor. https://www.rfc-editor.org/info/rfc8484. What both DoH transports implement: §4.1's POST with the message as the body, §4.2's requirement that the answer say it is a DNS message, and §5.2's ask for HTTP/2, which src/transport/https.zig now meets when the server offers it and src/transport/h3.zig overshoots.

Harrenstien, K., M. Stahl, and E. Feinler. 1985. DoD Internet Host Table Specification. RFC 952. RFC Editor. https://www.rfc-editor.org/info/rfc952. Where the format of /etc/hosts comes from: an address, the canonical name, then the aliases. src/hosts.zig reads what every system has shipped since, which is that with # comments and no length limits.

Hoffman, P., and W. Wijngaards. 2012. Elliptic Curve Digital Signature Algorithm (DSA) for DNSSEC. RFC 6605. RFC Editor. https://www.rfc-editor.org/info/rfc6605. §4, for the two ECDSA algorithms sig0 can sign with: the public key is x then y without SEC 1's leading byte, and the signature is r then s at their natural width rather than in the DER wrapping everything else uses — which is why converting one for std.crypto is a matter of adding a byte and taking it away again.

Hu, Z., L. Zhu, J. Heidemann, A. Mankin, D. Wessels, and P. Hoffman. 2016. Specification for DNS over Transport Layer Security (TLS). RFC 7858. RFC Editor. https://www.rfc-editor.org/info/rfc7858. src/transport/tls.zig, including §3.3's ask that the length prefix and the message travel in one record — which is why the query is built somewhere contiguous first.

Huitema, C., S. Dickinson, and A. Mankin. 2022. DNS over Dedicated QUIC Connections. RFC 9250. RFC Editor. https://www.rfc-editor.org/info/rfc9250. src/transport/quic.zig: §4.1.2's doq ALPN token, §4.2's one query per bidirectional stream, §4.2.1's requirement that the message identifier be zero, and §4.3's error codes.

Iyengar, J., and M. Thomson, eds. 2021. QUIC: A UDP-Based Multiplexed and Secure Transport. RFC 9000. RFC Editor. https://www.rfc-editor.org/info/rfc9000. What the two QUIC transports run on, implemented in zig-quic rather than here — but §4.1's flow control and §18.2's transport parameters are chosen in src/transport/quic.zig, because what an endpoint may promise is what its buffers hold.

Iyengar, J., and I. Swett, eds. 2021. QUIC Loss Detection and Congestion Control. RFC 9002. RFC Editor. https://www.rfc-editor.org/info/rfc9002. Why a QUIC connection needs a task pumping it even while nothing arrives: §6.2's probe timeout fires on a connection nobody is reading.

Josefsson, S., and I. Liusvaara. 2017. Edwards-Curve Digital Signature Algorithm (EdDSA). RFC 8032. RFC Editor. https://www.rfc-editor.org/info/rfc8032. Ed25519, which is what signs a DNSCrypt certificate — and the only algorithm this version of that protocol allows, so Certificate.parse verifies with it and has no choice to make.

Langley, A., M. Hamburg, and S. Turner. 2016. Elliptic Curves for Security. RFC 7748. RFC Editor. https://www.rfc-editor.org/info/rfc7748. X25519, which is the key exchange in both of DNSCrypt's encryption systems. §6.1's all-zero output is the low-order public key SharedKey.compute refuses rather than agrees with.

Lewis, E., and A. Hoenes, eds. 2010. DNS Zone Transfer Protocol (AXFR). RFC 5936. RFC Editor. https://www.rfc-editor.org/info/rfc5936. What a whole-zone transfer is: §2.2's soa at each end with none between, §2.2.1's rule that a later message need not echo the question, §3.4's freedom to group the records any way the server likes — which is what the 128-grouping test exists to honour — and §4.2, which retired AXFR over UDP.

Mockapetris, P. 1987. Domain Names — Implementation and Specification. RFC 1035. RFC Editor. https://www.rfc-editor.org/info/rfc1035. The protocol itself. §4.2.1 and §4.2.2 are the two plain transports, and §4.1.1's truncated bit is what sends a query round again over TCP.

Nir, Y., and A. Langley. 2018. ChaCha20 and Poly1305 for IETF Protocols. RFC 8439. RFC Editor. https://www.rfc-editor.org/info/rfc8439. The cipher and the authenticator DNSCrypt's es-version 2 is built from — combined the way NaCl's secretbox combines them rather than the way §2.8 of this RFC does, which is the distinction Appendix 1 of the DNSCrypt draft exists to make.

Ohta, M. 1996. Incremental Zone Transfer in DNS. RFC 1995. RFC Editor. https://www.rfc-editor.org/info/rfc1995. The differences rather than the zone: §3's query with the client's own soa in the authority section, the three reply shapes told apart by their first two records, and §2's requirement that nothing replace the older version until every difference has been processed — which is what the journal in src/transfer.zig is for. §7's worked example is transcribed whole into tests/rfc1995.zig.

Schwartz, B., M. Bishop, and E. Nygren. 2023. Service Binding and Parameter Specification via the DNS (SVCB and HTTPS Resource Records). RFC 9460. RFC Editor. https://www.rfc-editor.org/info/rfc9460. The whole of src/ServiceLookup.zig: §9.1's query names, §3's resolution procedure and its appended endpoint, §2.4 and §2.5 on aliases and ., §7's parameters, §8's mandatory, and §3.1's split between a failure over a protected channel and one over a plain one.

Thaler, D., ed., R. Draves, A. Matsumae, and T. Chown. 2012. Default Address Selection for Internet Protocol Version 6 (IPv6). RFC 6724. RFC Editor. https://www.rfc-editor.org/info/rfc6724. §2.1's default policy table, which ranks an IPv6 destination above an IPv4-mapped one, and §6's rules for sorting destinations with it. That is why src/HostLookup.zig queues aaaa answers before a ones, rather than following the standard library's DNS path.

Thomson, M., and C. Benfield, eds. 2022. HTTP/2. RFC 9113. RFC Editor. https://www.rfc-editor.org/info/rfc9113. The other version src/transport/https.zig speaks, and §3.1 is how it is reached: HTTP/2 over TLS has the ALPN identifier h2 and no other way in, so what the session offers is the whole of the negotiation. §8.2.2 is why the Connection: close this transport sends over HTTP/1.1 is not sent over HTTP/2.

Thomson, M., and S. Turner, eds. 2021. Using TLS to Secure QUIC. RFC 9001. RFC Editor. https://www.rfc-editor.org/info/rfc9001. Why the QUIC transports share a trust store with the TLS ones rather than having one of their own, and where §4.8's CRYPTO_ERROR would have said which alert ended a handshake — the thing the QUIC client cannot report.

Sury, O., and R. Edmonds. 2017. Edwards-Curve Digital Security Algorithm (EdDSA) for DNSSEC. RFC 8080. RFC Editor. https://www.rfc-editor.org/info/rfc8080. §3, which is how an Ed25519 public key and an Ed25519 signature are written in a DNS record: the 32 bytes and the 64 bytes, with nothing wrapped around either. It is the algorithm a SIG(0) key should be, and the one std.crypto makes cheapest.

Thomson, S., C. Huitema, V. Ksinant, and M. Souissi. 2003. DNS Extensions to Support IP Version 6. RFC 3596. RFC Editor. https://www.rfc-editor.org/info/rfc3596. The aaaa record, and the ip6.arpa reverse tree that zdig -x walks.

Toorop, W., S. Dickinson, S. Sahib, P. Aras, and A. Mankin. 2021. DNS Zone Transfer over TLS. RFC 9103. RFC Editor. https://www.rfc-editor.org/info/rfc9103. XoT, which is a transfer inside the session src/transport/tls.zig already opens. §7.1 makes the dot ALPN token mandatory where RFC 7858 merely registered it, §7.9.1 makes a message carrying no records at all legal — so an empty envelope is not the end of anything — and §9.3.1 names a client certificate as one of the two ways a primary authenticates a secondary.

Vixie, P. 1996. A Mechanism for Prompt Notification of Zone Changes (DNS NOTIFY). RFC 1996. RFC Editor. https://www.rfc-editor.org/info/rfc1996. What tells a secondary that a zone has changed, and so what would drive a refresh rather than a refresh being asked for. Named in src/transfer.zig's list of what it deliberately leaves to whoever is above it.

Vixie, P., ed., S. Thomson, Y. Rekhter, and J. Bound. 1997. Dynamic Updates in the Domain Name System (DNS UPDATE). RFC 2136. RFC Editor. https://www.rfc-editor.org/info/rfc2136. src/update.zig and zig-dns's dns.update. §2 renames the four sections into the zone, the prerequisites and the updates; §2.4 and §2.5 are the nine shapes, which are encodings rather than ideas — the class and the emptiness of the rdata are what tell a deletion from an addition; §3.4.1's prescan is run on this side, so a name outside the zone is an error at the call that named it rather than a notzone a round trip later; and §3.8 is why send returns nothing but an error, since what a server echoes is up to it.

Wellington, B. 2000. Secure Domain Name System (DNS) Dynamic Update. RFC 3007. RFC Editor. https://www.rfc-editor.org/info/rfc3007. What ties an update to a signature: §2 makes TSIG and SIG(0) the two ways to authenticate one, and the authentication is on the request rather than anything inside it — which is why update, tsig and sig0 need to know nothing about each other.

Licence

MIT. The project follows the REUSE standard; reuse lint checks it.