mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-29 15:28:48 +02:00
- Resolve over DNS-over-HTTPS (1.1.1.1/8.8.8.8, cached, SSRF-filtered) when the OS resolver fails, on all transports and the uTLS dial path, so DNS-level ISP blocking no longer kills the connection outright - Flush the pooled uTLS h2 conns from CloseIdleConnections so a network switch no longer leaves the first bypass request hanging on a dead conn - Abort retry backoff sleeps on context cancellation - Cap honored Retry-After at 2 minutes - Restore the response body consumed by the 403/451 marker scan
238 lines
6.0 KiB
Go
238 lines
6.0 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 dials addr normally and, when the failure is a DNS
|
|
// error, retries with DoH-resolved addresses. Non-DNS failures pass through
|
|
// untouched.
|
|
func dialWithDoHFallback(ctx context.Context, dialer *net.Dialer, network, addr string) (net.Conn, error) {
|
|
conn, err := dialer.DialContext(ctx, network, addr)
|
|
if err == nil {
|
|
return conn, nil
|
|
}
|
|
var dnsErr *net.DNSError
|
|
if !errors.As(err, &dnsErr) {
|
|
return nil, err
|
|
}
|
|
host, port, splitErr := net.SplitHostPort(addr)
|
|
if splitErr != nil || net.ParseIP(host) != nil {
|
|
return nil, err
|
|
}
|
|
ips, dohErr := dohResolve(ctx, host)
|
|
if dohErr != nil {
|
|
// Surface the OS resolver's error, not the fallback's.
|
|
return nil, err
|
|
}
|
|
GoLog("[DoH] OS resolver failed for %s (%v), dialing DoH answer\n", host, err)
|
|
lastErr := err
|
|
for _, ip := range ips {
|
|
conn, dialErr := dialer.DialContext(ctx, network, net.JoinHostPort(ip.String(), port))
|
|
if dialErr == nil {
|
|
return conn, nil
|
|
}
|
|
lastErr = dialErr
|
|
}
|
|
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 ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() ||
|
|
ip.IsLinkLocalMulticast() || ip.IsUnspecified() {
|
|
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)}
|
|
}
|