DNS
Crate hopf-dns: async stub resolver and a composable DNS server: a protocol shell with pluggable handlers, of which the caching forwarder and authoritative zone server (zone files, NOTIFY, dynamic UPDATE, AXFR/IXFR, TSIG, secondaries) are stock implementations. The resolver is reactor-affine (UDP on a worker). Optional features add the server, DoT / DoQ / DoH, and DNSSEC validation.
Contents
Standards
| Reference | Role |
|---|---|
| RFC 1035 | Wire format / RRs |
| RFC 6891 | EDNS0: OPT, DO bit, UDP payload 4096, extended RCODE/VERSION read+write (DnsResourceRecord::edns_extended_rcode/edns_version/edns_full_rcode/with_edns_rcode_version) |
| RFC 7830 | EDNS Padding option codec (encode_edns_padding/edns_padding_length) — not automatically applied to any transport, callers compose it into their own OPT options |
| RFC 7873 | DNS Cookies, client + server side (DnsCookie) |
| — | TCP fallback on truncation; bailiwick filtering |
| RFC 7858 | DNS over TLS (DoT) |
| RFC 7766 §6.2.1 | TCP/DoT connection pooling — TcpDnsConnectionPool reuses a live connection per destination server across queries, transparently reconnecting if the peer has since closed it |
| RFC 9250 | DNS over QUIC (DoQ): message ID zeroed both directions (§4.2.1); DOQ_* error-code constants, malformed query aborts the stream with DOQ_PROTOCOL_ERROR (§4.3) |
| RFC 8484 | DNS over HTTPS: both POST and GET (DohClientTransport::with_get, base64url `dns` query parameter) |
| RFC 4034 / 4035 | DNSSEC validation, chain-of-trust walking, AD/CD handling |
| RFC 5155 | NSEC3 authenticated denial-of-existence |
| RFC 1996 / 2136 | NOTIFY (sent after a change, accepted by secondaries from their primary) and dynamic UPDATE with prerequisites |
| RFC 5936 / 1995 | AXFR and IXFR, server and client |
| RFC 8945 | TSIG (HMAC-SHA256/384/512) on requests, responses and multi-message transfers |
| RFC 2308, 4592, 8020, 8482 | Authoritative negative answers, wildcards, empty non-terminals, minimal ANY |
See DNSSEC below for algorithm support and trust anchors.
Architecture
App
│ DnsResolver (per Runtime / ReactorHandle)
│ ├── hosts file + literal IP short-circuit
│ ├── per-server transport (add_server*): UDP (+EDNS, cookies) + TCP
│ │ fallback by default, or DoT / DoQ / DoH per configured server
│ ├── retry/failover across configured servers (mixed transports OK)
│ ├── CNAME chase (depth ≤ 8, same transport as the server it started on)
│ └── optional DnsCache + DnssecValidator (identical for every transport)
│
└── (feature server) DnsService - protocol shell: cookies, TSIG, framing
│ └── DnsQueryHandler - policy, composed as needed
│ ├── EmptyHandler (default: NOERROR, no answers)
│ ├── ForwarderHandler (cache + upstream resolver)
│ ├── AuthoritativeZoneHandler (zones, NOTIFY, UPDATE, AXFR/IXFR)
│ ├── FnHandler (closures)
│ └── ChainHandler (first that does not decline)
├── listen_dns_udp
├── listen_dns_tcp
├── listen_dns_dot (+dot)
└── listen_dns_doq (+doq)
RuntimeDnsExt::connect_by_name — async resolve then Runtime::connect on Arc<Runtime> (returns immediately; dial runs from the DNS callback).
Resolver
DnsResolver — async stub on a reactor, and (since issue #90) multi-protocol: each configured upstream server carries its own transport (UDP+TCP-fallback by default, or DoT/DoQ/DoH), selected per-server via add_server_dot/add_server_doq/add_server_doh rather than globally — a resolver can mix a fast plaintext server with an encrypted fallback. Every query helper (query_a, resolve, …), retry/failover across configured servers, CNAME chase, response caching, and DNSSEC validation work identically regardless of which transport actually carried the query — they all run in the same transport-agnostic completion path the wire bytes land in once decoded. RFC 7873 cookies stay UDP-only (the anti-spoofing defence they exist for doesn't apply to a TLS/QUIC/HTTPS-authenticated transport).
Query helpers: query_a, query_aaaa, query_mx, query_txt, query_ptr, query_srv, generic query(DnsQuestion, …).
Happy Eyeballs: resolve(host, port, ResolveCallback) — parallel A+AAAA, IPv6-first ordering.
Helpers: HostsFile, parse_literal_ip, DnsCache, DnsCookie, bailiwick filters (filter_answers_in_bailiwick, is_within_bailiwick, names_equal).
Wire types: DnsMessage, DnsQuestion, DnsResourceRecord, SoaData, DnsType, DnsClass, DnsQueryIdGenerator, DnsFormatError, plus FLAG_* / RCODE_* (including RCODE_BADVERS) / OPCODE_QUERY / EDNS_FLAG_DO / OPT_UDP_PAYLOAD / EDNS_OPTION_PADDING constants.
Every standard record type has a matching constructor and decode accessor on DnsResourceRecord: a()/as_a(), aaaa()/as_aaaa(), cname()/ns()/ptr() via as_domain_name(), mx()/as_mx(), txt()/as_txt(), soa()/as_soa() -> Option<SoaData>, srv()/as_srv() -> Option<(u16, u16, u16, String)>.
DNS server (server feature)
Enable Cargo feature server. DnsService is the protocol shell: it validates messages, runs the DNS Cookie exchange, verifies and signs TSIG, frames responses for each transport and truncates over UDP. It makes no decisions about answers. With no handler attached it answers every query with an empty NOERROR and every other opcode with NOTIMP. All behaviour comes from a composed DnsQueryHandler.
| Type | Role |
|---|---|
DnsService / DnsServiceHandle |
Shell; with_handler, set_tsig_keyring, start/stop, process_wire, metrics |
DnsQueryHandler |
Trait: handle_query, handle_non_query_opcode, start, stop. Returns HandlerOutcome::{Respond, Sequence, Decline} |
EmptyHandler |
The default no-op |
ForwarderHandler |
Caching forwarder |
AuthoritativeZoneHandler |
Authoritative zones (below) |
FnHandler |
Closure handler: on_query, on_opcode |
ChainHandler |
Tries handlers in order; the first that does not Decline answers |
QueryContext |
Per-message context: peer, transport (DnsTransport::{Udp, Tcp, Dot, Doq}), tsig_key, metrics |
DnsServerMetrics |
Counters |
listen_dns_udp / DnsUdpListenConfig |
UDP listen |
listen_dns_tcp |
Cleartext TCP listen (RFC 7766). Needed for zone transfers and truncated-answer retries |
listen_dns_dot |
DoT listen (dot feature) |
listen_dns_doq |
DoQ listen (doq feature) |
parse_upstream_list |
Parse upstream addrs |
Composition. A handler that does not want a message returns Decline; for a query the shell then answers an empty NOERROR, for another opcode NOTIMP. Only requests reach a handler: a message with the QR bit set is refused by the shell. Handlers run on the listener thread and should not block. Handlers that need background work (the authoritative handler's NOTIFY, write-back and secondary refresh) get it from DnsService::start(&rt), which calls DnsQueryHandler::start; call it before binding listeners and stop() on shutdown.
// Caching forwarder
let service = DnsService::with_handler(ForwarderHandler::new(cache).with_upstream(resolver));
// Authoritative zones first; anything else is forwarded (split horizon)
let zones = AuthoritativeZoneHandler::builder()
.zone_file(path, None, ZoneFileMode::ReadWrite, options)?
.decline_outside_zones(true)
.build()?;
let service = DnsService::with_handler(
ChainHandler::new().then(zones).then(ForwarderHandler::new(cache).with_upstream(resolver)),
);
service.start(&rt)?;
let handle = DnsServiceHandle::new(service);
listen_dns_udp(rt.pick_worker(), DnsUdpListenConfig { addr, service: handle.clone() })?;
listen_dns_tcp(&rt, addr, handle)?;
Caching forwarder
ForwarderHandler::new(cache).with_upstream(resolver), plus cache(). It never declines, so in a chain it goes last. Strips DNSSEC RRs when the client query lacks DO. With no upstream a cache miss is SERVFAIL.
Three resolver behaviours that the RFCs recommend but do not require are on by default, and each is a policy hook on the handler that can be replaced or turned off (any closure will do):
- Serve-Stale (RFC 8767): when the upstream times out, errors, or answers with anything but NOERROR or NXDOMAIN, an expired positive answer still inside the cache's stale window (
DnsCache::with_max_stale, default 1 day) is served with a 30-second TTL instead ofSERVFAIL. With stale data in hand the forwarder waits only the client response timer (1.8 s,with_client_response_timer) rather than the full upstream timeout, and the upstream query keeps running, so a late answer still refreshes the cache. After a failure it does not ask again for the failure recheck interval (30 s,with_failure_recheck). Configure withwith_stale_policy(StalePolicy:ServeStale(StaleTerms),ServeStaleDisabled, orFn(&DnsQuestion) -> Option<StaleTerms>for a per-name window and TTL). Negative answers are never served stale.DnsServerMetrics::stale_servedcounts these separately fromcache_hits. - NXDOMAIN cut (RFC 8020): a cached NXDOMAIN answers queries for every name beneath it without asking the upstream. Disable, or exclude zones where a name may exist beneath one that does not (split horizon), with
with_nxdomain_cut_policy(NxdomainCutPolicy). - Minimal
ANY(RFC 8482): the upstream'sANYanswer for an existing name is reduced to one synthesisedHINFO "RFC8482" ""(TTLwith_minimal_any_ttl, default 3600) and cached that way; NXDOMAIN and NODATA are reported as they are. A query with DO set gets the full answer, since a synthesised record cannot be signed. It sharesMinimalAnyPolicywith the authoritative handler (with_minimal_any_policy). The upstream still assembles the full RRset; the saving is in what the forwarder sends and caches. - Aggressive use of validated NSEC/NSEC3 (RFC 8198, feature
dnssec): while the upstreamDnsResolverhas DNSSEC validation enabled, a negative answer carrying NSEC or NSEC3 records is validated up its chain of trust in the background (the client's own answer does not wait for it), and a denial that comes outSecureis kept as a per-zone proof inDnsCache::denials(). Later queries the proof covers are answered NXDOMAIN or NODATA locally. NXDOMAIN needs the wildcard at the closest encloser ruled out as well, NSEC3 Opt-Out proves nothing, names at or below a delegation or DNAME are never synthesised, and NSEC3 iteration counts above 100 are not used. A DNSSEC-aware client (DO) gets the NSEC/NSEC3 records with their signatures and AD; others get the SOA alone. Lifetimes are capped at three hours and at the SOA minimum. Disable withwith_aggressive_nsec_policy(AggressiveNsecPolicy);DnsServerMetrics::aggressive_nsec_hitscounts synthesised answers. Not done: wildcard-positive synthesis (RFC 8198 §5.3) and more than one NSEC3 parameter set per zone.
Other opcodes (NOTIFY, UPDATE). DnsQueryHandler::handle_non_query_opcode is offered every request whose opcode is not QUERY (RFC 1996 NOTIFY, RFC 2136 UPDATE, ...). Update sections arrive in the question (zone), answer (prerequisite), authority (update) and additional fields with the NONE class (254) preserved in each record's raw_class. For a closure, FnHandler::on_opcode(|message, peer| ...). Build responses with DnsMessage::response_template, which echoes the opcode.
Server-side DNS Cookies (RFC 7873 §5.2): an inbound query's COOKIE option (if any) is parsed and any presented server cookie validated, via HMAC-SHA256 over the client cookie and source address keyed by a per-service secret from getrandom (fail closed), compared in constant time. A COOKIE option shorter than the mandatory 8-byte client cookie is FORMERR with no cookie exchange (§5.2.2). Otherwise the response carries a COOKIE option echoing the client cookie plus a freshly issued server cookie. A client that presents a cookie without a verifiable server cookie gets a cookie-only reply and the handler is not called (RFC 7873 §5.2.3 anti-amplification), which covers upstream forwarding, zone transfers and updates alike. A client presenting no cookie is not filtered.
process_wire is what every listener calls: it parses, verifies TSIG, dispatches, truncates UDP answers to the size the client advertised (records dropped, TC set), zeroes the message ID over DoQ, and signs. The forwarder uses a blocking upstream path (pragmatic).
Authoritative zones
AuthoritativeZoneHandler serves one or more zones from memory. Zones are loaded from BIND-style zone files, built with Zone::from_records, or transferred from a primary. Answers carry the AA bit and no RA; names outside every zone are REFUSED, or, with decline_outside_zones(true), declined so a ChainHandler can forward them. The most specific zone wins when zones nest.
Answers
- RFC 1034 §4.3.2 lookup: positive answers with the apex NS set in the authority section and in-zone glue (A/AAAA of NS and MX targets) in the additional section.
- CNAME chains followed within the zone (a loop is
SERVFAIL); a chain leaving the zone is returned as far as it goes. - Wildcards synthesised at the closest encloser (RFC 4592), with the queried name as owner. Empty non-terminals exist: NODATA, not NXDOMAIN (RFC 8020).
- Delegations: a name at or below a zone cut gets a referral (NS in authority, glue, AA clear);
DSis answered by the parent side. - Negative answers (NXDOMAIN, NODATA) carry the SOA with TTL
min(SOA TTL, MINIMUM)(RFC 2308 §3). - Minimal
ANY(RFC 8482): a single synthesisedHINFO "RFC8482" "";minimal_any(false)returns the whole node, andminimal_any_policy(...)takes aMinimalAnyPolicy(or anyFn(&DnsQuestion) -> bool) to decide per question. Non-existent names stay NXDOMAIN. - EDNS: an OPT record is echoed (with DO); an EDNS version above 0 is
BADVERS. Classes other than IN are refused.
Zone files
Zone::from_zone_file(path, origin) and Zone::from_zone_text(text, origin). The parser is a single-pass push parser (chunk-fed lexer, explicit state machine, events), so a file is never held in memory whole and any chunking gives the same zone. Loading blocks on file I/O: do it at start-up or on a storage worker.
| Feature | Notes |
|---|---|
$ORIGIN, $TTL |
$TTL accepts BIND units (1h30m, 2w). A record with no TTL takes $TTL, else the previous explicit TTL, else the SOA MINIMUM |
$INCLUDE file [origin] |
Path relative to the including file; origin and last owner restored afterwards; nesting limited to 8 |
$GENERATE range lhs [ttl] [class] type rhs |
start-stop[/step], $, ${offset,width,radix}, $$; at most 65536 records per directive |
| Owner names | @, relative and absolute names; a line beginning with whitespace reuses the previous owner; parenthesised multi-line groups and ; comments |
| Types | A, AAAA, NS, CNAME, PTR, MX, TXT/SPF (several strings), SOA, SRV, HINFO in presentation format. Any type (RRSIG, DNSKEY, TLSA, CAA, ...) in the RFC 3597 generic form \# length hex, so a pre-signed zone serves and transfers intact. TYPEnnn mnemonics |
| Validation | Exactly one SOA, at the origin; no owner outside the zone; CNAME alone at its name; duplicates collapsed; RRset TTLs harmonised to the lowest (RFC 2181 §5.2). Errors carry the line number |
Only class IN is supported. DNSSEC signing is not: a zone signed by an external tool is served as data.
Zone::to_zone_text and write_zone_file write a zone back (atomic temp-file-and-rename) in a form the loader reads identically.
Per-zone options
The defaults are closed: nobody can transfer or update a zone until ZoneOptions says so.
| Option | Meaning |
|---|---|
allow_transfer(Acl) |
Who may AXFR/IXFR. Default: nobody |
allow_update(Acl) |
Who may send RFC 2136 updates (ignored on a secondary, which always refuses). Default: nobody |
also_notify(addr) / also_notify_host(name, port) |
NOTIFY targets. A host is resolved on each change and every address gets a NOTIFY, so a headless service reaches every replica (a single address behind a load balancer would reach only one) |
notify_ns_records(bool) |
Also NOTIFY in-zone NS targets with address records (port 53), except the SOA MNAME (the primary itself). Default on |
persist(path, ZoneFileMode) |
ReadOnly: changes stay in memory. ReadWrite: every successful update, and on a secondary every transfer, is written back atomically to this zone's file. Do not point two processes at one path |
tsig_key(TsigKey) |
Key this server signs its own requests with (a secondary's SOA queries and transfers; NOTIFYs) |
Acl is built from Acl::none(), Acl::any(), Acl::from_cidrs(["192.0.2.0/24", "2001:db8::/32"]), Acl::tsig_key(name) and .or_tsig_key(name): a request is allowed if its source matches a network or it is authenticated by a listed key.
Dynamic update (RFC 2136)
Prerequisites (name in use / not in use, RRset exists / does not exist, value-dependent RRset match) are checked, every update record is prescanned, and only then are changes applied, so an update is atomic. The SOA serial advances once per change (unless the update itself installs a newer SOA), and the change is journalled for IXFR. Class NONE deletes an exact record; class ANY deletes an RRset or a name. The apex SOA and NS are protected, CNAME/data conflicts are ignored as the RFC specifies, and an RRset takes the TTL of the record last added to it. Response codes: NOERROR, FORMERR, NOTAUTH, NOTZONE, YXDOMAIN, YXRRSET, NXRRSET, NXDOMAIN, REFUSED (not permitted, or a secondary).
Zone transfer and secondaries
Server. AXFR (RFC 5936): SOA, every record, SOA, split into messages that each fit a TCP frame. IXFR (RFC 1995): nothing if the client is current, the journalled differences (up to 512 changes) in RFC 1995 order, or an AXFR-style full transfer when the journal does not reach back. Stream transports only: over UDP an AXFR is answered with TC so the client retries over TCP, and an IXFR that fits one message is answered directly.
Secondary. builder.secondary("example.org", primary_addr, options). The secondary transfers at start-up, when its primary sends NOTIFY (only the configured primary's address is accepted), and on the SOA REFRESH timer, retrying on RETRY after a failure, so it converges even if every NOTIFY is lost. It compares the primary's SOA serial (RFC 1982 arithmetic) first and asks for an IXFR when it has data, falling back to AXFR if a difference does not apply. If the primary cannot be reached for the SOA EXPIRE interval it stops answering the zone (SERVFAIL) until a transfer succeeds. Until its first transfer (or a persisted file) it also answers SERVFAIL. With persist(path, ...) an existing file is served straight away while the primary is checked. Secondaries refuse updates (REFUSED) and reply to NOTIFY from anyone else with REFUSED (NOTAUTH for a zone not served).
The NOTIFY, write-back and refresh work runs on one background thread per handler, between DnsService::start and stop. Each network step is bounded by a 3 second timeout, and work is sequential, so a dead peer delays maintenance but cannot wedge it. Failures are reported on standard error with the hopf-dns: prefix.
TSIG (RFC 8945)
TsigKey::new(name, TsigAlgorithm::HmacSha256, secret) or TsigKey::from_base64(...) (BIND key-file secrets), collected in a TsigKeyring and installed with DnsService::set_tsig_keyring. HMAC-SHA256, SHA384 and SHA512 are supported; HMAC-MD5 and SHA1 deliberately are not. A signed request that verifies is authenticated as its key (QueryContext::tsig_key, checked by Acl) and its responses, including every message of a transfer, are signed. A signature that does not verify is answered NOTAUTH with BADKEY, BADSIG or BADTIME in the TSIG record and never reaches a handler. Clock skew of up to 300 seconds is tolerated. Verification works on the wire bytes as received, so it interoperates with other implementations' name compression; a transfer from a primary that signs only some messages (up to 99 unsigned in between) verifies too. TSIG is not applied over DoQ.
Zone client
server::zone::client::ZoneClient is a blocking client (run it from a worker thread) with notify, soa_serial, transfer(server, origin, have_serial) -> Transfer::{UpToDate, Full, Incremental} and update, optionally signing with with_tsig; build_update assembles an UPDATE message. The secondary refresh uses it.
Transports
| Transport | Feature |
|---|---|
UdpDnsClientTransport |
always |
TcpDnsClientTransport / TcpDnsConnectionPool |
always |
DoqClientTransport |
doq (+ hopf-quic) |
DohClientTransport |
doh (+ hopf-http, hopf-core::tls) |
Traits: DnsClientTransport, DnsClientTransportHandler. Each send_query schedules I/O and returns immediately; responses arrive via the handler callback — there is no blocking query API. These aren't just standalone building blocks: DnsResolver::add_server_doq/add_server_doh construct and drive them directly as part of the resolver's own query pipeline (see Resolver), keeping the transport instance alive for exactly as long as one query is outstanding.
DoH (DohClientTransport) and DoQ (DoqClientTransport) are DnsClientTransport implementations: DoH dials HTTP/1.1 (POST by default, or GET via with_get(true)) on a shared Arc<Runtime>; DoQ dials via hopf-quic, zeroing the DNS message ID both directions (RFC 9250 §4.2.1). Neither waits on the caller thread.
DoT wraps the shared TcpDnsConnectionPool with a SharedTlsConnector from hopf-core::tls (typically connector_from_pem or public_trust_connector) when feature dot is enabled. TcpDnsConnectionPool (shared by plain TCP and DoT) reuses a live connection per destination server across queries instead of dialing fresh every time, transparently reconnecting (and, for DoT, re-handshaking) if a reused connection turns out to be stale. Since query_dot is synchronous, DnsResolver::add_server_dot drives each query on a dedicated spawned thread — the same pattern already used for UDP's own truncation → TCP fallback.
DoQ limitations: every query still dials a brand-new QUIC connection — no session-ticket caching, 0-RTT, or cross-query connection reuse (RFC 9250 §4.5/§5.5.1) — and EDNS Padding (encode_edns_padding, RFC 7830/RFC 9250 §5.4) isn't automatically applied to DoQ/DoT queries, though the codec is available for a caller to compose in.
DNSSEC
Feature dnssec (+ aws-lc-rs and ed448-goldilocks-plus):
| Type | Role |
|---|---|
DnssecValidator |
Per-message validation (RRSIGs/DNSKEYs present in that one message, against directly configured trust anchors) |
DnssecChainWalk / ChainStep |
Full chain-of-trust walk from a trust anchor down to a name's own zone (RFC 4035 §5.3.1), driven over the network by DnsResolver::validate_chain_of_trust |
verify_denial |
Authenticated denial-of-existence (NSEC / NSEC3, RFC 4035 §5.4) against a chain-verified zone key, driven over the network by DnsResolver::validate_denial_of_existence |
DnssecStatus / DnssecAlgorithm |
Outcomes / algs |
DnssecTrustAnchor / AnchorDs |
Trust anchors (with_iana_root() ships the current IANA root DS) |
verify_rrsig / verify_ds / nsec3_hash / … |
Low-level |
Validation only — no signing. Off by default on the resolver (set_dnssec_enabled / set_dnssec_validator). Algorithms: RSASHA256(8), RSASHA512(10), ECDSAP256SHA256(13), ECDSAP384SHA384(14), Ed25519(15), Ed448(16) — Ed448 verification uses the pure-Rust ed448-goldilocks-plus crate (aws-lc-rs has no Ed448 support).
The DnsResolver's own per-query validation (run automatically whenever a validator is configured) sets the response's AD bit when a message validates as Secure, and honours the query's own CD bit (RFC 4035 §3.2.2/§3.2.3) — a CD=1 query gets its answer even if validation comes back Bogus, with AD left unset. The forwarder (DnsService) relays a downstream client's own CD bit upstream via DnsResolver::query_with_cd, so a validating downstream resolver's own re-validation still works through the forwarder. Known limitation: the AD bit is not currently preserved across a cache hit (resolver- or forwarder-side) — a cached answer is served without re-asserting AD.
NSEC3 closest-encloser proofs cover direct NXDOMAIN/NODATA denial only — wildcard non-existence (RFC 5155 §8.3's optional extra step) and Opt-Out (§3/§6) are not implemented.
Configuration
Defaults
| Knob | Default |
|---|---|
DEFAULT_DNS_PORT |
53 |
DEFAULT_TIMEOUT |
5 s |
MAX_CNAME_DEPTH |
8 |
use_edns |
true |
use_cookies |
true |
use_bailiwick |
true |
tcp_fallback |
true |
| DNSSEC | off |
Resolver setters
set_timeout, set_cache / cache, add_server / add_server_str (UDP+TCP-fallback), add_server_dot (dot feature), add_server_doq (doq feature), add_server_doh (doh feature), use_public_resolvers (Cloudflare, Quad9 and Google over IPv6 and IPv4 - see below), use_system_resolvers, set_dnssec_enabled, set_dnssec_validator.
Note: addresses passed to any add_server* method must be pre-resolved. add_server_dot takes a TLS SNI name + SharedTlsConnector; add_server_doq takes a TLS SNI name + Arc<QuicClientConfig> (ALPN doq); add_server_doh takes an Arc<Runtime> (needed to dial), host/path, a SharedTlsConnector, and a GET-vs-POST flag.
Public fallback resolvers and IPv6
When the host has no nameserver configured (/etc/resolv.conf missing or empty), use_system_resolvers falls back to the well-known public resolvers, as does an explicit use_public_resolvers(). Nameservers from resolv.conf, and anything added with add_server*, are used exactly as configured and are never subject to what follows.
The list is Cloudflare, Quad9, then Google (Google last, having no DNS over QUIC), each provider's IPv6 address placed just ahead of its IPv4 one, primaries then secondaries:
2606:4700:4700::1111 1.1.1.1 2620:fe::fe 9.9.9.9 2001:4860:4860::8888 8.8.8.8
2606:4700:4700::1001 1.0.0.1 2620:fe::9 149.112.112.112 2001:4860:4860::8844 8.8.4.4
Interleaving means a dead address family cannot stack timeouts: a query advances only after the query timeout (5 s by default), so listing all six IPv6 addresses first would cost 30 s on a host without IPv6. Instead each IPv6 entry is subject to three separate signals, each with its own bounded memory:
- No route. Before using an IPv6 fallback the resolver asks the kernel, sending nothing (a connected UDP socket's source address). No route, no IPv6 at all, or only link-local, loopback or unique-local addresses means no path to the public IPv6 internet: every IPv6 fallback is skipped instantly and not probed again for 5 minutes, so a host that later gains IPv6 is noticed.
- This address failed. A timeout marks that one address bad for 2 minutes. Other IPv6 addresses stay eligible, so providers cover each other and the probe moves on to the next provider.
- Path blackholed. When IPv6 addresses of two or more providers have timed out and an IPv4 address answers, or when a hedged IPv4 copy beats the IPv6 attempt, IPv6 loses its head start for 5 minutes. An answer over IPv6 restores it immediately.
Head start. While IPv6 is eligible it is tried first, but only briefly: if nothing has come back after 250 ms (RFC 8305's connection attempt delay) the same query is also sent to that provider's IPv4 address and the first answer wins. A timeout of the pair skips to the next provider rather than repeating the IPv4 address. IPv4 addresses always stay in the list.
Encrypted transports. Every listed address is seeded with the encrypted transports its operator is known to offer, and a dynamically chosen transport that times out (for instance UDP/853 filtered) is now demoted just like one that errors, so a network that blocks DoQ costs one query timeout per address, once, before falling back to DoT or plain UDP, instead of one on every lookup. A hedged copy uses the same transport selection as the primary attempt, so hedging never downgrades a query to cleartext.
Not covered: a filtered encrypted transport is learned per address, not per provider, so each address of an affected provider pays its one timeout; and an explicitly configured IPv6 nameserver is never probed or skipped.
Cargo features
| Feature | Effect |
|---|---|
| (default) | Stub resolver client |
server |
Caching forwarder + UDP listen |
dot |
DoT (+ hopf-core::tls) |
doq |
DoQ (+ hopf-quic) |
doh |
DoH (+ hopf-http, hopf-core::tls) |
dnssec |
Validation (+ aws-lc-rs + goldilocks) |
integration |
Integration tests |
Wiring APIs
use std::sync::Arc;
use hopf_dns::{DnsResolver, RuntimeDnsExt};
let resolver = DnsResolver::for_runtime(rt.as_ref())?;
// or: DnsResolver::for_reactor(reactor_handle)?
resolver.query_a("example.com", |result| { /* … */ });
resolver.resolve("example.com", 443, |addrs| { /* Happy Eyeballs */ });
// Extension on Arc<Runtime> — async DNS, dial from callback:
rt.connect_by_name("example.com", 443, factory)?;Forwarder sketch:
use hopf_dns::server::{listen_dns_udp, DnsService, DnsServiceHandle, DnsUdpListenConfig, ForwarderHandler};
use hopf_dns::DnsCache;
use std::sync::Arc;
let cache = Arc::new(DnsCache::default());
let service = DnsService::with_handler(ForwarderHandler::new(cache).with_upstream(upstream_resolver));
listen_dns_udp(worker, DnsUdpListenConfig { addr, service: DnsServiceHandle::new(service) })?;
Examples
See Cookbook: DNS proxy.
# UDP caching forwarder
DNS_UPSTREAM="8.8.8.8 1.1.1.1" cargo run -p dns-proxy -- 127.0.0.1:5353| Package | Demonstrates | Knobs |
|---|---|---|
examples/dns-proxy |
UDP caching forwarder | arg1 = bind; DNS_UPSTREAM (def 8.8.8.8 1.1.1.1) |
examples/dns-authoritative |
Authoritative server for a zone file (UDP + TCP), transfers and updates from loopback written back to the file, optional TSIG and forwarding of other names | arg1 = zone file; arg2 = bind (def 127.0.0.1:5353); DNS_UPSTREAM; DNS_TSIG_KEY=name:algorithm:base64 |
cargo run -p dns-authoritative -- examples/dns-authoritative/example.com.zone 127.0.0.1:5353
dig @127.0.0.1 -p 5353 www.example.com A +norecurse
dig @127.0.0.1 -p 5353 example.com AXFR
printf 'server 127.0.0.1 5353\nzone example.com\nupdate add new.example.com 60 A 192.0.2.77\nsend\n' | nsupdate
Smoke: cargo test -p hopf-dns --features integration (tests/resolver_stub.rs; tests/authoritative.rs runs a primary and secondary over real sockets: UPDATE, NOTIFY, timer-only refresh, expiry, IXFR, TSIG).
Telemetry
DNS is not wired for W3C Trace Context propagation the way HTTP (and Hopf’s mail/MQTT services) are. There is no IETF standard for carrying traceparent on DNS queries; experimental EDNS options (e.g. PowerDNS TRACEPARENT) are out of scope.
What is typical in practice — and what Hopf supports today:
- Connection logs — Accept/Dial/Close for DNS listeners and client dials still flow through
TelemetryHookwhen the runtime is started with a telemetry pipeline. - Local server counters —
DnsService::metrics()exposes process-local query/cookie stats (not OTLP instruments). - Optional client spans — applications may time a DNS lookup as a child span under an HTTP or protocol request; that is local timing only and does not continue a distributed trace across the DNS hop.
See Telemetry: DNS for the same guidance from the OTel side.
Limitations
Explicitly out of scope:
- DNSSEC signing of authoritative zones (an externally signed zone is served as data)
- DoH server
- Recursive resolution (the forwarder relies on configured upstreams)
Authoritative serving:
- Class
INonly; zone-file owner names may not contain backslash escapes; only the types listed under Zone files have a presentation format (any type can use the generic form). - Write-back, NOTIFY target selection and applying an IXFR take a copy of the zone under a read lock, so a very large zone costs a copy per change. An update or a completed transfer holds the write lock only while it is applied. Zone loading is blocking file I/O; write-back runs on the maintenance thread.
- Zone maintenance is one sequential thread per handler; NOTIFY is UDP with three attempts per peer.
- The IXFR journal is in memory (512 changes) and starts empty at each start-up, so a restarted primary answers an IXFR with a full transfer.
- Secondaries do not forward updates to the primary (RFC 2136 §6): they refuse them.
- No access control beyond source address and TSIG key (no per-record-type or per-name update policy).
Other:
- The forwarder blocks on its upstream (bounded at five seconds).
- Server addresses must be pre-resolved IPs.
- No W3C Trace Context on the DNS wire (see Telemetry).
- DNS over DTLS (DoDTLS) — not implemented; Gumdrop had transparent DTLS on UDP. Planned when DTLS lands in
hopf-core(see conformance → Security substrate).