Skip to content

v1.8.0

Choose a tag to compare

@semihalev semihalev released this 18 Aug 03:34
· 14 commits to main since this release
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 handling is case-correct (#566): uppercase spellings (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.
  • PTR name parsing is exact (#570): IPv6 PTR accepts precisely the RFC 3596 32-nibble shape; malformed spellings the old split-and-rejoin parser incidentally accepted now fall through as ordinary misses.
  • Blocklist async persistence can no longer roll the on-disk file backwards on shutdown races (#529), and a source URL carrying an explicit port now loads on Windows instead of failing on a file name the OS refuses (#539).

Observability

  • 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.
  • Access logs and observers now report the response's true wire length — no more decoded-size inflation, stream length prefixes excluded (#534). dnstap no longer logs a response twice when a wire write falls back to the message path, and reflex scores a source on actual response bytes instead of estimates (#552).

Testing and dependencies

  • The test suite resolves nothing on the live internet: a signed loopback namespace stands in for the world (#539), and the forwarder harness heals its own port races (#554).
  • testify is retired; the standard library says it plainly (#561).
  • Allocation gates in CI pin the zero-allocation hit classes so they cannot regress silently.

Upgrade and compatibility notes

  • Middleware and plugin API — source-breaking. 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.
  • Config schema is v1.8.0. No keys were removed or renamed; older files keep working, with defaults filling what they omit. Regenerate to see the new sections. New keys, all with derived defaults that reach the measured numbers untouched: 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).
  • The legacy 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.
  • Truncated UDP responses are now minimal (RFC 6891 §7): header, question, and — only when the request carried one — OPT. Previous versions shipped partial record sets under TC=1; a client that consumed those instead of retrying over TCP will now see none. Standards-conforming resolvers and stub libraries are unaffected.
  • 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.
  • TCP sessions persist (#572): clients are no longer disconnected after a per-connection query budget. Connection-count limits and idle timeouts are unchanged.
  • dnstap frames now carry the client's original wire bytes for wire-born queries instead of a re-packed message — semantically identical, but a byte-level consumer may notice different name compression than 1.7.x produced. A connected tap no longer disables the byte-serving path.
  • The default bind now serves IPv6 (#559): 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.
  • No toolchain change for source builds: the Go version requirement is the same as 1.7.4.