From cdc1ba0a68863a263713250b9e9024c40f39628f Mon Sep 17 00:00:00 2001 From: zarzet Date: Tue, 14 Jul 2026 12:49:43 +0700 Subject: [PATCH] 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 --- go_backend/dns_doh.go | 237 ++++++++++++++++++++++++++++++ go_backend/httputil.go | 63 +++++--- go_backend/httputil_ios.go | 2 + go_backend/httputil_retry_test.go | 51 +++++++ go_backend/httputil_utls.go | 22 ++- 5 files changed, 357 insertions(+), 18 deletions(-) create mode 100644 go_backend/dns_doh.go create mode 100644 go_backend/httputil_retry_test.go diff --git a/go_backend/dns_doh.go b/go_backend/dns_doh.go new file mode 100644 index 00000000..87ccaeae --- /dev/null +++ b/go_backend/dns_doh.go @@ -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)} +} diff --git a/go_backend/httputil.go b/go_backend/httputil.go index b2c333b9..4c225154 100644 --- a/go_backend/httputil.go +++ b/go_backend/httputil.go @@ -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) } } diff --git a/go_backend/httputil_ios.go b/go_backend/httputil_ios.go index 43f41aae..58acba1e 100644 --- a/go_backend/httputil_ios.go +++ b/go_backend/httputil_ios.go @@ -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) diff --git a/go_backend/httputil_retry_test.go b/go_backend/httputil_retry_test.go new file mode 100644 index 00000000..3676cea3 --- /dev/null +++ b/go_backend/httputil_retry_test.go @@ -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") + } +} diff --git a/go_backend/httputil_utls.go b/go_backend/httputil_utls.go index 346857a1..db6fd3f7 100644 --- a/go_backend/httputil_utls.go +++ b/go_backend/httputil_utls.go @@ -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()