Files
fancy-dns/internal/server/server.go
T
developer b6d70a50fe
build / build-push (push) Successful in 22s
Finish the server shutdown tests; fix the two bugs they found
internal/server was the last package whose shutdown was tested by assumption:
four transports, three shutdown shapes, and every one of them can strand a
goroutine that a client is holding open. Both bugs below were live in the tree
as handed over and neither had a test.

An idle client could hold the whole shutdown open. The stream accept loop waits
for its connections before Stop returns, and a connection parked in ReadMsg with
no deadline in front of it only ends when the client goes away — ctx.Err() is
checked between reads, so cancelling the context cannot reach a read that is
already blocked. One connection that connected and said nothing was enough to
make Stop sit there for the caller's entire grace period (15s in main) and then
return context.DeadlineExceeded, which the app reports as "shutdown completed
with errors"; and the cache snapshot the app takes right after Stop never
happened, because the orchestrator had given up first. It is a shutdown DoS that
costs the attacker one connection. The fix is a read deadline in the past on
every accepted connection when the context is cancelled, and deliberately not a
close: a query already inside the pipeline is past its read, so its answer still
goes out, which is the invariant TestInFlightQueryIsAnsweredAcrossStop pins. The
accept loop keeps a small registry of live connections for this — the accept
path, not the query path, and no query ever takes that mutex.

A UDP query in flight at Stop was lost and nothing waited for it. The stream
paths register their connections with s.wait; servePacket did not register at
all, so a UDP query already inside the pipeline was gone twice over: nothing
waited for it, and the answer had nowhere to go once it was ready, because a
datagram is written to the very socket the query arrived on. The visible damage
was not the lost answer (the client retries) but the app, which snapshots the
cache and closes the store the moment Stop returns — a query still in the
pipeline could be writing to a store on its way out. Packets are now counted in
s.wait, and the UDP listener's close wakes the read loop with an expired
deadline and then waits, bounded by udpDrainGrace (250ms), for the loop and the
packets it spawned before closing the socket. The bound is deliberate: a
shutdown may not spend its grace period on one slow upstream, and past the bound
the answer is dropped, which is what the client would see anyway.

Stop returning did not mean the process was done, so nothing could tell a
leaking shutdown from a clean one.
TestStopLeavesNoGoroutineRunningServerCode counts goroutines whose stack still
has a frame in the package — not runtime.NumGoroutine(), which in a test binary
is net/http, crypto/tls and the race detector talking over each other — and it
found the idle-connection bug above. TestStopIsNotBlockedByAnIdleStreamConnection
sets a 60s DoT idle timeout on purpose so a config value cannot hide the
regression behind a lucky tick, and TestInFlightQueryIsAnsweredAcrossStop now
covers all four transports. harness.idleStream exists because a test that only
dials a stream transport races the accept loop: Stop can close the listener
while the connection is still in the backlog, the server never accepts it, and a
shutdown assertion that needs a live server-side goroutine passes for the wrong
reason.

AGENTS.md gains the two durable invariants (no step in the shutdown may wait for
a client; every query goroutine is registered with s.wait, UDP included) and the
two test-hygiene traps. STATUS.md drops the UDP query lost at Stop from its
pinned list, since it is now a test rather than an accident, and the plan starts
at the pinned decisions that are actually left.
2026-09-27 19:28:40 +00:00

969 lines
27 KiB
Go

