Skip to content
 
 

Repository files navigation

dnsproxy (dnsdoh.art edge fork)

Upstream: Forked from AdguardTeam/dnsproxy (Apache-2.0).
Maintained by: Ozy-666 for the dnsdoh.art production stack.
Base Version: v0.81.4-edge.1 + edge-udp-pool patchset.


Purpose

Maintained specifically for the AdGuardHome-edge project. All changes target the hot DNS query path and are benchmarked on the production host before deployment.

This fork is public and free for everyone. Every patch here runs in production on a live, internet-facing resolver under real DDoS and amplification attacks before it lands on this branch. Use it, study it, or cherry-pick individual commits — same license as upstream (Apache-2.0).

Optimization Highlights

Zero-Alloc UDP Write Path

proxy/serverudp.gorespondUDP() replaces resp.Pack() (1 heap alloc per response) with resp.PackBuffer(*pb) backed by udpPackPool sync.Pool[*[]byte] (2048-byte slots). The kernel copies bytes synchronously in UDPWrite/sendmsg before returning, so the buffer is returned to the pool immediately with no correctness risk.

ns/op B/op allocs/op
Before (resp.Pack()) 255 160 1
After (udpPackPool) ~282 0 0

Zero-Alloc TCP/DoT Path

proxy/servertcp.gotcpPackPool sync.Pool[*[]byte] with 65537-byte slots ([len_hi][len_lo][dns_wire_0..N]).

  • Read: readPrefixedBuf uses two io.ReadFull calls (fixes the original conn.Read short-read bug in readPrefixed). Body bytes are a zero-copy sub-slice of the pool buffer returned after Unpack.
  • Write: resp.PackBuffer((*pb)[2:]) packs into the pool slot; length prefix written into (*pb)[0:2]; entire frame sent via a single conn.Write. A msgLen > dns.MaxMsgSize guard precedes the prefix write: an oversized response cannot fit the pool slot (PackBuffer would allocate wire outside *pb), so the frame is refused with errTooLarge rather than truncating the uint16 prefix and slicing (*pb) out of bounds.

Net result: 4 heap allocs per TCP round-trip → 0. Brings TCP/DoT to performance parity with UDP.

ns/op B/op allocs/op
Before (4× make + resp.Pack()) ~512 4
After (tcpPackPool) ~251 0 0

Lock-Free Server State Checks

proxy/proxy.goProxy.started bool converted to atomic.Bool. isStarted() previously acquired the global embedded sync.RWMutex on every TCP keepalive iteration. Under a Shutdown() call, the pending write-lock caused all concurrent RLock() callers to queue behind it — a thundering-herd unblock on restart. Now a single p.started.Load() with no lock.

Lock-Free Upstream RTT Statistics (Copy-on-Write)

proxy/proxy.go + proxy/exchange.goupstreamRTTStats map[string]upstreamRTTStats and its rttLock sync.Mutex are replaced with rttStats atomic.Pointer[map[string]upstreamRTTStats] and a narrow rttMu sync.Mutex used only by writers.

Before: calcWeights() (called on every load-balanced query) acquired an exclusive sync.Mutex to read the stats map — serializing all concurrent goroutines at the dispatch point. updateRTT() (called after every upstream response) acquired the same lock to write a single entry.

After:

  • calcWeights() calls p.rttStats.Load() — a single atomic pointer read, zero contention, no lock. The returned snapshot is consistent for the duration of the weight calculation.
  • updateRTT() holds rttMu only for the write: loads the current snapshot, shallow-copies the map (typically 2–3 entries), updates one entry, and stores the new pointer. Readers see either the old or the new snapshot atomically — never a partially-written map.

The shallow copy is proportional to the number of configured upstreams (our production config: 2–3 entries), making the write path negligible. All 5 sub-cases of TestProxy_Exchange_loadBalance pass with -race enabled.

Configurable QUIC Stream Limit (Finding 9.2)

proxy/config.go + proxy/serverquic.gonewServerQUICConfig() previously set MaxIncomingStreams: math.MaxUint16 (65535) for both DoQ and DoH3. A single QUIC client could open 65535 concurrent streams per connection, each spawning a goroutine and consuming a slot from the global requestsSema, starving all other clients and protocols.

New field: QUICMaxIncomingStreams int in Config.

