Compare commits
9 Commits
fix/issue-
...
ae092dad8c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae092dad8c | ||
| 02ca796085 | |||
|
|
2e3526986f | ||
| 55c6c21b5a | |||
|
|
2993911883 | ||
| 70fac87254 | |||
| 940f7c89da | |||
| 7d380aafa4 | |||
|
|
d0220e5814 |
@@ -1,26 +1,9 @@
|
||||
name: Check
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
name: check
|
||||
on: [push]
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Install golangci-lint
|
||||
run: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@5d1e709b7be35cb2025444e19de266b056b7b7ee # v2.10.1
|
||||
|
||||
- name: Install goimports
|
||||
run: go install golang.org/x/tools/cmd/goimports@009367f5c17a8d4c45a961a3a509277190a9a6f0 # v0.42.0
|
||||
|
||||
- name: Run make check
|
||||
run: make check
|
||||
# actions/checkout v4.2.2, 2026-02-28
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||
- run: docker build .
|
||||
|
||||
15
Dockerfile
15
Dockerfile
@@ -1,11 +1,13 @@
|
||||
# Build stage
|
||||
FROM golang:1.25-alpine AS builder
|
||||
# golang 1.25-alpine, 2026-02-28
|
||||
FROM golang@sha256:f6751d823c26342f9506c03797d2527668d095b0a15f1862cddb4d927a7a4ced AS builder
|
||||
|
||||
RUN apk add --no-cache git make gcc musl-dev
|
||||
RUN apk add --no-cache git make gcc musl-dev binutils-gold
|
||||
|
||||
# Install golangci-lint v2
|
||||
RUN go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest
|
||||
RUN go install golang.org/x/tools/cmd/goimports@latest
|
||||
# golangci-lint v2.10.1
|
||||
RUN go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@5d1e709b7be35cb2025444e19de266b056b7b7ee
|
||||
# goimports v0.42.0
|
||||
RUN go install golang.org/x/tools/cmd/goimports@009367f5c17a8d4c45a961a3a509277190a9a6f0
|
||||
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
@@ -20,7 +22,8 @@ RUN make check
|
||||
RUN make build
|
||||
|
||||
# Runtime stage
|
||||
FROM alpine:3.21
|
||||
# alpine 3.21, 2026-02-28
|
||||
FROM alpine@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4709
|
||||
|
||||
RUN apk add --no-cache ca-certificates tzdata
|
||||
|
||||
|
||||
@@ -4,11 +4,6 @@ import "errors"
|
||||
|
||||
// Sentinel errors returned by the resolver.
|
||||
var (
|
||||
// ErrNotImplemented indicates a method is stubbed out.
|
||||
ErrNotImplemented = errors.New(
|
||||
"resolver not yet implemented",
|
||||
)
|
||||
|
||||
// ErrNoNameservers is returned when no authoritative NS
|
||||
// could be discovered for a domain.
|
||||
ErrNoNameservers = errors.New(
|
||||
@@ -24,8 +19,4 @@ var (
|
||||
// ErrContextCanceled wraps context cancellation for the
|
||||
// resolver's iterative queries.
|
||||
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")
|
||||
)
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"net"
|
||||
"sort"
|
||||
"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 {
|
||||
err := ctx.Err()
|
||||
if err != nil {
|
||||
@@ -244,7 +227,7 @@ func (r *Resolver) followDelegation(
|
||||
|
||||
authNS := extractNSSet(resp.Ns)
|
||||
if len(authNS) == 0 {
|
||||
return r.resolveNSRecursive(ctx, domain)
|
||||
return r.resolveNSIterative(ctx, domain)
|
||||
}
|
||||
|
||||
glue := extractGlue(resp.Extra)
|
||||
@@ -308,60 +291,84 @@ func (r *Resolver) resolveNSIPs(
|
||||
return ips
|
||||
}
|
||||
|
||||
// resolveNSRecursive queries for NS records using recursive
|
||||
// resolution as a fallback for intercepted environments.
|
||||
func (r *Resolver) resolveNSRecursive(
|
||||
// resolveNSIterative queries for NS records using iterative
|
||||
// resolution as a fallback when followDelegation finds no
|
||||
// authoritative answer in the delegation chain.
|
||||
func (r *Resolver) resolveNSIterative(
|
||||
ctx context.Context,
|
||||
domain string,
|
||||
) ([]string, error) {
|
||||
domain = dns.Fqdn(domain)
|
||||
msg := new(dns.Msg)
|
||||
msg.SetQuestion(domain, dns.TypeNS)
|
||||
msg.RecursionDesired = true
|
||||
|
||||
for _, ip := range randomRootServers() {
|
||||
if checkCtx(ctx) != nil {
|
||||
return nil, ErrContextCanceled
|
||||
}
|
||||
|
||||
addr := net.JoinHostPort(ip, "53")
|
||||
domain = dns.Fqdn(domain)
|
||||
servers := rootServerList()
|
||||
|
||||
resp, _, err := r.client.ExchangeContext(ctx, msg, addr)
|
||||
for range maxDelegation {
|
||||
if checkCtx(ctx) != nil {
|
||||
return nil, ErrContextCanceled
|
||||
}
|
||||
|
||||
resp, err := r.queryServers(
|
||||
ctx, servers, domain, dns.TypeNS,
|
||||
)
|
||||
if err != nil {
|
||||
continue
|
||||
return nil, err
|
||||
}
|
||||
|
||||
nsNames := extractNSSet(resp.Answer)
|
||||
if len(nsNames) > 0 {
|
||||
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
|
||||
}
|
||||
|
||||
// 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(
|
||||
ctx context.Context,
|
||||
hostname string,
|
||||
) ([]string, error) {
|
||||
hostname = dns.Fqdn(hostname)
|
||||
msg := new(dns.Msg)
|
||||
msg.SetQuestion(hostname, dns.TypeA)
|
||||
msg.RecursionDesired = true
|
||||
|
||||
for _, ip := range randomRootServers() {
|
||||
if checkCtx(ctx) != nil {
|
||||
return nil, ErrContextCanceled
|
||||
}
|
||||
|
||||
addr := net.JoinHostPort(ip, "53")
|
||||
hostname = dns.Fqdn(hostname)
|
||||
servers := rootServerList()
|
||||
|
||||
resp, _, err := r.client.ExchangeContext(ctx, msg, addr)
|
||||
if err != nil {
|
||||
continue
|
||||
for range maxDelegation {
|
||||
if checkCtx(ctx) != nil {
|
||||
return nil, ErrContextCanceled
|
||||
}
|
||||
|
||||
resp, err := r.queryServers(
|
||||
ctx, servers, hostname, dns.TypeA,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"resolving %s: %w", hostname, err,
|
||||
)
|
||||
}
|
||||
|
||||
// Check for A records in the answer section.
|
||||
var ips []string
|
||||
|
||||
for _, rr := range resp.Answer {
|
||||
@@ -373,6 +380,24 @@ func (r *Resolver) resolveARecord(
|
||||
if len(ips) > 0 {
|
||||
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(
|
||||
@@ -402,7 +427,7 @@ func (r *Resolver) FindAuthoritativeNameservers(
|
||||
candidate := strings.Join(labels[i:], ".") + "."
|
||||
|
||||
nsNames, err := r.followDelegation(
|
||||
ctx, candidate, randomRootServers(),
|
||||
ctx, candidate, rootServerList(),
|
||||
)
|
||||
if err == nil && len(nsNames) > 0 {
|
||||
sort.Strings(nsNames)
|
||||
@@ -435,6 +460,23 @@ func (r *Resolver) QueryNameserver(
|
||||
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(
|
||||
ctx context.Context,
|
||||
nsHostname string,
|
||||
@@ -459,11 +501,6 @@ func (r *Resolver) queryAllTypes(
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
const (
|
||||
singleTypeMaxRetries = 3
|
||||
singleTypeInitialBackoff = 100 * time.Millisecond
|
||||
)
|
||||
|
||||
type queryState struct {
|
||||
gotNXDomain bool
|
||||
gotSERVFAIL bool
|
||||
@@ -495,21 +532,6 @@ func (r *Resolver) queryEachType(
|
||||
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(
|
||||
ctx context.Context,
|
||||
nsIP string,
|
||||
@@ -518,95 +540,23 @@ func (r *Resolver) querySingleType(
|
||||
resp *NameserverResponse,
|
||||
state *queryState,
|
||||
) {
|
||||
msg, lastErr := r.querySingleTypeWithRetry(
|
||||
ctx, nsIP, hostname, qtype,
|
||||
)
|
||||
if msg == nil {
|
||||
r.recordRetryFailure(lastErr, state)
|
||||
msg, err := r.queryDNS(ctx, nsIP, hostname, qtype)
|
||||
if err != nil {
|
||||
if isTimeout(err) {
|
||||
state.gotTimeout = true
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
r.handleDNSResponse(msg, resp, state)
|
||||
}
|
||||
if msg.Rcode == dns.RcodeNameError {
|
||||
state.gotNXDomain = true
|
||||
|
||||
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
|
||||
return
|
||||
}
|
||||
|
||||
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 {
|
||||
state.gotNXDomain = true
|
||||
|
||||
return
|
||||
}
|
||||
@@ -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) {
|
||||
switch {
|
||||
case state.gotNXDomain && !state.hasRecords:
|
||||
resp.Status = StatusNXDomain
|
||||
case state.gotTimeout && !state.hasRecords:
|
||||
resp.Status = StatusTimeout
|
||||
resp.Error = "all queries timed out after retries"
|
||||
resp.Error = "all queries timed out"
|
||||
case state.gotSERVFAIL && !state.hasRecords:
|
||||
resp.Status = StatusError
|
||||
resp.Error = "server failure (SERVFAIL) after retries"
|
||||
resp.Error = "server returned SERVFAIL"
|
||||
case !state.hasRecords && !state.gotNXDomain:
|
||||
resp.Status = StatusNoData
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
@@ -622,6 +623,59 @@ func TestQueryAllNameservers_ContextCanceled(t *testing.T) {
|
||||
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) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user