8 Commits

Author SHA1 Message Date
84d63f749f Merge branch 'main' into fix/iterative-resolution-timeout
All checks were successful
Check / check (pull_request) Successful in 10m22s
2026-02-28 12:38:35 +01:00
55c6c21b5a Merge pull request 'fix: distinguish timeout from negative DNS responses (closes #35)' (#37) from fix/timeout-vs-negative-response into main
Some checks are pending
Check / check (push) Waiting to run
Reviewed-on: #37
2026-02-28 12:38:18 +01:00
clawbot
2993911883 fix: distinguish timeout from negative DNS responses (closes #35)
Some checks failed
Check / check (pull_request) Failing after 5m41s
2026-02-28 03:35:54 -08:00
70fac87254 Merge pull request 'fix: remove ErrNotImplemented stub — all checks fully implemented (closes #16)' (#23) from fix/remove-unimplemented-stubs into main
Some checks are pending
Check / check (push) Waiting to run
Reviewed-on: #23
2026-02-28 12:26:27 +01:00
clawbot
48bedaf579 fix: proper iterative resolution for A/NS lookups (closes #24)
All checks were successful
Check / check (pull_request) Successful in 10m23s
2026-02-28 03:19:53 -08:00
940f7c89da Merge branch 'main' into fix/remove-unimplemented-stubs
Some checks failed
Check / check (pull_request) Failing after 5m45s
2026-02-28 12:09:24 +01:00
7d380aafa4 Merge branch 'main' into fix/remove-unimplemented-stubs
Some checks failed
Check / check (pull_request) Failing after 5m42s
2026-02-28 12:08:35 +01:00
clawbot
d0220e5814 fix: remove ErrNotImplemented stub — resolver, port, and TLS checks are fully implemented (closes #16)
Some checks failed
Check / check (pull_request) Failing after 5m27s
The ErrNotImplemented sentinel error was dead code left over from
initial scaffolding. The resolver performs real iterative DNS
lookups from root servers, PortCheck does TCP connection checks,
and TLSCheck verifies TLS certificates and expiry. Removed the
unused error constant.
2026-02-21 00:55:38 -08:00
3 changed files with 160 additions and 155 deletions

View File

@@ -4,11 +4,6 @@ import "errors"
// Sentinel errors returned by the resolver. // Sentinel errors returned by the resolver.
var ( var (
// ErrNotImplemented indicates a method is stubbed out.
ErrNotImplemented = errors.New(
"resolver not yet implemented",
)
// ErrNoNameservers is returned when no authoritative NS // ErrNoNameservers is returned when no authoritative NS
// could be discovered for a domain. // could be discovered for a domain.
ErrNoNameservers = errors.New( ErrNoNameservers = errors.New(
@@ -24,8 +19,4 @@ var (
// ErrContextCanceled wraps context cancellation for the // ErrContextCanceled wraps context cancellation for the
// resolver's iterative queries. // resolver's iterative queries.
ErrContextCanceled = errors.New("context canceled") ErrContextCanceled = errors.New("context canceled")
// ErrSERVFAIL is returned when a DNS server responds with
// SERVFAIL after all retries are exhausted.
ErrSERVFAIL = errors.New("SERVFAIL from server")
) )

View File

@@ -4,7 +4,6 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"math/rand"
"net" "net"
"sort" "sort"
"strings" "strings"
@@ -42,22 +41,6 @@ func rootServerList() []string {
} }
} }
const maxRootServers = 3
// randomRootServers returns a shuffled subset of root servers.
func randomRootServers() []string {
all := rootServerList()
rand.Shuffle(len(all), func(i, j int) {
all[i], all[j] = all[j], all[i]
})
if len(all) > maxRootServers {
return all[:maxRootServers]
}
return all
}
func checkCtx(ctx context.Context) error { func checkCtx(ctx context.Context) error {
err := ctx.Err() err := ctx.Err()
if err != nil { if err != nil {
@@ -244,7 +227,7 @@ func (r *Resolver) followDelegation(
authNS := extractNSSet(resp.Ns) authNS := extractNSSet(resp.Ns)
if len(authNS) == 0 { if len(authNS) == 0 {
return r.resolveNSRecursive(ctx, domain) return r.resolveNSIterative(ctx, domain)
} }
glue := extractGlue(resp.Extra) glue := extractGlue(resp.Extra)
@@ -308,60 +291,84 @@ func (r *Resolver) resolveNSIPs(
return ips return ips
} }
// resolveNSRecursive queries for NS records using recursive // resolveNSIterative queries for NS records using iterative
// resolution as a fallback for intercepted environments. // resolution as a fallback when followDelegation finds no
func (r *Resolver) resolveNSRecursive( // authoritative answer in the delegation chain.
func (r *Resolver) resolveNSIterative(
ctx context.Context, ctx context.Context,
domain string, domain string,
) ([]string, error) { ) ([]string, error) {
domain = dns.Fqdn(domain) if checkCtx(ctx) != nil {
msg := new(dns.Msg) return nil, ErrContextCanceled
msg.SetQuestion(domain, dns.TypeNS) }
msg.RecursionDesired = true
for _, ip := range randomRootServers() { domain = dns.Fqdn(domain)
servers := rootServerList()
for range maxDelegation {
if checkCtx(ctx) != nil { if checkCtx(ctx) != nil {
return nil, ErrContextCanceled return nil, ErrContextCanceled
} }
addr := net.JoinHostPort(ip, "53") resp, err := r.queryServers(
ctx, servers, domain, dns.TypeNS,
resp, _, err := r.client.ExchangeContext(ctx, msg, addr) )
if err != nil { if err != nil {
continue return nil, err
} }
nsNames := extractNSSet(resp.Answer) nsNames := extractNSSet(resp.Answer)
if len(nsNames) > 0 { if len(nsNames) > 0 {
return nsNames, nil return nsNames, nil
} }
// Follow delegation.
authNS := extractNSSet(resp.Ns)
if len(authNS) == 0 {
break
}
glue := extractGlue(resp.Extra)
nextServers := glueIPs(authNS, glue)
if len(nextServers) == 0 {
break
}
servers = nextServers
} }
return nil, ErrNoNameservers return nil, ErrNoNameservers
} }
// resolveARecord resolves a hostname to IPv4 addresses. // resolveARecord resolves a hostname to IPv4 addresses using
// iterative resolution through the delegation chain.
func (r *Resolver) resolveARecord( func (r *Resolver) resolveARecord(
ctx context.Context, ctx context.Context,
hostname string, hostname string,
) ([]string, error) { ) ([]string, error) {
hostname = dns.Fqdn(hostname) if checkCtx(ctx) != nil {
msg := new(dns.Msg) return nil, ErrContextCanceled
msg.SetQuestion(hostname, dns.TypeA) }
msg.RecursionDesired = true
for _, ip := range randomRootServers() { hostname = dns.Fqdn(hostname)
servers := rootServerList()
for range maxDelegation {
if checkCtx(ctx) != nil { if checkCtx(ctx) != nil {
return nil, ErrContextCanceled return nil, ErrContextCanceled
} }
addr := net.JoinHostPort(ip, "53") resp, err := r.queryServers(
ctx, servers, hostname, dns.TypeA,
resp, _, err := r.client.ExchangeContext(ctx, msg, addr) )
if err != nil { if err != nil {
continue return nil, fmt.Errorf(
"resolving %s: %w", hostname, err,
)
} }
// Check for A records in the answer section.
var ips []string var ips []string
for _, rr := range resp.Answer { for _, rr := range resp.Answer {
@@ -373,6 +380,24 @@ func (r *Resolver) resolveARecord(
if len(ips) > 0 { if len(ips) > 0 {
return ips, nil return ips, nil
} }
// Follow delegation if present.
authNS := extractNSSet(resp.Ns)
if len(authNS) == 0 {
break
}
glue := extractGlue(resp.Extra)
nextServers := glueIPs(authNS, glue)
if len(nextServers) == 0 {
// Resolve NS IPs iteratively — but guard
// against infinite recursion by using only
// already-resolved servers.
break
}
servers = nextServers
} }
return nil, fmt.Errorf( return nil, fmt.Errorf(
@@ -402,7 +427,7 @@ func (r *Resolver) FindAuthoritativeNameservers(
candidate := strings.Join(labels[i:], ".") + "." candidate := strings.Join(labels[i:], ".") + "."
nsNames, err := r.followDelegation( nsNames, err := r.followDelegation(
ctx, candidate, randomRootServers(), ctx, candidate, rootServerList(),
) )
if err == nil && len(nsNames) > 0 { if err == nil && len(nsNames) > 0 {
sort.Strings(nsNames) sort.Strings(nsNames)
@@ -435,6 +460,23 @@ func (r *Resolver) QueryNameserver(
return r.queryAllTypes(ctx, nsHostname, nsIPs[0], hostname) return r.queryAllTypes(ctx, nsHostname, nsIPs[0], hostname)
} }
// QueryNameserverIP queries a nameserver by its IP address directly,
// bypassing NS hostname resolution.
func (r *Resolver) QueryNameserverIP(
ctx context.Context,
nsHostname string,
nsIP string,
hostname string,
) (*NameserverResponse, error) {
if checkCtx(ctx) != nil {
return nil, ErrContextCanceled
}
hostname = dns.Fqdn(hostname)
return r.queryAllTypes(ctx, nsHostname, nsIP, hostname)
}
func (r *Resolver) queryAllTypes( func (r *Resolver) queryAllTypes(
ctx context.Context, ctx context.Context,
nsHostname string, nsHostname string,
@@ -459,11 +501,6 @@ func (r *Resolver) queryAllTypes(
return resp, nil return resp, nil
} }
const (
singleTypeMaxRetries = 3
singleTypeInitialBackoff = 100 * time.Millisecond
)
type queryState struct { type queryState struct {
gotNXDomain bool gotNXDomain bool
gotSERVFAIL bool gotSERVFAIL bool
@@ -495,21 +532,6 @@ func (r *Resolver) queryEachType(
return state return state
} }
// isTimeout checks whether an error represents a DNS timeout.
func isTimeout(err error) bool {
if err == nil {
return false
}
var netErr net.Error
if errors.As(err, &netErr) && netErr.Timeout() {
return true
}
// Also catch i/o timeout strings from the dns library.
return strings.Contains(err.Error(), "i/o timeout")
}
func (r *Resolver) querySingleType( func (r *Resolver) querySingleType(
ctx context.Context, ctx context.Context,
nsIP string, nsIP string,
@@ -518,99 +540,27 @@ func (r *Resolver) querySingleType(
resp *NameserverResponse, resp *NameserverResponse,
state *queryState, state *queryState,
) { ) {
msg, lastErr := r.querySingleTypeWithRetry( msg, err := r.queryDNS(ctx, nsIP, hostname, qtype)
ctx, nsIP, hostname, qtype, if err != nil {
) if isTimeout(err) {
if msg == nil { state.gotTimeout = true
r.recordRetryFailure(lastErr, state) }
return return
} }
r.handleDNSResponse(msg, resp, state)
}
func (r *Resolver) querySingleTypeWithRetry(
ctx context.Context,
nsIP string,
hostname string,
qtype uint16,
) (*dns.Msg, error) {
var lastErr error
backoff := singleTypeInitialBackoff
for attempt := range singleTypeMaxRetries {
if checkCtx(ctx) != nil {
return nil, ErrContextCanceled
}
if attempt > 0 {
if !waitBackoff(ctx, backoff) {
return nil, ErrContextCanceled
}
backoff *= timeoutMultiplier
}
msg, err := r.queryDNS(ctx, nsIP, hostname, qtype)
if err != nil {
lastErr = err
if !isTimeout(err) {
return nil, err
}
continue
}
if msg.Rcode == dns.RcodeServerFailure {
lastErr = ErrSERVFAIL
continue
}
return msg, nil
}
return nil, lastErr
}
func waitBackoff(ctx context.Context, d time.Duration) bool {
select {
case <-ctx.Done():
return false
case <-time.After(d):
return true
}
}
func (r *Resolver) recordRetryFailure(
lastErr error,
state *queryState,
) {
if lastErr == nil {
return
}
if isTimeout(lastErr) {
state.gotTimeout = true
} else if errors.Is(lastErr, ErrSERVFAIL) {
state.gotSERVFAIL = true
}
}
func (r *Resolver) handleDNSResponse(
msg *dns.Msg,
resp *NameserverResponse,
state *queryState,
) {
if msg.Rcode == dns.RcodeNameError { if msg.Rcode == dns.RcodeNameError {
state.gotNXDomain = true state.gotNXDomain = true
return return
} }
if msg.Rcode == dns.RcodeServerFailure {
state.gotSERVFAIL = true
return
}
collectAnswerRecords(msg, resp, state) collectAnswerRecords(msg, resp, state)
} }
@@ -633,16 +583,26 @@ func collectAnswerRecords(
} }
} }
// isTimeout checks whether an error is a network timeout.
func isTimeout(err error) bool {
var netErr net.Error
if errors.As(err, &netErr) {
return netErr.Timeout()
}
return false
}
func classifyResponse(resp *NameserverResponse, state queryState) { func classifyResponse(resp *NameserverResponse, state queryState) {
switch { switch {
case state.gotNXDomain && !state.hasRecords: case state.gotNXDomain && !state.hasRecords:
resp.Status = StatusNXDomain resp.Status = StatusNXDomain
case state.gotTimeout && !state.hasRecords: case state.gotTimeout && !state.hasRecords:
resp.Status = StatusTimeout resp.Status = StatusTimeout
resp.Error = "all queries timed out after retries" resp.Error = "all queries timed out"
case state.gotSERVFAIL && !state.hasRecords: case state.gotSERVFAIL && !state.hasRecords:
resp.Status = StatusError resp.Status = StatusError
resp.Error = "server failure (SERVFAIL) after retries" resp.Error = "server returned SERVFAIL"
case !state.hasRecords && !state.gotNXDomain: case !state.hasRecords && !state.gotNXDomain:
resp.Status = StatusNoData resp.Status = StatusNoData
} }

View File

@@ -10,6 +10,7 @@ import (
"testing" "testing"
"time" "time"
"github.com/miekg/dns"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@@ -622,6 +623,59 @@ func TestQueryAllNameservers_ContextCanceled(t *testing.T) {
assert.Error(t, err) assert.Error(t, err)
} }
// ----------------------------------------------------------------
// Timeout tests
// ----------------------------------------------------------------
func TestQueryNameserverIP_Timeout(t *testing.T) {
t.Parallel()
log := slog.New(slog.NewTextHandler(
os.Stderr,
&slog.HandlerOptions{Level: slog.LevelDebug},
))
r := resolver.NewFromLoggerWithClient(
log, &timeoutClient{},
)
ctx, cancel := context.WithTimeout(
context.Background(), 10*time.Second,
)
t.Cleanup(cancel)
// Query any IP — the client always returns a timeout error.
resp, err := r.QueryNameserverIP(
ctx, "unreachable.test.", "192.0.2.1",
"example.com",
)
require.NoError(t, err)
assert.Equal(t, resolver.StatusTimeout, resp.Status)
assert.NotEmpty(t, resp.Error)
}
// timeoutClient simulates DNS timeout errors for testing.
type timeoutClient struct{}
func (c *timeoutClient) ExchangeContext(
_ context.Context,
_ *dns.Msg,
_ string,
) (*dns.Msg, time.Duration, error) {
return nil, 0, &net.OpError{
Op: "read",
Net: "udp",
Err: &timeoutError{},
}
}
type timeoutError struct{}
func (e *timeoutError) Error() string { return "i/o timeout" }
func (e *timeoutError) Timeout() bool { return true }
func (e *timeoutError) Temporary() bool { return true }
func TestResolveIPAddresses_ContextCanceled(t *testing.T) { func TestResolveIPAddresses_ContextCanceled(t *testing.T) {
t.Parallel() t.Parallel()