2 Commits
Author SHA1 Message Date
matsandClaude Sonnet 5.5 b513501773 Fix block-answer TTLs that were nanoseconds, and stop counting NXDOMAIN as an error
Both found by running the resolver locally against real upstreams.

response_ttl is a duration (60s) and a DNS TTL is seconds, but eight places
converted with a bare uint32(), which keeps the low 32 bits of the nanosecond
count. A blocked name's negative answer carried an SOA TTL and minimum of
997195776 (about 31 years), so any client or downstream cache that honoured it
would remember the block, and keep remembering it after the name was
unblocked. blocklist.TTLSeconds converts properly (whole seconds, clamped to
2^31-1) and is used by the rule parser and the block, sinkhole and redirect
answers. The tests read the TTL off the wire; with the old conversion they fail
with 4165425152.

An ordinary upstream NXDOMAIN was classified as an error. A name that does not
exist is the normal result of a typo, a search-domain expansion or a leaked mDNS
query, so on a real network the error metric counted mostly those and, with the
access log's errors-only default, every one wrote a line. Only the upstream
failing or refusing (SERVFAIL, REFUSED, FORMERR, NOTIMP) is an error now.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 10:26:49 +02:00
matsandClaude Sonnet 5.5 dd1117fd91 Coalesce identical misses, refuse non-QUERY opcodes, refresh hot entries early, check the answered question
Concurrent cache misses for one name each went upstream. After a restart, a
cache flush or a cluster-object change that is a burst of identical queries
exactly when the cache is empty. Identical exchanges (same name, type, class,
DO bit, pinned upstream set and forwarded client subnet) now share one: the
first query runs it on its own goroutine and context, the rest wait and get
private copies, and the leader alone writes the cache. If the leader's client
leaves, waiters take over instead of failing with its cancellation. Shared
answers are counted in fancydns_upstream_coalesced_total.

validate() accepted OpcodeUpdate, so a dynamic update was forwarded upstream as
an ordinary lookup, and every other opcode got FORMERR. A recursive resolver
implements QUERY only: anything else is now NOTIMP (RFC 1035 4.1.1), checked
before the question count since STATUS carries no question.

Prefetch only fired when a stale entry was served, so the first client after a
hot name expired got a TTL-1 stale answer. A fresh entry is now refreshed in
the background when it has been served at least twice and under a tenth of its
lifetime remains; the hit counter restarts with each stored entry, so a name
nobody comes back for stops being refreshed. Counted in
fancydns_cache_prefetch_total.

miekg/dns matches only the message ID of an upstream reply, so a forged reply
with the right ID for a different question was accepted and cached under the
name that was asked. exchangeOne now requires the reply to carry the question
that was sent (an empty section only for FORMERR, SERVFAIL, NOTIMP and
REFUSED). A mismatch fails the attempt without counting against the upstream,
so a spoofer cannot trip the circuit breaker.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 08:58:56 +02:00