Skip to content

Commit a42db5b

Browse files
committed
fix(dns): floor the resolver timeout to stop indefinite hangs
opts.Timeout <= 0 reached retryabledns as a literal zero, and retryabledns applies no default of its own, so a black-hole or non-responsive resolver blocked the query forever. floor it to defaultDNSTimeout before building the client, mirroring the tcp executor's existing floor.
1 parent f55090b commit a42db5b

2 files changed

Lines changed: 67 additions & 5 deletions

File tree

internal/modules/dns.go

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,12 @@ import (
2828
// timeout before giving up.
2929
const dnsMaxRetries = 3
3030

31+
// defaultDNSTimeout bounds a query when the caller passes no timeout.
32+
// retryabledns applies no default of its own: a zero Options.Timeout reaches
33+
// the underlying dns.Client as a literal zero, which blocks forever against a
34+
// non-responsive resolver.
35+
const defaultDNSTimeout = 3 * time.Second
36+
3137
// defaultDNSResolvers is the bundled pool: fast public anycast servers.
3238
var defaultDNSResolvers = []string{"1.1.1.1:53", "8.8.8.8:53", "9.9.9.9:53"}
3339

@@ -59,19 +65,22 @@ type dnsResolver interface {
5965
}
6066

6167
// newDNSResolver builds a resolver over the given pool (falling back to the
62-
// bundled default when it is empty) with the given timeout. It is a package var
63-
// so tests can supply a fake without touching the network.
68+
// bundled default when it is empty) with the given timeout, flooring a
69+
// non-positive timeout to the default so a caller can't request an
70+
// effectively unbounded resolve. It is a package var so tests can supply a
71+
// fake without touching the network.
6472
var newDNSResolver = func(resolvers []string, timeout time.Duration) (dnsResolver, error) {
6573
pool := resolvers
6674
if len(pool) == 0 {
6775
pool = defaultDNSResolvers
6876
}
77+
if timeout <= 0 {
78+
timeout = defaultDNSTimeout
79+
}
6980
opts := retryabledns.Options{
7081
BaseResolvers: pool,
7182
MaxRetries: dnsMaxRetries,
72-
}
73-
if timeout > 0 {
74-
opts.Timeout = timeout
83+
Timeout: timeout,
7584
}
7685
client, err := retryabledns.NewWithOptions(opts)
7786
if err != nil {

internal/modules/dns_test.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
"context"
1717
"errors"
1818
"fmt"
19+
"net"
1920
"os"
2021
"path/filepath"
2122
"reflect"
@@ -422,3 +423,55 @@ func TestNewDNSResolverBuildsClient(t *testing.T) {
422423
}
423424
r.Close()
424425
}
426+
427+
// TestNewDNSResolverFloorsZeroTimeout pins the timeout floor: opts.Timeout <= 0
428+
// must not reach retryabledns as a literal zero, since retryabledns applies no
429+
// default of its own and a zero-timeout dns.Client blocks forever on a
430+
// non-responsive resolver. A black-hole UDP listener (accepts the query,
431+
// never replies) stands in for that non-responsive resolver.
432+
func TestNewDNSResolverFloorsZeroTimeout(t *testing.T) {
433+
pc, err := net.ListenPacket("udp", "127.0.0.1:0")
434+
if err != nil {
435+
t.Fatalf("listen: %v", err)
436+
}
437+
defer pc.Close()
438+
go func() {
439+
buf := make([]byte, 512)
440+
for {
441+
if _, _, err := pc.ReadFrom(buf); err != nil {
442+
return
443+
}
444+
// never reply: this is the black hole.
445+
}
446+
}()
447+
448+
origResolvers := defaultDNSResolvers
449+
defaultDNSResolvers = []string{pc.LocalAddr().String()}
450+
t.Cleanup(func() { defaultDNSResolvers = origResolvers })
451+
452+
r, err := newDNSResolver(nil, 0)
453+
if err != nil {
454+
t.Fatalf("newDNSResolver: %v", err)
455+
}
456+
457+
done := make(chan error, 1)
458+
start := time.Now()
459+
go func() {
460+
_, err := r.Query("example.com", dns.TypeA)
461+
done <- err
462+
}()
463+
464+
// the executor retries dnsMaxRetries times, each capped at the floored
465+
// timeout, so the bound is a multiple of it, not the timeout itself.
466+
bound := time.Duration(dnsMaxRetries) * defaultDNSTimeout
467+
select {
468+
case err := <-done:
469+
elapsed := time.Since(start)
470+
t.Logf("query against a black-hole resolver returned after %v: %v", elapsed, err)
471+
if elapsed > bound+2*time.Second {
472+
t.Errorf("query took %v, want it bounded by ~%v (floored timeout x retries)", elapsed, bound)
473+
}
474+
case <-time.After(bound + 5*time.Second):
475+
t.Fatal("query against a black-hole resolver did not return in bounded time; a zero timeout is hanging forever")
476+
}
477+
}

0 commit comments

Comments
 (0)