From 4b15fccb787d915dfdf8a43bb230ce3f5553e987 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Fri, 17 Apr 2026 13:29:31 +0800 Subject: [PATCH] Scope HTTP/2 fallback and HTTP/3 broken state per authority --- common/httpclient/helpers.go | 30 ++++++ common/httpclient/helpers_test.go | 51 ++++++++++ common/httpclient/http2_fallback_transport.go | 48 +++++---- .../http2_fallback_transport_test.go | 37 +++++++ common/httpclient/http3_transport.go | 70 ++++++++----- common/httpclient/http3_transport_test.go | 99 +++++++++++++++++++ 6 files changed, 295 insertions(+), 40 deletions(-) create mode 100644 common/httpclient/helpers_test.go create mode 100644 common/httpclient/http2_fallback_transport_test.go create mode 100644 common/httpclient/http3_transport_test.go diff --git a/common/httpclient/helpers.go b/common/httpclient/helpers.go index cffc79719..7cc78cc6e 100644 --- a/common/httpclient/helpers.go +++ b/common/httpclient/helpers.go @@ -12,6 +12,8 @@ import ( E "github.com/sagernet/sing/common/exceptions" M "github.com/sagernet/sing/common/metadata" N "github.com/sagernet/sing/common/network" + + "golang.org/x/net/idna" ) func dialTLS(ctx context.Context, rawDialer N.Dialer, baseTLSConfig tls.Config, destination M.Socksaddr, nextProtos []string, expectProto string) (net.Conn, error) { @@ -73,6 +75,34 @@ func mustGetBody(request *http.Request) io.ReadCloser { return body } +func requestAuthority(request *http.Request) string { + if request == nil || request.URL == nil || request.URL.Host == "" { + return "" + } + host, port, err := net.SplitHostPort(request.URL.Host) + if err != nil { + host = request.URL.Host + port = "" + } + if port == "" { + if request.URL.Scheme == "http" { + port = "80" + } else { + port = "443" + } + } + if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") { + return host + ":" + port + } + ascii, idnaErr := idna.Lookup.ToASCII(host) + if idnaErr == nil { + host = ascii + } else { + host = strings.ToLower(host) + } + return net.JoinHostPort(host, port) +} + func buildSTDTLSConfig(baseTLSConfig tls.Config, destination M.Socksaddr, nextProtos []string) (*stdTLS.Config, error) { if baseTLSConfig == nil { return nil, nil diff --git a/common/httpclient/helpers_test.go b/common/httpclient/helpers_test.go new file mode 100644 index 000000000..2c451e0a5 --- /dev/null +++ b/common/httpclient/helpers_test.go @@ -0,0 +1,51 @@ +package httpclient + +import ( + "net/http" + "net/url" + "testing" +) + +func TestRequestAuthority(t *testing.T) { + testCases := []struct { + name string + url string + expect string + }{ + {name: "https default port", url: "https://example.com/foo", expect: "example.com:443"}, + {name: "http default port", url: "http://example.com/foo", expect: "example.com:80"}, + {name: "https explicit port", url: "https://example.com:8443/foo", expect: "example.com:8443"}, + {name: "https uppercase host", url: "https://EXAMPLE.COM/foo", expect: "example.com:443"}, + {name: "https ipv6 default port", url: "https://[2001:db8::1]/foo", expect: "[2001:db8::1]:443"}, + {name: "https ipv6 explicit port", url: "https://[2001:db8::1]:8443/foo", expect: "[2001:db8::1]:8443"}, + {name: "https ipv4", url: "https://192.0.2.1/foo", expect: "192.0.2.1:443"}, + } + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + parsed, err := url.Parse(testCase.url) + if err != nil { + t.Fatalf("parse url: %v", err) + } + got := requestAuthority(&http.Request{URL: parsed}) + if got != testCase.expect { + t.Fatalf("got %q, want %q", got, testCase.expect) + } + }) + } + + t.Run("nil request", func(t *testing.T) { + if got := requestAuthority(nil); got != "" { + t.Fatalf("got %q, want empty", got) + } + }) + t.Run("nil URL", func(t *testing.T) { + if got := requestAuthority(&http.Request{}); got != "" { + t.Fatalf("got %q, want empty", got) + } + }) + t.Run("empty host", func(t *testing.T) { + if got := requestAuthority(&http.Request{URL: &url.URL{Scheme: "https"}}); got != "" { + t.Fatalf("got %q, want empty", got) + } + }) +} diff --git a/common/httpclient/http2_fallback_transport.go b/common/httpclient/http2_fallback_transport.go index 5b16dff18..682b1ebad 100644 --- a/common/httpclient/http2_fallback_transport.go +++ b/common/httpclient/http2_fallback_transport.go @@ -6,7 +6,7 @@ import ( "errors" "net" "net/http" - "sync/atomic" + "sync" "github.com/sagernet/sing-box/common/tls" "github.com/sagernet/sing-box/option" @@ -20,35 +20,47 @@ import ( var errHTTP2Fallback = E.New("fallback to HTTP/1.1") type http2FallbackTransport struct { - h2Transport *http2.Transport - h1Transport *http1Transport - h2Fallback *atomic.Bool + h2Transport *http2.Transport + h1Transport *http1Transport + fallbackAccess sync.RWMutex + fallbackAuthority map[string]struct{} } func newHTTP2FallbackTransport(rawDialer N.Dialer, baseTLSConfig tls.Config, options option.HTTP2Options) (*http2FallbackTransport, error) { h1 := newHTTP1Transport(rawDialer, baseTLSConfig) - var fallback atomic.Bool h2Transport, err := ConfigureHTTP2Transport(options) if err != nil { return nil, err } h2Transport.DialTLSContext = func(ctx context.Context, network, addr string, _ *stdTLS.Config) (net.Conn, error) { - conn, dialErr := dialTLS(ctx, rawDialer, baseTLSConfig, M.ParseSocksaddr(addr), []string{http2.NextProtoTLS, "http/1.1"}, http2.NextProtoTLS) - if dialErr != nil { - if errors.Is(dialErr, errHTTP2Fallback) { - fallback.Store(true) - } - return nil, dialErr - } - return conn, nil + return dialTLS(ctx, rawDialer, baseTLSConfig, M.ParseSocksaddr(addr), []string{http2.NextProtoTLS, "http/1.1"}, http2.NextProtoTLS) } return &http2FallbackTransport{ - h2Transport: h2Transport, - h1Transport: h1, - h2Fallback: &fallback, + h2Transport: h2Transport, + h1Transport: h1, + fallbackAuthority: make(map[string]struct{}), }, nil } +func (t *http2FallbackTransport) isH2Fallback(authority string) bool { + if authority == "" { + return false + } + t.fallbackAccess.RLock() + _, found := t.fallbackAuthority[authority] + t.fallbackAccess.RUnlock() + return found +} + +func (t *http2FallbackTransport) markH2Fallback(authority string) { + if authority == "" { + return + } + t.fallbackAccess.Lock() + t.fallbackAuthority[authority] = struct{}{} + t.fallbackAccess.Unlock() +} + func (t *http2FallbackTransport) RoundTrip(request *http.Request) (*http.Response, error) { return t.roundTrip(request, true) } @@ -57,7 +69,8 @@ func (t *http2FallbackTransport) roundTrip(request *http.Request, allowHTTP1Fall if request.URL.Scheme != "https" || requestRequiresHTTP1(request) { return t.h1Transport.RoundTrip(request) } - if t.h2Fallback.Load() { + authority := requestAuthority(request) + if t.isH2Fallback(authority) { if !allowHTTP1Fallback { return nil, errHTTP2Fallback } @@ -70,6 +83,7 @@ func (t *http2FallbackTransport) roundTrip(request *http.Request, allowHTTP1Fall if !errors.Is(err, errHTTP2Fallback) || !allowHTTP1Fallback { return nil, err } + t.markH2Fallback(authority) return t.h1Transport.RoundTrip(cloneRequestForRetry(request)) } diff --git a/common/httpclient/http2_fallback_transport_test.go b/common/httpclient/http2_fallback_transport_test.go new file mode 100644 index 000000000..2c2085c86 --- /dev/null +++ b/common/httpclient/http2_fallback_transport_test.go @@ -0,0 +1,37 @@ +package httpclient + +import ( + "testing" +) + +func TestHTTP2FallbackAuthorityIsolation(t *testing.T) { + transport := &http2FallbackTransport{fallbackAuthority: make(map[string]struct{})} + + transport.markH2Fallback("a.example:443") + if !transport.isH2Fallback("a.example:443") { + t.Fatal("a.example:443 should be marked") + } + if transport.isH2Fallback("b.example:443") { + t.Fatal("b.example:443 must remain unmarked after marking a.example") + } + + transport.markH2Fallback("b.example:443") + if !transport.isH2Fallback("b.example:443") { + t.Fatal("b.example:443 should be marked after explicit mark") + } + if !transport.isH2Fallback("a.example:443") { + t.Fatal("a.example:443 mark must survive marking another authority") + } +} + +func TestHTTP2FallbackEmptyAuthorityNoOp(t *testing.T) { + transport := &http2FallbackTransport{fallbackAuthority: make(map[string]struct{})} + + transport.markH2Fallback("") + if len(transport.fallbackAuthority) != 0 { + t.Fatalf("empty authority must not be stored, got %d entries", len(transport.fallbackAuthority)) + } + if transport.isH2Fallback("") { + t.Fatal("isH2Fallback must be false for empty authority") + } +} diff --git a/common/httpclient/http3_transport.go b/common/httpclient/http3_transport.go index 0b8855d7c..d3eb5bc15 100644 --- a/common/httpclient/http3_transport.go +++ b/common/httpclient/http3_transport.go @@ -24,13 +24,17 @@ type http3Transport struct { h3Transport *http3.Transport } +type http3BrokenEntry struct { + until time.Time + backoff time.Duration +} + type http3FallbackTransport struct { h3Transport *http3.Transport h2Fallback innerTransport fallbackDelay time.Duration brokenAccess sync.Mutex - brokenUntil time.Time - brokenBackoff time.Duration + broken map[string]http3BrokenEntry } func newHTTP3RoundTripper( @@ -114,6 +118,7 @@ func newHTTP3FallbackTransport( h3Transport: newHTTP3RoundTripper(rawDialer, baseTLSConfig, options), h2Fallback: h2Fallback, fallbackDelay: fallbackDelay, + broken: make(map[string]http3BrokenEntry), }, nil } @@ -138,31 +143,32 @@ func (t *http3FallbackTransport) RoundTrip(request *http.Request) (*http.Respons } func (t *http3FallbackTransport) roundTripHTTP3(request *http.Request) (*http.Response, error) { - if t.h3Broken() { + authority := requestAuthority(request) + if t.h3Broken(authority) { return t.h2FallbackRoundTrip(request) } response, err := t.h3Transport.RoundTripOpt(request, http3.RoundTripOpt{OnlyCachedConn: true}) if err == nil { - t.clearH3Broken() + t.clearH3Broken(authority) return response, nil } if !errors.Is(err, http3.ErrNoCachedConn) { - t.markH3Broken() + t.markH3Broken(authority) return t.h2FallbackRoundTrip(cloneRequestForRetry(request)) } if !requestReplayable(request) { response, err = t.h3Transport.RoundTrip(request) if err == nil { - t.clearH3Broken() + t.clearH3Broken(authority) return response, nil } - t.markH3Broken() + t.markH3Broken(authority) return nil, err } - return t.roundTripHTTP3Race(request) + return t.roundTripHTTP3Race(request, authority) } -func (t *http3FallbackTransport) roundTripHTTP3Race(request *http.Request) (*http.Response, error) { +func (t *http3FallbackTransport) roundTripHTTP3Race(request *http.Request, authority string) (*http.Response, error) { ctx, cancel := context.WithCancel(request.Context()) defer cancel() type result struct { @@ -215,13 +221,13 @@ func (t *http3FallbackTransport) roundTripHTTP3Race(request *http.Request) (*htt received++ if raceResult.err == nil { if raceResult.h3 { - t.clearH3Broken() + t.clearH3Broken(authority) } drainRemaining() return raceResult.response, nil } if raceResult.h3 { - t.markH3Broken() + t.markH3Broken(authority) h3Err = raceResult.err if goroutines == 1 { goroutines++ @@ -269,29 +275,47 @@ func (t *http3FallbackTransport) Close() error { return t.h3Transport.Close() } -func (t *http3FallbackTransport) h3Broken() bool { +func (t *http3FallbackTransport) h3Broken(authority string) bool { + if authority == "" { + return false + } t.brokenAccess.Lock() defer t.brokenAccess.Unlock() - return !t.brokenUntil.IsZero() && time.Now().Before(t.brokenUntil) + entry, found := t.broken[authority] + if !found { + return false + } + if entry.until.IsZero() || !time.Now().Before(entry.until) { + delete(t.broken, authority) + return false + } + return true } -func (t *http3FallbackTransport) clearH3Broken() { +func (t *http3FallbackTransport) clearH3Broken(authority string) { + if authority == "" { + return + } t.brokenAccess.Lock() - t.brokenUntil = time.Time{} - t.brokenBackoff = 0 + delete(t.broken, authority) t.brokenAccess.Unlock() } -func (t *http3FallbackTransport) markH3Broken() { +func (t *http3FallbackTransport) markH3Broken(authority string) { + if authority == "" { + return + } t.brokenAccess.Lock() defer t.brokenAccess.Unlock() - if t.brokenBackoff == 0 { - t.brokenBackoff = 5 * time.Minute + entry := t.broken[authority] + if entry.backoff == 0 { + entry.backoff = 5 * time.Minute } else { - t.brokenBackoff *= 2 - if t.brokenBackoff > 48*time.Hour { - t.brokenBackoff = 48 * time.Hour + entry.backoff *= 2 + if entry.backoff > 48*time.Hour { + entry.backoff = 48 * time.Hour } } - t.brokenUntil = time.Now().Add(t.brokenBackoff) + entry.until = time.Now().Add(entry.backoff) + t.broken[authority] = entry } diff --git a/common/httpclient/http3_transport_test.go b/common/httpclient/http3_transport_test.go new file mode 100644 index 000000000..600e88db0 --- /dev/null +++ b/common/httpclient/http3_transport_test.go @@ -0,0 +1,99 @@ +//go:build with_quic + +package httpclient + +import ( + "testing" + "time" +) + +func TestHTTP3BrokenAuthorityIsolation(t *testing.T) { + transport := &http3FallbackTransport{broken: make(map[string]http3BrokenEntry)} + + transport.markH3Broken("a.example:443") + if !transport.h3Broken("a.example:443") { + t.Fatal("a.example:443 should be broken after mark") + } + if transport.h3Broken("b.example:443") { + t.Fatal("b.example:443 must not be affected by marking a.example") + } +} + +func TestHTTP3BrokenBackoffPerAuthority(t *testing.T) { + transport := &http3FallbackTransport{broken: make(map[string]http3BrokenEntry)} + + transport.markH3Broken("a.example:443") + if transport.broken["a.example:443"].backoff != 5*time.Minute { + t.Fatalf("first mark should set backoff to 5m, got %v", transport.broken["a.example:443"].backoff) + } + transport.markH3Broken("a.example:443") + if transport.broken["a.example:443"].backoff != 10*time.Minute { + t.Fatalf("second mark should double backoff to 10m, got %v", transport.broken["a.example:443"].backoff) + } + transport.markH3Broken("a.example:443") + if transport.broken["a.example:443"].backoff != 20*time.Minute { + t.Fatalf("third mark should double to 20m, got %v", transport.broken["a.example:443"].backoff) + } + + if _, found := transport.broken["b.example:443"]; found { + t.Fatal("marking a.example must not leak into b.example backoff state") + } + + transport.markH3Broken("b.example:443") + if transport.broken["b.example:443"].backoff != 5*time.Minute { + t.Fatalf("b.example first mark should start at 5m independent of a.example, got %v", transport.broken["b.example:443"].backoff) + } +} + +func TestHTTP3BrokenBackoffCap(t *testing.T) { + transport := &http3FallbackTransport{broken: make(map[string]http3BrokenEntry)} + + transport.broken["a.example:443"] = http3BrokenEntry{backoff: 48 * time.Hour, until: time.Now().Add(48 * time.Hour)} + transport.markH3Broken("a.example:443") + if transport.broken["a.example:443"].backoff != 48*time.Hour { + t.Fatalf("backoff must cap at 48h, got %v", transport.broken["a.example:443"].backoff) + } +} + +func TestHTTP3BrokenClearDeletesEntry(t *testing.T) { + transport := &http3FallbackTransport{broken: make(map[string]http3BrokenEntry)} + + transport.markH3Broken("a.example:443") + transport.markH3Broken("b.example:443") + transport.clearH3Broken("a.example:443") + + if _, found := transport.broken["a.example:443"]; found { + t.Fatal("clearH3Broken must delete the entry") + } + if !transport.h3Broken("b.example:443") { + t.Fatal("clearing a.example must not affect b.example") + } +} + +func TestHTTP3BrokenExpiredEntryGarbageCollected(t *testing.T) { + transport := &http3FallbackTransport{broken: make(map[string]http3BrokenEntry)} + + transport.broken["a.example:443"] = http3BrokenEntry{ + backoff: 5 * time.Minute, + until: time.Now().Add(-time.Second), + } + if transport.h3Broken("a.example:443") { + t.Fatal("expired entry must report not broken") + } + if _, found := transport.broken["a.example:443"]; found { + t.Fatal("expired entry must be garbage-collected on read") + } +} + +func TestHTTP3BrokenEmptyAuthorityNoOp(t *testing.T) { + transport := &http3FallbackTransport{broken: make(map[string]http3BrokenEntry)} + + transport.markH3Broken("") + if len(transport.broken) != 0 { + t.Fatalf("markH3Broken must ignore empty authority, got %d entries", len(transport.broken)) + } + if transport.h3Broken("") { + t.Fatal("h3Broken must return false for empty authority") + } + transport.clearH3Broken("") +}