git » dnss » commit a0b51fe

Apply more mechanical simplifications using recent Go features

author Alberto Bertogli
2026-10-23 20:03:53 UTC
committer Alberto Bertogli
2026-10-23 20:06:34 UTC
parent 26c45235b90ca765c6e1a01286dd59373ff04112

Apply more mechanical simplifications using recent Go features

This is a follow-up on the `go fix` run in the previous commit, which recorded
automated updates.

This one applies further simplifications that are mechanical and do not
involve logic changes, but are outside of what `go fix` did.

The only user-visible behaviour changes are in the DoH server:
unsupported methods now get 405 (instead of 415), and POST messages that
are too large get rejected instead of truncated.

.github/workflows/tests.yaml +1 -1
dnss_test.go +1 -2
internal/dnsserver/caching_test.go +52 -54
internal/dnsserver/domainmap.go +5 -4
internal/dnsserver/resolver.go +30 -27
internal/dnsserver/server.go +18 -57
internal/dnsserver/server_test.go +3 -2
internal/httpresolver/resolver.go +21 -13
internal/httpserver/server.go +60 -48
internal/httpserver/server_test.go +19 -7
internal/nettrace/bench_test.go +2 -3
internal/nettrace/context_test.go +3 -4
internal/nettrace/http.go +3 -3
internal/nettrace/test_server.go +12 -12
internal/nettrace/trace.go +9 -16
internal/testutil/testutil.go +2 -1
tests/generate_cert.go +2 -3
tests/minidns.go +4 -8

diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml
index ad80dd2..6baeeaf 100644
--- a/.github/workflows/tests.yaml
+++ b/.github/workflows/tests.yaml
@@ -16,7 +16,7 @@ jobs:
     strategy:
       matrix:
         # Oldest supported version, and latest.
-        go-version: [ '1.21.x', '1.x' ]
+        go-version: [ '1.26.x', '1.x' ]
     steps:
       - uses: actions/checkout@v4
       - uses: actions/setup-go@v5
