Files
SpotiFLAC-Mobile/go_backend/dns_doh.go
T

265 lines
6.9 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
)
type dohCacheEntry struct {
ips []net.IP
expiresAt time.Time
}
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) {
lastErr := initialErr
for _, ip := range ips {
conn, dialErr := dialer.DialContext(ctx, network, net.JoinHostPort(ip.String(), port))
if dialErr == nil {
return conn, nil
}
lastErr = dialErr
}
if lastErr == nil {
lastErr = fmt.Errorf("no dialable address for %s", host)
}
return nil, lastErr
}
// 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)}
}