170 lines
5.4 KiB
Go
170 lines
5.4 KiB
Go
package edns
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/tls"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"io"
|
|
"mime"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const maxDNSMessageSize = 65535
|
|
|
|
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, 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: tlsConfig.Clone(),
|
|
TLSHandshakeTimeout: 5 * time.Second,
|
|
}
|
|
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 {
|
|
return fmt.Errorf("too many DoH redirects")
|
|
}
|
|
if request.URL.Scheme != "https" {
|
|
return fmt.Errorf("DoH redirect changed to a non-HTTPS scheme")
|
|
}
|
|
if !strings.EqualFold(request.URL.Hostname(), origin.Hostname()) {
|
|
return fmt.Errorf("DoH redirect changed authentication domain")
|
|
}
|
|
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: protocol,
|
|
Encrypted: true,
|
|
Bootstrap: "system_resolver",
|
|
}
|
|
|
|
requestURL := endpoint
|
|
var body io.Reader
|
|
switch strings.ToLower(method) {
|
|
case "get":
|
|
parsed, err := url.Parse(endpoint)
|
|
if err != nil {
|
|
return nil, info, fmt.Errorf("parse DoH endpoint: %w", err)
|
|
}
|
|
query := parsed.Query()
|
|
query.Set("dns", base64.RawURLEncoding.EncodeToString(wire))
|
|
parsed.RawQuery = query.Encode()
|
|
requestURL = parsed.String()
|
|
case "post", "":
|
|
method = "post"
|
|
body = bytes.NewReader(wire)
|
|
default:
|
|
return nil, info, fmt.Errorf("unsupported DoH method %q", method)
|
|
}
|
|
|
|
request, err := http.NewRequestWithContext(ctx, strings.ToUpper(method), requestURL, body)
|
|
if err != nil {
|
|
return nil, info, fmt.Errorf("create DoH request: %w", err)
|
|
}
|
|
request.Header.Set("Accept", "application/dns-message")
|
|
if strings.EqualFold(method, "post") {
|
|
request.Header.Set("Content-Type", "application/dns-message")
|
|
}
|
|
request.Header.Set("User-Agent", "ednsdiag/0.1.0-dev")
|
|
|
|
response, err := client.Do(request)
|
|
info.ElapsedMS = time.Since(started).Milliseconds()
|
|
if err != nil {
|
|
return nil, info, fmt.Errorf("perform DoH exchange: %w", err)
|
|
}
|
|
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)
|
|
}
|
|
mediaType, _, err := mime.ParseMediaType(response.Header.Get("Content-Type"))
|
|
if err != nil || !strings.EqualFold(mediaType, "application/dns-message") {
|
|
return nil, info, fmt.Errorf("DoH server returned unsupported content type %q", response.Header.Get("Content-Type"))
|
|
}
|
|
|
|
payload, err := io.ReadAll(io.LimitReader(response.Body, maxDNSMessageSize+1))
|
|
if err != nil {
|
|
return nil, info, fmt.Errorf("read DoH response: %w", err)
|
|
}
|
|
if len(payload) > maxDNSMessageSize {
|
|
return nil, info, fmt.Errorf("DoH response exceeds %d bytes", maxDNSMessageSize)
|
|
}
|
|
return payload, info, nil
|
|
}
|
|
|
|
func tlsVersionName(version uint16) string {
|
|
switch version {
|
|
case tls.VersionTLS13:
|
|
return "TLS1.3"
|
|
case tls.VersionTLS12:
|
|
return "TLS1.2"
|
|
default:
|
|
return fmt.Sprintf("0x%04x", version)
|
|
}
|
|
}
|