feat(ednsdiag): add DoQ/DoH3/DNSCrypt transports, proxy support, probe & compare

This commit is contained in:
windyboy
2026-08-14 18:17:26 +08:00
parent 8303d78caf
commit 70aea6cd72
21 changed files with 1665 additions and 236 deletions
@@ -6,6 +6,7 @@ import (
"encoding/binary"
"fmt"
"net"
"net/netip"
"strings"
"golang.org/x/net/dns/dnsmessage"
@@ -24,20 +25,31 @@ var recordTypes = map[string]dnsmessage.Type{
"SRV": dnsmessage.TypeSRV,
"SVCB": dnsmessage.TypeSVCB,
"HTTPS": dnsmessage.TypeHTTPS,
"PTR": dnsmessage.TypePTR,
}
func BuildQuery(name, recordType string) ([]byte, QueryInfo, uint16, error) {
canonical, err := canonicalName(name)
if err != nil {
return nil, QueryInfo{}, 0, err
}
typeName := strings.ToUpper(recordType)
qtype, ok := recordTypes[typeName]
if !ok {
return nil, QueryInfo{}, 0, fmt.Errorf("unsupported record type %q", recordType)
}
var canonical string
var err error
if typeName == "PTR" {
address, parseErr := netip.ParseAddr(strings.TrimSpace(name))
if parseErr != nil {
return nil, QueryInfo{}, 0, fmt.Errorf("PTR queries require an IPv4 or IPv6 address")
}
canonical = reverseName(address.Unmap())
} else {
canonical, err = canonicalName(name)
}
if err != nil {
return nil, QueryInfo{}, 0, err
}
dnsName, err := dnsmessage.NewName(canonical + ".")
if err != nil {
return nil, QueryInfo{}, 0, fmt.Errorf("encode domain name: %w", err)
@@ -63,6 +75,22 @@ func BuildQuery(name, recordType string) ([]byte, QueryInfo, uint16, error) {
return wire, QueryInfo{Name: canonical, Type: typeName}, id, nil
}
func reverseName(address netip.Addr) string {
if address.Is4() {
bytes := address.As4()
return fmt.Sprintf("%d.%d.%d.%d.in-addr.arpa", bytes[3], bytes[2], bytes[1], bytes[0])
}
bytes := address.As16()
var builder strings.Builder
// Each IPv6 nibble is emitted from least to most significant per RFC 3596.
for index := len(bytes) - 1; index >= 0; index-- {
fmt.Fprintf(&builder, "%x.%x.", bytes[index]&0x0f, bytes[index]>>4)
}
builder.WriteString("ip6.arpa")
return builder.String()
}
func ParseResponse(wire []byte, expectedID uint16, query QueryInfo) (DNSInfo, error) {
var message dnsmessage.Message
if err := message.Unpack(wire); err != nil {
@@ -71,6 +99,12 @@ func ParseResponse(wire []byte, expectedID uint16, query QueryInfo) (DNSInfo, er
if !message.Header.Response {
return DNSInfo{}, fmt.Errorf("received a DNS query instead of a response")
}
if message.Header.OpCode != 0 {
return DNSInfo{}, fmt.Errorf("DNS response uses unexpected opcode %d", message.Header.OpCode)
}
if message.Header.Truncated {
return DNSInfo{}, fmt.Errorf("DNS response is truncated")
}
if message.Header.ID != expectedID {
return DNSInfo{}, fmt.Errorf("DNS transaction ID mismatch")
}
@@ -79,13 +113,20 @@ func ParseResponse(wire []byte, expectedID uint16, query QueryInfo) (DNSInfo, er
}
wantType := recordTypes[query.Type]
question := message.Questions[0]
if trimRoot(question.Name.String()) != query.Name || question.Type != wantType {
if trimRoot(question.Name.String()) != query.Name || question.Type != wantType || question.Class != dnsmessage.ClassINET {
return DNSInfo{}, fmt.Errorf("DNS response question does not match request")
}
answers := make([]AnswerRecord, 0, len(message.Answers))
for _, resource := range message.Answers {
answers = append(answers, normalizeAnswer(resource))
if resource.Header.Class != dnsmessage.ClassINET {
return DNSInfo{}, fmt.Errorf("DNS answer %q uses unsupported class %d", trimRoot(resource.Header.Name.String()), resource.Header.Class)
}
answer, err := normalizeAnswer(resource)
if err != nil {
return DNSInfo{}, err
}
answers = append(answers, answer)
}
return DNSInfo{
@@ -129,7 +170,7 @@ func canonicalName(input string) (string, error) {
return ascii, nil
}
func normalizeAnswer(resource dnsmessage.Resource) AnswerRecord {
func normalizeAnswer(resource dnsmessage.Resource) (AnswerRecord, error) {
record := AnswerRecord{
"name": trimRoot(resource.Header.Name.String()),
"type": typeName(resource.Header.Type),
@@ -177,13 +218,32 @@ func normalizeAnswer(resource dnsmessage.Resource) AnswerRecord {
record["tag"] = string(body.Data[2 : 2+tagLength])
record["value"] = string(body.Data[2+tagLength:])
} else {
record["rdata_base64"] = base64.StdEncoding.EncodeToString(body.Data)
return nil, fmt.Errorf("CAA answer contains a truncated tag")
}
} else {
record["rdata_base64"] = base64.StdEncoding.EncodeToString(body.Data)
return nil, fmt.Errorf("DNS answer type %s cannot be represented by result-v1", typeName(resource.Header.Type))
}
default:
return nil, fmt.Errorf("DNS answer type %s has an unexpected wire representation", typeName(resource.Header.Type))
}
return record, nil
}
func applyHTTPAge(info *DNSInfo, ageSeconds int64) {
if ageSeconds <= 0 {
return
}
for _, answer := range info.Answers {
ttl, ok := answer["ttl"].(uint32)
if !ok {
continue
}
if ageSeconds >= int64(ttl) {
answer["ttl"] = uint32(0)
} else {
answer["ttl"] = ttl - uint32(ageSeconds)
}
}
return record
}
func addSVCBFields(record AnswerRecord, priority uint16, target dnsmessage.Name, params []dnsmessage.SVCParam) {
@@ -2,6 +2,8 @@ package edns
import (
"encoding/binary"
"reflect"
"strings"
"testing"
"golang.org/x/net/dns/dnsmessage"
@@ -68,13 +70,87 @@ func TestBuildQueryIDNAAndBlockedNames(t *testing.T) {
}
}
func TestBuildPTRQueryFromIPAddress(t *testing.T) {
_, ipv4, _, err := BuildQuery("192.0.2.1", "PTR")
if err != nil {
t.Fatalf("build IPv4 PTR query: %v", err)
}
if ipv4.Name != "1.2.0.192.in-addr.arpa" || ipv4.Type != "PTR" {
t.Fatalf("unexpected IPv4 PTR query: %#v", ipv4)
}
_, ipv6, _, err := BuildQuery("2001:db8::1", "PTR")
if err != nil {
t.Fatalf("build IPv6 PTR query: %v", err)
}
if ipv6.Name != "1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa" {
t.Fatalf("unexpected IPv6 PTR name: %q", ipv6.Name)
}
_, mappedIPv4, _, err := BuildQuery("::ffff:192.0.2.1", "PTR")
if err != nil {
t.Fatalf("build IPv4-mapped PTR query: %v", err)
}
if mappedIPv4.Name != ipv4.Name {
t.Fatalf("IPv4-mapped PTR name = %q, want %q", mappedIPv4.Name, ipv4.Name)
}
if _, _, _, err := BuildQuery("example.com", "PTR"); err == nil {
t.Fatal("PTR query accepted a non-IP input")
}
}
func TestNormalizeSupportedAnswerTypes(t *testing.T) {
name := dnsmessage.MustNewName("example.com.")
target := dnsmessage.MustNewName("target.example.")
resources := []dnsmessage.Resource{
{Header: dnsmessage.ResourceHeader{Name: name, Type: dnsmessage.TypeAAAA, TTL: 60}, Body: &dnsmessage.AAAAResource{AAAA: [16]byte{0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1}}},
{Header: dnsmessage.ResourceHeader{Name: name, Type: dnsmessage.TypeCNAME, TTL: 60}, Body: &dnsmessage.CNAMEResource{CNAME: target}},
{Header: dnsmessage.ResourceHeader{Name: name, Type: dnsmessage.TypeMX, TTL: 60}, Body: &dnsmessage.MXResource{Pref: 10, MX: target}},
{Header: dnsmessage.ResourceHeader{Name: name, Type: dnsmessage.TypeTXT, TTL: 60}, Body: &dnsmessage.TXTResource{TXT: []string{"one", "two"}}},
{Header: dnsmessage.ResourceHeader{Name: name, Type: dnsmessage.TypeNS, TTL: 60}, Body: &dnsmessage.NSResource{NS: target}},
{Header: dnsmessage.ResourceHeader{Name: name, Type: dnsmessage.TypeSOA, TTL: 60}, Body: &dnsmessage.SOAResource{NS: target, MBox: target, Serial: 1}},
{Header: dnsmessage.ResourceHeader{Name: name, Type: dnsmessage.TypeSRV, TTL: 60}, Body: &dnsmessage.SRVResource{Priority: 1, Weight: 2, Port: 443, Target: target}},
{Header: dnsmessage.ResourceHeader{Name: name, Type: dnsmessage.TypePTR, TTL: 60}, Body: &dnsmessage.PTRResource{PTR: target}},
{Header: dnsmessage.ResourceHeader{Name: name, Type: dnsmessage.TypeSVCB, TTL: 60}, Body: &dnsmessage.SVCBResource{Priority: 1, Target: target}},
{Header: dnsmessage.ResourceHeader{Name: name, Type: dnsmessage.TypeHTTPS, TTL: 60}, Body: &dnsmessage.HTTPSResource{SVCBResource: dnsmessage.SVCBResource{Priority: 1, Target: target}}},
}
wantTypes := []string{"AAAA", "CNAME", "MX", "TXT", "NS", "SOA", "SRV", "PTR", "SVCB", "HTTPS"}
wantFields := []map[string]any{
{"address": "2001:db8::1"},
{"target": "target.example"},
{"priority": uint16(10), "exchange": "target.example"},
{"strings": []string{"one", "two"}},
{"host": "target.example"},
{"primary_ns": "target.example", "responsible_mailbox": "target.example", "serial": uint32(1)},
{"priority": uint16(1), "weight": uint16(2), "port": uint16(443), "target": "target.example"},
{"target": "target.example"},
{"priority": uint16(1), "target": "target.example", "params": []map[string]any{}},
{"priority": uint16(1), "target": "target.example", "params": []map[string]any{}},
}
for index, resource := range resources {
record, err := normalizeAnswer(resource)
if err != nil {
t.Fatalf("normalize %s: %v", wantTypes[index], err)
}
if record["type"] != wantTypes[index] || record["name"] != "example.com" || record["ttl"] != uint32(60) {
t.Fatalf("unexpected %s normalization: %#v", wantTypes[index], record)
}
for field, want := range wantFields[index] {
if got := record[field]; !reflect.DeepEqual(got, want) {
t.Fatalf("%s field %s = %#v, want %#v", wantTypes[index], field, got, want)
}
}
}
}
func TestNormalizeCAA(t *testing.T) {
name := dnsmessage.MustNewName("example.com.")
data := append([]byte{0, 5}, []byte("issueletsencrypt.org")...)
record := normalizeAnswer(dnsmessage.Resource{
record, err := normalizeAnswer(dnsmessage.Resource{
Header: dnsmessage.ResourceHeader{Name: name, Type: dnsmessage.Type(257), Class: dnsmessage.ClassINET, TTL: 300},
Body: &dnsmessage.UnknownResource{Type: dnsmessage.Type(257), Data: data},
})
if err != nil {
t.Fatalf("normalize CAA: %v", err)
}
if record["tag"] != "issue" || record["value"] != "letsencrypt.org" {
t.Fatalf("unexpected CAA normalization: %#v", record)
}
@@ -98,3 +174,131 @@ func TestParseResponseRejectsTransactionMismatch(t *testing.T) {
t.Fatal("test response ID was not encoded")
}
}
func TestParseResponseRejectsIncompleteOrNonStandardMessages(t *testing.T) {
queryWire, query, transactionID, err := BuildQuery("example.com", "A")
if err != nil {
t.Fatalf("build query: %v", err)
}
var request dnsmessage.Message
if err := request.Unpack(queryWire); err != nil {
t.Fatalf("unpack query: %v", err)
}
validAnswer := dnsmessage.Resource{
Header: dnsmessage.ResourceHeader{Name: request.Questions[0].Name, Type: dnsmessage.TypeA, Class: dnsmessage.ClassINET, TTL: 60},
Body: &dnsmessage.AResource{A: [4]byte{192, 0, 2, 1}},
}
tests := []struct {
name string
message dnsmessage.Message
want string
}{
{
name: "truncated",
message: dnsmessage.Message{Header: dnsmessage.Header{ID: transactionID, Response: true, Truncated: true},
Questions: request.Questions},
want: "truncated",
},
{
name: "unexpected opcode",
message: dnsmessage.Message{Header: dnsmessage.Header{ID: transactionID, Response: true, OpCode: 1},
Questions: request.Questions},
want: "opcode",
},
{
name: "non-IN question",
message: dnsmessage.Message{Header: dnsmessage.Header{ID: transactionID, Response: true}, Questions: []dnsmessage.Question{{
Name: request.Questions[0].Name, Type: dnsmessage.TypeA, Class: dnsmessage.ClassCHAOS,
}}},
want: "question does not match",
},
{
name: "non-IN answer",
message: dnsmessage.Message{Header: dnsmessage.Header{ID: transactionID, Response: true}, Questions: request.Questions, Answers: []dnsmessage.Resource{{
Header: dnsmessage.ResourceHeader{Name: request.Questions[0].Name, Type: dnsmessage.TypeA, Class: dnsmessage.ClassCHAOS, TTL: 60},
Body: &dnsmessage.AResource{A: [4]byte{192, 0, 2, 1}},
}}},
want: "unsupported class",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
wire, err := test.message.Pack()
if err != nil {
t.Fatalf("pack response: %v", err)
}
if _, err := ParseResponse(wire, transactionID, query); err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("error = %v, want substring %q", err, test.want)
}
})
}
valid := dnsmessage.Message{Header: dnsmessage.Header{ID: transactionID, Response: true}, Questions: request.Questions, Answers: []dnsmessage.Resource{validAnswer}}
if _, err := valid.Pack(); err != nil {
t.Fatalf("valid fixture does not pack: %v", err)
}
}
func TestParseResponseRejectsNonRepresentableAnswers(t *testing.T) {
queryWire, query, transactionID, err := BuildQuery("example.com", "CAA")
if err != nil {
t.Fatalf("build query: %v", err)
}
var request dnsmessage.Message
if err := request.Unpack(queryWire); err != nil {
t.Fatalf("unpack query: %v", err)
}
tests := []struct {
name string
record dnsmessage.Resource
contains string
}{
{
name: "truncated CAA tag",
record: dnsmessage.Resource{
Header: dnsmessage.ResourceHeader{Name: request.Questions[0].Name, Type: dnsmessage.Type(257), Class: dnsmessage.ClassINET, TTL: 60},
Body: &dnsmessage.UnknownResource{Type: dnsmessage.Type(257), Data: []byte{0, 5, 'i'}},
},
contains: "truncated tag",
},
{
name: "unknown answer type",
record: dnsmessage.Resource{
Header: dnsmessage.ResourceHeader{Name: request.Questions[0].Name, Type: dnsmessage.Type(99), Class: dnsmessage.ClassINET, TTL: 60},
Body: &dnsmessage.UnknownResource{Type: dnsmessage.Type(99), Data: []byte{1, 2}},
},
contains: "cannot be represented",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
message := dnsmessage.Message{
Header: dnsmessage.Header{ID: transactionID, Response: true},
Questions: request.Questions,
Answers: []dnsmessage.Resource{test.record},
}
wire, err := message.Pack()
if err != nil {
t.Fatalf("pack response: %v", err)
}
if _, err := ParseResponse(wire, transactionID, query); err == nil || !strings.Contains(err.Error(), test.contains) {
t.Fatalf("error = %v, want substring %q", err, test.contains)
}
})
}
}
func TestApplyHTTPAgeClampsAnswerTTL(t *testing.T) {
info := DNSInfo{Answers: []AnswerRecord{
{"ttl": uint32(120)},
{"ttl": uint32(30)},
}}
applyHTTPAge(&info, 45)
if info.Answers[0]["ttl"] != uint32(75) || info.Answers[1]["ttl"] != uint32(0) {
t.Fatalf("unexpected aged TTLs: %#v", info.Answers)
}
}
@@ -11,28 +11,46 @@ import (
"net"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
const maxDNSMessageSize = 65535
func exchangeDoH(ctx context.Context, provider Provider, wire []byte, method string) ([]byte, TransportInfo, error) {
client := newDoHClient(provider.DoHURL)
return exchangeDoHWithClient(ctx, client, provider.DoHURL, wire, method)
func exchangeDoH(ctx context.Context, provider Provider, wire []byte, method, explicitProxy string) ([]byte, TransportInfo, error) {
client, proxyLabel, err := newDoHClient(provider.DoHURL, explicitProxy)
if err != nil {
return nil, TransportInfo{Protocol: "doh", Encrypted: true, Bootstrap: "system_resolver"}, err
}
response, info, err := exchangeDoHWithClient(ctx, client, provider.DoHURL, wire, method)
info.Proxy = proxyLabel
return response, info, err
}
func newDoHClient(endpoint string) *http.Client {
origin, _ := url.Parse(endpoint)
func newDoHClient(endpoint, explicitProxy string) (*http.Client, string, error) {
return newDoHClientWithTLSConfig(endpoint, explicitProxy, &tls.Config{MinVersion: tls.VersionTLS12})
}
func newDoHClientWithTLSConfig(endpoint, explicitProxy string, tlsConfig *tls.Config) (*http.Client, string, error) {
origin, err := url.Parse(endpoint)
if err != nil {
return nil, "", fmt.Errorf("parse DoH endpoint: %w", err)
}
proxyURL, err := resolveProxy(origin, explicitProxy)
if err != nil {
return nil, "", fmt.Errorf("select DoH proxy: %w", err)
}
transport := &http.Transport{
ForceAttemptHTTP2: true,
DialContext: (&net.Dialer{Timeout: 5 * time.Second, KeepAlive: 30 * time.Second}).DialContext,
TLSClientConfig: &tls.Config{
MinVersion: tls.VersionTLS12,
},
ForceAttemptHTTP2: true,
DialContext: (&net.Dialer{Timeout: 5 * time.Second, KeepAlive: 30 * time.Second}).DialContext,
TLSClientConfig: tlsConfig.Clone(),
TLSHandshakeTimeout: 5 * time.Second,
}
return &http.Client{
if proxyURL != nil {
transport.Proxy = http.ProxyURL(proxyURL)
}
client := &http.Client{
Transport: transport,
CheckRedirect: func(request *http.Request, via []*http.Request) error {
if len(via) >= 3 {
@@ -47,12 +65,17 @@ func newDoHClient(endpoint string) *http.Client {
return nil
},
}
return client, proxyDisplayURL(proxyURL), nil
}
func exchangeDoHWithClient(ctx context.Context, client *http.Client, endpoint string, wire []byte, method string) ([]byte, TransportInfo, error) {
return exchangeHTTPSDNSWithClient(ctx, client, endpoint, wire, method, "doh")
}
func exchangeHTTPSDNSWithClient(ctx context.Context, client *http.Client, endpoint string, wire []byte, method, protocol string) ([]byte, TransportInfo, error) {
started := time.Now()
info := TransportInfo{
Protocol: "doh",
Protocol: protocol,
Encrypted: true,
Bootstrap: "system_resolver",
}
@@ -94,12 +117,27 @@ func exchangeDoHWithClient(ctx context.Context, client *http.Client, endpoint st
defer response.Body.Close()
info.HTTPVersion = response.Proto
if age := response.Header.Get("Age"); age != "" {
parsedAge, err := strconv.ParseInt(age, 10, 64)
if err != nil || parsedAge < 0 {
return nil, info, fmt.Errorf("DoH server returned invalid Age header %q", age)
}
info.HTTPAgeSeconds = parsedAge
}
if response.TLS == nil || len(response.TLS.VerifiedChains) == 0 {
return nil, info, fmt.Errorf("DoH server TLS identity was not verified")
}
info.ServerAuthenticated = true
info.TLSVersion = tlsVersionName(response.TLS.Version)
info.ALPN = response.TLS.NegotiatedProtocol
if protocol == "doh3" {
if response.ProtoMajor != 3 {
return nil, info, fmt.Errorf("DoH3 server used unexpected HTTP version %q", response.Proto)
}
if info.ALPN != "h3" {
return nil, info, fmt.Errorf("DoH3 server negotiated unexpected ALPN protocol %q", info.ALPN)
}
}
if response.StatusCode < 200 || response.StatusCode > 299 {
return nil, info, fmt.Errorf("DoH server returned HTTP status %d", response.StatusCode)
@@ -4,6 +4,7 @@ import (
"io"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"golang.org/x/net/dns/dnsmessage"
@@ -42,6 +43,7 @@ func TestExchangeDoHGETAndPOST(t *testing.T) {
return
}
writer.Header().Set("Content-Type", "application/dns-message")
writer.Header().Set("Age", "10")
_, _ = writer.Write(responseWire)
}))
defer server.Close()
@@ -56,13 +58,78 @@ func TestExchangeDoHGETAndPOST(t *testing.T) {
if err != nil {
t.Fatalf("exchange DoH: %v", err)
}
if len(response) == 0 || !info.Encrypted || !info.ServerAuthenticated {
if len(response) == 0 || !info.Encrypted || !info.ServerAuthenticated || info.HTTPAgeSeconds != 10 {
t.Fatalf("unexpected result: response=%d info=%#v", len(response), info)
}
})
}
}
func TestExchangeDoHThroughHTTPConnectProxy(t *testing.T) {
server := httptest.NewTLSServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
payload, err := io.ReadAll(request.Body)
if err != nil {
http.Error(writer, err.Error(), http.StatusBadRequest)
return
}
var query dnsmessage.Message
if err := query.Unpack(payload); err != nil {
http.Error(writer, err.Error(), http.StatusBadRequest)
return
}
response := dnsmessage.Message{
Header: dnsmessage.Header{ID: query.Header.ID, Response: true},
Questions: query.Questions,
}
responseWire, err := response.Pack()
if err != nil {
http.Error(writer, err.Error(), http.StatusInternalServerError)
return
}
writer.Header().Set("Content-Type", "application/dns-message")
_, _ = writer.Write(responseWire)
}))
defer server.Close()
endpoint, err := url.Parse(server.URL)
if err != nil {
t.Fatalf("parse test endpoint: %v", err)
}
proxyURL, proxyError := startConnectProxy(t, endpoint.Host, "Basic dXNlcjpzZWNyZXQ=")
testTransport := server.Client().Transport.(*http.Transport)
client, proxyLabel, err := newDoHClientWithTLSConfig(server.URL, proxyURL, testTransport.TLSClientConfig)
if err != nil {
t.Fatalf("create proxied DoH client: %v", err)
}
if proxyLabel == "" || proxyLabel == proxyURL {
t.Fatalf("proxy label = %q, want sanitized URL", proxyLabel)
}
wire, _, _, err := BuildQuery("example.com", "A")
if err != nil {
t.Fatalf("build query: %v", err)
}
if _, _, err := exchangeDoHWithClient(t.Context(), client, server.URL, wire, "post"); err != nil {
t.Fatalf("exchange DoH through proxy: %v", err)
}
client.CloseIdleConnections()
if err := <-proxyError; err != nil {
t.Fatalf("serve CONNECT proxy: %v", err)
}
}
func TestExchangeDoHRejectsInvalidAge(t *testing.T) {
server := httptest.NewTLSServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
writer.Header().Set("Content-Type", "application/dns-message")
writer.Header().Set("Age", "invalid")
_, _ = writer.Write([]byte{1})
}))
defer server.Close()
if _, _, err := exchangeDoHWithClient(t.Context(), server.Client(), server.URL, []byte{1}, "post"); err == nil {
t.Fatal("invalid HTTP Age was accepted")
}
}
func TestExchangeDoHRejectsHTTPError(t *testing.T) {
server := httptest.NewTLSServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
http.Error(writer, "unavailable", http.StatusServiceUnavailable)
@@ -3,22 +3,25 @@ package edns
import (
"context"
"crypto/tls"
"encoding/binary"
"fmt"
"io"
"net"
"net/url"
"time"
)
func exchangeDoT(ctx context.Context, provider Provider, wire []byte) ([]byte, TransportInfo, error) {
return exchangeDoTWithTLSConfig(ctx, provider, wire, &tls.Config{
func exchangeDoT(ctx context.Context, provider Provider, wire []byte, explicitProxy string) ([]byte, TransportInfo, error) {
return exchangeDoTWithTLSConfigAndProxy(ctx, provider, wire, &tls.Config{
ServerName: provider.DoTName,
MinVersion: tls.VersionTLS12,
NextProtos: []string{"dot"},
})
}, explicitProxy)
}
func exchangeDoTWithTLSConfig(ctx context.Context, provider Provider, wire []byte, tlsConfig *tls.Config) ([]byte, TransportInfo, error) {
return exchangeDoTWithTLSConfigAndProxy(ctx, provider, wire, tlsConfig, "")
}
func exchangeDoTWithTLSConfigAndProxy(ctx context.Context, provider Provider, wire []byte, tlsConfig *tls.Config, explicitProxy string) ([]byte, TransportInfo, error) {
started := time.Now()
info := TransportInfo{
Protocol: "dot",
@@ -26,7 +29,13 @@ func exchangeDoTWithTLSConfig(ctx context.Context, provider Provider, wire []byt
Bootstrap: "system_resolver",
}
rawConnection, err := (&net.Dialer{}).DialContext(ctx, "tcp", provider.DoTAddr)
endpoint := &url.URL{Scheme: "https", Host: provider.DoTAddr}
proxyURL, err := resolveProxy(endpoint, explicitProxy)
if err != nil {
return nil, info, fmt.Errorf("select DoT proxy: %w", err)
}
info.Proxy = proxyDisplayURL(proxyURL)
rawConnection, err := dialTCP(ctx, provider.DoTAddr, proxyURL)
if err != nil {
info.ElapsedMS = time.Since(started).Milliseconds()
return nil, info, fmt.Errorf("connect to DoT server: %w", err)
@@ -66,29 +75,10 @@ func exchangeDoTWithTLSConfig(ctx context.Context, provider Provider, wire []byt
}
func exchangeTCPFrame(connection io.ReadWriter, wire []byte) ([]byte, error) {
if len(wire) == 0 || len(wire) > maxDNSMessageSize {
return nil, fmt.Errorf("invalid DNS message length %d", len(wire))
}
frame := make([]byte, 2+len(wire))
binary.BigEndian.PutUint16(frame[:2], uint16(len(wire)))
copy(frame[2:], wire)
if err := writeAll(connection, frame); err != nil {
if err := writeDNSFrame(connection, wire); err != nil {
return nil, fmt.Errorf("write framed DNS query: %w", err)
}
var lengthBytes [2]byte
if _, err := io.ReadFull(connection, lengthBytes[:]); err != nil {
return nil, fmt.Errorf("read DNS response length: %w", err)
}
length := int(binary.BigEndian.Uint16(lengthBytes[:]))
if length == 0 {
return nil, fmt.Errorf("DoT server returned an empty DNS message")
}
response := make([]byte, length)
if _, err := io.ReadFull(connection, response); err != nil {
return nil, fmt.Errorf("read DNS response: %w", err)
}
return response, nil
return readDNSFrame(connection)
}
func writeAll(writer io.Writer, payload []byte) error {
@@ -104,6 +104,59 @@ func TestExchangeDoTAuthenticatesServer(t *testing.T) {
}
}
func TestExchangeDoTThroughHTTPConnectProxy(t *testing.T) {
certificate, roots := newTestCertificate(t, "resolver.test")
listener, err := tls.Listen("tcp", "127.0.0.1:0", &tls.Config{
Certificates: []tls.Certificate{certificate},
MinVersion: tls.VersionTLS12,
NextProtos: []string{"dot"},
})
if err != nil {
t.Fatalf("listen for DoT: %v", err)
}
defer listener.Close()
serverError := make(chan error, 1)
go func() {
connection, err := listener.Accept()
if err != nil {
serverError <- err
return
}
defer connection.Close()
response, err := serveOneDoTQuery(connection)
if err == nil {
err = writeAll(connection, response)
}
serverError <- err
}()
proxyURL, proxyError := startConnectProxy(t, listener.Addr().String(), "Basic dXNlcjpzZWNyZXQ=")
queryWire, _, _, err := BuildQuery("example.com", "A")
if err != nil {
t.Fatalf("build query: %v", err)
}
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
defer cancel()
_, info, err := exchangeDoTWithTLSConfigAndProxy(ctx, Provider{DoTAddr: listener.Addr().String(), DoTName: "resolver.test"}, queryWire, &tls.Config{
RootCAs: roots,
MinVersion: tls.VersionTLS12,
NextProtos: []string{"dot"},
}, proxyURL)
if err != nil {
t.Fatalf("exchange DoT through proxy: %v", err)
}
if info.Proxy == "" || strings.Contains(info.Proxy, "secret") || strings.Contains(info.Proxy, "user") {
t.Fatalf("proxy metadata was missing or exposed credentials: %#v", info)
}
if err := <-serverError; err != nil {
t.Fatalf("serve DoT: %v", err)
}
if err := <-proxyError; err != nil {
t.Fatalf("serve CONNECT proxy: %v", err)
}
}
func TestExchangeDoTAllowsMissingALPN(t *testing.T) {
certificate, roots := newTestCertificate(t, "resolver.test")
listener, err := tls.Listen("tcp", "127.0.0.1:0", &tls.Config{
@@ -136,13 +189,13 @@ func TestExchangeDoTAllowsMissingALPN(t *testing.T) {
}
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
defer cancel()
_, info, err := exchangeDoTWithTLSConfig(ctx, Provider{DoTAddr: listener.Addr().String(), DoTName: "resolver.test"}, queryWire, &tls.Config{
response, info, err := exchangeDoTWithTLSConfig(ctx, Provider{DoTAddr: listener.Addr().String(), DoTName: "resolver.test"}, queryWire, &tls.Config{
RootCAs: roots,
MinVersion: tls.VersionTLS12,
NextProtos: []string{"dot"},
})
if err != nil {
t.Fatalf("exchange DoT without server ALPN: %v", err)
t.Fatalf("exchange DoT without ALPN: %v", err)
}
if err := <-serverError; err != nil {
t.Fatalf("serve DoT: %v", err)
@@ -150,6 +203,9 @@ func TestExchangeDoTAllowsMissingALPN(t *testing.T) {
if !info.ServerAuthenticated || info.ALPN != "" {
t.Fatalf("unexpected transport info: %#v", info)
}
if _, err := ParseResponse(response, binary.BigEndian.Uint16(queryWire[:2]), QueryInfo{Name: "example.com", Type: "A"}); err != nil {
t.Fatalf("parse response without ALPN: %v", err)
}
}
func TestExchangeDoTRejectsUnexpectedALPN(t *testing.T) {
@@ -205,7 +261,6 @@ func newTestCertificate(t *testing.T, name string) (tls.Certificate, *x509.CertP
}
template := &x509.Certificate{
SerialNumber: big.NewInt(1),
DNSNames: []string{name},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign,
@@ -213,6 +268,11 @@ func newTestCertificate(t *testing.T, name string) (tls.Certificate, *x509.CertP
IsCA: true,
BasicConstraintsValid: true,
}
if address := net.ParseIP(name); address != nil {
template.IPAddresses = []net.IP{address}
} else {
template.DNSNames = []string{name}
}
der, err := x509.CreateCertificate(rand.Reader, template, template, publicKey, privateKey)
if err != nil {
t.Fatalf("create certificate: %v", err)
@@ -1,12 +1,29 @@
package edns
import "time"
type QueryOptions struct {
Name string
RecordType string
Protocol string
Provider string
Method string
EndpointURL string
Proxy string
}
type CompareTarget struct {
Protocol string `json:"protocol"`
Provider string `json:"provider"`
Method string `json:"method,omitempty"`
}
type CompareOptions struct {
Name string
RecordType string
Targets []CompareTarget
AttemptTimeout time.Duration
MaxAttempts int
Proxy string
}
type Result struct {
@@ -21,15 +38,34 @@ type Result struct {
Error *ErrorInfo `json:"error,omitempty"`
}
type CompareResult struct {
SchemaVersion int `json:"schema_version"`
Operation string `json:"operation"`
Completed bool `json:"completed"`
Query QueryInfo `json:"query"`
Attempts []Result `json:"attempts"`
Summary CompareSummary `json:"summary"`
Error *ErrorInfo `json:"error,omitempty"`
}
type CompareSummary struct {
Total int `json:"total"`
Completed int `json:"completed"`
Failed int `json:"failed"`
Unsupported int `json:"unsupported"`
}
type QueryInfo struct {
Name string `json:"name"`
Type string `json:"type"`
}
type ResolverInfo struct {
Provider string `json:"provider"`
Endpoint string `json:"endpoint"`
Profile string `json:"profile"`
Provider string `json:"provider"`
Endpoint string `json:"endpoint"`
Profile string `json:"profile"`
AuthenticationName string `json:"authentication_name,omitempty"`
CertificateSerial uint32 `json:"certificate_serial,omitempty"`
}
type TransportInfo struct {
@@ -41,6 +77,10 @@ type TransportInfo struct {
TLSVersion string `json:"tls_version,omitempty"`
ALPN string `json:"alpn,omitempty"`
HTTPVersion string `json:"http_version,omitempty"`
HTTPAgeSeconds int64 `json:"http_age_seconds,omitempty"`
QUICVersion string `json:"quic_version,omitempty"`
CryptoConstruction string `json:"crypto_construction,omitempty"`
Proxy string `json:"proxy,omitempty"`
}
type DNSInfo struct {
@@ -1,49 +1,102 @@
package edns
import (
"errors"
"fmt"
"strings"
)
type UnsupportedError struct {
Message string
}
func (err *UnsupportedError) Error() string { return err.Message }
func IsUnsupported(err error) bool {
var unsupported *UnsupportedError
return errors.As(err, &unsupported)
}
type Provider struct {
ID string
Profile string
DoHURL string
DoTAddr string
DoTName string
ID string
Profile string
SourceURL string
VerifiedDate string
DoHURL string
DoTAddr string
DoTName string
DoQAddr string
DoQName string
DoH3URL string
DNSCryptStamp string
}
var providers = map[string]Provider{
"cloudflare": {
ID: "cloudflare",
Profile: "unfiltered",
DoHURL: "https://cloudflare-dns.com/dns-query",
DoTAddr: "one.one.one.one:853",
DoTName: "one.one.one.one",
ID: "cloudflare",
Profile: "unfiltered",
SourceURL: "https://developers.cloudflare.com/1.1.1.1/encryption/",
VerifiedDate: "2026-08-13",
DoHURL: "https://cloudflare-dns.com/dns-query",
DoTAddr: "one.one.one.one:853",
DoTName: "one.one.one.one",
DoH3URL: "https://cloudflare-dns.com/dns-query",
},
"google": {
ID: "google",
Profile: "unfiltered",
DoHURL: "https://dns.google/dns-query",
DoTAddr: "dns.google:853",
DoTName: "dns.google",
ID: "google",
Profile: "unfiltered",
SourceURL: "https://developers.google.com/speed/public-dns/docs/secure-transports",
VerifiedDate: "2026-08-13",
DoHURL: "https://dns.google/dns-query",
DoTAddr: "dns.google:853",
DoTName: "dns.google",
DoH3URL: "https://dns.google/dns-query",
},
"quad9": {
ID: "quad9",
Profile: "security-filtered",
DoHURL: "https://dns.quad9.net/dns-query",
DoTAddr: "dns.quad9.net:853",
DoTName: "dns.quad9.net",
ID: "quad9",
Profile: "security-filtered",
SourceURL: "https://docs.quad9.net/services/",
VerifiedDate: "2026-08-13",
DoHURL: "https://dns.quad9.net/dns-query",
DoTAddr: "dns.quad9.net:853",
DoTName: "dns.quad9.net",
},
"adguard": {
ID: "adguard",
Profile: "ad-and-security-filtered",
DoHURL: "https://dns.adguard-dns.com/dns-query",
DoTAddr: "dns.adguard-dns.com:853",
DoTName: "dns.adguard-dns.com",
ID: "adguard",
Profile: "ad-and-security-filtered",
SourceURL: "https://adguard-dns.io/kb/en/public-dns/overview/",
VerifiedDate: "2026-08-13",
DoHURL: "https://dns.adguard-dns.com/dns-query",
DoTAddr: "dns.adguard-dns.com:853",
DoTName: "dns.adguard-dns.com",
DoQAddr: "dns.adguard-dns.com:853",
DoQName: "dns.adguard-dns.com",
DNSCryptStamp: "sdns://AQMAAAAAAAAAETk0LjE0MC4xNC4xNDo1NDQzINErR_JS3PLCu_iZEIbq95zkSV2LFsigxDIuUso_OQhzIjIuZG5zY3J5cHQuZGVmYXVsdC5uczEuYWRndWFyZC5jb20",
},
}
func (provider Provider) Endpoint(protocol string) (string, error) {
var endpoint string
switch strings.ToLower(protocol) {
case "doh":
endpoint = provider.DoHURL
case "dot":
endpoint = provider.DoTAddr
case "doq":
endpoint = provider.DoQAddr
case "doh3":
endpoint = provider.DoH3URL
case "dnscrypt":
endpoint = provider.DNSCryptStamp
default:
return "", &UnsupportedError{Message: fmt.Sprintf("protocol %q is not available", protocol)}
}
if endpoint == "" {
return "", &UnsupportedError{Message: fmt.Sprintf("provider %q does not support protocol %q", provider.ID, protocol)}
}
return endpoint, nil
}
func FindProvider(name string) (Provider, error) {
provider, ok := providers[strings.ToLower(name)]
if !ok {
@@ -1,6 +1,10 @@
package edns
import "testing"
import (
"net/url"
"testing"
"time"
)
func TestBuiltInProvidersHaveStrictEndpoints(t *testing.T) {
for _, name := range []string{"cloudflare", "google", "quad9", "adguard"} {
@@ -11,8 +15,47 @@ func TestBuiltInProvidersHaveStrictEndpoints(t *testing.T) {
if provider.DoHURL == "" || provider.DoTAddr == "" || provider.DoTName == "" {
t.Fatalf("provider %s is incomplete: %#v", name, provider)
}
source, err := url.ParseRequestURI(provider.SourceURL)
if err != nil || source.Scheme != "https" || source.Host == "" {
t.Fatalf("provider %s has invalid official source URL %q: %v", name, provider.SourceURL, err)
}
if _, err := time.Parse(time.DateOnly, provider.VerifiedDate); err != nil {
t.Fatalf("provider %s has invalid verification date %q: %v", name, provider.VerifiedDate, err)
}
}
if _, err := FindProvider("custom"); err == nil {
t.Fatal("unapproved custom provider was accepted")
}
}
func TestProviderProtocolMatrix(t *testing.T) {
tests := []struct {
provider string
protocol string
allowed bool
}{
{provider: "cloudflare", protocol: "doh3", allowed: true},
{provider: "google", protocol: "doh3", allowed: true},
{provider: "adguard", protocol: "doq", allowed: true},
{provider: "adguard", protocol: "dnscrypt", allowed: true},
{provider: "cloudflare", protocol: "doq", allowed: false},
{provider: "cloudflare", protocol: "dnscrypt", allowed: false},
{provider: "quad9", protocol: "doh3", allowed: false},
{provider: "adguard", protocol: "doh3", allowed: false},
}
for _, test := range tests {
t.Run(test.provider+"/"+test.protocol, func(t *testing.T) {
provider, err := FindProvider(test.provider)
if err != nil {
t.Fatalf("find provider: %v", err)
}
_, err = provider.Endpoint(test.protocol)
if test.allowed && err != nil {
t.Fatalf("supported endpoint rejected: %v", err)
}
if !test.allowed && err == nil {
t.Fatal("unsupported endpoint was inferred")
}
})
}
}
@@ -2,11 +2,17 @@ package edns
import (
"context"
"encoding/binary"
"fmt"
"net/url"
)
type queryExchange func(context.Context, Provider, []byte, QueryOptions) ([]byte, TransportInfo, dnsCryptPeerInfo, error)
func Query(ctx context.Context, options QueryOptions) Result {
return queryWithExchange(ctx, options, exchangeProtocol)
}
func queryWithExchange(ctx context.Context, options QueryOptions, exchange queryExchange) Result {
wire, query, transactionID, err := BuildQuery(options.Name, options.RecordType)
result := Result{
SchemaVersion: 1,
@@ -30,41 +36,76 @@ func Query(ctx context.Context, options QueryOptions) Result {
result.Error = &ErrorInfo{Class: "input", Message: err.Error()}
return result
}
if options.EndpointURL != "" {
if options.Protocol != "doh" {
result.Error = &ErrorInfo{Class: "input", Message: "custom --url applies only to DoH"}
return result
}
parsed, err := url.Parse(options.EndpointURL)
if err != nil || parsed.Scheme != "https" || parsed.Hostname() == "" {
result.Error = &ErrorInfo{Class: "input", Message: fmt.Sprintf("invalid DoH endpoint URL %q", options.EndpointURL)}
return result
}
provider = Provider{ID: "custom", Profile: "custom", DoHURL: options.EndpointURL}
if err := ValidateProxyURL(options.Proxy); err != nil {
result.Error = &ErrorInfo{Class: "input", Message: err.Error()}
return result
}
if options.Proxy != "" && options.Protocol != "doh" && options.Protocol != "dot" {
result.Error = &ErrorInfo{Class: "unsupported", Message: fmt.Sprintf("proxying is not available for protocol %q", options.Protocol)}
return result
}
result.Resolver = ResolverInfo{Provider: provider.ID, Profile: provider.Profile}
var response []byte
switch options.Protocol {
case "doh":
result.Resolver.Endpoint = provider.DoHURL
response, result.Transport, err = exchangeDoH(ctx, provider, wire, options.Method)
case "dot":
result.Resolver.Endpoint = provider.DoTAddr
response, result.Transport, err = exchangeDoT(ctx, provider, wire)
default:
err = fmt.Errorf("protocol %q is not available; run ednsdiag capabilities", options.Protocol)
endpoint, err := provider.Endpoint(options.Protocol)
if err != nil {
class := "input"
if IsUnsupported(err) {
class = "unsupported"
}
result.Error = &ErrorInfo{Class: class, Message: err.Error()}
return result
}
result.Resolver.Endpoint = endpoint
if options.Protocol == "doh" || options.Protocol == "doh3" || options.Protocol == "doq" {
binary.BigEndian.PutUint16(wire[:2], 0)
transactionID = 0
}
response, transport, peer, err := exchange(ctx, provider, wire, options)
result.Transport = transport
if err != nil {
result.Error = &ErrorInfo{Class: "transport", Message: err.Error()}
return result
}
if options.Protocol == "dnscrypt" {
result.Resolver.Endpoint = peer.ServerAddress
result.Resolver.AuthenticationName = peer.ProviderName
result.Resolver.CertificateSerial = peer.CertificateSerial
}
result.DNS, err = ParseResponse(response, transactionID, query)
if err != nil {
result.Error = &ErrorInfo{Class: "protocol", Message: err.Error()}
return result
}
applyHTTPAge(&result.DNS, result.Transport.HTTPAgeSeconds)
result.Completed = true
return result
}
func exchangeProtocol(ctx context.Context, provider Provider, wire []byte, options QueryOptions) ([]byte, TransportInfo, dnsCryptPeerInfo, error) {
var response []byte
var transport TransportInfo
var peer dnsCryptPeerInfo
var err error
switch options.Protocol {
case "doh":
response, transport, err = exchangeDoH(ctx, provider, wire, options.Method, options.Proxy)
case "dot":
response, transport, err = exchangeDoT(ctx, provider, wire, options.Proxy)
case "doq":
response, transport, err = exchangeDoQ(ctx, provider, wire)
case "doh3":
response, transport, err = exchangeDoH3(ctx, provider, wire, options.Method)
case "dnscrypt":
response, transport, peer, err = exchangeDNSCrypt(ctx, provider.DNSCryptStamp, wire)
default:
err = fmt.Errorf("protocol %q is not available; run ednsdiag capabilities", options.Protocol)
}
return response, transport, peer, err
}
func Probe(ctx context.Context, options QueryOptions) Result {
result := Query(ctx, options)
result.Operation = "probe"
return result
}