Validation (resolvedQUICStreams):

Input Behaviour
0 (unset) Default 64, no log
[1, 1024] Used as-is
Outside range, non-zero Warn log + default 64

Both the DoQ listener (listenQUIC) and the DoH3 listener (listenH3) use the validated value. QUIC flow control (MAX_STREAMS frame) now rejects excess streams at the transport layer — no goroutine is ever spawned for a stream beyond the limit.

AdGuardHome-Edge wiring: QUICMaxIncomingStreams is exposed in AdGuardHome.yaml under dns.quic_max_incoming_streams (schema v35, internal/dnsforward/config.go). Default is 64. Leave unset or set to 0 to get the default silently; set to a value in [1, 1024] to override.

Bounded DoH POST Body

proxy/serverhttps.go — the newDoHReq handler previously called io.ReadAll(r.Body) with no upper bound, acknowledged by an in-tree TODO(d.kolyshev): Limit reader. that was never resolved upstream.

Under a POST flood, each goroutine would allocate memory proportional to the POST body length until the HTTP ReadTimeout fired, causing GC pressure spikes.

Fix: io.LimitReader(r.Body, dns.MaxMsgSize+1) caps the read at 65536 bytes. If the body exceeds dns.MaxMsgSize (65535 bytes — the DNS wire-format maximum per RFC 8484 §4.1), the handler returns 413 Request Entity Too Large before calling Unpack. Any legitimate DoH request fits in 64 KB.

Plain Upstream Connection Pooling

