258 lines
7.6 KiB
Go
258 lines
7.6 KiB
Go
package checker
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/tls"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/miekg/dns"
|
|
)
|
|
|
|
// Default per-query deadline. Public resolvers are expected to answer in far
|
|
// less; anything slower is either unreachable or too flaky to be useful for
|
|
// a propagation check.
|
|
const dnsTimeout = 5 * time.Second
|
|
|
|
// dohClient is a package-wide HTTP client reused across DoH probes. Setting
|
|
// the tls config and timeouts once keeps the allocations down when we
|
|
// probe dozens of resolvers concurrently.
|
|
var dohClient = &http.Client{
|
|
Timeout: dnsTimeout + 2*time.Second,
|
|
Transport: &http.Transport{
|
|
TLSClientConfig: &tls.Config{
|
|
MinVersion: tls.VersionTLS12,
|
|
},
|
|
TLSHandshakeTimeout: dnsTimeout,
|
|
ResponseHeaderTimeout: dnsTimeout,
|
|
ExpectContinueTimeout: 1 * time.Second,
|
|
DisableKeepAlives: false,
|
|
MaxIdleConnsPerHost: 4,
|
|
},
|
|
}
|
|
|
|
// queryResult is what a single DNS exchange returns , deliberately flatter
|
|
// than *dns.Msg so the collector can stay protocol-agnostic.
|
|
type queryResult struct {
|
|
Rcode int
|
|
Answer []dns.RR
|
|
AD bool
|
|
Latency time.Duration
|
|
}
|
|
|
|
// queryResolver dispatches a question to a single public resolver over the
|
|
// requested transport. It forces RD=1 (we want the resolver to recurse) and
|
|
// CD=0 (we want the resolver to validate DNSSEC when it can). AD=1 is
|
|
// requested so AD-capable resolvers signal validation in the response.
|
|
func queryResolver(ctx context.Context, r Resolver, tr Transport, name string, qtype uint16) (*queryResult, error) {
|
|
q := dns.Question{Name: dns.Fqdn(name), Qtype: qtype, Qclass: dns.ClassINET}
|
|
|
|
m := new(dns.Msg)
|
|
m.Id = dns.Id()
|
|
m.Question = []dns.Question{q}
|
|
m.RecursionDesired = true
|
|
m.CheckingDisabled = false
|
|
m.AuthenticatedData = true
|
|
m.SetEdns0(4096, true)
|
|
|
|
switch tr {
|
|
case TransportUDP:
|
|
return exchangeUDPOrTCP(ctx, m, r.IP+":53", "udp")
|
|
case TransportTCP:
|
|
return exchangeUDPOrTCP(ctx, m, r.IP+":53", "tcp")
|
|
case TransportDoT:
|
|
if r.DoTHost == "" {
|
|
return nil, fmt.Errorf("no DoT endpoint for %s", r.ID)
|
|
}
|
|
return exchangeDoT(ctx, m, r.IP, r.DoTHost)
|
|
case TransportDoH:
|
|
if r.DoHURL == "" {
|
|
return nil, fmt.Errorf("no DoH endpoint for %s", r.ID)
|
|
}
|
|
return exchangeDoH(ctx, m, r.DoHURL)
|
|
default:
|
|
return nil, fmt.Errorf("unknown transport %q", tr)
|
|
}
|
|
}
|
|
|
|
// exchangeUDPOrTCP performs the exchange over plain DNS. miekg/dns handles
|
|
// truncation and retries transparently when we say "udp" + EDNS; for larger
|
|
// answers we switch to TCP explicitly.
|
|
func exchangeUDPOrTCP(ctx context.Context, m *dns.Msg, server, proto string) (*queryResult, error) {
|
|
client := dns.Client{Net: proto, Timeout: dnsTimeout}
|
|
if deadline, ok := ctx.Deadline(); ok {
|
|
if d := time.Until(deadline); d > 0 && d < client.Timeout {
|
|
client.Timeout = d
|
|
}
|
|
}
|
|
|
|
r, rtt, err := client.ExchangeContext(ctx, m, server)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if r == nil {
|
|
return nil, fmt.Errorf("nil response from %s", server)
|
|
}
|
|
|
|
// Truncated UDP answers force a retry over TCP per RFC 5966.
|
|
if proto == "udp" && r.Truncated {
|
|
tcpClient := dns.Client{Net: "tcp", Timeout: dnsTimeout}
|
|
if r2, rtt2, err2 := tcpClient.ExchangeContext(ctx, m, server); err2 == nil && r2 != nil {
|
|
return &queryResult{
|
|
Rcode: r2.Rcode, Answer: r2.Answer,
|
|
AD: r2.AuthenticatedData, Latency: rtt2,
|
|
}, nil
|
|
}
|
|
}
|
|
|
|
return &queryResult{
|
|
Rcode: r.Rcode, Answer: r.Answer,
|
|
AD: r.AuthenticatedData, Latency: rtt,
|
|
}, nil
|
|
}
|
|
|
|
// exchangeDoT opens a TLS connection to ip:853 and speaks plain DNS over it,
|
|
// validating the certificate against sni. miekg/dns' Client does TLS natively
|
|
// when Net="tcp-tls".
|
|
func exchangeDoT(ctx context.Context, m *dns.Msg, ip, sni string) (*queryResult, error) {
|
|
client := dns.Client{
|
|
Net: "tcp-tls",
|
|
Timeout: dnsTimeout,
|
|
TLSConfig: &tls.Config{
|
|
ServerName: sni,
|
|
MinVersion: tls.VersionTLS12,
|
|
},
|
|
}
|
|
if deadline, ok := ctx.Deadline(); ok {
|
|
if d := time.Until(deadline); d > 0 && d < client.Timeout {
|
|
client.Timeout = d
|
|
}
|
|
}
|
|
r, rtt, err := client.ExchangeContext(ctx, m, net.JoinHostPort(ip, "853"))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if r == nil {
|
|
return nil, fmt.Errorf("nil response from %s", ip)
|
|
}
|
|
return &queryResult{
|
|
Rcode: r.Rcode, Answer: r.Answer,
|
|
AD: r.AuthenticatedData, Latency: rtt,
|
|
}, nil
|
|
}
|
|
|
|
// exchangeDoH sends the wire-format DNS message as the "dns" query parameter
|
|
// per RFC 8484, using GET for cache friendliness. Response is parsed with
|
|
// miekg/dns so we share the answer/rcode handling.
|
|
func exchangeDoH(ctx context.Context, m *dns.Msg, url string) (*queryResult, error) {
|
|
// DoH wants Id=0 so intermediate HTTP caches can merge equivalent
|
|
// queries. Setting it after composing keeps other fields intact.
|
|
m.Id = 0
|
|
packed, err := m.Pack()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("packing message: %w", err)
|
|
}
|
|
|
|
target := url + "?dns=" + base64.RawURLEncoding.EncodeToString(packed)
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Accept", "application/dns-message")
|
|
req.Header.Set("User-Agent", "happyDomain-checker-resolver-propagation/"+Version)
|
|
|
|
start := time.Now()
|
|
resp, err := dohClient.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("DoH HTTP %d", resp.StatusCode)
|
|
}
|
|
ct := resp.Header.Get("Content-Type")
|
|
if !strings.HasPrefix(ct, "application/dns-message") {
|
|
return nil, fmt.Errorf("DoH unexpected content-type %q", ct)
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
if _, err := io.Copy(&buf, io.LimitReader(resp.Body, 1<<16)); err != nil {
|
|
return nil, err
|
|
}
|
|
latency := time.Since(start)
|
|
|
|
r := new(dns.Msg)
|
|
if err := r.Unpack(buf.Bytes()); err != nil {
|
|
return nil, fmt.Errorf("unpacking DoH response: %w", err)
|
|
}
|
|
return &queryResult{
|
|
Rcode: r.Rcode, Answer: r.Answer,
|
|
AD: r.AuthenticatedData, Latency: latency,
|
|
}, nil
|
|
}
|
|
|
|
// canonicalRR returns an RR's RDATA in canonical, TTL-stripped form. We use
|
|
// miekg/dns's zone-file representation, cut off the header fields (name,
|
|
// class, TTL, type), and trim , what remains is the RDATA.
|
|
func canonicalRR(rr dns.RR) string {
|
|
if rr == nil {
|
|
return ""
|
|
}
|
|
s := rr.String()
|
|
// Presentation: "owner TTL class type rdata...". Split on tabs/spaces,
|
|
// skip the first four fields. dns.RR always emits the header so the
|
|
// shape is stable.
|
|
fields := strings.Fields(s)
|
|
if len(fields) <= 4 {
|
|
return ""
|
|
}
|
|
rdata := strings.Join(fields[4:], " ")
|
|
// TXT rdata retains its original quoting; lowercase hostnames to avoid
|
|
// case-only drift appearing as disagreement.
|
|
return strings.ToLower(strings.TrimSpace(rdata))
|
|
}
|
|
|
|
// signatureFromRRs returns a deterministic signature built from the RDATA of
|
|
// the RRs that match owner+qtype. It also returns the sorted presentation
|
|
// list used for display and the smallest TTL observed.
|
|
func signatureFromRRs(rrs []dns.RR, owner string, qtype uint16) (sig string, records []string, minTTL uint32) {
|
|
ownerL := strings.ToLower(dns.Fqdn(owner))
|
|
for _, rr := range rrs {
|
|
h := rr.Header()
|
|
if h == nil {
|
|
continue
|
|
}
|
|
if !strings.EqualFold(dns.Fqdn(h.Name), ownerL) {
|
|
continue
|
|
}
|
|
if h.Rrtype != qtype {
|
|
continue
|
|
}
|
|
if c := canonicalRR(rr); c != "" {
|
|
records = append(records, c)
|
|
if minTTL == 0 || h.Ttl < minTTL {
|
|
minTTL = h.Ttl
|
|
}
|
|
}
|
|
}
|
|
sort.Strings(records)
|
|
sig = strings.Join(records, "|")
|
|
return sig, records, minTTL
|
|
}
|
|
|
|
// rcodeToString is a small wrapper around dns.RcodeToString that falls back
|
|
// to a numeric representation when the rcode is unknown.
|
|
func rcodeToString(c int) string {
|
|
if s, ok := dns.RcodeToString[c]; ok {
|
|
return s
|
|
}
|
|
return fmt.Sprintf("RCODE%d", c)
|
|
}
|