diff --git a/dnss_test.go b/dnss_test.go
index ae7f6f2..b665ebd 100644
--- a/dnss_test.go
+++ b/dnss_test.go
@@ -175,10 +175,9 @@ func BenchmarkSimple(b *testing.B) {
 	ServerAddr := Setup(b)
 	resetAnswers()
 	addAnswers(b, "test.blah. 3600 A 1.2.3.4")
-	b.ResetTimer()
 
 	var err error
-	for i := 0; i < b.N; i++ {
+	for b.Loop() {
 		_, _, err = testutil.DNSQuery(ServerAddr, "test.blah.", dns.TypeA)
 		if err != nil {
 			b.Errorf("dns query returned error: %v", err)
diff --git a/internal/dnsserver/caching_test.go b/internal/dnsserver/caching_test.go
index 02d814d..5af1204 100644
--- a/internal/dnsserver/caching_test.go
+++ b/internal/dnsserver/caching_test.go
@@ -5,10 +5,11 @@ package dnsserver
 import (
 	"fmt"
 	"net"
-	"reflect"
+	"slices"
 	"strconv"
 	"strings"
 	"testing"
+	"testing/synctest"
 	"time"
 
 	"blitiri.com.ar/go/dnss/internal/testutil"
@@ -108,64 +109,62 @@ func TestHitReply(t *testing.T) {
 }
 
 // Test TTL handling.
+// It runs in a synctest bubble, so cache maintenance uses a fake clock and we
+// can check the TTLs precisely without waiting.
 func TestTTL(t *testing.T) {
-	r := testutil.NewTestResolver()
-	c := NewCachingResolver(r)
-	c.Init()
-	resetStats()
+	synctest.Test(t, func(t *testing.T) {
+		r := testutil.NewTestResolver()
+		c := NewCachingResolver(r)
+		c.Init()
+		resetStats()
 
-	// Note we don't start c.Maintain() yet, as we don't want the background
-	// TTL updater until later.
+		// Note we don't start c.Maintain() yet, as we don't want the
+		// background TTL updater until later.
 
-	// Test a record with a larger-than-max TTL (1 day).
-	// The TTL of the response should be capped.
-	resp := queryA(t, c, "test. 86400 A 1.2.3.4", "test.", "1.2.3.4")
-	if !statsEquals(1, 0, 1) {
-		t.Errorf("bad stats: %v", dumpStats())
-	}
-	if ttl := getTTL(resp.Answer); ttl != maxTTL {
-		t.Errorf("expected max TTL (%v), got %v", maxTTL, ttl)
-	}
+		// Test a record with a larger-than-max TTL (1 day).
+		// The TTL of the response should be capped.
+		resp := queryA(t, c, "test. 86400 A 1.2.3.4", "test.", "1.2.3.4")
+		if !statsEquals(1, 0, 1) {
+			t.Errorf("bad stats: %v", dumpStats())
+		}
+		if ttl := getTTL(resp.Answer); ttl != maxTTL {
+			t.Errorf("expected max TTL (%v), got %v", maxTTL, ttl)
+		}
 
-	// Same query, should be cached, and TTL also capped.
-	// As we've not enabled cache maintenance, we can be sure TTL == maxTTL.
-	resp = queryA(t, c, "", "test.", "1.2.3.4")
-	if !statsEquals(2, 1, 1) {
-		t.Errorf("bad stats: %v", dumpStats())
-	}
-	if ttl := getTTL(resp.Answer); ttl != maxTTL {
-		t.Errorf("expected max TTL (%v), got %v", maxTTL, ttl)
-	}
+		// Same query, should be cached, and TTL also capped.
+		// As we've not enabled cache maintenance, we can be sure TTL ==
+		// maxTTL.
+		resp = queryA(t, c, "", "test.", "1.2.3.4")
+		if !statsEquals(2, 1, 1) {
+			t.Errorf("bad stats: %v", dumpStats())
+		}
+		if ttl := getTTL(resp.Answer); ttl != maxTTL {
+			t.Errorf("expected max TTL (%v), got %v", maxTTL, ttl)
+		}
 
-	// To test that the TTL is reduced appropriately, set a small maintenance
-	// period, and then repeatedly query the record. We should see its TTL
-	// shrinking down within 1s.
-	// Even though the TTL resolution in the protocol is in seconds, we don't
-	// need to wait that much "thanks" to rounding artifacts.
-	maintenancePeriod = 50 * time.Millisecond
-	go c.Maintain()
-	resetStats()
+		// Start cache maintenance, and check that the back resolver's
+		// Maintain() is called.
+		go c.Maintain(t.Context())
+		synctest.Wait()
+		select {
+		case <-r.MaintainC:
+		default:
+			t.Errorf("back resolver Maintain() was not called")
+		}
 
-	// Check that the back resolver's Maintain() is called.
-	select {
-	case <-r.MaintainC:
-		t.Log("Maintain() called")
-	case <-time.After(1 * time.Second):
-		t.Errorf("back resolver Maintain() was not called")
-	}
+		// After each maintenance period, the TTL should be reduced
+		// accordingly.
+		for i := 1; i <= 3; i++ {
+			time.Sleep(maintenancePeriod)
+			synctest.Wait()
 
-	start := time.Now()
-	for time.Since(start) < 1*time.Second {
-		resp = queryA(t, c, "", "test.", "1.2.3.4")
-		t.Logf("TTL %v", getTTL(resp.Answer))
-		if ttl := getTTL(resp.Answer); ttl <= (maxTTL - 1*time.Second) {
-			break
+			resp = queryA(t, c, "", "test.", "1.2.3.4")
+			expected := maxTTL - time.Duration(i)*maintenancePeriod
+			if ttl := getTTL(resp.Answer); ttl != expected {
+				t.Errorf("%d: expected TTL %v, got %v", i, expected, ttl)
+			}
 		}
-		time.Sleep(maintenancePeriod)
-	}
-	if ttl := getTTL(resp.Answer); ttl > (maxTTL - 1*time.Second) {
-		t.Errorf("expected maxTTL-1s, got %v", ttl)
-	}
+	})
 }
 
 // Test that requests we can't answer from the cache bypass it.
@@ -482,8 +481,7 @@ func BenchmarkCacheSimple(b *testing.B) {
 
 	req := newQuery("test.", dns.TypeA)
 
-	b.ResetTimer()
-	for i := 0; i < b.N; i++ {
+	for b.Loop() {
 		_, err = c.Query(req, tr)
 		if err != nil {
 			b.Errorf("query failed: %v", err)
@@ -536,7 +534,7 @@ func queryA(t *testing.T, c *cachingResolver, rr, domain, expected string) *dns.
 		t.Errorf("expected %s, got %v", expected, a.A)
 	}
 
-	if !reflect.DeepEqual(req.Question, resp.Question) {
+	if !slices.Equal(req.Question, resp.Question) {
 		t.Errorf("question mis-match: request %v, response %v",
 			req.Question, resp.Question)
 	}
diff --git a/internal/dnsserver/domainmap.go b/internal/dnsserver/domainmap.go
index ddca8ce..fae48a0 100644
--- a/internal/dnsserver/domainmap.go
+++ b/internal/dnsserver/domainmap.go
@@ -1,6 +1,7 @@
 package dnsserver
 
 import (
+	"errors"
 	"fmt"
 	"strings"
 
@@ -57,13 +58,13 @@ func DomainMapFromString(s string) (DomainMap, error) {
 			continue
 		}
 
-		xs := strings.SplitN(pair, ":", 2)
-		if len(xs) != 2 {
+		domain, addr, ok := strings.Cut(pair, ":")
+		if !ok {
 			return nil, fmt.Errorf("%q: %w", pair, errInvalidFormat)
 		}
-		m.Set(strings.TrimSpace(xs[0]), strings.TrimSpace(xs[1]))
+		m.Set(strings.TrimSpace(domain), strings.TrimSpace(addr))
 	}
 	return m, nil
 }
 
-var errInvalidFormat = fmt.Errorf("entry does not have a ':'")
+var errInvalidFormat = errors.New("entry does not have a ':'")
diff --git a/internal/dnsserver/resolver.go b/internal/dnsserver/resolver.go
index 2087c5c..9cfcfa9 100644
--- a/internal/dnsserver/resolver.go
+++ b/internal/dnsserver/resolver.go
@@ -2,11 +2,14 @@ package dnsserver
 
 import (
 	"bytes"
+	"cmp"
+	"context"
 	"expvar"
 	"fmt"
+	"maps"
 	"math"
 	"net/http"
-	"sort"
+	"slices"
 	"sync"
 	"time"
 
@@ -21,9 +24,9 @@ type Resolver interface {
 	// Initialize the resolver.
 	Init() error
 
-	// Maintain performs resolver maintenance. It's expected to run
-	// indefinitely, but may return early if appropriate.
-	Maintain()
+	// Maintain performs resolver maintenance. It's expected to run until the
+	// context is canceled, but may return early if appropriate.
+	Maintain(ctx context.Context)
 
 	// Query responds to a DNS query.
 	Query(r *dns.Msg, tr *trace.Trace) (*dns.Msg, error)
@@ -42,7 +45,7 @@ type cachingResolver struct {
 	answer map[cacheKey][]dns.RR
 
 	// mu protects the answer map.
-	mu *sync.RWMutex
+	mu sync.RWMutex
 }
 
 // cacheKey is the key for the cache entries.
@@ -85,7 +88,6 @@ func NewCachingResolver(back Resolver) *cachingResolver {
 	return &cachingResolver{
 		back:   back,
 		answer: map[cacheKey][]dns.RR{},
-		mu:     &sync.RWMutex{},
 	}
 }
 
@@ -137,15 +139,13 @@ var stats = struct {
 	// Times the cache was full when recording an entry, so we evicted one
 	// to make room.
 	cacheFull *expvar.Int
-}{}
-
-func init() {
-	stats.cacheTotal = expvar.NewInt("cache-total")
-	stats.cacheBypassed = expvar.NewInt("cache-bypassed")
-	stats.cacheHits = expvar.NewInt("cache-hits")
-	stats.cacheMisses = expvar.NewInt("cache-misses")
-	stats.cacheRecorded = expvar.NewInt("cache-recorded")
-	stats.cacheFull = expvar.NewInt("cache-full")
+}{
+	cacheTotal:    expvar.NewInt("cache-total"),
+	cacheBypassed: expvar.NewInt("cache-bypassed"),
+	cacheMisses:   expvar.NewInt("cache-misses"),
+	cacheHits:     expvar.NewInt("cache-hits"),
+	cacheRecorded: expvar.NewInt("cache-recorded"),
+	cacheFull:     expvar.NewInt("cache-full"),
 }
 
 func (c *cachingResolver) Init() error {
@@ -168,12 +168,8 @@ func (c *cachingResolver) DumpCache(w http.ResponseWriter, r *http.Request) {
 
 	// Sort output by expiration, so it is somewhat consistent and practical
 	// to read.
-	keys := []cacheKey{}
-	for k := range c.answer {
-		keys = append(keys, k)
-	}
-	sort.Slice(keys, func(i, j int) bool {
-		return getTTL(c.answer[keys[i]]) < getTTL(c.answer[keys[j]])
+	keys := slices.SortedFunc(maps.Keys(c.answer), func(a, b cacheKey) int {
+		return cmp.Compare(getTTL(c.answer[a]), getTTL(c.answer[b]))
 	})
 
 	// Go through the sorted list and dump the entries.
@@ -212,10 +208,19 @@ func (c *cachingResolver) FlushCache(w http.ResponseWriter, r *http.Request) {
 	w.Write([]byte("cache flush complete"))
 }
 
-func (c *cachingResolver) Maintain() {
-	go c.back.Maintain()
+func (c *cachingResolver) Maintain(ctx context.Context) {
+	go c.back.Maintain(ctx)
+
+	ticker := time.NewTicker(maintenancePeriod)
+	defer ticker.Stop()
+
+	for {
+		select {
+		case <-ctx.Done():
+			return
+		case <-ticker.C:
+		}
 
-	for range time.Tick(maintenancePeriod) {
 		tr := trace.New("dnsserver.Cache", "GC")
 		var total, expired int
 
@@ -331,9 +336,7 @@ func limitTTL(answer []dns.RR) time.Duration {
 			rrTTL = 0
 		}
 
-		if t := time.Duration(rrTTL) * time.Second; t < ttl {
-			ttl = t
-		}
+		ttl = min(ttl, time.Duration(rrTTL)*time.Second)
 	}
 
 	return ttl
diff --git a/internal/dnsserver/server.go b/internal/dnsserver/server.go
index 2f26de2..ccf992f 100644
--- a/internal/dnsserver/server.go
+++ b/internal/dnsserver/server.go
@@ -3,9 +3,7 @@
 package dnsserver
 
 import (
-	"crypto/rand"
-	"encoding/binary"
-	"fmt"
+	"context"
 	"net"
 	"sync"
 
@@ -16,31 +14,6 @@ import (
 	"github.com/miekg/dns"
 )
 
-// newID is a channel used to generate new request IDs.
-// There is a goroutine created at init() time that will get IDs randomly, to
-// help prevent guesses.
-var newID chan uint16
-
-func init() {
-	// Buffer 100 numbers to avoid blocking on crypto rand.
-	newID = make(chan uint16, 100)
-
-	go func() {
-		var id uint16
-		var err error
-
-		for {
-			err = binary.Read(rand.Reader, binary.LittleEndian, &id)
-			if err != nil {
-				panic(fmt.Sprintf("error creating id: %v", err))
-			}
-
-			newID <- id
-		}
-
-	}()
-}
-
 // Server implements a DNS proxy, which will (mostly) use the given resolver
 // to resolve queries.
 type Server struct {
@@ -113,10 +86,10 @@ func (s *Server) Handler(w dns.ResponseWriter, r *dns.Msg) {
 		return
 	}
 
-	// Create our own IDs, in case different users pick the same id and we
-	// pass that upstream.
+	// Create our own (random) IDs, in case different users pick the same id
+	// and we pass that upstream.
 	oldid := r.Id
-	r.Id = <-newID
+	r.Id = dns.Id()
 
 	fromUp, err := s.resolver.Query(r, tr)
 	if err != nil {
@@ -139,16 +112,16 @@ func (s *Server) writeReply(tr *trace.Trace, w dns.ResponseWriter, r, reply *dns
 	// UDP by default has a maximum of 512 bytes. This can be extended via
 	// the client in the EDNS0 record. TCP has a maximum of 64 KiB.
 	network := w.RemoteAddr().Network()
-	max := dns.MaxMsgSize
+	maxSize := dns.MaxMsgSize
 	if network == "udp" {
-		max = 512
+		maxSize = 512
 		ednsOPT := r.IsEdns0()
 		if ednsOPT != nil {
-			max = int(ednsOPT.UDPSize())
+			maxSize = int(ednsOPT.UDPSize())
 		}
 	}
-	reply.Truncate(max)
-	tr.Printf("%s max:%d truncated:%v", network, max, reply.Truncated)
+	reply.Truncate(maxSize)
+	tr.Printf("%s max:%d truncated:%v", network, maxSize, reply.Truncated)
 
 	// Always compress the reply. Messages are unpacked without compression,
 	// and packing them back as-is can make them much larger, even beyond what
@@ -168,7 +141,7 @@ func (s *Server) ListenAndServe() {
 		log.Fatalf("Error initializing: %v", err)
 	}
 
-	go s.resolver.Maintain()
+	go s.resolver.Maintain(context.Background())
 
 	if s.Addr == "systemd" {
 		s.systemdServe()
@@ -219,31 +192,19 @@ func (s *Server) systemdServe() {
 	var wg sync.WaitGroup
 
 	for _, pconn := range pconns {
-		if pconn == nil {
-			continue
-		}
-
-		wg.Add(1)
-		go func(c net.PacketConn) {
-			defer wg.Done()
-			log.Infof("Activate on packet connection (UDP): %v", c.LocalAddr())
-			err := dns.ActivateAndServe(nil, c, dns.HandlerFunc(s.Handler))
+		wg.Go(func() {
+			log.Infof("Activate on packet connection (UDP): %v", pconn.LocalAddr())
+			err := dns.ActivateAndServe(nil, pconn, dns.HandlerFunc(s.Handler))
 			log.Fatalf("Exiting UDP listener: %v", err)
-		}(pconn)
+		})
 	}
 
 	for _, lis := range listeners {
-		if lis == nil {
-			continue
-		}
-
-		wg.Add(1)
-		go func(l net.Listener) {
-			defer wg.Done()
-			log.Infof("Activate on listening socket (TCP): %v", l.Addr())
-			err := dns.ActivateAndServe(l, nil, dns.HandlerFunc(s.Handler))
+		wg.Go(func() {
+			log.Infof("Activate on listening socket (TCP): %v", lis.Addr())
+			err := dns.ActivateAndServe(lis, nil, dns.HandlerFunc(s.Handler))
 			log.Fatalf("Exiting TCP listener: %v", err)
-		}(lis)
+		})
 	}
 
 	wg.Wait()
diff --git a/internal/dnsserver/server_test.go b/internal/dnsserver/server_test.go
index 2fafb65..9a57080 100644
--- a/internal/dnsserver/server_test.go
+++ b/internal/dnsserver/server_test.go
@@ -1,6 +1,7 @@
 package dnsserver
 
 import (
+	"context"
 	"fmt"
 	"testing"
 
@@ -101,8 +102,8 @@ func queryFailure(t *testing.T, srv, domain string) {
 // every query, and the server modifies it when truncating.
 type largeResolver struct{}
 
-func (largeResolver) Init() error { return nil }
-func (largeResolver) Maintain()   {}
+func (largeResolver) Init() error                  { return nil }
+func (largeResolver) Maintain(ctx context.Context) {}
 func (largeResolver) Query(r *dns.Msg, tr *trace.Trace) (*dns.Msg, error) {
 	return &dns.Msg{
 		Question: r.Question,
diff --git a/internal/httpresolver/resolver.go b/internal/httpresolver/resolver.go
index 47b2fa9..1d1339d 100644
--- a/internal/httpresolver/resolver.go
+++ b/internal/httpresolver/resolver.go
@@ -5,13 +5,14 @@ import (
 	"context"
 	"crypto/tls"
 	"crypto/x509"
+	"errors"
 	"fmt"
 	"io"
-	"io/ioutil"
 	"mime"
 	"net"
 	"net/http"
 	"net/url"
+	"os"
 	"sync"
 	"time"
 
@@ -39,10 +40,10 @@ type httpsResolver struct {
 	tr       *trace.Trace
 }
 
-var errAppendingCerts = fmt.Errorf("error appending certificates")
+var errAppendingCerts = errors.New("error appending certificates")
 
 func loadCertPool(caFile string) (*x509.CertPool, error) {
-	pemData, err := ioutil.ReadFile(caFile)
+	pemData, err := os.ReadFile(caFile)
 	if err != nil {
 		return nil, err
 	}
@@ -121,7 +122,6 @@ func (r *httpsResolver) newClient() (*http.Client, error) {
 		DialContext: (&net.Dialer{
 			Timeout:   10 * time.Second,
 			KeepAlive: 1 * time.Second,
-			DualStack: true,
 			Resolver:  r.fallbackResolver,
 		}).DialContext,
 		ForceAttemptHTTP2:     true,
@@ -155,9 +155,17 @@ func (r *httpsResolver) setClientError(err error) {
 	}
 }
 
-func (r *httpsResolver) Maintain() {
-	for range time.Tick(2 * time.Second) {
-		r.maybeRotateClient()
+func (r *httpsResolver) Maintain(ctx context.Context) {
+	ticker := time.NewTicker(2 * time.Second)
+	defer ticker.Stop()
+
+	for {
+		select {
+		case <-ctx.Done():
+			return
+		case <-ticker.C:
+			r.maybeRotateClient()
+		}
 	}
 }
 
@@ -201,7 +209,7 @@ func (r *httpsResolver) maybeRotateClient() {
 func (r *httpsResolver) Query(req *dns.Msg, tr *trace.Trace) (*dns.Msg, error) {
 	packed, err := req.Pack()
 	if err != nil {
-		return nil, fmt.Errorf("cannot pack query: %v", err)
+		return nil, fmt.Errorf("cannot pack query: %w", err)
 	}
 
 	if log.V(1) {
@@ -220,7 +228,7 @@ func (r *httpsResolver) Query(req *dns.Msg, tr *trace.Trace) (*dns.Msg, error) {
 		bytes.NewReader(packed))
 	r.setClientError(err)
 	if err != nil {
-		return nil, fmt.Errorf("POST failed: %v", err)
+		return nil, fmt.Errorf("POST failed: %w", err)
 	}
 	tr.Printf("%s  %s", hr.Proto, hr.Status)
 	defer hr.Body.Close()
@@ -232,22 +240,22 @@ func (r *httpsResolver) Query(req *dns.Msg, tr *trace.Trace) (*dns.Msg, error) {
 	// Read the HTTPS response, and parse the message.
 	ct, _, err := mime.ParseMediaType(hr.Header.Get("Content-Type"))
 	if err != nil {
-		return nil, fmt.Errorf("failed to parse content type: %v", err)
+		return nil, fmt.Errorf("failed to parse content type: %w", err)
 	}
 
 	if ct != "application/dns-message" {
 		return nil, fmt.Errorf("unknown response content type %q", ct)
 	}
 
-	respRaw, err := ioutil.ReadAll(io.LimitReader(hr.Body, 64*1024))
+	respRaw, err := io.ReadAll(io.LimitReader(hr.Body, 64*1024))
 	if err != nil {
-		return nil, fmt.Errorf("error reading from body: %v", err)
+		return nil, fmt.Errorf("error reading from body: %w", err)
 	}
 
 	respDNS := &dns.Msg{}
 	err = respDNS.Unpack(respRaw)
 	if err != nil {
-		return nil, fmt.Errorf("error unpacking response: %v", err)
+		return nil, fmt.Errorf("error unpacking response: %w", err)
 	}
 
 	return respDNS, nil
diff --git a/internal/httpserver/server.go b/internal/httpserver/server.go
index 52a58c3..87eef7e 100644
--- a/internal/httpserver/server.go
+++ b/internal/httpserver/server.go
@@ -8,7 +8,6 @@ package httpserver
 import (
 	"encoding/base64"
 	"io"
-	"io/ioutil"
 	"mime"
 	"net/http"
 
@@ -30,12 +29,9 @@ type Server struct {
 
 // ListenAndServe starts the HTTPS server.
 func (s *Server) ListenAndServe() {
-	mux := http.NewServeMux()
-	mux.HandleFunc("/dns-query", s.Resolve)
-	mux.HandleFunc("/resolve", s.Resolve)
 	srv := http.Server{
 		Addr:    s.Addr,
-		Handler: mux,
+		Handler: s.newMux(),
 	}
 
 	log.Infof("HTTPS listening on %s", s.Addr)
@@ -48,58 +44,74 @@ func (s *Server) ListenAndServe() {
 	log.Fatalf("HTTPS exiting: %s", err)
 }
 
-// Resolve incoming DoH requests.
-func (s *Server) Resolve(w http.ResponseWriter, req *http.Request) {
+func (s *Server) newMux() *http.ServeMux {
+	mux := http.NewServeMux()
+	for _, path := range []string{"/dns-query", "/resolve"} {
+		mux.HandleFunc("GET "+path, s.resolveGET)
+		mux.HandleFunc("POST "+path, s.resolvePOST)
+	}
+	return mux
+}
+
+func newTrace(req *http.Request) *trace.Trace {
 	tr := trace.New("httpserver", "/resolve")
-	defer tr.Finish()
 	tr.Printf("from:%v", req.RemoteAddr)
 	tr.Printf("method:%v", req.Method)
+	return tr
+}
+
+// Resolve incoming DoH GET requests, which have a "dns=" query parameter.
+func (s *Server) resolveGET(w http.ResponseWriter, req *http.Request) {
+	tr := newTrace(req)
+	defer tr.Finish()
+
+	dnsParam := req.URL.Query().Get("dns")
+	if dnsParam == "" {
+		tr.Errorf("unknown request type")
+		http.Error(w, "unknown request type", http.StatusUnsupportedMediaType)
+		return
+	}
+
+	tr.Printf("DoH:GET")
+	dnsQuery, err := base64.RawURLEncoding.DecodeString(dnsParam)
+	if err != nil {
+		tr.Error(err)
+		http.Error(w, err.Error(), http.StatusBadRequest)
+		return
+	}
+
+	s.resolveDoH(tr, w, dnsQuery)
+}
+
+// Resolve incoming DoH POST requests, which have a content-type of
+// application/dns-message.
+func (s *Server) resolvePOST(w http.ResponseWriter, req *http.Request) {
+	tr := newTrace(req)
+	defer tr.Finish()
+
+	ct, _, err := mime.ParseMediaType(req.Header.Get("Content-Type"))
+	if err != nil {
+		tr.Error(err)
+		http.Error(w, err.Error(), http.StatusBadRequest)
+		return
+	}
 
-	req.ParseForm()
-
-	// Identify DoH requests:
-	//  - GET requests have a "dns=" query parameter.
-	//  - POST requests have a content-type = application/dns-message.
-	if req.Method == "GET" && req.FormValue("dns") != "" {
-		tr.Printf("DoH:GET")
-		dnsQuery, err := base64.RawURLEncoding.DecodeString(
-			req.FormValue("dns"))
-		if err != nil {
-			tr.Error(err)
-			http.Error(w, err.Error(), http.StatusBadRequest)
-			return
-		}
-
-		s.resolveDoH(tr, w, dnsQuery)
+	if ct != "application/dns-message" {
+		tr.Errorf("unknown request type")
+		http.Error(w, "unknown request type", http.StatusUnsupportedMediaType)
 		return
 	}
 
-	if req.Method == "POST" {
-		ct, _, err := mime.ParseMediaType(req.Header.Get("Content-Type"))
-		if err != nil {
-			tr.Error(err)
-			http.Error(w, err.Error(), http.StatusBadRequest)
-			return
-		}
-
-		if ct == "application/dns-message" {
-			tr.Printf("DoH:POST")
-			// Limit the size of request to 4k.
-			dnsQuery, err := ioutil.ReadAll(io.LimitReader(req.Body, 4092))
-			if err != nil {
-				tr.Error(err)
-				http.Error(w, err.Error(), http.StatusBadRequest)
-				return
-			}
-
-			s.resolveDoH(tr, w, dnsQuery)
-			return
-		}
+	tr.Printf("DoH:POST")
+	// Limit the size of request to 4k.
+	dnsQuery, err := io.ReadAll(http.MaxBytesReader(w, req.Body, 4096))
+	if err != nil {
+		tr.Error(err)
+		http.Error(w, err.Error(), http.StatusBadRequest)
+		return
 	}
 
-	// Could not found how to handle this request.
-	tr.Errorf("unknown request type")
-	http.Error(w, "unknown request type", http.StatusUnsupportedMediaType)
+	s.resolveDoH(tr, w, dnsQuery)
 }
 
 // Resolve DNS over HTTPS requests, as specified in RFC 8484.
diff --git a/internal/httpserver/server_test.go b/internal/httpserver/server_test.go
index 7caf7da..cb717a0 100644
--- a/internal/httpserver/server_test.go
+++ b/internal/httpserver/server_test.go
@@ -25,7 +25,7 @@ func TestBasic(t *testing.T) {
 
 	// Simple successful query.
 	resp := query(t, srv, "GET",
-		"/ignored?dns=q80BAAABAAAAAAAAA3d3dwdleGFtcGxlA2NvbQAAAQAB", "")
+		"/dns-query?dns=q80BAAABAAAAAAAAA3d3dwdleGFtcGxlA2NvbQAAAQAB", "")
 	if resp.StatusCode != http.StatusOK {
 		t.Errorf("good test: expected http status ok, got %v",
 			resp.StatusCode)
@@ -33,7 +33,7 @@ func TestBasic(t *testing.T) {
 
 	// Invalid request (error unpacking)
 	resp = query(t, srv, "GET",
-		"/ignored?dns=0000", "")
+		"/dns-query?dns=0000", "")
 	if resp.StatusCode != http.StatusBadRequest {
 		t.Errorf("unpacking test: expected bad request, got %v",
 			resp.StatusCode)
@@ -41,11 +41,11 @@ func TestBasic(t *testing.T) {
 
 	// Error reading request body.
 	{
-		req := httptest.NewRequest("POST", "/ignored", nil)
+		req := httptest.NewRequest("POST", "/dns-query", nil)
 		req.Header.Set("Content-Type", "application/dns-message")
 		req.Body = errorReadCloser{}
 		w := httptest.NewRecorder()
-		srv.Resolve(w, req)
+		srv.newMux().ServeHTTP(w, req)
 		resp := w.Result()
 		if resp.StatusCode != http.StatusBadRequest {
 			t.Errorf("bad body test: expected bad request, got %v",
@@ -53,11 +53,23 @@ func TestBasic(t *testing.T) {
 		}
 	}
 
+	// Requests we don't know how to handle.
+	resp = query(t, srv, "GET", "/dns-query", "")
+	if resp.StatusCode != http.StatusUnsupportedMediaType {
+		t.Errorf("GET without dns: expected unsupported media type, got %v",
+			resp.StatusCode)
+	}
+	resp = query(t, srv, "PUT", "/dns-query", "")
+	if resp.StatusCode != http.StatusMethodNotAllowed {
+		t.Errorf("PUT: expected method not allowed, got %v",
+			resp.StatusCode)
+	}
+
 	// Upstream error.
 	// Put this last because we override the upstream address.
 	srv.Upstream = "localhost:0"
 	resp = query(t, srv, "GET",
-		"/ignored?dns=q80BAAABAAAAAAAAA3d3dwdleGFtcGxlA2NvbQAAAQAB", "")
+		"/dns-query?dns=q80BAAABAAAAAAAAA3d3dwdleGFtcGxlA2NvbQAAAQAB", "")
 	if resp.StatusCode != http.StatusFailedDependency {
 		t.Errorf("bad upstream test: expected failed dependency, got %v",
 			resp.StatusCode)
@@ -93,7 +105,7 @@ func TestLargeAnswer(t *testing.T) {
 		t.Fatalf("error packing query: %v", err)
 	}
 
-	resp := query(t, srv, "POST", "/ignored", string(packed))
+	resp := query(t, srv, "POST", "/dns-query", string(packed))
 	if resp.StatusCode != http.StatusOK {
 		t.Fatalf("expected http status ok, got %v", resp.StatusCode)
 	}
@@ -122,7 +134,7 @@ func query(t *testing.T, srv *Server, method, url, body string) *http.Response {
 	req.Header.Set("Content-Type", "application/dns-message")
 
 	w := httptest.NewRecorder()
-	srv.Resolve(w, req)
+	srv.newMux().ServeHTTP(w, req)
 
 	return w.Result()
 }
diff --git a/internal/nettrace/bench_test.go b/internal/nettrace/bench_test.go
index 9fd1f81..c2fb3b2 100644
--- a/internal/nettrace/bench_test.go
+++ b/internal/nettrace/bench_test.go
@@ -39,7 +39,7 @@ func BenchmarkTrace_10000(b *testing.B) {
 }
 
 func BenchmarkNewAndFinish(b *testing.B) {
-	for i := 0; i < b.N; i++ {
+	for b.Loop() {
 		tr := New("bench", "test")
 		tr.Finish()
 	}
@@ -48,8 +48,7 @@ func BenchmarkNewAndFinish(b *testing.B) {
 func BenchmarkPrintf(b *testing.B) {
 	tr := New("bench", "test")
 	defer tr.Finish()
-	b.ResetTimer()
-	for i := 0; i < b.N; i++ {
+	for b.Loop() {
 		// Keep this without any formatting, so we measure our code instead of
 		// the performance of fmt.Sprintf.
 		tr.Printf("this is printf")
diff --git a/internal/nettrace/context_test.go b/internal/nettrace/context_test.go
index 11eb401..bd14775 100644
--- a/internal/nettrace/context_test.go
+++ b/internal/nettrace/context_test.go
@@ -1,7 +1,6 @@
 package nettrace
 
 import (
-	"context"
 	"testing"
 )
 
@@ -10,7 +9,7 @@ func TestContext(t *testing.T) {
 	defer tr.Finish()
 
 	// Attach the trace to a new context.
-	ctx := NewContext(context.Background(), tr)
+	ctx := NewContext(t.Context(), tr)
 
 	// Get the trace back from the context.
 	{
@@ -46,7 +45,7 @@ func TestContext(t *testing.T) {
 	// FromContextOrNew needs to create a new one.
 	{
 		tr5, ctx5 := FromContextOrNew(
-			context.Background(), "TestContext", "tr5")
+			t.Context(), "TestContext", "tr5")
 		if tr, _ := FromContext(ctx5); tr != tr5 {
 			t.Errorf("Context with trace returned the wrong trace: %v != %v",
 				tr, tr5)
@@ -57,7 +56,7 @@ func TestContext(t *testing.T) {
 	// Child from a context that has no trace attached.
 	{
 		tr6 := ChildFromContext(
-			context.Background(), "TestContext", "child")
+			t.Context(), "TestContext", "child")
 		tr6.Finish()
 		if p := tr6.(*trace).Parent; p != nil {
 			t.Errorf("Expected orphan trace, it has a parent: %v", p)
diff --git a/internal/nettrace/http.go b/internal/nettrace/http.go
index 2f51acc..4e0b51e 100644
--- a/internal/nettrace/http.go
+++ b/internal/nettrace/http.go
@@ -8,7 +8,7 @@ import (
 	"html/template"
 	"math"
 	"net/http"
-	"sort"
+	"slices"
 	"strconv"
 	"time"
 )
@@ -156,8 +156,8 @@ func allEvents(tr *trace) []traceAndEvent {
 	evts := appendAllEvents(tr, []traceAndEvent{}, seen, 0)
 
 	// Sort them by time.
-	sort.Slice(evts, func(i, j int) bool {
-		return evts[i].Event.When.Before(evts[j].Event.When)
+	slices.SortFunc(evts, func(a, b traceAndEvent) int {
+		return a.Event.When.Compare(b.Event.When)
 	})
 
 	return evts
diff --git a/internal/nettrace/test_server.go b/internal/nettrace/test_server.go
index a25d40a..3dcc54f 100644
--- a/internal/nettrace/test_server.go
+++ b/internal/nettrace/test_server.go
@@ -5,7 +5,7 @@ package main
 import (
 	"flag"
 	"fmt"
-	"math/rand"
+	"math/rand/v2"
 	"net/http"
 	"time"
 
@@ -48,23 +48,23 @@ func RandomEvents(family string) {
 func randomTrace(family string, tr nettrace.Trace) {
 	tr.Printf("this is a random event")
 	tr.Printf("and it has a random delay")
-	delay := time.Duration(rand.Intn(1000)) * time.Millisecond
+	delay := rand.N(1000 * time.Millisecond)
 	tr.Printf("sleeping %v", delay)
 	time.Sleep(delay)
 
-	if rand.Intn(100) < 1 {
+	if rand.IntN(100) < 1 {
 		tr.Printf("this unlucky one is an error")
 		tr.SetError()
 	}
 
-	if rand.Intn(100) < 10 {
+	if rand.IntN(100) < 10 {
 		tr.Printf("this one got super verbose!")
 		for j := 0; j < 100; j++ {
 			tr.Printf("message %d", j)
 		}
 	}
 
-	if rand.Intn(100) < 30 {
+	if rand.IntN(100) < 30 {
 		tr.Printf("this one had a child")
 		c := tr.NewChild(family, "achild")
 		go randomTrace(family, c)
@@ -78,7 +78,7 @@ func RandomLongEvent(family, title string) {
 	tr := nettrace.New(family, title)
 	tr.Printf("this is a random long event")
 	for i := 0; ; i++ {
-		delay := time.Duration(rand.Intn(100)) * time.Millisecond
+		delay := rand.N(100 * time.Millisecond)
 		time.Sleep(delay)
 		tr.Printf("message %d, slept %v", i, delay)
 	}
@@ -90,16 +90,16 @@ func RandomBunny(family, title string) {
 	tr.SetMaxEvents(100)
 	tr.Printf("this is the top 🐇")
 	for i := 0; ; i++ {
-		delay := time.Duration(rand.Intn(100)) * time.Millisecond
+		delay := rand.N(100 * time.Millisecond)
 		time.Sleep(delay)
 		tr.Printf("message %d, slept %v", i, delay)
 
-		if rand.Intn(100) < 40 {
+		if rand.IntN(100) < 40 {
 			c := tr.NewChild(family, fmt.Sprintf("child-%d", i))
 			go randomTrace(family, c)
 		}
 
-		if rand.Intn(100) < 40 {
+		if rand.IntN(100) < 40 {
 			n := nettrace.New(family, fmt.Sprintf("linked-%d", i))
 			go randomTrace(family, n)
 			tr.Link(n, "linking with this guy")
@@ -114,7 +114,7 @@ func randomNested(family string, depth int, parent nettrace.Trace) {
 
 	tr.Printf("I am a spoiled child")
 
-	delay := time.Duration(rand.Intn(100)) * time.Millisecond
+	delay := rand.N(100 * time.Millisecond)
 	time.Sleep(delay)
 	tr.Printf("slept %v", delay)
 
@@ -124,12 +124,12 @@ func randomNested(family string, depth int, parent nettrace.Trace) {
 	}
 
 	// If we make this < 50, then it grows forever.
-	if rand.Intn(100) < 75 {
+	if rand.IntN(100) < 75 {
 		tr.Printf("I sang really well")
 		return
 	}
 
-	max := rand.Intn(5)
+	max := rand.IntN(5)
 	for i := 0; i < max; i++ {
 		tr.Printf("spawning %d", i)
 		go randomNested(family, depth+1, tr)
diff --git a/internal/nettrace/trace.go b/internal/nettrace/trace.go
index e9963ff..d9b5950 100644
--- a/internal/nettrace/trace.go
+++ b/internal/nettrace/trace.go
@@ -6,8 +6,8 @@ import (
 	"container/ring"
 	"fmt"
 	"maps"
-	"math/rand"
-	"sort"
+	"math/rand/v2"
+	"slices"
 	"strconv"
 	"strings"
 	"sync"
@@ -29,11 +29,8 @@ func newID(family string, ts int64) id {
 }
 
 func (id id) Family() string {
-	sp := strings.SplitN(string(id), "!", 2)
-	if len(sp) != 2 {
-		return string(id)
-	}
-	return sp[0]
+	family, _, _ := strings.Cut(string(id), "!")
+	return family
 }
 
 // Trace represents a single request trace.
@@ -302,7 +299,7 @@ func (tr *trace) IsError() bool {
 // We keep this many buckets of finished traces.
 const nBuckets = 8
 
-// Buckets to use. Lenght must match nBuckets.
+// Buckets to use. Length must match nBuckets.
 // "Traces with a latency >= $duration".
 var buckets = []time.Duration{
 	time.Duration(0),
@@ -433,8 +430,8 @@ func (ft *familyTraces) TracesFor(b int, allgt bool) []*trace {
 
 	// Sort them by start, newer first. This is the order that will be used
 	// when displaying them.
-	sort.Slice(trs, func(i, j int) bool {
-		return trs[i].Start.After(trs[j].Start)
+	slices.SortFunc(trs, func(a, b *trace) int {
+		return b.Start.Compare(a.Start)
 	})
 	return trs
 }
@@ -510,13 +507,9 @@ var (
 )
 
 func copyFamilies() map[string]*familyTraces {
-	n := map[string]*familyTraces{}
-
 	familiesMu.Lock()
-	maps.Copy(n, families)
-	familiesMu.Unlock()
-
-	return n
+	defer familiesMu.Unlock()
+	return maps.Clone(families)
 }
 
 func findInFamilies(traceID id, refID id) *trace {
diff --git a/internal/testutil/testutil.go b/internal/testutil/testutil.go
index 1cbde18..356b2f0 100644
--- a/internal/testutil/testutil.go
+++ b/internal/testutil/testutil.go
@@ -2,6 +2,7 @@
 package testutil
 
 import (
+	"context"
 	"fmt"
 	"net"
 	"net/http"
@@ -119,7 +120,7 @@ func (r *TestResolver) Init() error {
 }
 
 // Maintain the resolver.
-func (r *TestResolver) Maintain() {
+func (r *TestResolver) Maintain(ctx context.Context) {
 	r.MaintainC <- true
 }
 
diff --git a/tests/generate_cert.go b/tests/generate_cert.go
index 07daed7..3029db8 100644
--- a/tests/generate_cert.go
+++ b/tests/generate_cert.go
@@ -3,7 +3,6 @@
 // license that can be found in the LICENSE file.
 
 //go:build ignore
-// +build ignore
 
 // Generate a self-signed X.509 certificate for a TLS server. Outputs to
 // 'cert.pem' and 'key.pem' and will overwrite existing files.
@@ -77,8 +76,8 @@ func main() {
 		BasicConstraintsValid: true,
 	}
 
-	hosts := strings.Split(*host, ",")
-	for _, h := range hosts {
+	hosts := strings.SplitSeq(*host, ",")
+	for h := range hosts {
 		if ip := net.ParseIP(h); ip != nil {
 			template.IPAddresses = append(template.IPAddresses, ip)
 		} else {
diff --git a/tests/minidns.go b/tests/minidns.go
index 1cc8843..dca91f3 100644
--- a/tests/minidns.go
+++ b/tests/minidns.go
@@ -1,5 +1,4 @@
 //go:build ignore
-// +build ignore
 
 // minidns is a trivial DNS server used for testing.
 //
@@ -62,15 +61,12 @@ func main() {
 	srv.loadZones(zonesFile)
 
 	var wg sync.WaitGroup
-	wg.Add(1)
-	go func() {
-		defer wg.Done()
+	wg.Go(func() {
 		srv.listenAndServeUDP(*addr)
-	}()
-	go func() {
-		defer wg.Done()
+	})
+	wg.Go(func() {
 		srv.listenAndServeTCP(*addr)
-	}()
+	})
 	wg.Wait()
 }