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:
zarzet
2026-07-14 12:49:43 +07:00
parent bd9b98b942
commit cdc1ba0a68
5 changed files with 357 additions and 18 deletions
+237
View File
@@ -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
View File
@@ -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)
}
}
+2
View File
@@ -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)
+51
View File
@@ -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")
}
}
+21 -1
View File
@@ -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()