upstream/plain.goplainDNS.dialExchange dialed a fresh connection and closed it on every exchange. When the host resolver forwards every query to a local upstream (the AdGuardHome-Edge deployment runs with AGH's own cache disabled and delegates caching to an on-box unbound at 127.0.0.1:5353), a CPU profile under a DoT flood — taken after the AGH-layer lock contention was removed, leaving the resolver network-IO bound — showed net.Dialer.DialContext at ~19% of all CPU: pure per-query socket/connect/close syscall overhead, the single largest avoidable cost remaining.

Fix. A per-upstream, per-network LIFO connection pool (idleUDP/idleTCP). A connection is removed from the pool for the exclusive duration of one exchange (write + read) and returned only after a clean, validated response — never shared concurrently, which would let DNS responses cross-talk between in-flight queries (a pooled UDP socket read could return another goroutine's reply). A pooled connection that errors (stale: upstream restart or idle keep-alive close) is discarded and the exchange retried once with a fresh dial, folding in the existing isExpectedConnErr retry. The pool is bounded (maxIdlePlainConns = 1024 per network; overflow connections are closed on return) and drained on Close.

Feature-flagged: DNSPROXY_PLAIN_POOL=0 restores the original dial-per-query behaviour.

Result (A/B, identical DoT loopback ramp, deployed AGH-Edge host):

concurrency answers/s before answers/s after Δ
c=100 10,672 14,975 +40%
c=200 7,319 11,110 +52%
c=400 4,775 9,605 +101%
c=600 3,196 6,126 +92%

Re-profiled, DialContext is gone and total CPU fell (245% → 216%) while throughput roughly doubled — the per-query dial was a blocking connect() serialization, not merely CPU. Connection-count sampling confirmed the upstream sockets plateau at a bounded count per concurrency level and are reused, instead of churning thousands of dials per second. The remaining CPU is genuine work: the downstream TLS response write and the upstream exchange.

Clamped EDNS0 UDP Payload Size (Anti-Amplification)

proxy/dnscontext.go — the EDNS0 UDP buffer size a client advertises is now honored only up to 1232 bytes (maxAdvertisedUDPSize, the DNS Flag Day 2020 recommendation) for plain-UDP responses. Both the truncation limit (dnsSize) and the OPT size echoed back to the client (calcFlagsAndSizescrub) are clamped; the resolver never advertises more than it honors.

Motivation: a live reflection campaign against the production host sent spoofed-source queries advertising bufsize 10000 to reflect 2,257-byte TXT answers at victims (31.8× amplification). With the clamp, the same query yields a ≤1232-byte TC=1 answer: real clients transparently retry over TCP and get the full response; spoofed sources cannot complete a TCP handshake, so the reflection dies. TCP, DoT, DoQ and DoH are connection-verified and unaffected — full-size answers still flow there.

This complements the response-rate-limiting (RRL) layer in AdGuardHome-Edge (spec §7.18): RRL kills the bulk of a flood; the clamp caps the blast radius of whatever budget RRL still answers.

DoT ALPN ("dot")

proxy/servertcp.goinitTLSListeners now offers the RFC 7858 ALPN token dot on DNS-over-TLS listeners. Upstream hands p.TLSConfig to tls.NewListener unchanged, so the DoT listener negotiates no ALPN at all, while the HTTPS and QUIC listeners clone the same config and set their own NextProtos.

Motivation: a resolver publishing DDR/SVCB designations (RFC 9461, RFC 9462) advertises alpn="dot" for its port-853 TCP endpoint, and the handshake never confirmed the token it had just promised. Verified on the production host before and after:

# before
$ openssl s_client -connect <resolver>:853 -alpn dot </dev/null | grep ALPN
No ALPN negotiated

# after
$ openssl s_client -connect <resolver>:853 -alpn dot </dev/null | grep ALPN
ALPN protocol: dot

ALPN remains optional for DoT: RFC 7858 Section 3.1 forbids rejecting a connection that does not use it, and Go honors that — a client sending no ALPN extension still connects, verified against three client stacks. The one behavior change is RFC 7301 conformance: a client that offers an ALPN list with no token in common is now refused rather than silently accepted.

AA Bit Cleared on Relayed Responses

proxy/proxy.gohandleExchangeResult clears the AA (Authoritative Answer) flag on responses received from an upstream. A forwarding resolver is not authoritative for anything it relays, and passing the bit through lets a downstream client believe the answer came from the zone's authoritative server. Upstream fix for AdGuardHome issue #7955, taken as 35baa86.

FORMERR Instead of a Silent Drop (JIGGLE Mitigation)

proxy/serverudp.go, proxy/proxy.go — a UDP packet whose DNS header parses but whose body does not is answered FORMERR rather than dropped, and a request whose question count is not exactly 1 gets FORMERR rather than SERVFAIL, per RFC 1035 §4.1.1. This is the GHSA-p5f5-3p5g-rfjw (JIGGLE) mitigation, hand-merged here against the fork's respondUDP, which carries the pooled pack buffer and a different signature.

Note for anyone tracking the advisory: the upstream commit named for JIGGLE contains only the regression test and a Go bump; the mitigation itself is a separate commit.

Answering a malformed packet deserves scrutiny on a host that has been used for reflection. It cannot amplify: a question that fails to parse is not echoed back, so the reply is smaller than the packet that triggered it. Measured on the production host, a 33-byte malformed query drew a 12-byte response — 0.36×, a deamplifier:

reply 12 B  (request was 33 B)   rcode=1 (FORMERR)  qr=1 aa=0
qd=0 an=0 ns=0 ar=0

The reply is emitted inside dnsproxy, before the host's rate-limiting layer sees it; a packet-filter layer covers that gap independently.

DoQ Refuses Unidirectional QUIC Streams

proxy/serverquic.go, proxy/serverhttps.gonewServerQUICConfig is now DoQ-only and sets MaxIncomingUniStreams: -1; DoH3 moves to its own newServerDoH3Config, which keeps the unidirectional allowance it genuinely needs for the HTTP/3 control stream and the QPACK encoder/decoder streams (≥3 per RFC 9114), plus this fork's configurable bidirectional limit.

This is the fix for GHSA-w6v6-f44j-3rj2 ("DoQ unidirectional stream state exhaustion", patched upstream in v0.83.1). A client can open only the highest-numbered unidirectional stream; under QUIC stream-ID semantics every lower-numbered stream of the same type is then considered open, so quic-go materialises receive-stream state for all 65,535 of them, and dnsproxy — which never accepts or cancels them — holds that state until the connection closes.

This fork was already partly shielded: f9ab1de had bounded the limit at 64 against upstream's math.MaxUint16, capping the vector before the advisory published. Refusing the streams outright is strictly better for DoQ, which reads none of them. Landed here as df16938 on 2026-07-31, before the advisory was published on 2026-08-18.

DoQ Rejects Non-Replayable Opcodes in 0-RTT Data

proxy/serverquic.govalidQUICMsg now implements check 7 of RFC 9250 §4.5, which had stood as a TODO since the file was written ("The information necessary to validate this is not exposed by quic-go"). A message whose opcode is neither QUERY nor NOTIFY, arriving on a connection with Used0RTT set and before HandshakeComplete() has fired, is early data carrying a transaction the RFC forbids in 0-RTT; the connection is closed with DOQ_PROTOCOL_ERROR, one of the three server behaviours §4.5 permits.

It is reachable here rather than theoretical: this fork serves DoQ with Allow0RTT and ListenEarly, and nothing anywhere in the proxy filters opcodes, so a replayed 0-RTT UPDATE was previously handled like any other message.

The check is best-effort by construction, and upstream's design is kept rather than improved on: the handshake state is read when the message is validated, not when it arrived, so early data whose handshake completes first is classified as 1-RTT and processed; and conversely a genuine 1-RTT non-QUERY message that overtakes the handshake-complete signal aborts the connection. Both are confined to opcodes this resolver has no legitimate use for either way.

Versioning

The fork is based on upstream stable releases and extended with edge commits on the edge-udp-pool branch.

Tag Upstream base Notes
v0.81.4-edge.1 v0.81.4 DNSSEC DO-bit cache key fix, QUIC idle timeout hardening

Branch edge-udp-pool commits on top of v0.81.4-edge.1 (in order):

Commit Description
bbd79ad atomic.Bool for Proxy.started; tcpPackPool zero-alloc TCP/DoT path
0b14b22 rttLock mutex → atomic.Pointer CoW map (lock-free RTT reads)
00fc061 DoH POST body bounded to dns.MaxMsgSize via io.LimitReader
716e780 QUICMaxIncomingStreams configurable field, default 64, range [1,1024]
f9ab1de MaxIncomingUniStreams decoupled from the bidi cap (fixed 64) so a low DoQ limit can't break DoH3 control/QPACK streams
4728330 respondTCP oversized-response guard (msgLen > dns.MaxMsgSizeerrTooLarge); closes uint16 prefix truncation + out-of-bounds reslice panic (audit H2)
7363632 Plain UDP/TCP upstream connection pool (reuse instead of dial-per-query); eliminates ~19% per-query connect() CPU; goodput ≈ doubled at high concurrency. DNSPROXY_PLAIN_POOL=0 to disable
e1cef22 EDNS0 UDP payload clamp to 1232 (DNS Flag Day 2020, anti-amplification): honored truncation size and echoed OPT size both capped for plain UDP; TCP/DoT/DoQ/DoH untouched
14c4d5b RFC 7858 dot ALPN on DoT listeners: upstream negotiated no ALPN on port 853 while advertising alpn="dot" in its DDR/SVCB designation; ALPN stays optional per RFC 7858 §3.1
1e17078 logDNSMessage skips Msg.String() unless debug logging is enabled — the formatting cost was paid on every query regardless of level
3927e02 UDP listener sharded into SO_REUSEPORT sockets, one per core; the kernel spreads incoming datagrams instead of funnelling them through a single socket queue
6645070 Requests cache sharded to split the global cache lock
7363632 (see above) plain upstream connection pooling
71dc552 Diagnostic watchdog for malformed question names
b74b7bb Client connection resets logged at Debug rather than Error — a reset peer is not a server error, and at flood volume the log itself became the load
25d8f46 DoH upstream response body bounded (io.LimitReader) before parsing — upstream AGDNS-4074
ad5e739 golang.org/x/net bumped to v0.55.0 (GO-2026-5026)
2c1f097 DoH upstream validation hardened: message ID zeroed in the wire bytes, ID-0 echo required, question-section validation unified with plain DNS (upstream AGDNS-4080, GHSA-4qjf-2hgm-92q6)
35baa86 AA bit cleared on relayed upstream responses (AdGuardHome #7955)
c9d9863 FORMERR for malformed UDP and for a question count ≠ 1, instead of a silent drop / SERVFAIL — GHSA-p5f5-3p5g-rfjw (JIGGLE)
df16938 DoQ refuses unidirectional QUIC streams (-1); DoH3 split into newServerDoH3Config — GHSA-w6v6-f44j-3rj2, landed here 18 days before the advisory published
c9c8de7 Truncated UDP responses emptied, not filled: once TC is set, Answer/Ns are dropped and Extra reduced to the OPT (cookie preserved), leaving a bare header + question. RFC 2181 §9 forbids a client from using those records anyway; matches Google/Cloudflare/Quad9. Pairs with the 1232 clamp (e1cef22) — the clamp decides when TC is set, this decides how much is reflected once it is
e66fa7f DoQ rejects non-replayable opcodes in 0-RTT data: a transaction whose opcode is neither QUERY nor NOTIFY, received as QUIC early data, closes the connection with DOQ_PROTOCOL_ERROR instead of being processed — RFC 9250 §4.5 check 7, ported from upstream 096ff91 (AGH-32) in v0.84.2
2aa166a A failed UDP listener is retired, not abandoned: udpPacketLoop used to break on any read error, leaving the socket bound but unread — under the 3927e02 SO_REUSEPORT sharding the kernel keeps hashing 1/N of all datagrams into it, so it black-holes that share once the buffer fills, silently (the net.ErrClosed branch logs at Debug). Seen live: 3 of 4 listeners dead after ~17h, 37% of UDP datagrams dropped at the socket, dig answering 4/12, while DoH/DoT/DoQ stayed healthy. udpLoopActionFor decides continue/retire/stop; retiring closes the socket (which is what removes it from the REUSEPORT group) and reopens a replacement, increments udpListenersRetired and logs at Error

The fork module path remains github.com/AdguardTeam/dnsproxy (unchanged from upstream) so it integrates via a go.mod replace directive in the host repo:

replace github.com/AdguardTeam/dnsproxy => ../dnsproxy

Builds must be run from the AdGuardHome-Edge repo root with this fork checked out at ../dnsproxy. A remote versioned replace does not work because the fork's go.mod declares the original module path, not the fork's.

Upstream Tracking

This fork tracks upstream by review, not by merge. Upstream releases are read commit by commit; what applies is ported by hand onto the patched code and what does not is recorded with a reason. The base version therefore stays where the code is, and is not bumped to advertise currency.

Reviewed Upstream Outcome
2026-07-31 releases up to v0.83.0 Three patches taken: 35baa86, c9d9863, df16938. The DNSCrypt-upstream validation work was skipped — the consuming deployment speaks plain DNS to a local DNSCrypt process, so this code never runs there.
2026-08-21 v0.83.1v0.84.1 Nothing taken. The range is four commits: a DNS64 CNAME/DNAME chain fix (#438), a dnsproxytest helper package, a Go bump, and AGDNS-4357, which unexports every field of proxy.Proxy. That last one is a breaking API change through the exact surface this fork patches — the pooled UDP write path, the QUIC server config, the rate-limit and cookie hooks — with no security content behind it. The DNS64 fix is unreachable in the consuming deployment (use_dns64: false). GHSA-w6v6-f44j-3rj2, patched upstream in v0.83.1, was already answered here by df16938.
2026-09-04 v0.84.2 One patch taken: e66fa7f, adapting 096ff91 (AGH-32) — the RFC 9250 §4.5 0-RTT opcode check, reachable here because DoQ is served with Allow0RTT. The release's only other commit (9b6551d) is a Go directive bump, 1.26.6 → 1.26.8 — one line of go.mod plus five CI/Docker strings — which this fork does not need, as builds run on Go 1.27.1. Correction (2026-09-04, same day): this row first called that commit a Go and quic-go v0.59.0 → v0.60.0 bump, and e66fa7f's commit message says the same. Wrong, and the diffstat already quoted here showed it — `go.mod

Constants mirrored downstream

maxAdvertisedUDPSize (1232, proxy/dnscontext.go) is mirrored in the consuming AdGuardHome fork's internal/dnsforward/msg.go, which clamps the EDNS(0) buffer size it echoes on responses it generates itself (blocked answers, NXDOMAIN, REFUSED, SERVFAIL, NODATA, FORMERR). Upstream AdGuard Home echoes the client's raw advertised size there; that would advertise a buffer this fork never honours, on exactly the responses cheapest for a reflector to trigger.

If this constant changes, change it in both places.


License & Attribution

About

AdGuard dnsproxy fork: pooled connections, SO_REUSEPORT listener sharding, lock-free upstream RTT map. Transport layer for the dnsdoh.art edge stack.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages