CI / test (pull_request) Failing after 3m40s
- Exponential backoff retry logic (100ms, 200ms, 400ms... capped at 2s) replacing fixed 100ms delay between retries - Explicit REFUSED and NOTIMP response types (RespREFUSED, RespNOTIMPL) surfaced as terminal results with user-visible messages - CNAME loop detection: walking the ancestor referral chain before following a CNAME prevents infinite recursion; produces RespCNAMELoop - DNAME record support: synthesize CNAME target from DNAME mapping when the server omits the RFC 6672 synthesized CNAME record - ErrorMessage field on Response for surfacing error details to users - Fix resolveGlueViaSystem timeout bug: deadline.Sub(deadline) was always 0; replaced with time.Until(deadline) - DNAME records excluded from hasFinalAnswer so DNAME-only responses are correctly classified as RespCNAMEFollow - Text and JSON output updated with labels for all new response types - Tests: CNAME loop (2-step and direct), REFUSED, NOTIMP, graceful degradation (partial and total server failure), DNAME synthesis, IsNameInChain, backoffDelay, ResponseClassification strings Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: multica-agent <github@multica.ai>
219 lines
4.8 KiB
Go
219 lines
4.8 KiB
Go
package dns
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net"
|
|
"time"
|
|
|
|
"github.com/miekg/dns"
|
|
)
|
|
|
|
type QueryConfig struct {
|
|
UDPSize int
|
|
Timeout time.Duration
|
|
Retries int
|
|
UseTCP bool
|
|
AllowTCP bool
|
|
}
|
|
|
|
func DefaultQueryConfig() *QueryConfig {
|
|
return &QueryConfig{
|
|
UDPSize: DefaultEDNS0UDPSize(),
|
|
Timeout: 5 * time.Second,
|
|
Retries: 3,
|
|
UseTCP: false,
|
|
AllowTCP: true,
|
|
}
|
|
}
|
|
|
|
type ExchangeFunc func(ctx context.Context, server string, msg *dns.Msg, useTCP bool) (*dns.Msg, error)
|
|
|
|
func Query(ctx context.Context, server net.IP, name string, qtype uint16, cfg *QueryConfig) (*dns.Msg, error) {
|
|
if cfg == nil {
|
|
cfg = DefaultQueryConfig()
|
|
}
|
|
|
|
if cfg.UDPSize <= 0 {
|
|
cfg.UDPSize = DefaultEDNS0UDPSize()
|
|
}
|
|
if cfg.Timeout <= 0 {
|
|
cfg.Timeout = 5 * time.Second
|
|
}
|
|
|
|
return QueryWithExchange(ctx, server, name, qtype, cfg, realExchange)
|
|
}
|
|
|
|
func realExchange(ctx context.Context, server string, msg *dns.Msg, useTCP bool) (*dns.Msg, error) {
|
|
addr := net.JoinHostPort(server, "53")
|
|
|
|
var c *dns.Client
|
|
if useTCP {
|
|
c = &dns.Client{
|
|
Net: "tcp",
|
|
ReadTimeout: 5 * time.Second,
|
|
WriteTimeout: 5 * time.Second,
|
|
}
|
|
} else {
|
|
c = &dns.Client{
|
|
Net: "udp",
|
|
ReadTimeout: 5 * time.Second,
|
|
WriteTimeout: 5 * time.Second,
|
|
}
|
|
}
|
|
|
|
if deadline, ok := ctx.Deadline(); ok {
|
|
c.ReadTimeout = time.Until(deadline)
|
|
c.WriteTimeout = time.Until(deadline)
|
|
}
|
|
|
|
r, _, err := c.ExchangeContext(ctx, msg, addr)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("dns exchange (%s) with %s: %w", c.Net, addr, err)
|
|
}
|
|
|
|
return r, nil
|
|
}
|
|
|
|
func QueryWithExchange(ctx context.Context, server net.IP, name string, qtype uint16, cfg *QueryConfig, exchangeFn ExchangeFunc) (*dns.Msg, error) {
|
|
if cfg == nil {
|
|
cfg = DefaultQueryConfig()
|
|
}
|
|
if cfg.UDPSize <= 0 {
|
|
cfg.UDPSize = DefaultEDNS0UDPSize()
|
|
}
|
|
|
|
msg := buildQuery(name, qtype, cfg.UDPSize)
|
|
serverStr := server.String()
|
|
|
|
var lastErr error
|
|
|
|
for attempt := 0; attempt < cfg.Retries; attempt++ {
|
|
if attempt > 0 {
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, fmt.Errorf("query retries cancelled: %w", ctx.Err())
|
|
case <-time.After(backoffDelay(attempt)):
|
|
}
|
|
}
|
|
|
|
if cfg.UseTCP {
|
|
resp, err := exchangeFn(ctx, serverStr, msg, true)
|
|
if err != nil {
|
|
lastErr = err
|
|
continue
|
|
}
|
|
return resp, nil
|
|
}
|
|
|
|
resp, err := exchangeFn(ctx, serverStr, msg, false)
|
|
if err != nil {
|
|
lastErr = err
|
|
continue
|
|
}
|
|
|
|
if resp.Truncated && cfg.AllowTCP {
|
|
resp, err = exchangeFn(ctx, serverStr, msg, true)
|
|
if err != nil {
|
|
lastErr = err
|
|
continue
|
|
}
|
|
return resp, nil
|
|
}
|
|
|
|
return resp, nil
|
|
}
|
|
|
|
return nil, fmt.Errorf("query %s %s failed after %d retries: %w", name, QNameType(qtype), cfg.Retries, lastErr)
|
|
}
|
|
|
|
func IterativeQuery(ctx context.Context, server net.IP, name string, qtype uint16, cfg *QueryConfig) (*dns.Msg, error) {
|
|
if cfg == nil {
|
|
cfg = DefaultQueryConfig()
|
|
}
|
|
if cfg.UDPSize <= 0 {
|
|
cfg.UDPSize = DefaultEDNS0UDPSize()
|
|
}
|
|
return IterativeQueryWithExchange(ctx, server, name, qtype, cfg, realExchange)
|
|
}
|
|
|
|
func IterativeQueryWithExchange(ctx context.Context, server net.IP, name string, qtype uint16, cfg *QueryConfig, exchangeFn ExchangeFunc) (*dns.Msg, error) {
|
|
if cfg == nil {
|
|
cfg = DefaultQueryConfig()
|
|
}
|
|
if cfg.UDPSize <= 0 {
|
|
cfg.UDPSize = DefaultEDNS0UDPSize()
|
|
}
|
|
|
|
msg := buildQuery(name, qtype, cfg.UDPSize)
|
|
msg.RecursionDesired = false
|
|
serverStr := server.String()
|
|
|
|
var lastErr error
|
|
|
|
for attempt := 0; attempt < cfg.Retries; attempt++ {
|
|
if attempt > 0 {
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, fmt.Errorf("query retries cancelled: %w", ctx.Err())
|
|
case <-time.After(backoffDelay(attempt)):
|
|
}
|
|
}
|
|
|
|
if cfg.UseTCP {
|
|
resp, err := exchangeFn(ctx, serverStr, msg, true)
|
|
if err != nil {
|
|
lastErr = err
|
|
continue
|
|
}
|
|
return resp, nil
|
|
}
|
|
|
|
resp, err := exchangeFn(ctx, serverStr, msg, false)
|
|
if err != nil {
|
|
lastErr = err
|
|
continue
|
|
}
|
|
|
|
if resp == nil {
|
|
lastErr = fmt.Errorf("nil response")
|
|
continue
|
|
}
|
|
|
|
if resp.Truncated && cfg.AllowTCP {
|
|
resp, err = exchangeFn(ctx, serverStr, msg, true)
|
|
if err != nil {
|
|
lastErr = err
|
|
continue
|
|
}
|
|
return resp, nil
|
|
}
|
|
|
|
return resp, nil
|
|
}
|
|
|
|
return nil, fmt.Errorf("iterative query %s %s failed after %d retries: %w", name, QNameType(qtype), cfg.Retries, lastErr)
|
|
}
|
|
|
|
// backoffDelay computes the wait duration before the given retry attempt (1-indexed).
|
|
// Delays: attempt=1 → 100ms, attempt=2 → 200ms, attempt=3 → 400ms, capped at 2s.
|
|
func backoffDelay(attempt int) time.Duration {
|
|
if attempt <= 0 {
|
|
return 0
|
|
}
|
|
delay := time.Duration(uint(1)<<uint(attempt-1)) * 100 * time.Millisecond
|
|
const maxDelay = 2 * time.Second
|
|
if delay > maxDelay {
|
|
return maxDelay
|
|
}
|
|
return delay
|
|
}
|
|
|
|
func buildQuery(name string, qtype uint16, udpSize int) *dns.Msg {
|
|
m := new(dns.Msg)
|
|
m.SetQuestion(dns.Fqdn(name), qtype)
|
|
m.RecursionDesired = true
|
|
m.SetEdns0(uint16(udpSize), false)
|
|
return m
|
|
}
|