Scope HTTP/2 fallback and HTTP/3 broken state per authority
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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("")
|
||||
}
|
||||
Reference in New Issue
Block a user