// Package server runs the DNS listeners: plain DNS on UDP/TCP, DNS-over-TLS
// (RFC 7858) and DNS-over-HTTPS (RFC 8484).
//
// All four transports funnel into one handler, so a query behaves identically
// whichever way it arrived. The transports are deliberately thin: parse, hand
// to the engine, write. Anything heavier belongs behind the engine.
package server
import (
"context"
"crypto/tls"
"crypto/x509"
"encoding/base64"
"errors"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"net/netip"
"os"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/miekg/dns"
"golang.org/x/net/http2"
"golang.org/x/net/http2/h2c"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/engine"
)
// Handler is the interface the transports use to answer a query.
type Handler interface {
Handle(ctx context.Context, req engine.Request) engine.Response
Inflight() int64
Flush()
}
// identityFunc resolves a client IP to its reverse-DNS names. The app installs
// it so the server does not depend on the engine's cache directly.
type identityFunc func(ip string) []string
// tagFunc derives ACL tags from a client IP.
type tagFunc func(ip string) []string
// Server owns every DNS listener.
type Server struct {
eng Handler
cfg *config.Store
log *slog.Logger
version string
names atomic.Pointer[identityFunc]
tags atomic.Pointer[tagFunc]
mu sync.Mutex
started bool
shutdown context.CancelFunc
wait sync.WaitGroup
listeners []listener
// counters is keyed by "<transport>.<field>".
counters sync.Map
// active is the listener snapshot the API serves.
active atomic.Pointer[[]ListenerInfo]
}
// listener couples a close function with the info the API reports for it.
type listener struct {
close func() error
info ListenerInfo
}
// ListenerInfo describes one bound listener.
type ListenerInfo struct {
Transport string `json:"transport"`
Addr string `json:"addr"`
Enabled bool `json:"enabled"`
Running bool `json:"running"`
Err string `json:"err,omitempty"`
}
// Options configures a Server.
type Options struct {
// Engine handles queries.
Engine Handler
// Config is the hot-reloadable config store; the server subscribes to it.
Config *config.Store
// Logger is the structured logger.
Logger *slog.Logger
// Version is reported in the DoH server header and the admin API.
Version string
}
// New builds a Server.
func New(opt Options) *Server {
log := opt.Logger
if log == nil {
log = slog.Default()
}
return &Server{
eng: opt.Engine,
cfg: opt.Config,
log: log.With("component", "dns"),
version: opt.Version,
}
}
// SetIdentity installs the client reverse-name and tag resolvers.
func (s *Server) SetIdentity(names identityFunc, tags tagFunc) {
s.names.Store(&names)
s.tags.Store(&tags)
}
// Start binds every enabled listener and begins serving. It returns once all
// listeners have been bound, so a bind failure surfaces synchronously.
func (s *Server) Start(ctx context.Context) error {
cfg := s.cfg.Get()
ctx, cancel := context.WithCancel(ctx)
s.mu.Lock()
if s.started {
s.mu.Unlock()
cancel()
return errors.New("server: already started")
}
s.started = true
s.shutdown = cancel
s.mu.Unlock()
var failures []error
for _, addr := range cfg.DNS.UDP {
if err := s.serveUDP(ctx, addr); err != nil {
failures = append(failures, err)
}
}
for _, addr := range cfg.DNS.TCP {
if err := s.serveTCP(ctx, addr); err != nil {
failures = append(failures, err)
}
}
if cfg.DNS.DoT.Enabled {
if err := s.serveDoT(ctx, cfg); err != nil {
failures = append(failures, err)
}
}
if cfg.DNS.DoH.Enabled {
if err := s.serveDoH(ctx, cfg); err != nil {
failures = append(failures, err)
}
}
s.publish()
if err := errors.Join(failures...); err != nil {
s.log.Warn("some listeners failed to start", "error", err)
}
if s.count() == 0 && len(failures) > 0 {
cancel()
return fmt.Errorf("server: no listener started: %w", errors.Join(failures...))
}
return nil
}
func (s *Server) count() int {
s.mu.Lock()
defer s.mu.Unlock()
return len(s.listeners)
}
func (s *Server) add(l listener) {
s.mu.Lock()
s.listeners = append(s.listeners, l)
s.mu.Unlock()
}
func (s *Server) publish() {
s.mu.Lock()
infos := make([]ListenerInfo, 0, len(s.listeners))
for _, l := range s.listeners {
infos = append(infos, l.info)
}
s.mu.Unlock()
s.active.Store(&infos)
}
// Listeners returns the current listener snapshot.
func (s *Server) Listeners() []ListenerInfo {
if p := s.active.Load(); p != nil {
return *p
}
return nil
}
// Stop shuts every listener down and waits for their goroutines.
func (s *Server) Stop(ctx context.Context) error {
s.mu.Lock()
if !s.started {
s.mu.Unlock()
return nil
}
s.started = false
shutdown := s.shutdown
ls := s.listeners
s.listeners = nil
s.mu.Unlock()
if shutdown != nil {
shutdown()
}
var errs []error
for _, l := range ls {
if err := l.close(); err != nil && !errors.Is(err, net.ErrClosed) {
// The DoH listener is closed TWICE, by two different owners: once
// from this list, and once by http.Server.Shutdown, which tracks
// the very same net.Listener because serveDoH hands it to
// srv.Serve. Whichever of the two lost that race made an
// otherwise clean Stop return "use of closed network
// connection" at random, so a clean shutdown looked like a
// failed one. Closing an already-closed listener is not a
// failure; only a real close error is. Pinned by
// TestStopIsIdempotent.
errs = append(errs, err)
}
}
done := make(chan struct{})
go func() {
s.wait.Wait()
close(done)
}()
select {
case <-done:
case <-ctx.Done():
errs = append(errs, ctx.Err())
}
s.publish()
return errors.Join(errs...)
}
// serveUDP binds a UDP listener. One short-lived goroutine per packet keeps
// lock hold times at zero; a large socket buffer absorbs bursts instead of
// dropping them.
func (s *Server) serveUDP(ctx context.Context, addr string) error {
pc, err := net.ListenPacket("udp", addr)
if err != nil {
return fmt.Errorf("bind udp %s: %w", addr, err)
}
if uc, ok := pc.(*net.UDPConn); ok {
_ = uc.SetReadBuffer(4 << 20)
_ = uc.SetWriteBuffer(4 << 20)
}
// The read deadline is what lets the loop notice cancellation promptly;
// without it a blocked ReadFrom would outlive shutdown. It has to be reset
// on every iteration: a deadline set once expires for good and turns the
// loop into a spin that never reads another packet.
const readTick = 500 * time.Millisecond
// Two counters, two jobs. s.wait is the lifecycle one: Stop must not
// return while a query is still inside the engine, because the app
// snapshots the cache and closes the store the moment Stop returns.
// packets is the drain one, and only the reader goroutine ever waits on
// it — after it has stopped adding to it, which is what makes waiting on
// it legal at all.
var packets sync.WaitGroup
readerDone := make(chan struct{})
s.add(listener{close: func() error {
// A read deadline that has already expired wakes the read loop at
// once instead of leaving it to the read tick, so a Stop with an
// idle listener costs nothing. The loop returns on its next
// iteration either way: the context is already cancelled by the time
// Stop reaches this close.
_ = pc.SetReadDeadline(aLongPast)
// And then the queries already in the pipeline get their chance to
// be answered on this socket. Unlike the stream transports, a UDP
// answer has nowhere else to go: the write goes to the very socket
// the query arrived on, so a packet that is written after Close is
// simply lost and the client has nothing but its own retry. The wait
// is bounded — a shutdown may not spend its whole grace period on one
// slow upstream.
select {
case <-readerDone:
case <-time.After(udpDrainGrace):
}
return pc.Close()
}, info: ListenerInfo{
Transport: "udp", Addr: pc.LocalAddr().String(), Enabled: true, Running: true,
}})
s.log.Info("dns listener up", "transport", "udp", "addr", pc.LocalAddr().String())
s.wait.Add(1)
go func() {
defer s.wait.Done()
// The deferred order is the whole contract: drain the packets, then
// report the reader done — which is what releases the close func
// above — and only then leave s.wait.
defer close(readerDone)
defer packets.Wait()
buf := make([]byte, dns.MaxMsgSize)
for {
if ctx.Err() != nil {
return
}
_ = pc.SetReadDeadline(time.Now().Add(readTick))
n, from, err := pc.ReadFrom(buf)
if err != nil {
if ctx.Err() != nil {
return
}
if isTimeout(err) {
continue
}
// A closed listener during shutdown lands here.
if errors.Is(err, net.ErrClosed) {
return
}
s.log.Debug("udp read", "error", err)
continue
}
pkt := make([]byte, n)
copy(pkt, buf[:n])
// No ResolveUDPAddr here: the peer is already a *net.UDPAddr, and
// re-parsing from.String() costs a string build plus a SplitHostPort
// on the hottest path in the process. servePacket only needs the
// address to write back to, so pass what we already have.
addr, _ := from.(*net.UDPAddr)
packets.Add(1)
s.wait.Add(1)
go func() {
defer packets.Done()
defer s.wait.Done()
s.servePacket(ctx, pc, pkt, from, addr)
}()
}
}()
return nil
}
func isTimeout(err error) bool {
var ne net.Error
return errors.As(err, &ne) && ne.Timeout()
}
// udpDrainGrace is how long Stop waits for the queries already inside the
// pipeline before it closes the UDP socket. See serveUDP: the point is not to
// make a shutdown slow, it is to give an answer that is milliseconds from
// being written somewhere it can still reach the client.
const udpDrainGrace = 250 * time.Millisecond
// aLongPast is a read deadline that has already expired. Setting it is how a
// blocked read is woken without closing anything under it: the read fails
// with a timeout, which is what a shutdown wants, and a connection that still
// owes the client an answer can still write it.
var aLongPast = time.Unix(1, 0)
// servePacket answers one UDP query. The listener is passed in so the reply
// goes out on the very same socket the query arrived on: a fresh socket per
// answer would burn an ephemeral port and a connect handshake per query.
func (s *Server) servePacket(ctx context.Context, pc net.PacketConn, pkt []byte, from net.Addr, to *net.UDPAddr) {
m := new(dns.Msg)
if err := m.Unpack(pkt); err != nil {
// An unparsable message cannot be replied to: there is no ID and no
// question to echo. Dropping it is the only correct response.
s.bump("udp", "errors")
return
}
// A message with no question is still answerable (FORMERR), so it goes
// through the engine's validation rather than being dropped here.
// Cap the client's advertised buffer: nobody needs 64 KiB of DNS, and an
// unbounded value is a cheap amplification vector.
if o := m.IsEdns0(); o != nil && o.UDPSize() > 4096 {
o.SetUDPSize(4096)
}
req := s.request(ctx, m, from, "udp")
res := s.eng.Handle(ctx, req)
s.bump("udp", "queries")
s.classify("udp", res)
if res.Drop || res.Msg == nil {
s.bump("udp", "dropped")
return
}
if to == nil {
return
}
out := s.fitUDP(res.Msg, m, 512)
if out == nil {
return
}
if c, ok := pc.(interface{ SetWriteDeadline(time.Time) error }); ok {
_ = c.SetWriteDeadline(time.Now().Add(2 * time.Second))
}
s.bump("udp", "sent")
_, _ = pc.WriteTo(out, to)
}
// fitUDP packs a reply into a UDP datagram, setting TC and returning a
// question-only response when it does not fit (RFC 1035 §4.2.1, RFC 2181 §9).
func (s *Server) fitUDP(res *dns.Msg, q *dns.Msg, floor int) []byte {
limit := floor
if o := q.IsEdns0(); o != nil {
if n := int(o.UDPSize()); n >= 512 {
limit = n
}
}
if limit > 4096 {
limit = 4096
}
out, err := res.Pack()
if err != nil {
return nil
}
if len(out) <= limit {
return out
}
tc := new(dns.Msg)
tc.SetReply(q)
tc.Rcode = res.Rcode
tc.Truncated = true
tc.AuthenticatedData = res.AuthenticatedData
if o := q.IsEdns0(); o != nil {
tc.SetEdns0(uint16(limit), o.Do())
}
out, err = tc.Pack()
if err != nil {
return nil
}
s.bump("udp", "truncated")
return out
}
// request builds an engine.Request, filling in the client's identity.
func (s *Server) request(ctx context.Context, m *dns.Msg, from net.Addr, transport string) engine.Request {
ip := addrToAddr(from)
key := ip.String()
req := engine.Request{
Msg: m,
Remote: ip,
Transport: transport,
Start: time.Now(),
}
if f := s.names.Load(); f != nil {
req.ClientNames = (*f)(key)
}
if f := s.tags.Load(); f != nil {
req.ClientTags = (*f)(key)
}
return req
}
// classify records per-transport outcome counters.
func (s *Server) classify(transport string, res engine.Response) {
switch res.Action {
case "block":
s.bump(transport, "blocked")
case "cached":
s.bump(transport, "cached")
case "error", "drop":
s.bump(transport, "errors")
}
}
// serveTCP binds a TCP listener for plain DNS.
func (s *Server) serveTCP(ctx context.Context, addr string) error {
ln, err := net.Listen("tcp", addr)
if err != nil {
return fmt.Errorf("bind tcp %s: %w", addr, err)
}
return s.serveStream(ctx, ln, "tcp", nil, 0)
}
// serveStream runs the accept loop shared by TCP and DoT.
func (s *Server) serveStream(ctx context.Context, ln net.Listener, transport string, tlsCfg *tls.Config, idleTimeout time.Duration) error {
if tlsCfg != nil {
ln = tls.NewListener(ln, tlsCfg)
}
s.add(listener{close: ln.Close, info: ListenerInfo{
Transport: transport, Addr: ln.Addr().String(), Enabled: true, Running: true,
}})
s.log.Info("dns listener up", "transport", transport, "addr", ln.Addr().String())
// The accepted connections, so that a cancellation can reach the ones
// parked in a read. This is the accept path, not the query path: a
// connection is added once and removed once, and no query ever takes this
// mutex.
var (
connMu sync.Mutex
conns = map[net.Conn]struct{}{}
)
s.wait.Add(1)
go func() {
defer s.wait.Done()
var wg sync.WaitGroup
defer wg.Wait()
// A connection that is open but idle is the one thing a cancelled
// context cannot reach by itself: the loop below checks ctx.Err()
// between reads, so a client that connects and then says nothing
// parks in ReadMsg until the client goes away. Stop waits for these
// goroutines, so the shutdown would sit there until the caller's
// grace period ran out and then report a context error — a shutdown
// DoS that costs a client one connection.
//
// The nudge is a read deadline in the past, not a close: a query
// already inside the pipeline is past its read, so its answer is
// still written. That is the invariant
// TestInFlightQueryIsAnsweredAcrossStop pins, and closing the
// connection here would break it.
stop := make(chan struct{})
defer close(stop)
wg.Add(1)
go func() {
defer wg.Done()
select {
case <-stop:
return
case <-ctx.Done():
}
connMu.Lock()
for c := range conns {
_ = c.SetReadDeadline(aLongPast)
}
connMu.Unlock()
}()
for {
conn, err := ln.Accept()
if err != nil {
if ctx.Err() != nil {
return
}
if isTimeout(err) {
continue
}
return
}
connMu.Lock()
conns[conn] = struct{}{}
connMu.Unlock()
wg.Add(1)
go func() {
defer wg.Done()
defer func() {
connMu.Lock()
delete(conns, conn)
connMu.Unlock()
}()
s.serveConn(ctx, conn, transport, idleTimeout)
}()
}
}()
return nil
}
// serveConn handles one stream connection with RFC 1035 §4.2.2 length
// prefixes, so a client may pipeline several queries.
func (s *Server) serveConn(ctx context.Context, conn net.Conn, transport string, idleTimeout time.Duration) {
defer func() { _ = conn.Close() }()
if idleTimeout > 0 {
_ = conn.SetDeadline(time.Now().Add(idleTimeout))
}
co := &dns.Conn{Conn: conn}
for {
if ctx.Err() != nil {
return
}
if idleTimeout > 0 {
_ = conn.SetReadDeadline(time.Now().Add(idleTimeout))
}
m, err := co.ReadMsg()
if err != nil {
return
}
if len(m.Question) == 0 {
s.bump(transport, "errors")
return
}
req := s.request(ctx, m, conn.RemoteAddr(), transport)
req.TCP = true
res := s.eng.Handle(ctx, req)
s.bump(transport, "queries")
s.classify(transport, res)
if res.Drop || res.Msg == nil {
s.bump(transport, "dropped")
return
}
if idleTimeout > 0 {
_ = conn.SetWriteDeadline(time.Now().Add(idleTimeout))
}
if err := co.WriteMsg(res.Msg); err != nil {
return
}
s.bump(transport, "sent")
}
}
// serveDoT binds the DNS-over-TLS listener.
func (s *Server) serveDoT(ctx context.Context, cfg *config.Config) error {
tlsCfg, err := tlsConfig(cfg.DNS.DoT.CertFile, cfg.DNS.DoT.KeyFile, cfg.DNS.DoT.ClientCA, cfg.DNS.DoT.MinVersion)
if err != nil {
return fmt.Errorf("dot: %w", err)
}
if tlsCfg == nil {
return errors.New("dot: enabled but no certificate configured")
}
tlsCfg.NextProtos = []string{"dot"}
timeout := cfg.Server.Timeouts.TLS.D()
var errs []error
for _, addr := range cfg.DNS.DoT.Listen {
ln, err := net.Listen("tcp", addr)
if err != nil {
errs = append(errs, fmt.Errorf("bind dot %s: %w", addr, err))
continue
}
if err := s.serveStream(ctx, ln, "dot", tlsCfg, timeout); err != nil {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}
// serveDoH binds the DNS-over-HTTPS listener. GET (with ?dns=) and POST
// (application/dns-message) are both supported per RFC 8484, and the handler
// speaks h2 with an h2c fallback so it also works on a plain TCP listener.
func (s *Server) serveDoH(ctx context.Context, cfg *config.Config) error {
base := normaliseBase(cfg.DNS.DoH.BasePath)
mux := http.NewServeMux()
h := s.doHHandler(base, cfg.DNS.DoH.AllowPost)
mux.Handle(base, h)
// RFC 8484 §4.1 reserves /.well-known/dns-query as an alias.
if base != "/dns-query" {
mux.Handle("/.well-known/dns-query", h)
}
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/plain")
_, _ = w.Write([]byte("ok\n"))
})
h2s := &http2.Server{IdleTimeout: 90 * time.Second}
handler := h2c.NewHandler(mux, h2s)
tlsCfg, tlsErr := tlsConfig(cfg.DNS.DoH.CertFile, cfg.DNS.DoH.KeyFile, "", cfg.DNS.DoH.MinVersion)
if tlsCfg != nil {
tlsCfg.NextProtos = []string{"h2", "http/1.1"}
}
var errs []error
for _, addr := range cfg.DNS.DoH.Listen {
ln, err := net.Listen("tcp", addr)
if err != nil {
errs = append(errs, fmt.Errorf("bind doh %s: %w", addr, err))
continue
}
actual := ln.Addr().String()
srv := &http.Server{
Handler: handler,
ReadHeaderTimeout: 10 * time.Second,
IdleTimeout: 90 * time.Second,
// No WriteTimeout: a long-lived h2 stream should not be cut off,
// and ReadHeaderTimeout plus IdleTimeout already bound the damage.
}
closeFn := ln.Close
if tlsCfg != nil {
srv.TLSConfig = tlsCfg
ln = tls.NewListener(ln, tlsCfg)
closeFn = ln.Close
}
s.add(listener{close: closeFn, info: ListenerInfo{
Transport: "doh", Addr: actual, Enabled: true, Running: true,
}})
if tlsErr != nil {
s.log.Warn("doh: running without TLS", "error", tlsErr)
}
s.log.Info("dns listener up", "transport", "doh", "addr", actual, "path", base)
s.wait.Add(1)
go func() {
defer s.wait.Done()
if err := srv.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) {
s.log.Debug("doh server stopped", "error", err)
}
}()
s.wait.Add(1)
go func() {
defer s.wait.Done()
<-ctx.Done()
shutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = srv.Shutdown(shutCtx)
}()
}
return errors.Join(errs...)
}
const contentTypeDNS = "application/dns-message"
// doHHandler builds the RFC 8484 handler.
func (s *Server) doHHandler(base string, allowPost bool) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodPost {
w.Header().Set("Allow", "GET, POST")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var (
payload []byte
err error
)
switch r.Method {
case http.MethodGet:
// RFC 8484 §4.1 defines exactly one parameter, "dns", holding
// unpadded base64url. An unknown parameter is a 400 rather than a
// 404 so a client can tell its own mistake from a wrong path.
q := r.URL.Query()
if !q.Has("dns") {
writeDoHError(w, http.StatusBadRequest, "missing dns parameter")
return
}
if len(q) > 1 {
for k := range q {
if k != "dns" {
writeDoHError(w, http.StatusBadRequest, "unsupported parameter "+k)
return
}
}
}
// A DNS message is at most 65535 bytes on the wire (every section
// carries a 16-bit length), so its base64url form cannot be
// longer than this. POST gets the same bound from readCapped;
// without it here an unauthenticated client could make the
// server allocate, base64-decode and Unpack a megabyte per
// request, where the adjacent POST path refuses at 64 KiB.
enc := q.Get("dns")
if len(enc) > base64.RawURLEncoding.EncodedLen(dns.MaxMsgSize) {
writeDoHError(w, http.StatusRequestEntityTooLarge, "dns parameter too large")
return
}
payload, err = decodeBase64URL(enc)
if err != nil {
writeDoHError(w, http.StatusBadRequest, "malformed dns parameter")
return
}
case http.MethodPost:
if !allowPost {
w.Header().Set("Allow", "GET")
http.Error(w, "POST not allowed", http.StatusMethodNotAllowed)
return
}
if ct := r.Header.Get("Content-Type"); ct != contentTypeDNS {
writeDoHError(w, http.StatusUnsupportedMediaType, "content-type must be "+contentTypeDNS)
return
}
payload, err = readCapped(r, dns.MaxMsgSize)
if err != nil {
writeDoHError(w, http.StatusBadRequest, "cannot read body")
return
}
}
m := new(dns.Msg)
if err := m.Unpack(payload); err != nil {
writeDoHError(w, http.StatusBadRequest, "malformed DNS message")
return
}
if len(m.Question) == 0 {
writeDoHError(w, http.StatusBadRequest, "no question")
return
}
req := s.request(r.Context(), m, httpRemoteAddr(r), "doh")
req.TCP = true
req.DoHPath = r.URL.Path
res := s.eng.Handle(r.Context(), req)
s.bump("doh", "queries")
s.classify("doh", res)
if res.Drop || res.Msg == nil {
// A dropped query still needs a body: unlike UDP, this is a
// well-formed HTTP exchange, so hanging up would read as a
// transport error rather than a policy decision. It is still a
// drop, so it counts as one here too — the UDP and TCP paths bump
// "dropped" in the same position, and the DoH path used to be
// the only one that did not.
s.bump("doh", "dropped")
res.Msg = new(dns.Msg)
res.Msg.SetRcode(m, dns.RcodeSuccess)
}
wire, err := res.Msg.Pack()
if err != nil {
http.Error(w, "pack failed", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", contentTypeDNS)
w.Header().Set("Cache-Control", "max-age=0")
if !cfgHideIdentity(s) {
w.Header().Set("Server", "fancy-dns/"+s.version)
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write(wire)
})
}
func cfgHideIdentity(s *Server) bool {
if s.cfg == nil {
return false
}
return s.cfg.Get().DNS.HideIdentity
}
func writeDoHError(w http.ResponseWriter, code int, msg string) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("X-Content-Type-Options", "nosniff")
http.Error(w, msg, code)
}
// tlsConfig loads a certificate for the encrypted listeners. It returns
// (nil, nil) when no certificate is configured, which means "run plaintext".
func tlsConfig(certFile, keyFile, clientCA, minVersion string) (*tls.Config, error) {
if certFile == "" || keyFile == "" {
return nil, nil
}
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
if err != nil {
return nil, fmt.Errorf("load certificate: %w", err)
}
cfg := &tls.Config{
Certificates: []tls.Certificate{cert},
MinVersion: parseTLSVersion(minVersion),
}
if clientCA != "" {
pem, err := os.ReadFile(clientCA)
if err != nil {
return nil, fmt.Errorf("read client CA: %w", err)
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(pem) {
return nil, errors.New("client CA: no certificates found")
}
cfg.ClientCAs = pool
cfg.ClientAuth = tls.RequireAndVerifyClientCert
}
return cfg, nil
}
func parseTLSVersion(s string) uint16 {
switch s {
case "1.3":
return tls.VersionTLS13
case "1.0":
return tls.VersionTLS10
default:
return tls.VersionTLS12
}
}
func normaliseBase(p string) string {
if p == "" {
return "/dns-query"
}
if !strings.HasPrefix(p, "/") {
p = "/" + p
}
for len(p) > 1 && p[len(p)-1] == '/' {
p = p[:len(p)-1]
}
return p
}
func readCapped(r *http.Request, max int64) ([]byte, error) {
if r.ContentLength > max {
return nil, fmt.Errorf("body too large: %d", r.ContentLength)
}
// LimitReader plus one extra byte is how you detect "too large" without
// trusting Content-Length, which a client may lie about.
body := io.LimitReader(r.Body, max+1)
buf, err := io.ReadAll(body)
if err != nil {
return nil, err
}
if int64(len(buf)) > max {
return nil, errors.New("body too large")
}
return buf, nil
}
func decodeBase64URL(s string) ([]byte, error) {
// RFC 8484 requires unpadded base64url, but every major client sends
// padding, so accept both rather than fail a legitimate request.
if b, err := base64.RawURLEncoding.DecodeString(s); err == nil {
return b, nil
}
return base64.URLEncoding.DecodeString(s)
}
// httpRemoteAddr turns an http.Request's RemoteAddr into a net.Addr so the
// shared request builder can be used. Over plain HTTP this is the socket peer;
// behind a reverse proxy it is the proxy, which is why the caller must trust
// X-Forwarded-For before that matters (handled in the API layer).
func httpRemoteAddr(r *http.Request) net.Addr {
if r.RemoteAddr == "" {
return &net.TCPAddr{}
}
ap, err := netip.ParseAddrPort(r.RemoteAddr)
if err != nil {
return &net.TCPAddr{}
}
return net.UDPAddrFromAddrPort(ap)
}
// addrToAddr extracts a netip.Addr from a net.Addr.
func addrToAddr(a net.Addr) netip.Addr {
switch v := a.(type) {
case *net.UDPAddr:
return v.AddrPort().Addr().Unmap()
case *net.TCPAddr:
return v.AddrPort().Addr().Unmap()
default:
ap, err := netip.ParseAddrPort(a.String())
if err == nil {
return ap.Addr().Unmap()
}
if ip, err := netip.ParseAddr(a.String()); err == nil {
return ip.Unmap()
}
return netip.Addr{}
}
}
// Stats aggregates the per-transport counters plus the engine's own view.
func (s *Server) Stats() HandlerStats {
var out HandlerStats
s.counters.Range(func(k, v any) bool {
key, _ := k.(string)
n := v.(*atomic.Uint64).Load()
dot := strings.IndexByte(key, '.')
if dot < 0 {
return true
}
switch key[dot+1:] {
case "queries":
out.Queries += n
case "blocked":
out.Blocked += n
case "cached":
out.Cached += n
case "errors":
out.Errors += n
case "dropped":
out.Dropped += n
}
return true
})
if s.eng != nil {
out.Inflight = s.eng.Inflight()
}
return out
}
func (s *Server) bump(transport, field string) {
if transport == "" {
transport = "udp"
}
key := transport + "." + field
v, _ := s.counters.LoadOrStore(key, new(atomic.Uint64))
v.(*atomic.Uint64).Add(1)
}
// flusher is implemented by the engine: it pushes buffered cache and log state
// to disk on demand (used by the admin API's "flush" action).
type flusher interface{ Flush() }
// Flush pushes buffered state to disk.
func (s *Server) Flush() {
if f, ok := s.eng.(flusher); ok {
f.Flush()
}
}