NewYour coding agent can read the release notes before it upgrades.Set up the MCP server →
Go modules · #1714 by repository stars
Last release 3 days ago
05 Oct 2026
Ships unpredictably
gaps range from 8 days to 5 months
Rarely documented
notes for 13 of 59 stable releases
Nothing withdrawn
no release was ever pulled
8 years old
101 releases · first in 2018
Nothing published for this version
The post-quantum release. sdns now validates DNSSEC signed with ML-DSA-44, a post-quantum signature algorithm assigned for DNSSEC, and its encrypted t
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 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.
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).
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.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.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.
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_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.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.dot ALPN for a client that offers it; other clients connect exactly as before.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.
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.
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.
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.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.| 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 |
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.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.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.
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.
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 ALPN; clients still on a draft token will not connect.cookiesecret on every member.resolver.arpa is answered locally even with [ddr] off.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.One column per quarter.
Nothing published for this version
The correctness release. The cache now serves only what it can vouch for: a denial lives exactly as long as its zone says, a signature that has lapsed
The correctness release. The cache now serves only what it can vouch for: a denial lives exactly as long as its zone says, a signature that has lapsed or has not yet begun is never handed out from cache, and a client that did not ask for DNSSEC receives none of it. Two cold-cache validation failures that answered SERVFAIL on a freshly started resolver are fixed, ANY is declined the same way on every path, and RPZ feeds in the shape most vendors publish now load. Recommended for all deployments, and especially for validating ones.
RFC 2308 says how long a resolver may cache a denial: the smaller of the SOA's TTL and its MINIMUM field. sdns lifted anything shorter to five seconds. A zone that publishes a one-second negative TTL now gets one second, and a denial with no lifetime, or with no SOA at all, is not cached.
Taking that floor away exposed how the cache judged signatures, and the rest of this section is the rule every change below enforces: the cache serves only what it vouches for, at a TTL it can honour, with no DNSSEC record a DO=0 client did not ask for.
RRSIG query is cached whole or not at all.Hit paths cost the same as before; admission pays 30 to 40 ns per response for the extra passes.
Two failures that answered SERVFAIL on a freshly started resolver and cleared once the cache was warm, found and fixed by @Du-vy.
ANY is declined on every path (#617)ANY is answered NOTIMP by design, and now that holds in every mode. A whole-server forwarder and a forward zone no longer hand the question upstream, the cache no longer answers it from failure backoff or a subtree cut, and the refusal is neither cached as a failure nor taken as the recovery that resets a zone's backoff: the next question for the same name resolves normally. NOTIMP from an upstream is unchanged, so failover still moves to the next server.
Many feeds, abuse.ch URLhaus among them, write their SOA as @ with every rule relative to it and leave the apex to the consuming server. A file zone now accepts origin for this:
[[rpz.zone]]
name = "urlhaus"
file = "/var/lib/sdns/urlhaus.rpz"
origin = "rpz.urlhaus.abuse.ch."A file whose SOA is absolute needs nothing new. Without origin a relative feed fails to compile and sdns -t says so.
The documentation moved out of the README into a site: getting started, configuration by what a setting does, a page per feature, deployment, a full config key reference, the benchmarks, and the first reference for all of the metrics sdns exports. The README keeps install, a quick start and a summary.
quic-go 0.62.0, golang.org/x/sys 0.48.0, x/sync 0.23.0, x/time 0.16.0, prometheus client_model 0.6.3, and CI action updates (#614, #615, #616, #618, #619, #620, #621, #622, #626).
A drop-in replacement for 1.8.2. No setting is renamed or removed. Configuration files keep working; the startup notice about an older config version is informational, and regenerating the file picks up the 1.8.3 template.
Nothing published for this version
The policy release: 1.8.1 rebuilt how sdns resolves, 1.8.2 adds what a resolver is allowed to answer. Response Policy Zones arrive complete — every tr
The policy release: 1.8.1 rebuilt how sdns resolves, 1.8.2 adds what a resolver is allowed to answer. Response Policy Zones arrive complete — every trigger, every action, commercial feeds unmodified — alongside a locally served root zone (RFC 8806), stale answers as a last resort (RFC 8767), per-zone forwarding, and a configuration gate that judges the whole file at once. Everything new ships off or in shadow by default. Recommended for all deployments.
A full RPZ subscriber: policy zones in the standard encoding — locally maintained files or vendor AXFR feeds — rewrite, deny, or drop answers for the names, client networks, and answer addresses they list. Existing commercial feeds work unmodified.
rpz-ip) prefixes, IPv4 and IPv6 with family separation — a v6 rule can never match a v4 address or the reverse. All six standard actions: NXDOMAIN, NODATA, PASSTHRU, DROP, TCP-Only, and Local Data served as if authoritative for the query name, CNAME chasing included. Per-zone policy overrides replace a whole zone's actions, cname builds a walled garden, and disabled observes without consuming — a later zone still acts.rpz_action_total{zone,trigger,action,outcome} is the flip-decision dashboard.sdns -t validates the whole [rpz] block with the same loaders the server runs.Serve the root zone from a verified local copy: AXFR from the root servers, ZONEMD-verified (RFC 8976) against the DNSSEC trust anchors, refreshed on the zone's own SOA schedule. Root referrals, junk-TLD NXDOMAINs, and questions at the root itself cost no upstream query — and the answers are provably the root zone, not whatever a transfer happened to deliver. Off by default (hyperlocal_root).
When resolution ends in SERVFAIL, an expired positive answer can serve instead of the failure — bounded by serve_stale_max_ttl (24h default), and always by the parent-granted delegation lease, which remains a hard ceiling: ghost-domain protection is not traded away for availability. Positive answers only, triggered by failure only. A stale alias whose target is still fresh serves the completed chain and is charged like the hit it is. Off by default (serve_stale).
sdns -t now checks every setting it can judge — addresses, ports, CIDRs, enumerated values, upstream formats, TLS files, RPZ zones — and reports all problems at once instead of dying on the first. Keys the file carries that no setting claims fail the test (a typo, or a key an older sdns understood); startup only warns, so an upgrade with a stale key cannot become an outage. The validators mirror the runtime: ports resolve through the same lookup dialing uses, TSIG keys parse with the same parser the feed loop runs, RPZ files load with the server's own loader — a config that passes is one the server can actually run.
[[forward_zone]] sends one zone's queries to its own recursive upstreams while everything else resolves normally — the classic stub/conditional-forwarding split. Most specific zone wins; servers accept the same UDP/TCP, DoT, and DoH forms as the global forwarder.
SDNS_PPROF and debug switches read as booleans — any value used to count as on (#593).The README was audited key-by-key against the configuration it documents: every TOML section now reaches the master table, the RPZ operator reference lives in the README, and a section index sits above the configuration table.
Dependency updates: k8s.io/client-go 0.37.0, github/codeql-action 4.37.9.
…widens the step instead, and so does sdns now. Deprecated qname_min_level is still read when the new key is unset (with a startup warning), and an exp…
The resolution release: 1.8.0 rebuilt how sdns serves, 1.8.1 rebuilds how it resolves. QNAME minimisation now follows RFC 9156's actual mechanism at the RFC's recommended values, upstream selection is rebuilt around honest evidence, the walk stops waiting on nameserver bookkeeping it doesn't need, and two served-TTL bugs found in production are fixed — one of which affects every deployment. Recommended for all deployments.
qname_min_level capped the label depth walked minimised, and depth counts labels the resolver never had to expose: once a delegation was cached, the budget arrived already spent and the full name went out on the first query — the warmer the cache, the less a resolution minimised, and the labels still hidden at that point are the private ones. The budget now counts the minimised queries the request actually sends, which is what RFC 9156 §2.3 bounds.qname_max_minimize_count (MAX_MINIMISE_COUNT) and qname_minimize_one_label (MINIMISE_ONE_LAB) ship at 10 and 4. The old degenerate pair had no grouping phase — at the cap the whole name went out; the RFC keeps minimising and widens the step instead, and so does sdns now. Deprecated qname_min_level is still read when the new key is unset (with a startup warning), and an explicit qname_max_minimize_count = 0 now really disables minimisation even with the old key still in the file — zero used to read as "unset."_dmarc, _25._tcp, …): a run of service labels is taken in one step — no zone cut hides behind _tcp, so probing it alone spends a query to hide nothing. Probes go out as QTYPE A per §2.1, restoring a 2020 behaviour a 2023 refactor silently lost: a client asking DS or NSEC no longer has that type put on a name whose zone doesn't answer for it, and one cached probe serves clients of every type. Internal DS/DNSKEY lookups stop minimising — they carry the resolver's own bookkeeping, and minimising them let deep names exhaust query budgets on walks that hid nothing.Upstream selection was rebuilt around a simple principle: rank on evidence, and don't let silence or refusal manufacture any.
BENCHMARKS.md measured the cached-answer serving ceiling and said plainly that it wasn't a prediction of miss-path behaviour. That half is now measured: 50,000 names against an empty cache, four resolvers alternating over three rounds on one host, dual-stack shipped defaults, file descriptors and timeouts equalised, in both minimisation modes. Medians: minimisation off (the engine comparison) — sdns 905 q/s, PowerDNS Recursor 799, Knot Resolver 534, Unbound 399; as shipped — meaningful for the first time, now that sdns runs the same 10/4 the others do — sdns 658, PowerDNS 636, Knot 436, Unbound 243. The document records the methodology corrections that mattered most (address-family pins, fd limits — each moved the answer by more than the gaps being measured) and why the unanswered column (SERVFAIL + lost together) is what makes the throughput column readable.
Drop-in. Configs still on qname_min_level keep working with a deprecation warning; moving to qname_max_minimize_count = 10 / qname_minimize_one_label = 4 (the generated template's values) is recommended — the legacy key's mapping is a weaker privacy setting than the shipped default. Operators with TTL-sensitive automation downstream should note that served TTLs are now consistently bounded by the delegation lease from the first response onward.
The serving-engine release: sdns now owns its transport engines end to end, answers cached queries as stored bytes without building a message, and — m
The serving-engine release: sdns now owns its transport engines end to end, answers cached queries as stored bytes without building a message, and — measured on identical load against PowerDNS Recursor, Unbound, and Knot Resolver — outserves all three on both UDP and TCP. Recommended for all deployments; high-QPS resolvers benefit most.
miekg/dns server layer is retired. UDP runs on preallocated job slabs, fixed workers behind a ready ring, and batched kernel I/O (recvmmsg/sendmmsg on Linux); TCP and DoT run an owned accept loop with prefix-first framing and syscall-batched streams. Every reply leaves as raw bytes from job-owned storage.memory_trim setting returns burst memory to the OS once the engines quiesce — and quiescence is a real barrier the tests assert, not a heuristic.dns.Msg being built.Middlewares no longer materialize a request just to look at it: metrics reads the domain from the wire (#562), ratelimit runs its token and cookie checks on parsed offsets (#563), hostsfile and as112 answer from wire-keyed lookups (#563, #566), and the reflex/dnstap writer wrappers pass the byte path through instead of pushing hits back onto the message path (#552). On a production node, the share of client traffic served on the byte path went from zero to over 80%.
dns.UnpackDomainName is retired repo-wide (#553, #555, #564): purpose-built wire walkers present, fold, and canonicalize names from packet bytes with stack buffers and map-index lookups — zero allocations, parity-tested against the library on every vector including the hostile ones.netip parity (#570); the RFC 9520 attempt guard keyed by hash instead of composed strings (#570); endpoint identity kept, not re-derived per lookup (#548, #533, #532); five question formatters folded into one wire-reading helper with hot debug lines guarded (#556); per-query context plumbing trimmed (#557).The verification rewrite was driven by allocation profiles, but holding the library's semantics up to the RFCs fixed real validation outcomes along the way:
foo\.example.com. is a two-label name and no longer authenticates against example.com.'s keys.Profile-driven, each step A/B-measured on a 32-core host before the next was attempted:
sendmmsg per cycle. Misses replay on a worker under a chain-level replay mark that keeps entry effects (rate-limit tokens, reflex scores, dnstap query frames) exactly once per query while response observers still fire.Shipped only after independent multi-pass adversarial review — every finding (in-flight accounting, the replay contract for dual-keyed middlewares, dnstap wire transparency, per-entry rate-limit double charges) closed with the contract under test, including a Linux end-to-end whose quiescence assertion fails on the broken accounting.
Measured on the same corpus and harness across the arc: UDP cached answers 268k → 424k qps median / 444k best; TCP ~100k → 226k median / 273k best.
Head-to-head on one 32-core host, identical load, DNSSEC validation on everywhere — full method, configurations, spread bands, and caveats in BENCHMARKS.md:
| resolver | UDP median | TCP median |
|---|---|---|
| sdns 1.8.0 | 424k | 226k |
| PowerDNS Recursor 5.4.1 | 371k | 56k |
| Unbound 1.24.2 | 343k | 136k |
| Knot Resolver 6.2.0 | 191k | 142k |
sdns runs its full middleware chain per query in those numbers, and the untouched default configuration measures in the same band as the tuned one. These are cached-answer serving ceilings on loopback, not production predictions — the document says so plainly.
10.IN-ADDR.ARPA.) no longer slip past the as112 empty zones into recursion, and a mixed-case emptyzones entry no longer becomes a dead key that passes validation yet never serves.dns_udp_inline_total{outcome} — inline-served vs handed-off queries; the live health signal of the new fast path.dns_udp_ingress_drops_total{reason} and dns_tcp_ingress_drops_total{reason} — every shed packet or connection has a named reason; dns_udp_ingress_overflow_total counts queries served outside the fixed pool, the signal that the pool is undersized for the traffic.dns_cache_wire_* decline counters (#543) — which gate turned a hit away from byte serving.dns_blocklist_entries (#538), and domain metrics now read from the wire with a bounded default (domainmetricslimit = 1000) (#562).dns_ingress_plan — the engine's computed resource plan (slabs, workers, sockets, connection caps) as a labeled gauge, and the same bounds printed in the listeners' startup lines, so what the admission arithmetic decided for this host is visible instead of implied.testify is retired; the standard library says it plainly (#561).Chain.Request is no longer a *dns.Msg: it is a *middleware.Request, a zero-copy view over the query's wire bytes that decodes only on demand. External middlewares and plugins that read the message directly must now either take wire-level facts from the Request's accessors (qname, qtype, EDNS state — no decode, keeps the query on the byte-serving path) or fetch the decoded message with ctx, req := ch.Materialize(ctx). Two new chain marks also matter for handler semantics: on the UDP fast path the chain can run twice per query — an inline pass on the transport reader and, when the cache hands off, a replay pass marked ch.Replay(). A handler with once-per-query entry effects (tokens, scores, counters, log lines) should skip them when ch.Replay() is true, and a handler that must block — network calls, contended locks — should decline an ch.InlineOnly() pass with ch.MarkHandoff() and return, exactly as the cache does. For transport and module authors: custom transports implement middleware.Transport (the dns.ResponseWriter-based contract is gone), though *Server remains a dns.Handler through retained delegating shims; WriteMsg no longer mutates the message it is handed (extended rcodes land in the wire alone, so sharing a message across goroutines is sound); response sizes surface through the optional ResponseSizer capability rather than a widened writer interface; and the DoQ server's Handler is its own small interface now.ingressworkers (engine workers per listener), ingressqueue (ready-queue depth), ingresstcpconns (TCP/DoT connection cap — an explicit value above what the descriptor limit can serve is clamped with a warning, because connections past the kernel's grant are EMFILE at accept, not capacity), and memorytrim (opt-in burst-memory return on quiescence).sdns.toml fallback is removed. 1.7.x silently loaded a working-directory sdns.toml when sdns.conf was missing; 1.8.0 generates a fresh config at the requested path and logs a warning if a leftover sdns.toml is found — migrate those settings manually, or the process runs on defaults. Relatedly, -c with a custom path now generates a missing config at that path instead of failing to load./metrics scrapes are uncompressed and the promhttp_metric_handler_* self-instrumentation series are gone (#570). A dashboard charting the scrape handler's own stats loses those series; every DNS metric is unchanged, and none were removed or relabeled.domainmetricslimit default dropped from 10000 to 1000 (#562). An explicit value in your config is honored unchanged; only deployments relying on the old default track fewer domains. 0 still means unlimited.bind = ":53" listens on [::]:53 alongside 0.0.0.0:53, so a dual-stack host answers over IPv6 with the stock configuration. Review firewall rules written for v4 only; an explicit address in bind behaves exactly as before.A resilience release: request-level work budgets, standards-based negative caching, and the fixes from a production outage post-mortem — plus a measur
A resilience release: request-level work budgets, standards-based negative caching, and the fixes from a production outage post-mortem — plus a measurably faster hot path. Recommended for all deployments; open resolvers exposed to untrusted query load benefit most.
off / shadow / enforce via [recursion_firewall] mode. Shadow records budget crossings in metrics plus a rate-limited log line naming the query, without changing any response; enforce terminates over-budget trees with SERVFAIL and an RFC 8914 Extended DNS Error. Calibrate from the dnssec_work_per_request and dns_recursion_fanout_ratio histograms before enforcing.rfc9520. First field hour on a public node: ~16 answers/s served from failure state — retry load that no longer reaches upstreams.rfc8198. (~4.8k upstream lookups/hour avoided on the same node.)Root-caused from a 2026-07-28 production incident (1.43M goroutines, 93% blocked behind cache segment locks during a partial upstream outage):
dns_resolution_shed_total{scope}.querytimeout now bounds the whole pipeline from ingress on every transport (UDP/TCP/DoT/DoH/DoQ). A lazily-armed deadline context keeps its cost invisible: versus a standard context.WithTimeout at ingress, pipeline overhead drops 481→200 ns (7→1 allocs) and a positive cache hit 760→471 ns (15→9 allocs).querytimeout is now end-to-end: cache dedup waits, DNS64 subqueries, and failover all count against it, and fallbackservers are not tried once a blackholed upstream has consumed the window.cachesize now backs positive/NXDOMAIN entries, and SERVFAILs use the RFC 9520 failure cache (failure_cache_* settings under [recursion_firewall]).rfc8198, rfc9520, [recursion_firewall]). Older config files get the out-of-version notice — regenerate to see the new sections, or keep running: omitted settings mean shadow mode with both switches on.maxconcurrentqueries/16 (min 16); very-high-QPS cold-cache deployments should size maxconcurrentqueries accordingly.Full changelog: v1.7.3...v1.7.4
A security release fixing two reported advisories and two additional DNSSEC forgery vectors. Recommended for all deployments — validating resolvers sh…
A security release fixing two reported advisories and two additional DNSSEC forgery vectors. Recommended for all deployments — validating resolvers should upgrade promptly.
min(answer TTL, cut deadline) enforced at read time, with the prefetch write-back made CAS so a stale refresh cannot resurrect an expired lease (#515). The full design is committed at docs/security/ghost-phoenix-durable-design.md.NSEC3.Cover() accepts a hash equal to the owner inside an ordinary interval, so an NSEC3 proving a name exists was accepted as proof it doesn't — enabling forged authenticated NXDOMAINs and bypassing the wildcard next-closer check above. Coverage is now strict: exact owner matches are excluded at every NSEC3 coverage call site. (#516)tether.edge.apple, #506): when a server authoritative for several zones of the chain answers with no referral crossed, the insecure-delegation proof demanded an exact-match NSEC3 that opt-out zones cannot have by definition — and the resulting SERVFAIL latched in the negative cache. Opt-out delegations are now proven via the RFC 5155 §8.6 covering path. (#507)Thanks to @MaciejTe for reporting and providing a clean PoC for the CD-bit cache poisoning, and to @Bubb1eGvm for reporting the ghost-domain attack — both reported responsibly through GitHub private vulnerability reporting.
Full changelog: v1.7.2...v1.7.3
A DNSSEC correctness release. Recommended for all validating deployments.
A DNSSEC correctness release. Recommended for all validating deployments.
tr.+ns.tr., comcast.net+tx.comcast.net), it answers the child name authoritatively — no referral is crossed. v1.7.1 mis-attributed the answer to the signed parent and SERVFAIL'd the child's legitimately-unsigned records, breaking every signed zone whose nameservers live in an unsigned in-bailiwick subzone (e.g. the whole .tr TLD intermittently lost validation). Such data is now accepted as insecure only when cryptographically proven — an authenticated DS, or an exact NSEC3/NSEC delegation proof (NS set, DS/SOA clear; opt-out rejected for the no-referral case). The DS authentication is fail-closed and downgrade-safe: the DS lookup is validated explicitly before any conclusion, and a forged unsigned DS (even with an unsupported algorithm) cannot downgrade a signed child. (#501)internal/dnsclient) replacing the vendored miekg client copy — UDP/TCP, DoT, and DoH — with the resolver and forwarder client paths trimmed accordingly. (#500)Full changelog: v1.7.1...v1.7.2
SECURITY.md now supports the 1.7.x line and directs vulnerability reports to GitHub's private reporting.
A security and hardening patch.
crypto/rsa ceiling (2³¹-1) — restores DNSSEC validation for zones anchored on such keys, including the entire .lv (Latvia) TLD and mailbox.org. (#495)govulncheck clean); the snap build was on the long-stale Go 1.23.4.--config path handling fixed for Windows.doc.go) to match the current architecture.SECURITY.md now supports the 1.7.x line and directs vulnerability reports to GitHub's private reporting.
Full changelog: v1.7.0...v1.7.1
Nothing published for this version
A feature release: EDNS Client Subnet (RFC 7871) lands in two stages — opt-in upstream forwarding plus an ECS-aware cache that closes #417 (cache poll
A feature release: EDNS Client Subnet (RFC 7871) lands in two stages — opt-in upstream forwarding plus an ECS-aware cache that closes #417 (cache pollution across client subnets). DNS-over-HTTPS forwarder upstreams (RFC 8484) close #473. The metrics surface is rebuilt around a new sharded-counter shim that's roughly 20× faster than direct Prometheus on the hot path, and observability gains 23 new counters across the policy, resolver, server, and DNSSEC paths. Two correctness fixes for blocklist subdomain matching and a resolver context-key collision panic are included.
EDNS Client Subnet — Stage 1: opt-in upstream forwarding (RFC 7871, #483). When a client sends an EDNS0_SUBNET option, SDNS now forwards a clamped form upstream — previously every ECS option was stripped per RFC 7871 §11 privacy guidance. Forwarding stays off by default; operators opt in per server.
forward_v4 / forward_v6 ceilings (defaults 24 / 56) so a privacy-leaky client (e.g. one sending its full /32) can't widen the leak beyond the operator's policy.client_networks = [...] (CIDRs); empty = all clients.Configuration:
```toml
[ecs]
enabled = false # default off
forward_v4 = 24
forward_v6 = 56
client_networks = [] # CIDRs; [] = all clients
cache_limit_ttl = "5m" # ceiling on scoped-cache entries (Stage 2)
min_scope_v4 = 24 # cache-cardinality floor (Stage 2)
min_scope_v6 = 56
```
EDNS Client Subnet — Stage 2: cache partitions by ECS scope (closes #417, #484). The cache keys entries by the authority's response SCOPE so a geo-tailored answer for one client subnet is never served to a client in a different subnet — the original "cache pollution" report.
Stage 1 is the prerequisite for Stage 2; Stage 2 cannot be enabled without Stage 1's forwarding. The split was deliberate so operators could validate ECS reaches their authorities first, then opt into the cache change separately.
DNS-over-HTTPS forwarder upstreams (RFC 8484, closes #473, #486). `forwarderservers` now accepts `https://` URLs alongside the existing UDP and `tls://` (DoT) forms — both IP-literal and hostname URLs supported.
```toml
forwarderservers = [
"1.1.1.1:53", # plain UDP
"tls://1.1.1.1:853", # DoT
"https://1.1.1.1/dns-query", # DoH, IP literal
"https://cloudflare-dns.com/dns-query" # DoH, hostname (system-resolver bootstrap)
]
```
`internal/metric`: sharded-counter shim + 23 new metrics (#485). A thin layer over Prometheus that trades a small amount of staleness for a much faster hot path. Hot-path costs measured on 8-core M5 contention:
```
ns/op allocs notes
─────────────────────────────────────────────────────────────────────
prometheus.CounterVec.WithLabelValues 128.3 0 previous SDNS pattern
metric.Counter unlabeled 1.6 0 per-CPU shard via procPin
metric.CounterVec single-label hot 6.5 0 atomic.Pointer[map] lookup
metric.CounterVec multi-label hot 12.1 0 length-prefix key, alloc-free
```
A background goroutine flushes shard sums to Prometheus on a 1 s tick (configurable). The lag is invisible at the typical 15 s scrape since `rate()` operates over windows that dwarf the flush interval.
All existing per-query counters migrated to the new package — wire-compatible (names, labels, label values unchanged) so existing dashboards keep working. Gauges, `GaugeFunc`, and dynamic-cardinality metrics (`dns_domain_queries_total`) stay on direct Prometheus by design.
23 new counters filling previously-silent decision points:
Policy / security:
Resolver / DNSSEC:
Server / transport:
Existing internal-counter exports:
Full Changelog: v1.6.7...v1.7.0
Nothing published for this version
…layout gets tightened with an internal/ move. No security fixes in this one.
A feature release: DNS64 (RFC 6147) lands as a first-class middleware, and the private-package layout gets tightened with an internal/ move. No security fixes in this one.
DNS64 middleware (RFC 6147, #472). New middleware/dns64 synthesises AAAA records from A records for IPv6-only clients reaching IPv4-only services. Sits between kubernetes and cache; activates when a client's AAAA query has no usable answer and a secondary A lookup succeeds, embedding each IPv4 into a configured Pref64::/n per RFC 6052 §2.2.
RFC 6147 coverage at a glance:
::ffff:0:0/96; default A exclusion under WKP follows the IANA Special-Purpose registry (incl. 192.88.99.0/24 per RFC 7526).min(A TTL, AAAA negative-cache TTL); 600 s ceiling when no SOA is present.64:ff9b::/96 is the runtime default when none is configured.in-addr.arpa with optional best-effort chase.CD=1 requests and SERVFAILs carrying DNSSEC-validation EDE codes (1, 2, 5–12, 27) bypass synthesis entirely; on synthesised replies, AD is cleared and EDE 4 (Forged Answer) is attached when the upstream had AD=1.Configuration:
[dns64]
enabled = true
prefixes = [\"64:ff9b::/96\"]
client_networks = [] # empty = all clients
exclude_zones = []
exclude_aaaa_networks = [\"::ffff:0:0/96\"]
exclude_a_networks = [...] # IANA Special-Purpose defaultMetrics: `dns64_synthesised_total`, `dns64_ptr_translated_total`, `dns64_passthrough_total{reason}`, `dns64_a_lookup_failures_total{reason}`.
Closes the last open item on the README TODO list.
Five packages that were never intended as public API move under `internal/`:
| Old path | New path |
|---|---|
| `cache` | `internal/cache` |
| `util` | `internal/dnsutil` (renamed) |
| `waitgroup` | `internal/waitgroup` |
| `mock` | `internal/mock` |
| `authority` | `internal/authority` |
`util` is renamed to `dnsutil` at the same time — the old name was the lowest-information identifier in Go and the package's actual contents (EDE, TTL, RRset construction, response classification) are entirely DNS-message helpers. The new name is self-documenting.
Pure rename + import-path updates, no behavioural changes. The module path is unchanged so the public binary and middleware-extension API (`middleware.Constructor` / `*config.Config`) keep working. External plugin authors who imported any of the five packages directly will need to either pin to an older version, vendor, or remove the dependency.
`api/README.md` rewritten end-to-end (139 → 105 lines). Fixes several inaccuracies in the previous draft:
Conversational style with an endpoint table up top for quick scan and curl examples interleaved with prose where they help.
DNS64 is opt-in. `enabled = false` by default; existing deployments behave identically to 1.6.6 until you flip it.
Config compatibility: `configver` bumps to `1.6.7`. Existing configs continue to parse — you'll see a one-line "Config file is out of version" log warning until you regenerate. `contrib/linux/sdns.conf` has been refreshed and is the easiest reference for the new `[dns64]` block.
Plugin authors who imported moved packages need to update import paths. Migration cheat-sheet:
github.com/semihalev/sdns/cache → github.com/semihalev/sdns/internal/cache
github.com/semihalev/sdns/util → github.com/semihalev/sdns/internal/dnsutil # also rename util.X → dnsutil.X
github.com/semihalev/sdns/waitgroup → github.com/semihalev/sdns/internal/waitgroup
github.com/semihalev/sdns/mock → github.com/semihalev/sdns/internal/mock
github.com/semihalev/sdns/authority → github.com/semihalev/sdns/internal/authority
Since these are now `internal/`, the Go compiler will refuse to compile any out-of-tree code that imports them. The intended path forward for plugin authors is to depend only on the public middleware-extension surface (`config`, `middleware`, `ctx`, `server`).
No on-disk format changes to `trust-anchor.db` / `trust-anchor-tombstones.db` / blocklist persistence.
Full Changelog: v1.6.6...v1.6.7
Security release. Closes a cache-poisoning vulnerability in both forwarder and resolver paths (issue #469 ). Operators on 1.6.5 should upgrade.
Security release. Closes a cache-poisoning vulnerability in both forwarder and resolver paths (issue #469). Operators on 1.6.5 should upgrade.
CVE / advisory: the issue was reported and disclosed publicly via the issue tracker. A GHSA entry will follow.
Drop upstream responses with mismatched question section (#470, #471). Both the forwarder (middleware/forwarder/forwarder.go) and the resolver wire layer (middleware/resolver/client.go:Conn.Exchange) used to accept an upstream reply as long as the DNS transaction ID matched. A malicious or misbehaving upstream could answer a query for attacker.example. with a message whose question section was victim.example. — and because the cache is keyed on the response's question, the unrelated answer was stored under victim.example. and served from cache to later clients.
Both paths now require the response to contain exactly one question whose Name (case-insensitively, per DNS wire rules), Qtype, and Qclass match the outstanding request. Mismatches drop the response and fall through to the next upstream, with the existing retry path covering transient cases. New regression tests pin the contract at both layers.
Closes #469.
Per-client static-answer middleware ("views", #360). New [[views]] config block returns different DNS answers based on the originating client's source IP — split-horizon resolution where *.example.lan. can resolve to one address for LAN clients and a different one for VPN clients without disturbing recursion for everyone else. Each view declares a zone label, a list of networks (CIDR), and a list of answers (zone-file format, wildcards allowed).
Match precedence follows RFC 4592: exact owners override a covering wildcard (§3.2); among wildcards, the longest matching suffix (closest encloser, §2.2.1) wins. Views are evaluated in declaration order; the first whose networks contain the client IP wins. A matched-but-no-answer view falls through (CoreDNS-style "fallthrough" semantics). Internal sub-pipelines skip views entirely. Position in the chain: between hostsfile and blocklist, so a view-curated answer wins over a global blocklist rule for that name. See the example block in contrib/linux/sdns.conf and the README for usage.
Non-blocking blocklist persistence + bulk import API. Reported issue: blocklist mutations via the HTTP API caused DNS to temporarily stop responding while changes were applied. Root cause: Set / Remove held b.mu (mutually exclusive with the RLock that ServeDNS takes on every query) for the full duration of the synchronous disk write in save(). Large blocklists turned that into multi-millisecond stalls of every in-flight query.
Fixes:
b.mu, snapshot, release b.mu, then persist outside the lock. ServeDNS readers no longer wait on disk I/O.saveMu serializes concurrent persists; the os.Rename of a temp file (CreateTemp + Sync + Rename) is the linearisation point, so the on-disk file always matches some in-memory state and never a half-written intermediate.SetBatch / RemoveBatch perform one map lock + one disk write for an entire batch instead of one disk write per entry.Two new HTTP endpoints accept {"keys":[...]} JSON bodies (8 MiB cap, unknown fields rejected), returning {requested, added/removed, skipped/missing}:
POST /api/v1/block/set/batchPOST /api/v1/block/remove/batchA new contract test (Test_BlockList_NoStallDuringSave) holds saveMu from a goroutine and asserts that a concurrent ServeDNS-style RLock returns within 2s, so a future regression that re-introduces disk I/O inside the map lock fails loudly.
Collapses the dual-mode (killer/boring) implementation into one sharded registry with per-headless-service incremental state. Slice events go through ApplyEndpointSlice / RemoveEndpointSlice plus a worker-coalesced MaterialiseHeadless, so a one-pod change in a 1000-pod headless service costs O(slice size) for state work and O(delta) RR allocations.
Correctness fixes that came along with the refactor:
EndpointSlice events from a deleted Service via tombstone tracking and ownerRef.UID matching, plus dirty-replay on AddService so the synthetic seed handover doesn't drop other slices.onEndpointSliceUpdate retracts the slice from the old service on a service-name relabel.cluster_domain is normalised (trailing dot, mixed case) at construction and at Registry.SetClusterDomain.10-0-0-1.svc...) instead of collapsing to one record.buildConfig defers to clientcmd's default loading rules so multi-file KUBECONFIG entries merge correctly.applyEndpointSlice eliminates rebuilds for resourceVersion-only update events.*dns.SRV pointer; SRV glue refresh allocates a new answerSet rather than mutating the published one in place.Run waits on per-handler HasSynced (not just informer.HasSynced) and flushes pending rebuilds before publishing synced=true.DeleteService order is now tombstone → flush → DeleteService, preventing a worker rebuild from re-populating the registry after wipe.config.KubernetesConfig.killer_mode is dropped from the live API; existing configs still parse (the field is retained but ignored), but new configs should omit it.
Pure DNSSEC verify functions (RRSIG, DS, NSEC, NSEC3 denial-of-existence proofs) and the EDE-coded sentinel errors that go with them moved into a new middleware/resolver/dnssec subpackage. The generic DNS RR helpers (ExtractRRSet, FilterRRsToZone, NameInZone, DnameTarget) and the EDEError type moved into util/, where both resolver and dnssec can share them without a circular import. Resolver-side network errors keep their identities but now use *util.EDEError instead of the resolver-local ValidationError type.
(*Resolver).lookup() was split in place: the per-server query goroutine moved to a queryServer method, the adaptive RTT-based timeout became adaptiveServerTimeout, and the trailing fallback-picker became pickFallbackResponse. Behaviour is unchanged; lookup() drops from ~250 lines to ~140 and the goroutine entry no longer captures state via closure. Net diff: −2016 / +265 in middleware/resolver/, ~1100 lines under middleware/resolver/dnssec/.
199.9.14.201 → 170.247.170.2; IPv6 2001:500:200::b → 2801:1b8:10::b). The old addresses still answer for transitional reasons and priming even discovers the new ones at runtime, but the embedded default config, the Linux packaging config, the benchmark fixtures, and the fuzz seed corpus now match the canonical named.root list.github.com/semihalev/zlog/v2 → v2.0.8 (v2.0.7 broke the variadic-KV signature; v2.0.8 restores it, so this is a no-op upgrade).github.com/fsnotify/fsnotify → v1.10.0.goreleaser/goreleaser-action → v7.2.1.configver bumps to 1.6.6; existing configs continue to parse, you'll just see a one-line "Config file is out of version" log warning until you regenerate. The deprecated kubernetes.killer_mode key is now ignored.trust-anchor.db / trust-anchor-tombstones.db / blocklist persistence — the new blocklist save path is a strict superset of the old format.Full Changelog: v1.6.5...v1.6.6
Nothing published for this version
Patch release for 1.6.3. Major focus on RFC 5011 trust-anchor correctness, DNSSEC validation hardening, and listener lifecycle. Also closes a build-ta
Patch release for 1.6.3. Major focus on RFC 5011 trust-anchor correctness, DNSSEC validation hardening, and listener lifecycle. Also closes a build-tag bug that prevented 1.6.4 from releasing on FreeBSD/NetBSD/OpenBSD/DragonFly.
Note: 1.6.4 was tagged but never published — the goreleaser pipeline failed on
freebsd_amd64because of thereuseport_*build constraint bug fixed in this release. 1.6.5 is the first available shipping point that includes the trust-anchor work below.
A full pass over middleware/resolver/auto_trust_anchor.go to bring the resolver into alignment with RFC 5011 §2 / §4 and to harden persistence against partial failures. Highlights:
verifyFetchedKeys is now correct under KSK rollover. At-least-one-trusted-anchor RRSIG semantics with a narrow revoked-bootstrap carve-out (RFC 5011 §2.1: a revoked key may authenticate the RRset that contains it, but only for the purpose of validating its own revocation). Returns a split-mode flag so a revoked-only proof can tombstone the matching key but cannot seed AddPend or mark other anchors missing.kskCurrent into a material-keyed store with its own state file. Tag collisions with future legitimate KSKs can no longer suppress them.cfg.RootKeys snapshot taken at startup, honours tombstones by key material, filters seeded entries on load, and refuses to resurrect a stale admin-config anchor that the root has revoked.StateRevoked marker so a tombstone-write failure survives across retries; selective fail-closed only when an actual contraction would otherwise be lost on disk.errTrustAnchorsUnavailable gates answer / authority / validateDelegation and the two delegation-cache Set sites, so an empty trust set fails closed with SERVFAIL instead of slipping into the "unsigned delegation" branch. AutoTA's own DNSKEY query runs CD=true so it doesn't depend on r.rootKeys.reuseport_darwin.go, which Go treats as an implicit GOOS=darwin build constraint. The explicit //go:build darwin || freebsd || netbsd || openbsd || dragonfly was ANDed against that, so freebsd/netbsd/openbsd/dragonfly all failed to link with undefined: defaultUDPWorkers / kernelLoadBalances / reusePortControl. Renamed to reuseport_bsds.go so the explicit tag governs.os.Open doesn't grant). Tombstones-file open errors now distinguish Windows sharing violations from real corruption — only decode failures fail closed.net.Dialer and bypass DialContext on UDP upstream. Per-query allocation cut on the recursive hot path.authcache → authority (split into server.go + cache.go). Type renames: AuthServer/AuthServers → Server/Servers; NSCache → Cache; NS → Delegation; DSRR → DSSet; Version → IPVersion.parentDSRR/parentdsrr → parentDS.accesslist.AccessList → accesslist.List; accesslog.AccessLog → accesslog.Log (config field names preserved).r.ncache → r.delegations; nameservers map type → hostSet; nameserverInfo → delegationInfo with hosts field.rootservers/rootkeys (smashed lowercase) → rootServers/rootKeys.ipv4cache/ipv6cache → glueV4/glueV6.Queryer interface; util.ExchangeInternal retired.github.com/semihalev/zlog/v2 → v2.0.6.k8s.io/apimachinery → 0.36.0.k8s.io/client-go → 0.36.0.codecov/codecov-action → v6.Full Changelog: v1.6.3...v1.6.5
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Your coding agent can read these notes before it upgrades. Set up the MCP server →