- Zig 91.7%
- Nix 7.7%
- Python 0.6%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
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 |
||
| .forgejo/workflows | ||
| LICENSES | ||
| src | ||
| tests | ||
| tools | ||
| .gitignore | ||
| build.zig | ||
| build.zig.zon | ||
| build.zig.zon.nix | ||
| flake.lock | ||
| flake.nix | ||
| package.nix | ||
| README.md | ||
| REUSE.toml | ||
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
wwwis never answered with an IPv6 address forwww.example.comand an IPv4 one forwwwitself. - IPv6 comes first, the order RFC 6724's default policy prefers. The
standard library puts
abeforeaaaafor a name from the DNS and the other way round forlocalhost. - The canonical name is the owner of the addresses rather than the target
of the last
cnameseen, 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 iserror.NoAddressReturned. The standard library reportsNoAddressReturnedfor 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
mandatoryhas to be one this implements (alpn,no-default-alpn,port,ipv4hint,ipv6hint);echis 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-alpnwithoutalpn, 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>._httpslabels 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), orhost-2for the host. Two probing for one name at once are told apart by their records (§8.2). - A
PTRanswer brings theSRV,TXTand 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 --publishannounces, beside Avahi or with none, is seen by Avahi'savahi-browse, and its host resolves throughavahi-resolve; - an instance name already taken becomes
Kitchen (2), on a machine with a responder on 5353 already; zmdns --watchsees 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.