Files
SpotiFLAC-Mobile/go_backend/httputil_utls.go
T

338 lines
8.6 KiB
Go

//go:build !ios
package gobackend
import (
"context"
"io"
"net"
"net/http"
"net/url"
"strings"
"sync"
"time"
utls "github.com/refraction-networking/utls"
"golang.org/x/net/http2"
)
// utlsSessionCache is shared by every uTLS handshake so TLS 1.3 tickets enable
// resumption (fewer round-trips) across requests and hosts.
var utlsSessionCache = utls.NewLRUClientSessionCache(0)
// utlsTransport dials with a Chrome TLS fingerprint and pools one healthy HTTP/2
// connection per host, re-dialing when it dies (e.g. after a network switch).
type utlsTransport struct {
dialer *net.Dialer
h2 *http2.Transport
mu sync.Mutex
conns map[string]pooledHTTP2ClientConn
}
type pooledHTTP2ClientConn interface {
RoundTrip(*http.Request) (*http.Response, error)
ReserveNewRequest() bool
State() http2.ClientConnState
Close() error
Shutdown(context.Context) error
}
func newUTLSTransport() *utlsTransport {
return &utlsTransport{
dialer: &net.Dialer{
Timeout: 10 * Second,
KeepAlive: 30 * Second,
},
h2: &http2.Transport{},
conns: make(map[string]pooledHTTP2ClientConn),
}
}
func (t *utlsTransport) RoundTrip(req *http.Request) (*http.Response, error) {
if req.URL.Scheme != "https" {
return sharedTransport.RoundTrip(req)
}
host := req.URL.Hostname()
addr := net.JoinHostPort(host, t.getPort(req.URL))
if cc := t.cachedConn(addr); cc != nil {
resp, err := cc.RoundTrip(req)
if err == nil {
return resp, nil
}
if req.Context().Err() != nil {
return nil, err
}
// A pooled conn can be silently dead after a network switch. Drop it
// and, when the request is safely repeatable, fall through to a fresh
// dial instead of failing where the old dial-per-request code would
// have succeeded.
t.invalidate(addr, cc)
retryReq, ok := rewindRequestBody(req)
if !ok {
return nil, err
}
req = retryReq
}
tlsConn, proto, err := t.dial(req.Context(), host, addr)
if err != nil {
return nil, err
}
if proto != "h2" {
// HTTP/1.1: single-use conn closed once the body is drained.
transport := &http.Transport{
DialTLSContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
return tlsConn, nil
},
DisableKeepAlives: true,
}
return transport.RoundTrip(req)
}
cc, err := t.h2.NewClientConn(tlsConn)
if err != nil {
tlsConn.Close()
return nil, err
}
pooled := t.storeConn(addr, cc)
return pooled.RoundTrip(req)
}
// rewindRequestBody returns a request whose body can be sent again after a
// failed attempt on a pooled connection: bodyless requests as-is, requests
// with GetBody with a rebuilt body, anything else not ok.
func rewindRequestBody(req *http.Request) (*http.Request, bool) {
if req.Body == nil {
return req, true
}
if req.GetBody == nil {
return nil, false
}
body, err := req.GetBody()
if err != nil {
return nil, false
}
retryReq := req.Clone(req.Context())
retryReq.Body = body
return retryReq, true
}
// 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 := dialWithDoHFallback(ctx, t.dialer, "tcp", addr)
if err != nil {
return nil, "", err
}
opts := GetNetworkCompatibilityOptions()
tlsConn := utls.UClient(conn, &utls.Config{
RootCAs: supplementalRootCAs(),
InsecureSkipVerify: opts.InsecureTLS,
ServerName: host,
NextProtos: []string{"h2", "http/1.1"},
ClientSessionCache: utlsSessionCache,
}, utls.HelloChrome_Auto)
if err := tlsConn.Handshake(); err != nil {
conn.Close()
return nil, "", err
}
return tlsConn, tlsConn.ConnectionState().NegotiatedProtocol, nil
}
func (t *utlsTransport) cachedConn(addr string) pooledHTTP2ClientConn {
t.mu.Lock()
cc := t.conns[addr]
if cc != nil && cc.ReserveNewRequest() {
t.mu.Unlock()
return cc
}
if cc != nil {
delete(t.conns, addr)
}
t.mu.Unlock()
if cc != nil {
retirePooledHTTP2Conn(cc)
}
return nil
}
func (t *utlsTransport) invalidate(addr string, cc pooledHTTP2ClientConn) {
t.mu.Lock()
removed := false
if t.conns[addr] == cc {
delete(t.conns, addr)
removed = true
}
t.mu.Unlock()
if removed {
retirePooledHTTP2Conn(cc)
}
}
// storeConn caches cc, but if a concurrent dial already cached a healthy conn for
// addr it discards the freshly built cc (no in-flight requests) and returns the
// existing one, avoiding a leaked connection.
func (t *utlsTransport) storeConn(addr string, cc pooledHTTP2ClientConn) pooledHTTP2ClientConn {
t.mu.Lock()
if existing := t.conns[addr]; existing != nil && existing.ReserveNewRequest() {
t.mu.Unlock()
_ = cc.Close()
return existing
}
stale := t.conns[addr]
t.conns[addr] = cc
_ = cc.ReserveNewRequest()
t.mu.Unlock()
if stale != nil {
retirePooledHTTP2Conn(stale)
}
return cc
}
// retirePooledHTTP2Conn prevents new streams while allowing existing streams
// to finish. The independent watchdog also bounds Shutdown implementations
// that block before observing their context.
func pooledHTTP2RetirementTimeout(state http2.ClientConnState) time.Duration {
if state.StreamsActive > 0 || state.StreamsPending > 0 || state.StreamsReserved > 0 {
return 0
}
return 5 * Second
}
func retirePooledHTTP2Conn(conn pooledHTTP2ClientConn) {
go func() {
retirePooledHTTP2ConnWithTimeout(conn, pooledHTTP2RetirementTimeout(conn.State()))
}()
}
func retirePooledHTTP2ConnWithTimeout(conn pooledHTTP2ClientConn, timeout time.Duration) {
go func() {
var closeOnce sync.Once
forceClose := func() {
closeOnce.Do(func() { _ = conn.Close() })
}
if timeout <= 0 {
if err := conn.Shutdown(context.Background()); err != nil {
forceClose()
}
return
}
watchdogDone := make(chan struct{})
watchdog := time.AfterFunc(timeout, func() {
forceClose()
close(watchdogDone)
})
if err := conn.Shutdown(context.Background()); err != nil {
forceClose()
}
if !watchdog.Stop() {
<-watchdogDone
}
}()
}
// 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]pooledHTTP2ClientConn)
t.mu.Unlock()
for _, cc := range conns {
retirePooledHTTP2Conn(cc)
}
}
// 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()
}
if u.Scheme == "https" {
return "443"
}
return "80"
}
var cloudflareBypassTransport = newUTLSTransport()
var cloudflareBypassClient = &http.Client{
Transport: cloudflareBypassTransport,
Timeout: DefaultTimeout,
}
func GetCloudflareBypassClient() *http.Client {
return cloudflareBypassClient
}
func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
req.Header.Set("User-Agent", userAgentForURL(req.URL))
resp, err := sharedClient.Do(req)
if err == nil {
if resp.StatusCode == 403 || resp.StatusCode == 503 {
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr == nil {
bodyStr := strings.ToLower(string(body))
cloudflareMarkers := []string{
"cloudflare", "cf-ray", "checking your browser",
"please wait", "ddos protection", "ray id",
"enable javascript", "challenge-platform",
}
isCloudflare := false
for _, marker := range cloudflareMarkers {
if strings.Contains(bodyStr, marker) {
isCloudflare = true
break
}
}
if isCloudflare {
LogDebug("HTTP", "Cloudflare detected, retrying with Chrome TLS fingerprint...")
reqCopy := req.Clone(req.Context())
reqCopy.Header.Set("User-Agent", userAgentForURL(reqCopy.URL))
return cloudflareBypassClient.Do(reqCopy)
}
}
return &http.Response{
Status: resp.Status,
StatusCode: resp.StatusCode,
Header: resp.Header,
Body: io.NopCloser(strings.NewReader(string(body))),
}, nil
}
return resp, nil
}
if isTLSHandshakeOrResetError(err) {
LogDebug("HTTP", "TLS error detected, retrying with Chrome TLS fingerprint: %v", err)
reqCopy := req.Clone(req.Context())
reqCopy.Header.Set("User-Agent", userAgentForURL(reqCopy.URL))
return cloudflareBypassClient.Do(reqCopy)
}
CheckAndLogISPBlocking(err, req.URL.String(), "HTTP")
return nil, err
}