Skip to content

v1.9.0

Latest

Choose a tag to compare

@semihalev semihalev released this 02 Oct 23:35
· 8 commits to main since this release
297eeb5

The post-quantum release. sdns now validates DNSSEC signed with ML-DSA-44, a post-quantum signature algorithm assigned for DNSSEC, and its encrypted transports already agree their keys with a post-quantum hybrid, so both halves of DNS security, the data and the channel, hold against a future quantum attacker. It is also the warm cache release: a restart no longer starts cold, and a long-running resolver no longer empties its cache every twelve hours. Clients can now find the encrypted listeners on their own (RFC 9462), each listener can bind several addresses, DNS cookies follow RFC 9018 and work across an anycast set, and a long list of protocol behaviours, found by comparing answers against other public resolvers, is fixed. Recommended for all deployments.

Post-quantum DNS

Post-quantum DNSSEC (#634). DNSSEC algorithm 18, ML-DSA-44, the lattice-based signature of FIPS 204, is now validated: an answer from a zone signed with it is checked and carries AD like any other, and a trust anchor can use it. The signature is the pure variant over the RFC 4034 signed data, with an empty context, as its draft specifies. A zone signed with both a classical and a post-quantum key validates through whichever path this resolver supports (RFC 6840). sdns validates; it does not sign zones. A build running in FIPS 140-3 v1.0 mode, which lacks ML-DSA, treats the algorithm as unsupported, and such answers are insecure rather than bogus.

Post-quantum key exchange on every encrypted transport. DoT, DoH, DoH3 and DoQ, and the DoT and DoH upstreams of a forwarder, agree their session keys with X25519MLKEM768, the hybrid of ML-KEM-768 (FIPS 203) and X25519, with any client or server that offers it, and fall back to X25519 with one that does not. Traffic recorded today cannot be decrypted later by breaking X25519 alone. This comes with the Go 1.24 and later TLS stack, so builds from that toolchain onward already negotiated it; 1.9.0 is the first release that states it, and sdns sets no curve preference that would turn it off.

A cache that stays warm

The cache no longer empties every twelve hours (#688). An answer can never outlive the delegation it was resolved through, and a delegation was only learned again after its lease ran out. A busy delegation such as com. is learned a moment after start, so every answer below it shared one end, and the answer and delegation caches emptied together at every twelve hours of uptime.

A delegation used in the last tenth of its lease is now renewed in the background from its parent, top down, so each renewed delegation inherits its parent's renewed lease; TLDs served from a local root copy are renewed from the copy. Nameserver addresses read in the last tenth of their lifetime are resolved again the same way. A renewal never extends anything on its own authority: the new lease is a fresh referral's, bound by the parent's, with the DS validated again, and a refresh that fails leaves the entry to run out as before. Delegations nobody uses are not renewed, so they and the answers under them still leave at the end of their lease. New metric dns_resolver_refresh_total{type,result}.

Warm restart (#629, #630, #645).

  • With hyperlocal_root = true, every verified root copy is also written to root.zone in the state directory and served from at the next start. It is verified again (ZONEMD) against the current trust anchors before use, and keeps its original transfer time, so a restart never extends how long the copy may be served.
  • With the new cache_persist = true (off by default), the answer cache is saved to cache.snapshot at a clean shutdown and loaded before the first answer. Each answer is aged by the time the process was down and admitted again like an answer from the network, never trusted as stored. Answers with under ten seconds left and ECS-scoped answers are not saved. Answers under the local root copy keep its signature expiration across the restart.
  • Startup takes longer by the load, about two seconds for a million answers. Shutdown can take up to five seconds longer while the file is written.
  • A snapshot written by a build or configuration that judges answers differently, a different set of supported DNSSEC algorithms included, is discarded rather than loaded. After a crash the next start loads the last clean save.

Stale while revalidate (#675). serve_stale_mode = "immediate" answers the first query after an entry expires at once from the stale entry, with EDE 3 and a TTL of 30 seconds, and refreshes it in the background. It applies only to a client's own recursive question, never to an ECS-scoped entry, and the delegation lease and serve_stale_max_ttl still bound it. The default, "failure", is unchanged. RFC 8767 advises against the immediate mode, so it is opt in.

Expired entries leave the caches (#676, #683). An answer nobody asked for again stayed in memory until evicted, counted as cached. Every answer is now filed in an expiry index for the instant nothing can serve it any more, and the pruner removes it within seconds of that instant without walking the cache. With serve-stale on, an expired denial is dropped at once, since serve-stale never answers from one. The resolver's delegation and glue caches, which never dropped an expired entry, are pruned the same way, and their sizes are exported for the first time: dns_resolver_cache_size{type} and dns_resolver_cache_pruned_total{type}.

A smaller cache (#632, #635, #638). The fixed part of a cached answer went from 224 to 160 bytes, and every cache map slot from 24 to 16 bytes. At a million answers that is about 50 bytes less heap per answer and some 17 MB less map storage.

Discovery of Designated Resolvers (#628, #640, #641, #646, #649, #671)

A device configured with the resolver's IP address can now learn that it also speaks DoT, DoH and DoQ, and upgrade on its own (RFC 9462):

[ddr]
enabled = true
name    = ""   # empty takes the certificate's first DNS name
  • sdns answers _dns.resolver.arpa with one SVCB record per listener that is actually up, in the order DoH, DoT, DoQ, and carries the target's own A and AAAA records in the Additional section when they fit.
  • Address hints come only from ipv4hint and ipv6hint, never from a bind address, and a listener bound to loopback is not advertised. A DoH listener behind a reverse proxy is advertised as the proxy publishes it, with doh_port and doh_alpn.
  • The DoT listener now selects the dot ALPN for a client that offers it; other clients connect exactly as before.
  • Clients upgrade only when the certificate lists the resolver's IP addresses; the feature page explains.

Every name under resolver.arpa is now answered locally and never sent upstream, whether discovery is enabled or not, as RFC 9462 requires. The apex SOA and NS are answered, anything else is NODATA.

Several addresses per listener (#686)

bind, bindtls, binddoh and binddoq take one "host:port", as before, or a list:

bind    = ["192.0.2.10:53", "[2001:db8::1]:53", "127.0.0.1:53"]
bindtls = ["192.0.2.10:853", "[2001:db8::1]:853"]

A listener on several addresses is still one listener: its workers, connection limit and memory budget are shared, not multiplied. It opens every address or none, and a failure names the address that could not be opened. sdns -t refuses an address listed twice, however it is spelled, and a wildcard listed with specific addresses. DoH's Alt-Svc names the port the request arrived on.

DoT upstreams authenticated by name (#685)

A DoT forwarder, in forwarderservers or a forward zone, can be held to a name given beside its address, RFC 8310's address plus authentication domain name:

forwarderservers = ["tls://9.9.9.9:853#dns.quad9.net"]

The name is sent as SNI and the certificate must be valid for it; it is never resolved. A certificate that does not match fails that upstream and the next one is tried; the same server is never retried without the name. Without a name the certificate must name the IP address, as before.

DNS cookies, RFC 9018 (#673, #674, #666)

  • Interoperable cookies. Server cookies are now RFC 9018 SipHash cookies, verified statelessly. cookiesecret given as 32 hex digits is the key itself, so the servers of an anycast set, other DNS software included, can share it; any other value keeps working as before.
  • NAT works. Clients behind one address no longer take turns getting BADCOOKIE.
  • Two buckets per client address. Traffic from proved sources and the rest are limited separately, so a spoofed flood cannot spend a real client's allowance. A matching cookie is rate limited like everything else, BADCOOKIE challenges are capped at one a second per address, and HTTP/3 early data counts as unverified until its handshake completes.
  • Asking for a cookie. A query with no question and a COOKIE option is answered with a server cookie (RFC 7873 §5.4) instead of FORMERR.
  • Strict parsing. Only the first COOKIE option is used, and one of an invalid length is FORMERR.

DNSSEC

  • A validation failure is never failed over (#639). fallbackservers no longer receive a query whose SERVFAIL came from DNSSEC validation, through every path including the failure cache; a fallback that does not validate would otherwise hand back the very data validation rejected. Network failures still fail over.
  • An alias whose target fails, fails (#657). A CNAME whose target answered SERVFAIL, a bogus one above all, was served as NOERROR with AD set. It is now SERVFAIL with the target's Extended DNS Error.
  • RRSIG questions (#659, #664, #668). Asking for the RRSIGs at a name in a signed zone answers instead of SERVFAIL, any other RRset such an answer carries is validated, and the question no longer leaves its zone's delegation cached as insecure for later questions.
  • Reasons, not just codes (#644, #662, #663, #667).
    • A signature not yet valid says EDE 8, not 7.
    • A cached failure replays its own Extended DNS Error instead of Cached Error.
    • An answer from a zone whose only DS this validator cannot use says why it is insecure (EDE 1 or 2), across aliases too.
    • A bogus verdict's text no longer repeats itself.
  • Two clocks (#637, #654, #655). A lease's monotonic deadline and a signature's wall-clock expiration are kept separately end to end, so a wall-clock step can neither extend nor cut short what the other bound allows. Under a local root copy, the RFC 8020 and RFC 8198 caches now work, where before they never shared a denial.

Protocol behaviour (#658, #660, #661, #662, #663, #666, #669, #670, #671, #672)

Question Before Now
A type with no registered name (RFC 3597) no reply resolved like any other
A class other than IN sent to the root servers NOTIMP with EDE 21
A CHAOS name the server does not answer sent to the root servers REFUSED
ANY NOTIMP NOTIMP with EDE 21
AXFR, IXFR resolved upstream REFUSED
NXNAME resolved upstream FORMERR with EDE 30 (RFC 9824)
RD=0, answer cached SERVFAIL the cached answer, RD echoed
RD=0, not cached SERVFAIL SERVFAIL with an EDE saying so
Unsupported opcode NOTIMP, could set AD NOTIMP, RD echoed, no AD
A second OPT, or one not owned by the root one of them used FORMERR
Padded query over DoT, DoH or DoQ reply unpadded reply padded (RFC 8467)
DoH Cache-Control always no-store private, max-age from the answer
A hosts file name, family not listed resolved upstream NODATA
  • Every reply echoes the client's RD, failures and policy answers included.
  • A UDP reply that would overflow the client's buffer drops its Extended DNS Error before it truncates.
  • CNAME chains and subtree-cut denials answered from the cache are compressed again; some chains were going out at more than twice their packed size.

Transports, API and blocklist

  • DoQ on its own engine (#650). DoQ moves to the byte path, with a connection cap and a place in the resource plan, and follows RFC 9250 as written. Only the doq ALPN is offered; the draft tokens are gone. A non-zero message ID, a second message on a stream or a client unidirectional stream closes the connection with DOQ_PROTOCOL_ERROR, and a query with no answer is reset rather than closed empty.
  • API (#647). The HTTP API runs on net/http's router. It compares the bearer token in constant time, refuses state-changing requests a browser sends on behalf of another site, no longer grants CORS, refuses HEAD on changing routes and serves pprof to GET only.
  • Blocklist (#648, #652). A server with a loaded list keeps its cache hits on the byte path, since the lookup no longer decodes the query. Blocking a name already blocked is reported as skipped, not added, and no longer rewrites the list file.

Dependencies

quic-go 0.63.0, k8s client-go 0.37.1, lz4 4.1.31, and a CI action update (#636, #679, #680, #687). Release binaries are built with Go 1.27.1.

Upgrading

A drop-in replacement for 1.8.3. No setting is renamed or removed, and every new feature is off until configured, except delegation renewal, which needs nothing. Configuration files keep working. The startup notice about an older config version is informational, and regenerating the file picks up the 1.9.0 template.

  • Building from source needs Go 1.27. Release binaries and images are unaffected.
  • Go API. The config.Config fields Bind, BindTLS, BindDOH and BindDOQ are now config.Addrs ([]string) instead of string. Code that builds a Config directly writes config.Addrs{":53"}.
  • DoQ offers only the doq ALPN; clients still on a draft token will not connect.
  • Cookies. Server cookies issued by 1.8.x are not recognised; a client is sent a fresh one on its next query. To share cookies across an anycast set, set the same 32 hex digit cookiesecret on every member.
  • resolver.arpa is answered locally even with [ddr] off.
  • With cache_persist = true, shutdown takes up to fifteen seconds: up to ten for the listeners to drain, as before, and up to five for the save. A service manager with a shorter stop timeout cuts the save short; systemd's default leaves ample room.