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) {