Files
SpotiFLAC-Mobile/go_backend/dns_doh.go
T

439 lines
11 KiB
Go

package gobackend
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net"
"net/http"
"sync"
"time"
"golang.org/x/net/dns/dnsmessage"
)
// DNS-over-HTTPS fallback for DNS-level ISP blocking. The OS resolver stays
// the primary path; only when it fails with a DNS error (NXDOMAIN, SERVFAIL,
// refused, resolver timeout) is the host re-resolved over DoH to hardcoded
// resolver IPs and dialed directly. TLS verification still runs against the
// original hostname, so a bad answer cannot silently redirect traffic.
var dohUpstreams = []string{
"https://1.1.1.1/dns-query",
"https://8.8.8.8/dns-query",
}
// Upstream URLs are literal IPs, so this client never needs DNS itself.
var dohClient = &http.Client{
Transport: &http.Transport{
DialContext: (&net.Dialer{Timeout: 5 * time.Second}).DialContext,
MaxIdleConnsPerHost: 1,
IdleConnTimeout: 60 * time.Second,
TLSHandshakeTimeout: 5 * time.Second,
ForceAttemptHTTP2: true,
TLSClientConfig: newTLSCompatibilityConfig(false),
},
Timeout: 10 * time.Second,
}
const (
dohCacheMaxEntries = 256
dohCacheMinTTL = time.Minute
dohCacheMaxTTL = 30 * time.Minute
dohCacheErrorTTL = 30 * time.Second
// Match Go's net.Dialer fallback cadence: give the preferred address family
// a brief head start, then race the remaining vetted answers. We cannot hand
// the hostname back to net.Dialer because the socket must stay pinned to an
// address that already passed the private-network filter.
happyEyeballsFallbackDelay = 300 * time.Millisecond
)
type dohCacheEntry struct {
ips []net.IP
expiresAt time.Time
}
type resolvedDialResult struct {
conn net.Conn
err error
}
type dialContextFunc func(context.Context, string, string) (net.Conn, error)
var (
dohMu sync.Mutex
dohCache = map[string]dohCacheEntry{}
)
// dialWithDoHFallback resolves once, filters every answer, and then dials the
// vetted IP directly. This closes the validation-to-dial DNS rebinding window:
// TLS still receives the original hostname from net/http for SNI and hostname
// verification, while the socket cannot be redirected to a private address.
func dialWithDoHFallback(ctx context.Context, dialer *net.Dialer, network, addr string) (net.Conn, error) {
host, port, splitErr := net.SplitHostPort(addr)
if splitErr != nil {
return nil, splitErr
}
if literal := net.ParseIP(host); literal != nil {
if !IsPrivateNetworkAllowed() && isPrivateIPAddr(literal) {
return nil, fmt.Errorf("network access denied: private/local address %s", host)
}
return dialer.DialContext(ctx, network, addr)
}
ips, lookupErr := net.DefaultResolver.LookupIP(ctx, "ip", host)
if lookupErr == nil {
ips = filterDialableIPs(ips)
if len(ips) == 0 {
return nil, fmt.Errorf("network access denied: %s resolved only to private/local addresses", host)
}
return dialResolvedIPs(ctx, dialer, network, host, port, ips, nil)
}
var dnsErr *net.DNSError
if !errors.As(lookupErr, &dnsErr) {
return nil, lookupErr
}
ips, dohErr := dohResolve(ctx, host)
if dohErr != nil {
// Surface the OS resolver's error, not the fallback's.
return nil, lookupErr
}
GoLog("[DoH] OS resolver failed for %s (%v), dialing DoH answer\n", host, lookupErr)
return dialResolvedIPs(ctx, dialer, network, host, port, ips, lookupErr)
}
func dialResolvedIPs(
ctx context.Context,
dialer *net.Dialer,
network string,
host string,
port string,
ips []net.IP,
initialErr error,
) (net.Conn, error) {
ordered := interleaveDialIPs(ips, network)
return raceResolvedIPs(
ctx,
network,
host,
port,
ordered,
initialErr,
happyEyeballsFallbackDelay,
dialer.DialContext,
)
}
func raceResolvedIPs(
ctx context.Context,
network string,
host string,
port string,
ordered []net.IP,
initialErr error,
fallbackDelay time.Duration,
dial dialContextFunc,
) (net.Conn, error) {
if len(ordered) == 0 {
if initialErr != nil {
return nil, initialErr
}
return nil, fmt.Errorf("no dialable address for %s", host)
}
raceCtx, cancel := context.WithCancel(ctx)
defer cancel()
results := make(chan resolvedDialResult, len(ordered))
started := 0
finished := 0
lastErr := initialErr
startNext := func() bool {
if started >= len(ordered) {
return false
}
ip := ordered[started]
started++
go func() {
conn, err := dial(
raceCtx,
network,
net.JoinHostPort(ip.String(), port),
)
// The channel is sized for every possible attempt, so each goroutine
// can always report exactly once. The winner path drains and closes any
// late successful connections after cancelling the race.
results <- resolvedDialResult{conn: conn, err: err}
}()
return true
}
startNext()
timer := time.NewTimer(fallbackDelay)
defer timer.Stop()
for {
select {
case <-ctx.Done():
cancel()
drainDialResults(results, started-finished)
return nil, ctx.Err()
case result := <-results:
finished++
if result.err == nil && result.conn != nil {
cancel()
drainDialResults(results, started-finished)
return result.conn, nil
}
if result.err != nil {
lastErr = result.err
}
if finished == len(ordered) {
if lastErr == nil {
lastErr = fmt.Errorf("no dialable address for %s", host)
}
return nil, lastErr
}
// A fast refusal should not wait for the fallback timer when there is
// no other connection attempt currently in flight.
if finished == started && startNext() {
resetTimer(timer, fallbackDelay)
}
case <-timer.C:
if startNext() && started < len(ordered) {
timer.Reset(fallbackDelay)
}
}
}
}
func drainDialResults(results <-chan resolvedDialResult, count int) {
if count <= 0 {
return
}
go func() {
for i := 0; i < count; i++ {
result := <-results
if result.conn != nil {
result.conn.Close()
}
}
}()
}
func resetTimer(timer *time.Timer, delay time.Duration) {
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
timer.Reset(delay)
}
// interleaveDialIPs preserves the resolver's preferred family while ensuring
// the first fallback uses the other family. This avoids waiting through every
// unreachable IPv6 address before trying IPv4 (and vice versa).
func interleaveDialIPs(ips []net.IP, network string) []net.IP {
ordered := make([]net.IP, 0, len(ips))
var v4, v6 []net.IP
for _, ip := range ips {
if ip == nil {
continue
}
if ip.To4() != nil {
if network != "tcp6" && network != "udp6" {
v4 = append(v4, ip)
}
} else if network != "tcp4" && network != "udp4" {
v6 = append(v6, ip)
}
}
if len(v4) == 0 {
return append(ordered, v6...)
}
if len(v6) == 0 {
return append(ordered, v4...)
}
firstV4 := false
for _, ip := range ips {
if ip == nil {
continue
}
firstV4 = ip.To4() != nil
break
}
for len(v4) > 0 || len(v6) > 0 {
if firstV4 {
if len(v4) > 0 {
ordered = append(ordered, v4[0])
v4 = v4[1:]
}
if len(v6) > 0 {
ordered = append(ordered, v6[0])
v6 = v6[1:]
}
} else {
if len(v6) > 0 {
ordered = append(ordered, v6[0])
v6 = v6[1:]
}
if len(v4) > 0 {
ordered = append(ordered, v4[0])
v4 = v4[1:]
}
}
}
return ordered
}
// dohResolve resolves host over DoH, IPv4 first. Failures are negative-cached
// briefly so a burst of dials does not hammer the resolvers.
func dohResolve(ctx context.Context, host string) ([]net.IP, error) {
if ips, ok := dohCachedIPs(host); ok {
if len(ips) == 0 {
return nil, fmt.Errorf("doh: cached failure for %s", host)
}
return ips, nil
}
var lastErr error
for _, upstream := range dohUpstreams {
ips, ttl, err := dohQuery(ctx, upstream, host, dnsmessage.TypeA)
if err == nil && len(ips) == 0 {
ips, ttl, err = dohQuery(ctx, upstream, host, dnsmessage.TypeAAAA)
}
if err != nil {
lastErr = err
continue
}
ips = filterDialableIPs(ips)
if len(ips) == 0 {
break
}
dohCachePut(host, ips, min(max(ttl, dohCacheMinTTL), dohCacheMaxTTL))
return ips, nil
}
dohCachePut(host, nil, dohCacheErrorTTL)
if lastErr == nil {
lastErr = fmt.Errorf("doh: no address for %s", host)
}
return nil, lastErr
}
func dohQuery(ctx context.Context, upstream, host string, qtype dnsmessage.Type) ([]net.IP, time.Duration, error) {
name, err := dnsmessage.NewName(host + ".")
if err != nil {
return nil, 0, fmt.Errorf("doh: invalid host %q: %w", host, err)
}
msg := dnsmessage.Message{
Header: dnsmessage.Header{RecursionDesired: true},
Questions: []dnsmessage.Question{{
Name: name,
Type: qtype,
Class: dnsmessage.ClassINET,
}},
}
packed, err := msg.Pack()
if err != nil {
return nil, 0, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, upstream, bytes.NewReader(packed))
if err != nil {
return nil, 0, err
}
req.Header.Set("Content-Type", "application/dns-message")
req.Header.Set("Accept", "application/dns-message")
resp, err := dohClient.Do(req)
if err != nil {
return nil, 0, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, 0, fmt.Errorf("doh: %s answered HTTP %d", upstream, resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
if err != nil {
return nil, 0, err
}
var reply dnsmessage.Message
if err := reply.Unpack(body); err != nil {
return nil, 0, err
}
if reply.RCode != dnsmessage.RCodeSuccess {
return nil, 0, fmt.Errorf("doh: rcode %v for %s", reply.RCode, host)
}
var ips []net.IP
ttl := dohCacheMaxTTL
for _, ans := range reply.Answers {
var ip net.IP
switch r := ans.Body.(type) {
case *dnsmessage.AResource:
ip = net.IP(r.A[:])
case *dnsmessage.AAAAResource:
ip = net.IP(r.AAAA[:])
default:
continue
}
ips = append(ips, ip)
if t := time.Duration(ans.Header.TTL) * time.Second; t < ttl {
ttl = t
}
}
return ips, ttl, nil
}
// filterDialableIPs drops private/loopback/link-local answers unless the user
// opted into private-network access — a DoH answer must not bypass the SSRF
// guard the OS-resolver path enforces.
func filterDialableIPs(ips []net.IP) []net.IP {
if IsPrivateNetworkAllowed() {
return ips
}
kept := ips[:0]
for _, ip := range ips {
if isPrivateIPAddr(ip) {
continue
}
kept = append(kept, ip)
}
return kept
}
func dohCachedIPs(host string) ([]net.IP, bool) {
dohMu.Lock()
defer dohMu.Unlock()
e, ok := dohCache[host]
if !ok || time.Now().After(e.expiresAt) {
return nil, false
}
return e.ips, true
}
func dohCachePut(host string, ips []net.IP, ttl time.Duration) {
dohMu.Lock()
defer dohMu.Unlock()
if len(dohCache) >= dohCacheMaxEntries {
now := time.Now()
for k, e := range dohCache {
if now.After(e.expiresAt) {
delete(dohCache, k)
}
}
for k := range dohCache {
if len(dohCache) < dohCacheMaxEntries {
break
}
delete(dohCache, k)
}
}
dohCache[host] = dohCacheEntry{ips: ips, expiresAt: time.Now().Add(ttl)}
}