Skip to content

Releases: semihalev/sdns

v1.8.2

Choose a tag to compare

@semihalev semihalev released this 01 Sep 09:12

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.

Response Policy Zones (#597, #598, #599, #601, #602)

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.

  • Every production trigger. QNAME rules (exact and wildcard), CLIENT-IP prefixes, and answer-address (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.
  • Shadow first, and the numbers predict the flip. The default mode counts and logs every match without rewriting anything. Counting is winner-bounded and identical between shadow and enforce — per query, one decision per zone, up to and including the first enabled winner — so a soak's counters describe exactly what enforcement will do, and rpz_action_total{zone,trigger,action,outcome} is the flip-decision dashboard.
  • Feeds keep themselves current. File zones reload on atomic replacement. AXFR zones follow their own SOA schedule — probe on refresh, transfer on serial change, retry on retry — with TSIG-signed transfers (hmac-sha1 through sha512) when the provider requires them. A source advertising an older serial is refused whole at the probe (RFC 1982), before any transfer is spent; a feed that cannot be refreshed past its SOA expire withdraws its rules and fails open rather than enforcing stale policy, and a push that parses to zero rules keeps the previous generation serving.
  • The cache stores the truth; policy is applied to the client. Answer-address policy is judged against what a query actually resolved to, but the stored answer is never modified — the verdict rides beside the cache entry, stamped with the policy generation it was computed under. A PASSTHRU-exempted client reads the truth from the very entry another client is rewritten from, and a reload re-evaluates already-cached answers on their next hit instead of waiting out the cache.
  • Priced for the serving path, and measured. A non-matching query allocates nothing, and a clean cache hit still serves from raw bytes with policy rules loaded — both pinned by allocation tests that run on every commit. On a 32-core host serving a warm corpus at a fixed offered rate, a two-million-rule shadow feed costs roughly 2 microseconds of CPU per query against the same host with RPZ disabled (≈45 → ≈47 µs), and about 190 bytes of memory per rule. Adding answer-address rules on top of that costs nothing measurable — the response-IP machinery (a wrapper per query, a verdict stamped beside every admitted entry, a gate on every byte-served hit) lands 0.1–0.7% away from the name-only feed, well inside the run-to-run spread of the control itself.
  • Rewritten answers carry the policy zone's SOA and Extended DNS Error 17 (Filtered), and never claim DNSSEC authenticity. Policy applies to client queries only — the resolver's own lookups are never policy-checked. sdns -t validates the whole [rpz] block with the same loaders the server runs.

Hyperlocal root zone (RFC 8806) (#582)

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).

Stale answers as a last resort (RFC 8767) (#588, #589)

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).

The configuration is judged whole (#592)

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.

Per-zone forwarding (#591)

[[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.

Resolver and validation fixes

  • A record that only claims to be a DNSKEY is skipped instead of asserted on (#596).
  • miekg/dns 1.1.73; the RSAMD5 key tag is computed per the RFC rather than delegated to the library (#594).
  • The SDNS_PPROF and debug switches read as booleans — any value used to count as on (#593).

Documentation

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.

v1.8.1

Choose a tag to compare

@semihalev semihalev released this 24 Aug 09:06
8558f01

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.

RFC 9156 QNAME minimisation, at the RFC's values (#578)

  • The budget counts queries, not delegation depth. The old 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.
  • Both RFC parameters, at the recommended values. 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."
  • Underscore labels ride along (_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.
  • A deliberate, documented departure from the RFC's own algorithm: a minimised denial that cannot be proven to cover the subtree — an unsigned zone, an NSEC3 opt-out span, a CD query — used to cost one query per hidden label. The walk now asks for the full name at once: one query, from the delegation already reached, since the client's answer depends only on the full name anyway. A validated, non-opt-out NXDOMAIN keeps its early RFC 8020 cut; an alias-bearing NXDOMAIN never cuts (it denies the end of the chain, not the asked name — RFC 2308 §2.1); and the decision sits on one RCODE gate ahead of any section-shape reading, so a REFUSED dressed with an SOA cannot masquerade as a walkable answer.

Choosing which server to ask (#576)

Upstream selection was rebuilt around a simple principle: rank on evidence, and don't let silence or refusal manufacture any.

  • An unmeasured server is priced as a guess (300 ms), not as instant. A server nobody had reached carried zero accumulated RTT — the ranking read that as the fastest option and sent one of every miss's two parallel queries to the one server whose speed nobody had established.
  • Only an answer is scored by the clock. A refused connection returns in microseconds — faster than any authority on earth — and a stale glue record pointing at a host that is up but not serving DNS could take the head of a delegation and never lose it. Transport errors, silence, and non-answering rcodes are priced at the timeout, which is what not answering is worth.
  • Evidence ages per server instead of being amnesiated. The old scheme cleared every server's statistics every thousandth sort, putting the whole set back into the state it ranked first. A measurement now drifts halfway back toward a guess after five minutes unrefreshed, and each new sample blends half-and-half — one bad sample moves the ranking at once, where the old all-time average kept feeding a degrading authority for dozens of queries.
  • Ties rotate and settled delegations still explore. The second racing slot rotated onto the same address forever by sort stability; it now rotates among tied candidates, and a settled delegation occasionally spends the slot on its stalest address — at a rate proportional to how much of the delegation is out of date, so a freshly met zone explores on most lookups and a settled one almost never.
  • An exploration probe outlives the winner. The leader's answer used to cancel the second query before it could report, so an address could only ever be measured by winning outright — and addresses genuinely slower than the leader stayed unmeasured for good. Probes now finish on a detached context (pooled, capped, one exchange, no retries or fallbacks), so every candidate eventually gets measured at its true latency.
  • Operators can see it: a server whose last exchange went unanswered reports FAILING in the delegation health line — priced at a timeout for the ranking, named for the human.
  • The ranking pass allocates nothing, and is measurably faster than the sort it replaced at every delegation size.

The walk stops waiting (#578)

  • One address is enough to take the next step. The walk used to resolve every glue-less nameserver of a delegation inline — up to a dozen full recursions, sequentially — before moving. It now resolves synchronously only until the delegation holds two distinct endpoints (counted on the deduped address list, so two names glued to one address don't fake a fallback), then queues the rest of the roster onto fixed process-wide worker pools, one lane per address family. Jobs are self-contained — they retain nothing of the originating request, so a backlog cannot anchor request state through an outage — and a full lane sheds. The IPv6 side moves onto the same lane, retiring the goroutine-per-referral scheme.
  • Glue caches honour record TTLs. Cached nameserver addresses used to live until eviction pressure; a renumbered nameserver's old address could be handed out indefinitely. Entries carry the smallest TTL of the records they came from (floored at 30 s, capped at 6 h), a read past the horizon deletes exactly what it saw — and a cache hit no longer renews the horizon, so a frequently read address can't outlive its records through the floor.

Served-TTL correctness (#579, #580)

  • Answers serve their own TTL, not the oldest key consulted on the walk. Internal DS/DNSKEY cache consults folded each consulted entry's remaining lifetime into the request, and the client answer inherited it as its serving bound — a fresh 3600-second answer could be served with 60 seconds, varying by name and time of day, depending on which infrastructure key the validation happened to read. Positive consults no longer bind the request; validated denials still do (a delegation held insecure by a cached DS denial must not outlive that proof).
  • A provisional delegation entry can no longer displace the real lease. The short-lived stub written before a nameserver address lookup could — via the new enrichment queue — overwrite an hours-long lease with its one-minute cap, collapsing served TTLs under whole delegations. The store is atomic insert-if-absent-or-expired now; a live lease always wins the race.
  • The delegation lease caps the first response too. Cache entries were always bounded by the parent-granted delegation lease (the ghost-domain protection, GHSA-mqfw-f48p-2vc8) — but the first response, served straight from resolution, advertised the records' full TTL, and a downstream cache could keep a withdrawn delegation's answer long past the lease sdns itself enforced. Every response now advertises min(record TTL, lease remaining): the first answer and every hit make the same promise.

DNSSEC validation

  • Empty-non-terminal NODATA proofs validate. A signed zone answers NODATA for a name with descendants but no RRsets using the covering NSEC alone, whose NextDomain lies strictly below the query name. The validator knew the exact-owner and wildcard proofs and nothing in between, so this shape — constant in reverse trees and service names — was rejected as incomplete on live signed zones, each rejection a wasted validation and a spurious warning. The strict check is in place: a NextDomain equal to or beside the query name still requires the wildcard proof.
  • ECDSA verification now parses the public key as a curve point rather than assembling it from coordinates — stricter in the direction a validator wants, rejecting off-curve points at the parse.

Cold-cache resolution, measured (#574, #577)

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.

Housekeeping (#581, dependency bumps)

  • The contrib config template is verified byte-identical to what the binary generates for a missing config; version marks move to 1.8.1.
  • The last RFC 7816 reference (obsoleted by RFC 9156 in 2021) is gone from the docs.

Upgrading

Drop-in. Configs still on `qname_mi...

Read more

v1.8.0

Choose a tag to compare

@semihalev semihalev released this 18 Aug 03:34
cabc654

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.

The serving engine (#559)

  • Owned UDP, TCP, and DoT engines. The 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.
  • Admission instead of backpressure by accident. A token/lease system bounds slab memory explicitly: what the engine may hold at full saturation is arithmetic checked by tests, not an emergent property of load. Overflow beyond the worker pool serves on bounded spill goroutines, so miss concurrency survives bursts without unbounded growth.
  • Traffic-following memory. Idle slabs are trimmable; the opt-in 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.
  • Strict wire ingress. An eligible query enters the middleware chain as a parsed view over its own packet bytes: no decoded message, no per-request context allocation, no copies. Ineligible packets take the classic decoded path unchanged; header-level rejections (FORMERR/NOTIMP/ignore) mirror the library byte for byte.

Answers served as stored bytes (#530#546, #550#551, #560)

  • The cache retains wire form (#531) and serves eligible hits straight from it (#534): plain hits, DO-stripped bodies for clients without DNSSEC (#544), additional-section and RRSIG-question shapes, fully cache-contained CNAME chases, NXDOMAIN subtree cuts, aggressive NSEC/NSEC3 synthesis, and cached-failure answers (#560) all leave without a dns.Msg being built.
  • Byte-identical packing from pooled storage (#550) and direct pack onto declared sdns-owned transports (#551): when the writer is our own UDP/TCP/DoT sink, even the decoded path packs once into the job buffer instead of allocating through the library.
  • Correctness carried across: derived and composed answers stay bound to their source entries' cache lineage in both directions — a chase target with one second of life is no longer re-published for the TTL floor's five, and a nearly-expired alias no longer truncates its freshly resolved target's lifetime (#544, #545); denial-proof zones publish once per bundle with precomputed canonical order (#530, #537, #541, #546); and every gate that turns a hit away from byte serving is a named counter (#543) — the diagnostic that later found real production bugs.

A wire-transparent middleware chain (#552, #562, #563, #566)

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%.

The allocation war (#547#549, #553#558, #564, #567#570)

  • 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.
  • DNSSEC without scratch buffers: DS digests and signatures verify without the library's fixed buffers (#547), key tags sum without decoding the key (#549), cached NSEC names canonicalize once at admission instead of per lookup (#537), and the aggressive-denial set and hit bodies stopped being rebuilt per query (#558).
  • Copies that know why they exist (#567): the resolver's per-attempt request views share immutable records and privatize exactly what the wire packer mutates — replacing whole-message deep copies with requirement-analyzed shells (51.9 ns/3 allocs → 26.3 ns/1 alloc per attempt).
  • Small knives: message IDs from the runtime's per-core ChaCha8 CSPRNG with zero allocations (#568); hand-parsed PTR names for both address families with 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).

DNSSEC validation: correctness and hardening (#547, #549, #553)

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:

  • RRset canonical ordering now sorts by RDATA as RFC 4034 §6.3 requires — the old comparator wrongly included RDLENGTH, so a TXT set the library signs and accepts could be rejected here as a bad signature. The bug predates this release.
  • Escaped label dots no longer confuse zone containment: foo\.example.com. is a two-label name and no longer authenticates against example.com.'s keys.
  • DS digest type 5 is not SHA-512: IANA assigns 5 to GOST R 34.11-2012 (RFC 9558); it was being computed as SHA-512 and is now refused like every other unimplemented digest type (1, 2, and 4 are admitted).
  • The EDE tells the truth: a signature that fails to decode now reports Bad Signature rather than Missing Key — the verdict was already right; the explanation the client saw was not.
  • Hostile-input hardening: attacker-sized DNSKEYs are refused on encoded length before anything decodes them; a crafted two-octet RSAMD5 key can no longer reach the library's slice-underflow panic, and RSAMD5 key tags follow RFC 4034 Appendix B.1 (errata 193), so a crafted key cannot collide a trust anchor at tag 0. ECDSA signatures must be the RFC 6605 fixed width, and RSA moduli above 4096 bits are refused.
  • NSEC coverage for escaped names compares the octets a name encodes, not its presentation-form escape text (RFC 4034 §6.3).

The final mile: inline serving, and the engine uncontended (#572)

Profile-driven, each step A/B-measured on a 32-core host before the next was attempted:

  • 16-way sharded slab caches — the single idle-slab mutex (~540k lock ops/s) leaves the profile entirely.
  • Fetch-add lease admission — the CAS retry spin becomes add-and-rollback.
  • Batch-slot persistence — received-but-unserved slots stay armed across reader cycles instead of churning through release/re-take.
  • Inline wire-hit serving. The reader runs the full middleware chain on every packet with an inline-only mark; the cache — the pipeline's declared inline barrier — answers from its wire ladder or hands off unwritten. Hits never cross the ring: no worker wake, and the receive batch leaves as one transmit batch, one 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.
  • TCP unchained. The per-connection query budget forced a server-side close every 2048 queries — under pipelining, a reconnect storm every half second. Fairness was already enforced per-frame and per-connection elsewhere, so the cap is gone; sessions serve until the client leaves, per RFC 7766.

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.

Benchmarks (#573)

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.

Standards and correctness

  • RFC 6891 §7 truncation: a response that cannot fit shrinks to the minimal truncated form — header, question, and (only when the request carried one) OPT — instead of shipping partial records (#571).
  • RFC 7766: TCP sessions persist; the server no longer closes pipelined connections mid-conversation (#572).
  • RFC 7828 EDNS TCP keepalive, client-facing (#559): a TCP or DoT client that sends the edns-tcp-keepalive option gets the server's idle-timeout advertisement (8 s) in its responses — on the byte-serving path too — an explicit invitation to hold the connection instead of reconnecting per query. The option stays hop-by-hop as the RFC requires: an upstream's keepalive answer is stripped before a response leaves, and the option never appears over UDP, where it is forbidden outright.
  • NSEC3 admission at the root zone and a precise wildcard acceptance boundary in the denial walkers (#555).
  • Exact UDP overflow measurement before the truncation decision (#542).
  • **Reverse-zone hand...
Read more

v1.7.4

Choose a tag to compare

@semihalev semihalev released this 09 Aug 16:06
e597a1c

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.

Recursion firewall (#527)

  • Request-tree work budgets. Outbound transport attempts, resolver-internal child queries, and DNSSEC operations (signature verifications, DS digests, NSEC3 hashes, per-RRset/candidate fan-out, process-wide crypto concurrency) are accounted against one ledger spanning the complete request tree — retries, UDP→TCP fallbacks, and nested DS/DNSKEY/NS lookups included — so one hostile query cannot amplify into unbounded upstream traffic or crypto work.
  • Three modes, shadow by default. 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.
  • RFC 9520 §3.1 retry ceiling. At most three attempts per (question, server, transport) tuple per resolution, always on.

Negative caching (#527)

  • RFC 9520 resolution-failure caching. SERVFAILs and failed-authority state live in a dedicated bounded cache with exponential backoff (5s→5m) and zone-wide reachability entries; after backoff expiry, concurrent retries elect a single upstream probe instead of stampeding. Request-local failures (budget rejections, attempt limits, cancellation) are never admitted to shared state. Cached failures answer with EDE 13. Kill switch: rfc9520. First field hour on a public node: ~16 answers/s served from failure state — retry load that no longer reaches upstreams.
  • RFC 8020 NXDOMAIN subtree cuts and RFC 8198 aggressive NSEC/NSEC3 synthesis. Locally validated denial proofs answer later negative queries without upstream traffic. Admission requires this resolver's own validation — the AD bit is never trusted — with exact-response provenance and fingerprint sealing; NSEC3 Opt-Out is excluded by design. The proof indexes carry their own per-zone and global entry/byte bounds so hostile denial churn cannot evict ordinary answers. Kill switch: rfc8198. (~4.8k upstream lookups/hour avoided on the same node.)

Outage containment (#527)

Root-caused from a 2026-07-28 production incident (1.43M goroutines, 93% blocked behind cache segment locks during a partial upstream outage):

  • Proportional cache eviction. Segment-clearing eviction (dropping 10–60% of entries in bulk while writers queued uncancellably) is replaced by self-paying eviction: an over-capacity insert evicts at most two entries under the lock it already holds. Worst single-insert loss drops from 50,003 entries to 1; the eviction-heavy insert benchmark improves 378→94 ns/op.
  • Ghost-entry fix. Backward-shift deletion stopped at the first unmovable entry, stranding later cluster entries — present and counted but unfindable until an incidental grow. Rewritten to the canonical linear-probing delete (Knuth 6.4R), which also removes an O(cluster²) cost from every delete.
  • In-flight ceilings and per-zone fairness. A hard cap on concurrent zone-level lookups plus a per-zone quota (the analog of BIND's fetches-per-zone): a popular destination going dark sheds itself with a scoped EDE while every other zone keeps resolving at full speed, through every phase of an incident. Detached IPv6 NS enrichment draws from a bounded pool instead of growing at arrival-rate × timeout. Sheds are observable via dns_resolution_shed_total{scope}.

DNSSEC hardening (#527)

  • Opt-Out denial proofs no longer set AD (RFC 5155 §9.2). The proof still validates; the response is correctly marked insecure instead.
  • NSEC3 evaluation on a prepared ring. Each response's NSEC3 set is bound to the RRSIG signer zone and one parameter tuple, then evaluated as a sorted ring — removing the per-record hash multiplier an attacker-authored zone could exploit. Responses mixing NSEC3 parameter tuples are rejected fail-closed per RFC 5155 §7.2/§8.2.
  • Sharper validation plumbing. Canonical wire-label handling in wildcard next-closer derivation (Unicode case folding could alias distinct octet names), deterministic deduplicated candidate ordering in DS/RRSIG verification, and RFC 5011 trust-anchor refresh brought under the same work accounting.

Performance (#527)

  • End-to-end request deadline, kept off the hot path. 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).
  • Cold-cache parity with PDNS Recursor 5.4.1 over 50k live queries: 641 vs 627 qps, 0 lost (PDNS: 14), response-code distribution identical to within 0.05% — with every new accounting and validation layer active.

Operator notes

  • 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.
  • The legacy negative answer cache is retired: the full cachesize now backs positive/NXDOMAIN entries, and SERVFAILs use the RFC 9520 failure cache (failure_cache_* settings under [recursion_firewall]).
  • The config schema is now v1.7.4 (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.
  • The per-zone in-flight quota defaults to maxconcurrentqueries/16 (min 16); very-high-QPS cold-cache deployments should size maxconcurrentqueries accordingly.

Build

  • Dependencies: quic-go 0.61.0 (#523), prometheus/client_golang 1.24.1 (#522), k8s.io 0.36.3 (#521), CI action bumps (#524, #526).

Full changelog: v1.7.3...v1.7.4

v1.7.3

Choose a tag to compare

@semihalev semihalev released this 19 Jul 17:45
6f745fe

A security release fixing two reported advisories and two additional DNSSEC forgery vectors. Recommended for all deployments — validating resolvers should upgrade promptly.

Security advisories

  • CD-bit cache poisoning (GHSA-g5mh-6738-qgh9, High). A single CD=1 query for a Bogus (validation-failing) name cached the unvalidated answer and served it as NOERROR to validating CD=0 clients — a DNSSEC downgrade any client of a shared resolver could trigger. Cache state is now strictly isolated across the CD bit: delegations are keyed on the client's CD bit only and the cross-CD fallback that leaked unvalidated CD=1 results into CD=0 answers is gone. The same change fixes a prefetch path that could refresh a cached answer without re-validation and silently drop AD. (#505)
  • Ghost/phoenix domain attack (GHSA-mqfw-f48p-2vc8, Medium). A withdrawn child zone could be kept resolving indefinitely: the delegation cache floored parent referral TTLs to one hour and prefetch kept renewing the lease, so the parent's NXDOMAIN was never observed. Fixed in three layers: the parent-granted delegation TTL is honoured verbatim as one absolute deadline that validation and NS-lookup time count against (#513); nested delegations inherit the shallowest ancestor deadline on the path, so a deep 12h referral cannot outlive the 3s ancestor lease that granted it (#514); and every cached answer is bound to its delegation cut — effective lifetime is 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.

DNSSEC hardening

  • Require wildcard-denial proof on positive answers (RFC 4035 §5.3.4). A zone's legitimately-signed wildcard RRSIG could be replayed over a concrete name that really exists and returned with AD=1 — RRSIG verification alone accepts it. Wildcard-expanded answers now additionally require an NSEC/NSEC3 proving the next closer name does not exist. (#512)
  • Reject exact-owner NSEC3 matches as denial coverage (RFC 5155). miekg/dns 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)

Fixes

  • False SERVFAILs for insecure names served by multi-zone authorities (e.g. 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)

Build

  • Toolchain bumped to go1.26.5; routine dependency and CI-action bumps. (#504, #508, #509, #510, #511)

Thanks

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

v1.7.2

Choose a tag to compare

@semihalev semihalev released this 25 Jun 22:19
5c33725

A DNSSEC correctness release. Recommended for all validating deployments.

DNSSEC

  • Validate insecure delegations served without a referral. When one server is authoritative for both a signed parent and an unsigned child delegated from it (e.g. 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)
  • Clear the AD bit for CD=1 clients (RFC 4035 §3.2.3 / RFC 6840 §5.7). A client that sets CD=1 has opted out of trusting the resolver's validation, so AD must never be asserted to it — including an upstream's bit passed through by the forwarder. (#501)

Internal

  • Own DNS exchange library (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

v1.7.1

Choose a tag to compare

@semihalev semihalev released this 24 Jun 20:04
5f51345

A security and hardening patch.

Security

  • DNSSEC: validate RSA keys whose public exponent exceeds Go's crypto/rsa ceiling (2³¹-1) — restores DNSSEC validation for zones anchored on such keys, including the entire .lv (Latvia) TLD and mailbox.org. (#495)
  • Dependencies: pinned the build toolchain to go1.26.4, clearing 5 reachable standard-library CVEs (govulncheck clean); the snap build was on the long-stale Go 1.23.4.
  • Cache: full-question verification on every cache hit — defends against xxhash key collisions being used to poison the cache.
  • DNS Cookies: generate a full-entropy (128-bit) server secret (the previous value was space-padded).

Fixes

  • Resolver: the circuit-breaker failure map could grow unbounded; idle entries are now evicted regardless of failure count.
  • Server: malformed queries (QDCOUNT ≠ 1) now return FORMERR instead of triggering a panic/recover cycle.
  • Blocklist: the whitelist now matches across the domain hierarchy on both the lookup and add paths (a parent whitelist exempts subdomains).
  • Config: --config path handling fixed for Windows.

Build / CI / Docs

  • CI: coverage upload no longer fails the build on a Codecov outage or missing token.
  • Rewrote the package documentation (doc.go) to match the current architecture.
  • Routine dependency and CI-action bumps.

Security policy

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

v1.7.0

Choose a tag to compare

@semihalev semihalev released this 31 May 18:51
f3fa249

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.

What's Changed

Features

  • 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.

    • Source-prefix clamp to 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 allow-list via client_networks = [...] (CIDRs); empty = all clients.
    • Fail-closed config: a malformed CIDR or out-of-range knob disables the entire policy (logged at startup) so a typo can't silently re-open forwarding.
    • The clamped ECS option is stripped from the client-facing response so it never round-trips to the client; the wire form only carries it upstream.

    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.

    • Each `(qname, qtype, qclass, CD)` tuple can hold one shared-key entry (the historical behaviour, used when `SCOPE = 0` or for non-ECS traffic) plus any number of scoped entries.
    • Lookup does longest-prefix-match from the client's source prefix down to `/1`, falling back to the shared key on a scoped miss so SCOPE=0 / pre-1.7.0 entries still hit.
    • Dedup is scope-aware too — two clients in different subnets get separate upstream queries instead of sharing one.
    • Scoped entries are prefetch-ineligible — the prefetch worker has no client IP to derive scope from, so refreshing a scoped entry would lose its scope and store the wrong-audience answer.
    • `cache_limit_ttl` caps the lifetime of scoped entries (geo answers go stale faster than the resolver's normal `MaxTTL`).
    • `min_scope_v4` / `min_scope_v6` refuse to cache scopes narrower than this — load-bearing safety knob against per-client cardinality blowup.
    • New metric: `dns_cache_ecs_lookups_total{outcome}` with `outcome ∈ hit_scoped / hit_shared / miss`. Non-ECS lookups stay on the existing `dns_cache_hits_total` / `dns_cache_misses_total`.

    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)
    ]
    ```

    • Hostnames are bootstrapped once at startup through `net.DefaultResolver` (the system resolver). Resolved IPs are pinned for the process lifetime — no per-query DNS dependency. Bootstrap failure is logged and the entry is skipped without aborting startup.
    • HTTP/2 per-server client with pinned-IP rotation. Each DoH server gets its own `*http.Client` with `MaxIdleConnsPerHost: 4` for connection reuse, and a custom `DialContext` that rotates through pinned IPs and caps each TCP dial at `cfg.Timeout` — a blackholed address (typical mixed-A/AAAA-with-broken-v6 case) bypasses to the next IP in ≤ 2 s instead of consuming the request budget.
    • TLS `ServerName` is set to the original URL hostname so cert SAN validation works correctly when dialing IPs.
    • POST `application/dns-message` per RFC 8484. Response body bounded at 64 KiB (oversized bodies rejected, not silently truncated). Response `Content-Type` parsed with `mime.ParseMediaType` so case-insensitive types and parameters are accepted.
    • Response TXID validated (echo of `req.Id` or `0` per RFC 8484 §4.1 cache normalization). Matches the UDP/DoT path's behaviour.
    • Shared query budget across upstreams. `cfg.QueryTimeout` is now also a forwarder-level constraint — `ServeDNS` wraps the chain ctx with `WithTimeout(queryTimeout)` once so three slow upstreams can't take ~3 × per-call timeout. Applies to UDP, DoT, and DoH alike.
  • `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:

    • `dns_blocklist_hits_total`
    • `dns_accesslist_denied_total`
    • `dns_ratelimit_exceeded_total`
    • `dns_forwarder_failures_total`
    • `dns_forwarder_response_mismatch_total` (potential poisoning signal)
    • `dns_failover_attempts_total` / `dns_failover_success_total`
    • `dns_edns_errors_total{reason}`
    • `dns_recovery_panics_total`

    Resolver / DNSSEC:

    • `dns_resolver_failures_total{reason}` (timeout, no_reachable_auth, max_depth, network_error, other)
    • `dns_resolver_dnssec_failures_total{reason}` (bogus, sig_expired, sig_not_yet_valid, dnskey_missing, rrsig_missing, nsec_missing, unsupported_algorithm, other)
    • `dns_circuit_breaker_trips_total` / `dns_circuit_breaker_resets_total`
    • `dns_trust_anchor_lifecycle_total{transition}` (new_pending, became_valid, revoked, missing, reappeared, deleted)

    Server / transport:

    • `dns_listener_errors_total{proto}`
    • `dns_doh_http_errors_total{code}`

    Existing internal-counter exports:

    • `dns_hostsfile_lookups_total` / `dns_hostsfile_hits_total`
    • `dns_kubernetes_queries_total` / `_answered_total` / `_errors_total` / `_write_errors_total`

Bug Fixes

  • `blocklist`: bare domains now block subdomains too (#478). Previously a `blocklists` entry of `example.com` blocked only the exact name; the new semantics match Pi-hole / AdGuard / dnsmasq — a bare domain blocks the apex AND every subdomain. Wildcard `*.example.com` continues to block subdomains only (not the apex), unchanged.
  • `resolver`: context-key collision no longer panics `checkLoop` (da884dc). A `A` query (qtype 1) used to land on the same context key as `contextKeyDnameDepth` and `checkLoop` would read an int as a `[]string`, panicking. The per-qtype NSList base key now sits far enough above the fixed keys (`1 << 16`) that adding a 16-bit qtype can never collide.

Dependencies

  • `golang.org/x/sys` → v0.45.0 (#481)
  • `goreleaser/goreleaser-action` → v7.2.2 (#480)

Upgrade Notes

  • ECS is opt-in. `enabled = false` by default; existing deployments behave identically to 1.6.7 until you flip it. Stage 2's cache changes are a no-op for non-ECS traffic — pre-1.7.0 cache entries continue to hit on the shared key.
  • DoH forwarder is fully transparent to existing configs. Add `https://...` entries to `forwarderservers` to opt in; existing `udp` / `tls://` entries are unchanged.
  • `metric` package callers MUST invoke `metric.Stop()` at shutdown to drain the final flush — wired automatically in `sdns.go`. Operators embedding the middleware packages directly should do the same.
  • Metric wire format is unchanged. All migrated counters keep their original names, label keys, and label values. Existing Prometheus scrapers, Grafana dashboards, and alert rules continue to work without modification. The 23 new counters are additive.
  • No on-disk format changes to `trust-anchor.db` / `trust-anchor-tombstones.db` / blocklist persistence.
  • Config compatibility: `configver` stays at `1.7.0...
Read more

v1.6.7

Choose a tag to compare

@semihalev semihalev released this 18 May 13:36

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.

What's Changed

Features

  • 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:

    • §5.1.2 / §5.1.3 RCODE handling — NOERROR-NODATA / non-NXDOMAIN errors trigger synthesis; NXDOMAIN passes through.
    • §5.1.4 exclusions — default AAAA exclusion ::ffff:0:0/96; default A exclusion under WKP follows the IANA Special-Purpose registry (incl. 192.88.99.0/24 per RFC 7526).
    • §5.1.5 CNAME / DNAME chains preserved on both synthesis and the no-A-records path.
    • §5.1.6 — when the AAAA query yields empty/error, the A response's RCODE, Authority section, and chain become the basis for the client reply.
    • §5.1.7 TTL = min(A TTL, AAAA negative-cache TTL); 600 s ceiling when no SOA is present.
    • §5.2 multiple Pref64 prefixes synthesise in parallel; the well-known prefix 64:ff9b::/96 is the runtime default when none is configured.
    • §5.3.1 PTR translation via CNAME to in-addr.arpa with optional best-effort chase.
    • §5.3.2 — only Answer-section AAAAs are synthesised; Authority/Additional pass through unmodified.
    • §5.5 DNSSEC safetyCD=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.
    • RD=0 clients skip DNS64 entirely.

    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 default

    Metrics: `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.

Internal Refactor (#479)

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 Documentation

`api/README.md` rewritten end-to-end (139 → 105 lines). Fixes several inaccuracies in the previous draft:

  • `/api/v1/block/exists/:key` returns `{"exists": }`, not the previously documented `{"success": true}`.
  • `/api/v1/block/set/:key` and `/api/v1/block/remove/:key` can return `success: false` on duplicate / missing — not always `true`.
  • 404 response shape for `/api/v1/block/get/:key` and 401 response shape are now documented explicitly.
  • Bulk batch 400 cases (malformed JSON, unknown fields, oversized body, empty keys) documented.
  • `/debug/pprof/*` routes documented with their auth-bypass caveat.
  • `ReadHeaderTimeout`, `MaxBytesReader`, graceful-shutdown timeout, and async blocklist persistence semantics documented.

Conversational style with an endpoint table up top for quick scan and curl examples interleaved with prose where they help.

Dependencies

  • `k8s.io/client-go`, `k8s.io/apimachinery` → v0.36.1 (#476, #477)
  • `github.com/quic-go/quic-go` → v0.59.1 (#475)
  • `golang.org/x/sys` → v0.44.0 (#474)
  • `github.com/fsnotify/fsnotify` → v1.10.1 (#468)

Upgrade Notes

  • 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

v1.6.6

Choose a tag to compare

@semihalev semihalev released this 07 May 21:03

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.

What's Changed

Security

  • 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.

Features

  • 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:

    • Mutate maps under b.mu, snapshot, release b.mu, then persist outside the lock. ServeDNS readers no longer wait on disk I/O.
    • A new 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.
    • New 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/batch
    • POST /api/v1/block/remove/batch

    A 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.

Kubernetes Middleware Refactor

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:

  • SERVFAIL for cluster-domain queries when not synced — forward queries no longer leak to public DNS during initial informer warmup; reverse queries still fall through.
  • UID guard rejects late 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.
  • Anonymous headless endpoints get distinct dashed-IP SRV targets (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.
  • Skip-if-equal guard in applyEndpointSlice eliminates rebuilds for resourceVersion-only update events.
  • SRV port-number edits invalidate the cached *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.

Resolver / DNSSEC Refactor

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/.

Config

  • Update B-root to current IANA addresses. ICANN/Verisign re-numbered B-root in late 2023 (IPv4 199.9.14.201170.247.170.2; IPv6 2001:500:200::b2801: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.

Dependencies

  • 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.

Upgrade Notes

  • Recommended for everyone on 1.6.5. The cache-poisoning fix is the headline reason for this release.
  • Config compatibility: 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.
  • No on-disk format changes to 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