Scope HTTP/2 fallback and HTTP/3 broken state per authority

This commit is contained in:
世界
2026-04-17 13:29:31 +08:00
parent 9bffb64bbd
commit 4b15fccb78
6 changed files with 295 additions and 40 deletions
+30
View File
@@ -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
+51
View File
@@ -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)
}
})
}
+31 -17
View File
@@ -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))
}
@@ -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")
}
}
+47 -23
View File
@@ -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
}
+99
View File
@@ -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("")
}