feat(ednsdiag): add DoQ/DoH3/DNSCrypt transports, proxy support, probe & compare
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user