Files

193 lines
5.6 KiB
Go

package app
import (
"context"
"crypto/tls"
"fmt"
"net"
"net/http"
"net/url"
"sync"
"time"
)
const cdnAlternateSubnet = "8.8.8.0/24"
type cdnRoute struct {
address string
expires time.Time
}
type cdnTransport struct {
base *http.Transport
resolver *safeDNSDialer
mu sync.Mutex
transports map[string]*http.Transport
preferred map[string]cdnRoute
}
func newCDNTransport(base *http.Transport, resolver *safeDNSDialer) *cdnTransport {
return &cdnTransport{base: base, resolver: resolver, transports: map[string]*http.Transport{}, preferred: map[string]cdnRoute{}}
}
func (transport *cdnTransport) RoundTrip(request *http.Request) (*http.Response, error) {
if request.URL.Scheme != "https" || !protectedCDNHost(request.URL.Hostname()) || (request.Method != http.MethodGet && request.Method != http.MethodHead) {
return transport.base.RoundTrip(request)
}
var proxyURL *url.URL
var err error
if transport.base.Proxy != nil {
proxyURL, err = transport.base.Proxy(request)
if err != nil {
return nil, err
}
}
routeKey := request.URL.Host
if proxyURL != nil {
routeKey += "|" + proxyURL.String()
}
transport.mu.Lock()
preferred := transport.preferred[routeKey]
transport.mu.Unlock()
tried := map[string]bool{}
var response *http.Response
if preferred.address != "" && time.Now().Before(preferred.expires) {
tried[preferred.address] = true
response, err = transport.roundTripAddress(request, proxyURL, routeKey, preferred.address)
if err == nil {
return response, nil
}
} else {
response, err = transport.base.RoundTrip(request)
if err == nil {
return response, nil
}
}
if request.Context().Err() != nil {
return nil, request.Context().Err()
}
lastErr := err
subnets := []string{cdnAlternateSubnet, ""}
for _, subnet := range subnets {
entry, lookupErr := transport.resolver.lookupEntry(request.Context(), request.URL.Hostname(), subnet)
if lookupErr != nil {
if request.Context().Err() != nil {
return nil, request.Context().Err()
}
continue
}
for _, address := range entry.addresses {
if tried[address] || len(tried) >= 4 {
continue
}
tried[address] = true
response, err = transport.roundTripAddress(request, proxyURL, routeKey, address)
if err == nil {
transport.mu.Lock()
transport.preferred[routeKey] = cdnRoute{address: address, expires: entry.expires}
transport.mu.Unlock()
return response, nil
}
if request.Context().Err() != nil {
return nil, request.Context().Err()
}
lastErr = err
}
}
return nil, fmt.Errorf("黄果 CDN %s 连接失败(已尝试安全 DNS 回退,未改变代理模式): %w", request.URL.Hostname(), publicError(lastErr))
}
func (transport *cdnTransport) roundTripAddress(request *http.Request, proxyURL *url.URL, routeKey, address string) (*http.Response, error) {
pinned := transport.pinnedTransport(request.URL.Hostname(), proxyURL, routeKey, address)
upstream := request.Clone(request.Context())
upstream.Host = request.Host
if upstream.Host == "" {
upstream.Host = request.URL.Host
}
upstream.URL.Host = address
if port := request.URL.Port(); port != "" {
upstream.URL.Host = net.JoinHostPort(address, port)
}
response, err := pinned.RoundTrip(upstream)
if response != nil {
response.Request = request
}
return response, err
}
func (transport *cdnTransport) pinnedTransport(host string, proxyURL *url.URL, routeKey, address string) *http.Transport {
transport.mu.Lock()
defer transport.mu.Unlock()
key := routeKey + "|" + address
if existing := transport.transports[key]; existing != nil {
return existing
}
pinned := transport.base.Clone()
if pinned.TLSClientConfig == nil {
pinned.TLSClientConfig = &tls.Config{}
}
pinned.TLSClientConfig.ServerName = host
pinned.Proxy = http.ProxyURL(proxyURL)
if proxyURL != nil && proxyURL.Scheme == "https" {
plainProxy := *proxyURL
plainProxy.Scheme = "http"
if plainProxy.Port() == "" {
plainProxy.Host = net.JoinHostPort(proxyURL.Hostname(), "443")
}
pinned.Proxy = http.ProxyURL(&plainProxy)
pinned.DialContext = transport.tlsProxyDialer(proxyURL.Hostname())
}
if len(transport.transports) >= 32 {
for expiredKey, expiredTransport := range transport.transports {
expiredTransport.CloseIdleConnections()
delete(transport.transports, expiredKey)
break
}
}
transport.transports[key] = pinned
return pinned
}
func (transport *cdnTransport) tlsProxyDialer(host string) func(context.Context, string, string) (net.Conn, error) {
return func(ctx context.Context, network, address string) (net.Conn, error) {
dial := transport.base.DialContext
if dial == nil {
dialer := &net.Dialer{Timeout: 10 * time.Second}
dial = dialer.DialContext
}
connection, err := dial(ctx, network, address)
if err != nil {
return nil, err
}
config := &tls.Config{}
if transport.base.TLSClientConfig != nil {
config = transport.base.TLSClientConfig.Clone()
}
config.ServerName = host
config.NextProtos = []string{"http/1.1"}
secured := tls.Client(connection, config)
if transport.base.TLSHandshakeTimeout > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, transport.base.TLSHandshakeTimeout)
defer cancel()
}
if err := secured.HandshakeContext(ctx); err != nil {
connection.Close()
return nil, err
}
return secured, nil
}
}
func (transport *cdnTransport) CloseIdleConnections() {
transport.base.CloseIdleConnections()
transport.resolver.client.CloseIdleConnections()
transport.mu.Lock()
defer transport.mu.Unlock()
for key, pinned := range transport.transports {
pinned.CloseIdleConnections()
delete(transport.transports, key)
}
transport.preferred = map[string]cdnRoute{}
}