mirror of
https://github.com/MetaCubeX/mihomo.git
synced 2024-12-22 23:57:26 +08:00
767 lines
22 KiB
Go
767 lines
22 KiB
Go
package dns
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/netip"
|
|
"net/url"
|
|
"runtime"
|
|
"strconv"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/metacubex/mihomo/component/ca"
|
|
C "github.com/metacubex/mihomo/constant"
|
|
"github.com/metacubex/mihomo/log"
|
|
"github.com/metacubex/quic-go"
|
|
"github.com/metacubex/quic-go/http3"
|
|
D "github.com/miekg/dns"
|
|
"golang.org/x/exp/slices"
|
|
"golang.org/x/net/http2"
|
|
)
|
|
|
|
// Values to configure HTTP and HTTP/2 transport.
|
|
const (
|
|
// transportDefaultReadIdleTimeout is the default timeout for pinging
|
|
// idle connections in HTTP/2 transport.
|
|
transportDefaultReadIdleTimeout = 30 * time.Second
|
|
|
|
// transportDefaultIdleConnTimeout is the default timeout for idle
|
|
// connections in HTTP transport.
|
|
transportDefaultIdleConnTimeout = 5 * time.Minute
|
|
|
|
// dohMaxConnsPerHost controls the maximum number of connections for
|
|
// each host.
|
|
dohMaxConnsPerHost = 1
|
|
dialTimeout = 10 * time.Second
|
|
|
|
// dohMaxIdleConns controls the maximum number of connections being idle
|
|
// at the same time.
|
|
dohMaxIdleConns = 1
|
|
maxElapsedTime = time.Second * 30
|
|
)
|
|
|
|
var DefaultHTTPVersions = []C.HTTPVersion{C.HTTPVersion11, C.HTTPVersion2}
|
|
|
|
// dnsOverHTTPS is a struct that implements the Upstream interface for the
|
|
// DNS-over-HTTPS protocol.
|
|
type dnsOverHTTPS struct {
|
|
// The Client's Transport typically has internal state (cached TCP
|
|
// connections), so Clients should be reused instead of created as
|
|
// needed. Clients are safe for concurrent use by multiple goroutines.
|
|
client *http.Client
|
|
clientMu sync.Mutex
|
|
|
|
// quicConfig is the QUIC configuration that is used if HTTP/3 is enabled
|
|
// for this upstream.
|
|
quicConfig *quic.Config
|
|
quicConfigGuard sync.Mutex
|
|
|
|
url *url.URL
|
|
httpVersions []C.HTTPVersion
|
|
dialer *dnsDialer
|
|
addr string
|
|
skipCertVerify bool
|
|
ecsPrefix netip.Prefix
|
|
ecsOverride bool
|
|
}
|
|
|
|
// type check
|
|
var _ dnsClient = (*dnsOverHTTPS)(nil)
|
|
|
|
// newDoH returns the DNS-over-HTTPS Upstream.
|
|
func newDoHClient(urlString string, r *Resolver, preferH3 bool, params map[string]string, proxyAdapter C.ProxyAdapter, proxyName string) dnsClient {
|
|
u, _ := url.Parse(urlString)
|
|
httpVersions := DefaultHTTPVersions
|
|
if preferH3 {
|
|
httpVersions = append(httpVersions, C.HTTPVersion3)
|
|
}
|
|
|
|
if params["h3"] == "true" {
|
|
httpVersions = []C.HTTPVersion{C.HTTPVersion3}
|
|
}
|
|
|
|
doh := &dnsOverHTTPS{
|
|
url: u,
|
|
addr: u.String(),
|
|
dialer: newDNSDialer(r, proxyAdapter, proxyName),
|
|
quicConfig: &quic.Config{
|
|
KeepAlivePeriod: QUICKeepAlivePeriod,
|
|
TokenStore: newQUICTokenStore(),
|
|
},
|
|
httpVersions: httpVersions,
|
|
}
|
|
|
|
if params["skip-cert-verify"] == "true" {
|
|
doh.skipCertVerify = true
|
|
}
|
|
|
|
if ecs := params["ecs"]; ecs != "" {
|
|
prefix, err := netip.ParsePrefix(ecs)
|
|
if err != nil {
|
|
addr, err := netip.ParseAddr(ecs)
|
|
if err != nil {
|
|
log.Warnln("DOH [%s] config with invalid ecs: %s", doh.addr, ecs)
|
|
} else {
|
|
doh.ecsPrefix = netip.PrefixFrom(addr, addr.BitLen())
|
|
}
|
|
} else {
|
|
doh.ecsPrefix = prefix
|
|
}
|
|
}
|
|
|
|
if doh.ecsPrefix.IsValid() {
|
|
log.Debugln("DOH [%s] config with ecs: %s", doh.addr, doh.ecsPrefix)
|
|
}
|
|
|
|
if params["ecs-override"] == "true" {
|
|
doh.ecsOverride = true
|
|
}
|
|
|
|
runtime.SetFinalizer(doh, (*dnsOverHTTPS).Close)
|
|
|
|
return doh
|
|
}
|
|
|
|
// Address implements the Upstream interface for *dnsOverHTTPS.
|
|
func (doh *dnsOverHTTPS) Address() string {
|
|
return doh.addr
|
|
}
|
|
|
|
func (doh *dnsOverHTTPS) ExchangeContext(ctx context.Context, m *D.Msg) (msg *D.Msg, err error) {
|
|
// Quote from https://www.rfc-editor.org/rfc/rfc8484.html:
|
|
// In order to maximize HTTP cache friendliness, DoH clients using media
|
|
// formats that include the ID field from the DNS message header, such
|
|
// as "application/dns-message", SHOULD use a DNS ID of 0 in every DNS
|
|
// request.
|
|
m = m.Copy()
|
|
id := m.Id
|
|
m.Id = 0
|
|
defer func() {
|
|
// Restore the original ID to not break compatibility with proxies.
|
|
m.Id = id
|
|
if msg != nil {
|
|
msg.Id = id
|
|
}
|
|
}()
|
|
|
|
if doh.ecsPrefix.IsValid() {
|
|
setEdns0Subnet(m, doh.ecsPrefix, doh.ecsOverride)
|
|
}
|
|
|
|
// Check if there was already an active client before sending the request.
|
|
// We'll only attempt to re-connect if there was one.
|
|
client, isCached, err := doh.getClient(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to init http client: %w", err)
|
|
}
|
|
|
|
// Make the first attempt to send the DNS query.
|
|
msg, err = doh.exchangeHTTPS(ctx, client, m)
|
|
|
|
// Make up to 2 attempts to re-create the HTTP client and send the request
|
|
// again. There are several cases (mostly, with QUIC) where this workaround
|
|
// is necessary to make HTTP client usable. We need to make 2 attempts in
|
|
// the case when the connection was closed (due to inactivity for example)
|
|
// AND the server refuses to open a 0-RTT connection.
|
|
for i := 0; isCached && doh.shouldRetry(err) && i < 2; i++ {
|
|
client, err = doh.resetClient(ctx, err)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to reset http client: %w", err)
|
|
}
|
|
|
|
msg, err = doh.exchangeHTTPS(ctx, client, m)
|
|
}
|
|
|
|
if err != nil && !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) {
|
|
// If the request failed anyway, make sure we don't use this client.
|
|
_, resErr := doh.resetClient(ctx, err)
|
|
|
|
return nil, fmt.Errorf("%w (resErr:%v)", err, resErr)
|
|
}
|
|
|
|
return msg, err
|
|
}
|
|
|
|
// Close implements the Upstream interface for *dnsOverHTTPS.
|
|
func (doh *dnsOverHTTPS) Close() (err error) {
|
|
doh.clientMu.Lock()
|
|
defer doh.clientMu.Unlock()
|
|
|
|
runtime.SetFinalizer(doh, nil)
|
|
|
|
if doh.client == nil {
|
|
return nil
|
|
}
|
|
|
|
return doh.closeClient(doh.client)
|
|
}
|
|
|
|
func (doh *dnsOverHTTPS) ResetConnection() {
|
|
doh.clientMu.Lock()
|
|
defer doh.clientMu.Unlock()
|
|
|
|
if doh.client == nil {
|
|
return
|
|
}
|
|
|
|
_ = doh.closeClient(doh.client)
|
|
doh.client = nil
|
|
}
|
|
|
|
// closeClient cleans up resources used by client if necessary.
|
|
func (doh *dnsOverHTTPS) closeClient(client *http.Client) (err error) {
|
|
client.CloseIdleConnections()
|
|
|
|
if isHTTP3(client) { // HTTP/3 may leak due to keep-alive connections.
|
|
return client.Transport.(io.Closer).Close()
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// exchangeHTTPS sends the DNS query to a DoH resolver using the specified
|
|
// http.Client instance.
|
|
func (doh *dnsOverHTTPS) exchangeHTTPS(ctx context.Context, client *http.Client, req *D.Msg) (resp *D.Msg, err error) {
|
|
buf, err := req.Pack()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("packing message: %w", err)
|
|
}
|
|
|
|
// It appears, that GET requests are more memory-efficient with Golang
|
|
// implementation of HTTP/2.
|
|
method := http.MethodGet
|
|
if isHTTP3(client) {
|
|
// If we're using HTTP/3, use http3.MethodGet0RTT to force using 0-RTT.
|
|
method = http3.MethodGet0RTT
|
|
}
|
|
|
|
requestUrl := *doh.url // don't modify origin url
|
|
requestUrl.RawQuery = fmt.Sprintf("dns=%s", base64.RawURLEncoding.EncodeToString(buf))
|
|
httpReq, err := http.NewRequestWithContext(ctx, method, requestUrl.String(), nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("creating http request to %s: %w", doh.url, err)
|
|
}
|
|
|
|
httpReq.Header.Set("Accept", "application/dns-message")
|
|
httpReq.Header.Set("User-Agent", "")
|
|
httpResp, err := client.Do(httpReq)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("requesting %s: %w", doh.url, err)
|
|
}
|
|
defer httpResp.Body.Close()
|
|
|
|
body, err := io.ReadAll(httpResp.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("reading %s: %w", doh.url, err)
|
|
}
|
|
|
|
if httpResp.StatusCode != http.StatusOK {
|
|
return nil,
|
|
fmt.Errorf(
|
|
"expected status %d, got %d from %s",
|
|
http.StatusOK,
|
|
httpResp.StatusCode,
|
|
doh.url,
|
|
)
|
|
}
|
|
|
|
resp = &D.Msg{}
|
|
err = resp.Unpack(body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf(
|
|
"unpacking response from %s: body is %s: %w",
|
|
doh.url,
|
|
body,
|
|
err,
|
|
)
|
|
}
|
|
|
|
if resp.Id != req.Id {
|
|
err = D.ErrId
|
|
}
|
|
|
|
return resp, err
|
|
}
|
|
|
|
// shouldRetry checks what error we have received and returns true if we should
|
|
// re-create the HTTP client and retry the request.
|
|
func (doh *dnsOverHTTPS) shouldRetry(err error) (ok bool) {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
|
|
var netErr net.Error
|
|
if errors.As(err, &netErr) && netErr.Timeout() {
|
|
// If this is a timeout error, trying to forcibly re-create the HTTP
|
|
// client instance. This is an attempt to fix an issue with DoH client
|
|
// stalling after a network change.
|
|
//
|
|
// See https://github.com/AdguardTeam/AdGuardHome/issues/3217.
|
|
return true
|
|
}
|
|
|
|
if isQUICRetryError(err) {
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// resetClient triggers re-creation of the *http.Client that is used by this
|
|
// upstream. This method accepts the error that caused resetting client as
|
|
// depending on the error we may also reset the QUIC config.
|
|
func (doh *dnsOverHTTPS) resetClient(ctx context.Context, resetErr error) (client *http.Client, err error) {
|
|
doh.clientMu.Lock()
|
|
defer doh.clientMu.Unlock()
|
|
|
|
if errors.Is(resetErr, quic.Err0RTTRejected) {
|
|
// Reset the TokenStore only if 0-RTT was rejected.
|
|
doh.resetQUICConfig()
|
|
}
|
|
|
|
oldClient := doh.client
|
|
if oldClient != nil {
|
|
closeErr := doh.closeClient(oldClient)
|
|
if closeErr != nil {
|
|
log.Warnln("warning: failed to close the old http client: %v", closeErr)
|
|
}
|
|
}
|
|
|
|
log.Debugln("re-creating the http client due to %v", resetErr)
|
|
doh.client, err = doh.createClient(ctx)
|
|
|
|
return doh.client, err
|
|
}
|
|
|
|
// getQUICConfig returns the QUIC config in a thread-safe manner. Note, that
|
|
// this method returns a pointer, it is forbidden to change its properties.
|
|
func (doh *dnsOverHTTPS) getQUICConfig() (c *quic.Config) {
|
|
doh.quicConfigGuard.Lock()
|
|
defer doh.quicConfigGuard.Unlock()
|
|
|
|
return doh.quicConfig
|
|
}
|
|
|
|
// resetQUICConfig Re-create the token store to make sure we're not trying to
|
|
// use invalid for 0-RTT.
|
|
func (doh *dnsOverHTTPS) resetQUICConfig() {
|
|
doh.quicConfigGuard.Lock()
|
|
defer doh.quicConfigGuard.Unlock()
|
|
|
|
doh.quicConfig = doh.quicConfig.Clone()
|
|
doh.quicConfig.TokenStore = newQUICTokenStore()
|
|
}
|
|
|
|
// getClient gets or lazily initializes an HTTP client (and transport) that will
|
|
// be used for this DoH resolver.
|
|
func (doh *dnsOverHTTPS) getClient(ctx context.Context) (c *http.Client, isCached bool, err error) {
|
|
startTime := time.Now()
|
|
|
|
doh.clientMu.Lock()
|
|
defer doh.clientMu.Unlock()
|
|
if doh.client != nil {
|
|
return doh.client, true, nil
|
|
}
|
|
|
|
// Timeout can be exceeded while waiting for the lock. This happens quite
|
|
// often on mobile devices.
|
|
elapsed := time.Since(startTime)
|
|
if elapsed > maxElapsedTime {
|
|
return nil, false, fmt.Errorf("timeout exceeded: %s", elapsed)
|
|
}
|
|
|
|
log.Debugln("creating a new http client")
|
|
doh.client, err = doh.createClient(ctx)
|
|
|
|
return doh.client, false, err
|
|
}
|
|
|
|
// createClient creates a new *http.Client instance. The HTTP protocol version
|
|
// will depend on whether HTTP3 is allowed and provided by this upstream. Note,
|
|
// that we'll attempt to establish a QUIC connection when creating the client in
|
|
// order to check whether HTTP3 is supported.
|
|
func (doh *dnsOverHTTPS) createClient(ctx context.Context) (*http.Client, error) {
|
|
transport, err := doh.createTransport(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("[%s] initializing http transport: %w", doh.url.String(), err)
|
|
}
|
|
|
|
client := &http.Client{
|
|
Transport: transport,
|
|
Timeout: DefaultTimeout,
|
|
Jar: nil,
|
|
}
|
|
|
|
doh.client = client
|
|
|
|
return doh.client, nil
|
|
}
|
|
|
|
// createTransport initializes an HTTP transport that will be used specifically
|
|
// for this DoH resolver. This HTTP transport ensures that the HTTP requests
|
|
// will be sent exactly to the IP address got from the bootstrap resolver. Note,
|
|
// that this function will first attempt to establish a QUIC connection (if
|
|
// HTTP3 is enabled in the upstream options). If this attempt is successful,
|
|
// it returns an HTTP3 transport, otherwise it returns the H1/H2 transport.
|
|
func (doh *dnsOverHTTPS) createTransport(ctx context.Context) (t http.RoundTripper, err error) {
|
|
transport := &http.Transport{
|
|
DisableCompression: true,
|
|
DialContext: doh.dialer.DialContext,
|
|
IdleConnTimeout: transportDefaultIdleConnTimeout,
|
|
MaxConnsPerHost: dohMaxConnsPerHost,
|
|
MaxIdleConns: dohMaxIdleConns,
|
|
}
|
|
|
|
if doh.url.Scheme == "http" {
|
|
return transport, nil
|
|
}
|
|
|
|
tlsConfig := ca.GetGlobalTLSConfig(
|
|
&tls.Config{
|
|
InsecureSkipVerify: doh.skipCertVerify,
|
|
MinVersion: tls.VersionTLS12,
|
|
SessionTicketsDisabled: false,
|
|
})
|
|
var nextProtos []string
|
|
for _, v := range doh.httpVersions {
|
|
nextProtos = append(nextProtos, string(v))
|
|
}
|
|
tlsConfig.NextProtos = nextProtos
|
|
transport.TLSClientConfig = tlsConfig
|
|
|
|
if slices.Contains(doh.httpVersions, C.HTTPVersion3) {
|
|
// First, we attempt to create an HTTP3 transport. If the probe QUIC
|
|
// connection is established successfully, we'll be using HTTP3 for this
|
|
// upstream.
|
|
transportH3, err := doh.createTransportH3(ctx, tlsConfig)
|
|
if err == nil {
|
|
log.Debugln("[%s] using HTTP/3 for this upstream: QUIC was faster", doh.url.String())
|
|
return transportH3, nil
|
|
}
|
|
}
|
|
|
|
log.Debugln("[%s] using HTTP/2 for this upstream: %v", doh.url.String(), err)
|
|
|
|
if !doh.supportsHTTP() {
|
|
return nil, errors.New("HTTP1/1 and HTTP2 are not supported by this upstream")
|
|
}
|
|
|
|
// Since we have a custom DialContext, we need to use this field to
|
|
// make golang http.Client attempt to use HTTP/2. Otherwise, it would
|
|
// only be used when negotiated on the TLS level.
|
|
transport.ForceAttemptHTTP2 = true
|
|
|
|
// Explicitly configure transport to use HTTP/2.
|
|
//
|
|
// See https://github.com/AdguardTeam/dnsproxy/issues/11.
|
|
var transportH2 *http2.Transport
|
|
transportH2, err = http2.ConfigureTransports(transport)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Enable HTTP/2 pings on idle connections.
|
|
transportH2.ReadIdleTimeout = transportDefaultReadIdleTimeout
|
|
|
|
return transport, nil
|
|
}
|
|
|
|
// http3Transport is a wrapper over *http3.RoundTripper that tries to optimize
|
|
// its behavior. The main thing that it does is trying to force use a single
|
|
// connection to a host instead of creating a new one all the time. It also
|
|
// helps mitigate race issues with quic-go.
|
|
type http3Transport struct {
|
|
baseTransport *http3.RoundTripper
|
|
|
|
closed bool
|
|
mu sync.RWMutex
|
|
}
|
|
|
|
// type check
|
|
var _ http.RoundTripper = (*http3Transport)(nil)
|
|
|
|
// RoundTrip implements the http.RoundTripper interface for *http3Transport.
|
|
func (h *http3Transport) RoundTrip(req *http.Request) (resp *http.Response, err error) {
|
|
h.mu.RLock()
|
|
defer h.mu.RUnlock()
|
|
|
|
if h.closed {
|
|
return nil, net.ErrClosed
|
|
}
|
|
|
|
// Try to use cached connection to the target host if it's available.
|
|
resp, err = h.baseTransport.RoundTripOpt(req, http3.RoundTripOpt{OnlyCachedConn: true})
|
|
|
|
if errors.Is(err, http3.ErrNoCachedConn) {
|
|
// If there are no cached connection, trigger creating a new one.
|
|
resp, err = h.baseTransport.RoundTrip(req)
|
|
}
|
|
|
|
return resp, err
|
|
}
|
|
|
|
// type check
|
|
var _ io.Closer = (*http3Transport)(nil)
|
|
|
|
// Close implements the io.Closer interface for *http3Transport.
|
|
func (h *http3Transport) Close() (err error) {
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
|
|
h.closed = true
|
|
|
|
return h.baseTransport.Close()
|
|
}
|
|
|
|
func (h *http3Transport) CloseIdleConnections() {
|
|
h.mu.RLock()
|
|
defer h.mu.RUnlock()
|
|
|
|
h.baseTransport.CloseIdleConnections()
|
|
}
|
|
|
|
// createTransportH3 tries to create an HTTP/3 transport for this upstream.
|
|
// We should be able to fall back to H1/H2 in case if HTTP/3 is unavailable or
|
|
// if it is too slow. In order to do that, this method will run two probes
|
|
// in parallel (one for TLS, the other one for QUIC) and if QUIC is faster it
|
|
// will create the *http3.RoundTripper instance.
|
|
func (doh *dnsOverHTTPS) createTransportH3(
|
|
ctx context.Context,
|
|
tlsConfig *tls.Config,
|
|
) (roundTripper http.RoundTripper, err error) {
|
|
if !doh.supportsH3() {
|
|
return nil, errors.New("HTTP3 support is not enabled")
|
|
}
|
|
|
|
addr, err := doh.probeH3(ctx, tlsConfig)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
rt := &http3.RoundTripper{
|
|
Dial: func(
|
|
ctx context.Context,
|
|
|
|
// Ignore the address and always connect to the one that we got
|
|
// from the bootstrapper.
|
|
_ string,
|
|
tlsCfg *tls.Config,
|
|
cfg *quic.Config,
|
|
) (c quic.EarlyConnection, err error) {
|
|
return doh.dialQuic(ctx, addr, tlsCfg, cfg)
|
|
},
|
|
DisableCompression: true,
|
|
TLSClientConfig: tlsConfig,
|
|
QUICConfig: doh.getQUICConfig(),
|
|
}
|
|
|
|
return &http3Transport{baseTransport: rt}, nil
|
|
}
|
|
|
|
func (doh *dnsOverHTTPS) dialQuic(ctx context.Context, addr string, tlsCfg *tls.Config, cfg *quic.Config) (quic.EarlyConnection, error) {
|
|
ip, port, err := net.SplitHostPort(addr)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
portInt, err := strconv.Atoi(port)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
udpAddr := net.UDPAddr{
|
|
IP: net.ParseIP(ip),
|
|
Port: portInt,
|
|
}
|
|
conn, err := doh.dialer.ListenPacket(ctx, "udp", addr)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
transport := quic.Transport{Conn: conn}
|
|
transport.SetCreatedConn(true) // auto close conn
|
|
transport.SetSingleUse(true) // auto close transport
|
|
tlsCfg = tlsCfg.Clone()
|
|
if host, _, err := net.SplitHostPort(doh.url.Host); err == nil {
|
|
tlsCfg.ServerName = host
|
|
} else {
|
|
// It's ok if net.SplitHostPort returns an error - it could be a hostname/IP address without a port.
|
|
tlsCfg.ServerName = doh.url.Host
|
|
}
|
|
return transport.DialEarly(ctx, &udpAddr, tlsCfg, cfg)
|
|
}
|
|
|
|
// probeH3 runs a test to check whether QUIC is faster than TLS for this
|
|
// upstream. If the test is successful it will return the address that we
|
|
// should use to establish the QUIC connections.
|
|
func (doh *dnsOverHTTPS) probeH3(
|
|
ctx context.Context,
|
|
tlsConfig *tls.Config,
|
|
) (addr string, err error) {
|
|
// We're using bootstrapped address instead of what's passed to the function
|
|
// it does not create an actual connection, but it helps us determine
|
|
// what IP is actually reachable (when there are v4/v6 addresses).
|
|
rawConn, err := doh.dialer.DialContext(ctx, "udp", doh.url.Host)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to dial: %w", err)
|
|
}
|
|
addr = rawConn.RemoteAddr().String()
|
|
// It's never actually used.
|
|
_ = rawConn.Close()
|
|
|
|
// Avoid spending time on probing if this upstream only supports HTTP/3.
|
|
if doh.supportsH3() && !doh.supportsHTTP() {
|
|
return addr, nil
|
|
}
|
|
|
|
// Use a new *tls.Config with empty session cache for probe connections.
|
|
// Surprisingly, this is really important since otherwise it invalidates
|
|
// the existing cache.
|
|
// TODO(ameshkov): figure out why the sessions cache invalidates here.
|
|
probeTLSCfg := tlsConfig.Clone()
|
|
probeTLSCfg.ClientSessionCache = nil
|
|
|
|
// Do not expose probe connections to the callbacks that are passed to
|
|
// the bootstrap options to avoid side-effects.
|
|
// TODO(ameshkov): consider exposing, somehow mark that this is a probe.
|
|
probeTLSCfg.VerifyPeerCertificate = nil
|
|
probeTLSCfg.VerifyConnection = nil
|
|
|
|
// Run probeQUIC and probeTLS in parallel and see which one is faster.
|
|
chQuic := make(chan error, 1)
|
|
chTLS := make(chan error, 1)
|
|
go doh.probeQUIC(ctx, addr, probeTLSCfg, chQuic)
|
|
go doh.probeTLS(ctx, probeTLSCfg, chTLS)
|
|
|
|
select {
|
|
case quicErr := <-chQuic:
|
|
if quicErr != nil {
|
|
// QUIC failed, return error since HTTP3 was not preferred.
|
|
return "", quicErr
|
|
}
|
|
|
|
// Return immediately, QUIC was faster.
|
|
return addr, quicErr
|
|
case tlsErr := <-chTLS:
|
|
if tlsErr != nil {
|
|
// Return immediately, TLS failed.
|
|
log.Debugln("probing TLS: %v", tlsErr)
|
|
return addr, nil
|
|
}
|
|
|
|
return "", errors.New("TLS was faster than QUIC, prefer it")
|
|
}
|
|
}
|
|
|
|
// probeQUIC attempts to establish a QUIC connection to the specified address.
|
|
// We run probeQUIC and probeTLS in parallel and see which one is faster.
|
|
func (doh *dnsOverHTTPS) probeQUIC(ctx context.Context, addr string, tlsConfig *tls.Config, ch chan error) {
|
|
startTime := time.Now()
|
|
conn, err := doh.dialQuic(ctx, addr, tlsConfig, doh.getQUICConfig())
|
|
if err != nil {
|
|
ch <- fmt.Errorf("opening QUIC connection to %s: %w", doh.Address(), err)
|
|
return
|
|
}
|
|
|
|
// Ignore the error since there's no way we can use it for anything useful.
|
|
_ = conn.CloseWithError(QUICCodeNoError, "")
|
|
|
|
ch <- nil
|
|
|
|
elapsed := time.Now().Sub(startTime)
|
|
log.Debugln("elapsed on establishing a QUIC connection: %s", elapsed)
|
|
}
|
|
|
|
// probeTLS attempts to establish a TLS connection to the specified address. We
|
|
// run probeQUIC and probeTLS in parallel and see which one is faster.
|
|
func (doh *dnsOverHTTPS) probeTLS(ctx context.Context, tlsConfig *tls.Config, ch chan error) {
|
|
startTime := time.Now()
|
|
|
|
conn, err := doh.tlsDial(ctx, "tcp", tlsConfig)
|
|
if err != nil {
|
|
ch <- fmt.Errorf("opening TLS connection: %w", err)
|
|
return
|
|
}
|
|
|
|
// Ignore the error since there's no way we can use it for anything useful.
|
|
_ = conn.Close()
|
|
|
|
ch <- nil
|
|
|
|
elapsed := time.Now().Sub(startTime)
|
|
log.Debugln("elapsed on establishing a TLS connection: %s", elapsed)
|
|
}
|
|
|
|
// supportsH3 returns true if HTTP/3 is supported by this upstream.
|
|
func (doh *dnsOverHTTPS) supportsH3() (ok bool) {
|
|
for _, v := range doh.supportedHTTPVersions() {
|
|
if v == C.HTTPVersion3 {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// supportsHTTP returns true if HTTP/1.1 or HTTP2 is supported by this upstream.
|
|
func (doh *dnsOverHTTPS) supportsHTTP() (ok bool) {
|
|
for _, v := range doh.supportedHTTPVersions() {
|
|
if v == C.HTTPVersion11 || v == C.HTTPVersion2 {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// supportedHTTPVersions returns the list of supported HTTP versions.
|
|
func (doh *dnsOverHTTPS) supportedHTTPVersions() (v []C.HTTPVersion) {
|
|
v = doh.httpVersions
|
|
if v == nil {
|
|
v = DefaultHTTPVersions
|
|
}
|
|
|
|
return v
|
|
}
|
|
|
|
// isHTTP3 checks if the *http.Client is an HTTP/3 client.
|
|
func isHTTP3(client *http.Client) (ok bool) {
|
|
_, ok = client.Transport.(*http3Transport)
|
|
|
|
return ok
|
|
}
|
|
|
|
// tlsDial is basically the same as tls.DialWithDialer, but we will call our own
|
|
// dialContext function to get connection.
|
|
func (doh *dnsOverHTTPS) tlsDial(ctx context.Context, network string, config *tls.Config) (*tls.Conn, error) {
|
|
// We're using bootstrapped address instead of what's passed
|
|
// to the function.
|
|
rawConn, err := doh.dialer.DialContext(ctx, network, doh.url.Host)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// We want the timeout to cover the whole process: TCP connection and
|
|
// TLS handshake dialTimeout will be used as connection deadLine.
|
|
conn := tls.Client(rawConn, config)
|
|
|
|
err = conn.SetDeadline(time.Now().Add(dialTimeout))
|
|
if err != nil {
|
|
// Must not happen in normal circumstances.
|
|
log.Errorln("cannot set deadline: %v", err)
|
|
return nil, err
|
|
}
|
|
|
|
err = conn.Handshake()
|
|
if err != nil {
|
|
defer conn.Close()
|
|
return nil, err
|
|
}
|
|
|
|
return conn, nil
|
|
}
|