mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-29 23:38:50 +02:00
feat(net): DoH fallback, uTLS pool flush on cleanup, retry hardening
- 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
This commit is contained in:
@@ -0,0 +1,237 @@
|
||||
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)}
|
||||
}
|
||||
+46
-17
@@ -1,6 +1,7 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
@@ -63,11 +64,17 @@ var (
|
||||
networkCompatibilityOptions NetworkCompatibilityOptions
|
||||
)
|
||||
|
||||
var transportDialer = &net.Dialer{
|
||||
Timeout: 10 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
}
|
||||
|
||||
func transportDialContext(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
return dialWithDoHFallback(ctx, transportDialer, network, addr)
|
||||
}
|
||||
|
||||
var sharedTransport = &http.Transport{
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: 10 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
}).DialContext,
|
||||
DialContext: transportDialContext,
|
||||
MaxIdleConns: 100,
|
||||
MaxIdleConnsPerHost: 10,
|
||||
MaxConnsPerHost: 20,
|
||||
@@ -88,10 +95,7 @@ var sharedTransport = &http.Transport{
|
||||
}
|
||||
|
||||
var extensionAPITransport = &http.Transport{
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: 10 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
}).DialContext,
|
||||
DialContext: transportDialContext,
|
||||
MaxIdleConns: 100,
|
||||
MaxIdleConnsPerHost: 10,
|
||||
MaxConnsPerHost: 20,
|
||||
@@ -108,10 +112,7 @@ var extensionAPITransport = &http.Transport{
|
||||
}
|
||||
|
||||
var metadataTransport = &http.Transport{
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: 10 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
}).DialContext,
|
||||
DialContext: transportDialContext,
|
||||
MaxIdleConns: 30,
|
||||
MaxIdleConnsPerHost: 5,
|
||||
MaxConnsPerHost: 10,
|
||||
@@ -152,6 +153,7 @@ func CloseIdleConnections() {
|
||||
sharedTransport.CloseIdleConnections()
|
||||
extensionAPITransport.CloseIdleConnections()
|
||||
metadataTransport.CloseIdleConnections()
|
||||
closeUTLSIdleConnections()
|
||||
}
|
||||
|
||||
func SetNetworkCompatibilityOptions(allowHTTP, insecureTLS bool) {
|
||||
@@ -292,7 +294,9 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
|
||||
if attempt < config.MaxRetries {
|
||||
GoLog("[HTTP] Request failed (attempt %d/%d): %v, retrying in %v...\n",
|
||||
attempt+1, config.MaxRetries+1, err, delay)
|
||||
time.Sleep(delay)
|
||||
if err := sleepRetry(req.Context(), delay); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
delay = calculateNextDelay(delay, config)
|
||||
}
|
||||
continue
|
||||
@@ -311,7 +315,9 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
|
||||
lastErr = fmt.Errorf("rate limited (429)")
|
||||
if attempt < config.MaxRetries {
|
||||
GoLog("[HTTP] Rate limited, waiting %v before retry...\n", delay)
|
||||
time.Sleep(delay)
|
||||
if err := sleepRetry(req.Context(), delay); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
delay = calculateNextDelay(delay, config)
|
||||
}
|
||||
continue
|
||||
@@ -336,6 +342,10 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
|
||||
return nil, fmt.Errorf("ISP blocking detected for %s (HTTP %d) - try using VPN or change DNS", req.URL.Host, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// No blocking marker: hand the caller back a readable body in
|
||||
// place of the one consumed by the scan above.
|
||||
resp.Body = io.NopCloser(bytes.NewReader(body))
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 500 {
|
||||
@@ -346,7 +356,9 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
|
||||
lastErr = fmt.Errorf("server error: HTTP %d", resp.StatusCode)
|
||||
if attempt < config.MaxRetries {
|
||||
GoLog("[HTTP] Server error %d, retrying in %v...\n", resp.StatusCode, delay)
|
||||
time.Sleep(delay)
|
||||
if err := sleepRetry(req.Context(), delay); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
delay = calculateNextDelay(delay, config)
|
||||
}
|
||||
continue
|
||||
@@ -358,6 +370,19 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
|
||||
return nil, fmt.Errorf("request failed after %d retries: %w", config.MaxRetries+1, lastErr)
|
||||
}
|
||||
|
||||
// sleepRetry waits out a retry delay, aborting early when the request context
|
||||
// is cancelled so a cancelled download never sits in a backoff sleep.
|
||||
func sleepRetry(ctx context.Context, d time.Duration) error {
|
||||
timer := time.NewTimer(d)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-timer.C:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// jitterFloat returns a fraction in [0,1); overridable in tests for
|
||||
// deterministic backoff assertions.
|
||||
var jitterFloat = rand.Float64
|
||||
@@ -374,6 +399,10 @@ func calculateNextDelay(currentDelay time.Duration, config RetryConfig) time.Dur
|
||||
return config.InitialDelay + time.Duration(jitterFloat()*float64(span))
|
||||
}
|
||||
|
||||
// maxRetryAfterDelay caps honored Retry-After values so a hostile or
|
||||
// misconfigured server cannot park a retry loop for an hour.
|
||||
const maxRetryAfterDelay = 2 * time.Minute
|
||||
|
||||
// Returns 0 if the header is missing or invalid so callers can keep their
|
||||
// normal exponential backoff instead of stalling for an arbitrary minute.
|
||||
func getRetryAfterDuration(resp *http.Response) time.Duration {
|
||||
@@ -383,13 +412,13 @@ func getRetryAfterDuration(resp *http.Response) time.Duration {
|
||||
}
|
||||
|
||||
if seconds, err := strconv.Atoi(retryAfter); err == nil {
|
||||
return time.Duration(seconds) * time.Second
|
||||
return min(time.Duration(seconds)*time.Second, maxRetryAfterDelay)
|
||||
}
|
||||
|
||||
if t, err := http.ParseTime(retryAfter); err == nil {
|
||||
duration := time.Until(t)
|
||||
if duration > 0 {
|
||||
return duration
|
||||
return min(duration, maxRetryAfterDelay)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ func GetCloudflareBypassClient() *http.Client {
|
||||
return sharedClient
|
||||
}
|
||||
|
||||
func closeUTLSIdleConnections() {}
|
||||
|
||||
func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
|
||||
req.Header.Set("User-Agent", userAgentForURL(req.URL))
|
||||
resp, err := sharedClient.Do(req)
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestRetryHardening(t *testing.T) {
|
||||
resp := &http.Response{Header: http.Header{"Retry-After": []string{"3600"}}}
|
||||
if d := getRetryAfterDuration(resp); d != maxRetryAfterDelay {
|
||||
t.Fatalf("Retry-After 3600s clamped to %v, want %v", d, maxRetryAfterDelay)
|
||||
}
|
||||
|
||||
// A 403 without ISP-blocking markers must reach the caller with a
|
||||
// readable body even though the marker scan consumed the original.
|
||||
client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: 403,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader("quota exceeded")),
|
||||
Request: req,
|
||||
}, nil
|
||||
})}
|
||||
got, err := DoRequestWithRetry(client, mustNewRequest(t, "https://example.com/x"), DefaultRetryConfig())
|
||||
if err != nil || got.StatusCode != 403 {
|
||||
t.Fatalf("DoRequestWithRetry = %#v/%v", got, err)
|
||||
}
|
||||
body, err := io.ReadAll(got.Body)
|
||||
got.Body.Close()
|
||||
if err != nil || string(body) != "quota exceeded" {
|
||||
t.Fatalf("403 body = %q/%v, want restored body", body, err)
|
||||
}
|
||||
|
||||
// Backoff sleeps must abort on context cancellation.
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
go func() {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
cancel()
|
||||
}()
|
||||
start := time.Now()
|
||||
if err := sleepRetry(ctx, 5*time.Second); err == nil {
|
||||
t.Fatal("sleepRetry ignored cancellation")
|
||||
}
|
||||
if time.Since(start) > time.Second {
|
||||
t.Fatal("sleepRetry did not abort promptly on cancel")
|
||||
}
|
||||
}
|
||||
@@ -111,7 +111,7 @@ func rewindRequestBody(req *http.Request) (*http.Request, bool) {
|
||||
// dial opens a TCP connection and completes the Chrome-fingerprint TLS handshake,
|
||||
// returning the connection and negotiated ALPN protocol.
|
||||
func (t *utlsTransport) dial(ctx context.Context, host, addr string) (*utls.UConn, string, error) {
|
||||
conn, err := t.dialer.DialContext(ctx, "tcp", addr)
|
||||
conn, err := dialWithDoHFallback(ctx, t.dialer, "tcp", addr)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
@@ -163,6 +163,26 @@ func (t *utlsTransport) storeConn(addr string, cc *http2.ClientConn) *http2.Clie
|
||||
return cc
|
||||
}
|
||||
|
||||
// closeIdleConnections drops every pooled conn so the next request re-dials —
|
||||
// needed after a network switch, where pooled conns are silently dead and the
|
||||
// first request would otherwise hang on one until its timeout. Conns are shut
|
||||
// down gracefully so in-flight streams finish (or fail) before the close.
|
||||
func (t *utlsTransport) closeIdleConnections() {
|
||||
t.mu.Lock()
|
||||
conns := t.conns
|
||||
t.conns = make(map[string]*http2.ClientConn)
|
||||
t.mu.Unlock()
|
||||
for _, cc := range conns {
|
||||
go cc.Shutdown(context.Background())
|
||||
}
|
||||
}
|
||||
|
||||
// closeUTLSIdleConnections lets platform-neutral code (CloseIdleConnections)
|
||||
// reach the uTLS pool; the ios build provides a no-op stub.
|
||||
func closeUTLSIdleConnections() {
|
||||
cloudflareBypassTransport.closeIdleConnections()
|
||||
}
|
||||
|
||||
func (t *utlsTransport) getPort(u *url.URL) string {
|
||||
if u.Port() != "" {
|
||||
return u.Port()
|
||||
|
||||
Reference in New Issue
Block a user