| author | Alberto Bertogli
<albertito@blitiri.com.ar> 2026-10-23 20:03:53 UTC |
| committer | Alberto Bertogli
<albertito@blitiri.com.ar> 2026-10-23 20:06:34 UTC |
| parent | 26c45235b90ca765c6e1a01286dd59373ff04112 |
| .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() }