mirror of
https://github.com/aykhans/sarin.git
synced 2026-04-18 13:49:37 +00:00
324 lines
9.0 KiB
Go
324 lines
9.0 KiB
Go
package sarin
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"crypto/tls"
|
|
"encoding/base64"
|
|
"math"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"time"
|
|
|
|
"github.com/valyala/fasthttp"
|
|
"github.com/valyala/fasthttp/fasthttpproxy"
|
|
"go.aykhans.me/sarin/internal/types"
|
|
utilsSlice "go.aykhans.me/utils/slice"
|
|
"golang.org/x/net/proxy"
|
|
)
|
|
|
|
type HostClientGenerator func() *fasthttp.HostClient
|
|
|
|
func safeUintToInt(u uint) int {
|
|
if u > math.MaxInt {
|
|
return math.MaxInt
|
|
}
|
|
return int(u)
|
|
}
|
|
|
|
// NewHostClients creates a list of fasthttp.HostClient instances for the given proxies.
|
|
// If no proxies are provided, a single client without a proxy is returned.
|
|
// It can return the following errors:
|
|
// - types.ProxyDialError
|
|
func NewHostClients(
|
|
ctx context.Context,
|
|
timeout time.Duration,
|
|
proxies []url.URL,
|
|
maxConns uint,
|
|
requestURL *url.URL,
|
|
skipVerify bool,
|
|
) ([]*fasthttp.HostClient, error) {
|
|
isTLS := requestURL.Scheme == "https"
|
|
|
|
if proxiesLen := len(proxies); proxiesLen > 0 {
|
|
clients := make([]*fasthttp.HostClient, 0, proxiesLen)
|
|
addr := requestURL.Host
|
|
if isTLS && requestURL.Port() == "" {
|
|
addr += ":443"
|
|
}
|
|
|
|
for _, proxy := range proxies {
|
|
dialFunc, err := NewProxyDialFunc(ctx, &proxy, timeout)
|
|
if err != nil {
|
|
return nil, types.NewProxyDialError(proxy.String(), err)
|
|
}
|
|
|
|
clients = append(clients, &fasthttp.HostClient{
|
|
MaxConns: safeUintToInt(maxConns),
|
|
IsTLS: isTLS,
|
|
TLSConfig: &tls.Config{
|
|
InsecureSkipVerify: skipVerify, //nolint:gosec
|
|
},
|
|
Addr: addr,
|
|
Dial: dialFunc,
|
|
MaxIdleConnDuration: timeout,
|
|
MaxConnDuration: timeout,
|
|
WriteTimeout: timeout,
|
|
ReadTimeout: timeout,
|
|
DisableHeaderNamesNormalizing: true,
|
|
DisablePathNormalizing: true,
|
|
NoDefaultUserAgentHeader: true,
|
|
},
|
|
)
|
|
}
|
|
|
|
return clients, nil
|
|
}
|
|
|
|
client := &fasthttp.HostClient{
|
|
MaxConns: safeUintToInt(maxConns),
|
|
IsTLS: isTLS,
|
|
TLSConfig: &tls.Config{
|
|
InsecureSkipVerify: skipVerify, //nolint:gosec
|
|
},
|
|
Addr: requestURL.Host,
|
|
MaxIdleConnDuration: timeout,
|
|
MaxConnDuration: timeout,
|
|
WriteTimeout: timeout,
|
|
ReadTimeout: timeout,
|
|
DisableHeaderNamesNormalizing: true,
|
|
DisablePathNormalizing: true,
|
|
NoDefaultUserAgentHeader: true,
|
|
}
|
|
return []*fasthttp.HostClient{client}, nil
|
|
}
|
|
|
|
// NewProxyDialFunc creates a dial function for the given proxy URL.
|
|
// It can return the following errors:
|
|
// - types.ProxyUnsupportedSchemeError
|
|
func NewProxyDialFunc(ctx context.Context, proxyURL *url.URL, timeout time.Duration) (fasthttp.DialFunc, error) {
|
|
var (
|
|
dialer fasthttp.DialFunc
|
|
err error
|
|
)
|
|
|
|
switch proxyURL.Scheme {
|
|
case "socks5":
|
|
dialer, err = fasthttpSocksDialerDualStackTimeout(ctx, proxyURL, timeout, true)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
case "socks5h":
|
|
dialer, err = fasthttpSocksDialerDualStackTimeout(ctx, proxyURL, timeout, false)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
case "http":
|
|
dialer = fasthttpproxy.FasthttpHTTPDialerDualStackTimeout(proxyURL.String(), timeout)
|
|
case "https":
|
|
dialer = fasthttpHTTPSDialerDualStackTimeout(proxyURL, timeout)
|
|
default:
|
|
return nil, types.NewProxyUnsupportedSchemeError(proxyURL.Scheme)
|
|
}
|
|
|
|
return dialer, nil
|
|
}
|
|
|
|
// The returned dial function can return the following errors:
|
|
// - types.ProxyDialError
|
|
func fasthttpSocksDialerDualStackTimeout(ctx context.Context, proxyURL *url.URL, timeout time.Duration, resolveLocally bool) (fasthttp.DialFunc, error) {
|
|
netDialer := &net.Dialer{}
|
|
|
|
// Parse auth from proxy URL if present
|
|
var auth *proxy.Auth
|
|
if proxyURL.User != nil {
|
|
auth = &proxy.Auth{
|
|
User: proxyURL.User.Username(),
|
|
}
|
|
if password, ok := proxyURL.User.Password(); ok {
|
|
auth.Password = password
|
|
}
|
|
}
|
|
|
|
// Create SOCKS5 dialer with net.Dialer as forward dialer
|
|
socksDialer, err := proxy.SOCKS5("tcp", proxyURL.Host, auth, netDialer)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
proxyStr := proxyURL.String()
|
|
|
|
// Assert to ContextDialer for timeout support
|
|
contextDialer, ok := socksDialer.(proxy.ContextDialer)
|
|
if !ok {
|
|
// Fallback without timeout (should not happen with net.Dialer)
|
|
return func(addr string) (net.Conn, error) {
|
|
conn, err := socksDialer.Dial("tcp", addr)
|
|
if err != nil {
|
|
return nil, types.NewProxyDialError(proxyStr, err)
|
|
}
|
|
return conn, nil
|
|
}, nil
|
|
}
|
|
|
|
// Return dial function that uses context with timeout
|
|
return func(addr string) (net.Conn, error) {
|
|
deadline := time.Now().Add(timeout)
|
|
|
|
if resolveLocally {
|
|
host, port, err := net.SplitHostPort(addr)
|
|
if err != nil {
|
|
return nil, types.NewProxyDialError(proxyStr, err)
|
|
}
|
|
|
|
dnsCtx, dnsCancel := context.WithTimeout(ctx, timeout)
|
|
ips, err := net.DefaultResolver.LookupIP(dnsCtx, "ip", host)
|
|
dnsCancel()
|
|
if err != nil {
|
|
return nil, types.NewProxyDialError(proxyStr, err)
|
|
}
|
|
if len(ips) == 0 {
|
|
return nil, types.NewProxyDialError(proxyStr, types.NewProxyResolveError(host))
|
|
}
|
|
|
|
// Use the first resolved IP
|
|
addr = net.JoinHostPort(ips[0].String(), port)
|
|
}
|
|
|
|
// Use remaining time for dial
|
|
remaining := time.Until(deadline)
|
|
if remaining <= 0 {
|
|
return nil, types.NewProxyDialError(proxyStr, context.DeadlineExceeded)
|
|
}
|
|
|
|
dialCtx, dialCancel := context.WithTimeout(ctx, remaining)
|
|
defer dialCancel()
|
|
|
|
conn, err := contextDialer.DialContext(dialCtx, "tcp", addr)
|
|
if err != nil {
|
|
return nil, types.NewProxyDialError(proxyStr, err)
|
|
}
|
|
return conn, nil
|
|
}, nil
|
|
}
|
|
|
|
// The returned dial function can return the following errors:
|
|
// - types.ProxyDialError
|
|
func fasthttpHTTPSDialerDualStackTimeout(proxyURL *url.URL, timeout time.Duration) fasthttp.DialFunc {
|
|
proxyAddr := proxyURL.Host
|
|
if proxyURL.Port() == "" {
|
|
proxyAddr = net.JoinHostPort(proxyURL.Hostname(), "443")
|
|
}
|
|
|
|
// Build Proxy-Authorization header if auth is present
|
|
var proxyAuth string
|
|
if proxyURL.User != nil {
|
|
username := proxyURL.User.Username()
|
|
password, _ := proxyURL.User.Password()
|
|
credentials := username + ":" + password
|
|
proxyAuth = "Basic " + base64.StdEncoding.EncodeToString([]byte(credentials))
|
|
}
|
|
|
|
proxyStr := proxyURL.String()
|
|
|
|
return func(addr string) (net.Conn, error) {
|
|
// Establish TCP connection to proxy with timeout
|
|
start := time.Now()
|
|
conn, err := fasthttp.DialDualStackTimeout(proxyAddr, timeout)
|
|
if err != nil {
|
|
return nil, types.NewProxyDialError(proxyStr, err)
|
|
}
|
|
|
|
remaining := timeout - time.Since(start)
|
|
if remaining <= 0 {
|
|
conn.Close() //nolint:errcheck,gosec
|
|
return nil, types.NewProxyDialError(proxyStr, context.DeadlineExceeded)
|
|
}
|
|
|
|
// Set deadline for the TLS handshake and CONNECT request
|
|
if err := conn.SetDeadline(time.Now().Add(remaining)); err != nil {
|
|
conn.Close() //nolint:errcheck,gosec
|
|
return nil, types.NewProxyDialError(proxyStr, err)
|
|
}
|
|
|
|
// Upgrade to TLS
|
|
tlsConn := tls.Client(conn, &tls.Config{
|
|
ServerName: proxyURL.Hostname(),
|
|
})
|
|
if err := tlsConn.Handshake(); err != nil {
|
|
tlsConn.Close() //nolint:errcheck,gosec
|
|
return nil, types.NewProxyDialError(proxyStr, err)
|
|
}
|
|
|
|
// Build and send CONNECT request
|
|
connectReq := &http.Request{
|
|
Method: http.MethodConnect,
|
|
URL: &url.URL{Opaque: addr},
|
|
Host: addr,
|
|
Header: make(http.Header),
|
|
}
|
|
if proxyAuth != "" {
|
|
connectReq.Header.Set("Proxy-Authorization", proxyAuth)
|
|
}
|
|
|
|
if err := connectReq.Write(tlsConn); err != nil {
|
|
tlsConn.Close() //nolint:errcheck,gosec
|
|
return nil, types.NewProxyDialError(proxyStr, err)
|
|
}
|
|
|
|
// Read response using buffered reader, but return wrapped connection
|
|
// to preserve any buffered data
|
|
bufReader := bufio.NewReader(tlsConn)
|
|
resp, err := http.ReadResponse(bufReader, connectReq)
|
|
if err != nil {
|
|
tlsConn.Close() //nolint:errcheck,gosec
|
|
return nil, types.NewProxyDialError(proxyStr, err)
|
|
}
|
|
resp.Body.Close() //nolint:errcheck,gosec
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
tlsConn.Close() //nolint:errcheck,gosec
|
|
return nil, types.NewProxyDialError(proxyStr, types.NewProxyConnectError(resp.Status))
|
|
}
|
|
|
|
// Clear deadline for the tunneled connection
|
|
if err := tlsConn.SetDeadline(time.Time{}); err != nil {
|
|
tlsConn.Close() //nolint:errcheck,gosec
|
|
return nil, types.NewProxyDialError(proxyStr, err)
|
|
}
|
|
|
|
// Return wrapped connection that uses the buffered reader
|
|
// to avoid losing any data that was read ahead
|
|
return &bufferedConn{Conn: tlsConn, reader: bufReader}, nil
|
|
}
|
|
}
|
|
|
|
// bufferedConn wraps a net.Conn with a buffered reader to preserve
|
|
// any data that was read during HTTP response parsing.
|
|
type bufferedConn struct {
|
|
net.Conn
|
|
|
|
reader *bufio.Reader
|
|
}
|
|
|
|
func (c *bufferedConn) Read(b []byte) (int, error) {
|
|
return c.reader.Read(b)
|
|
}
|
|
|
|
func NewHostClientGenerator(clients ...*fasthttp.HostClient) HostClientGenerator {
|
|
switch len(clients) {
|
|
case 0:
|
|
hostClient := &fasthttp.HostClient{}
|
|
return func() *fasthttp.HostClient {
|
|
return hostClient
|
|
}
|
|
case 1:
|
|
return func() *fasthttp.HostClient {
|
|
return clients[0]
|
|
}
|
|
default:
|
|
return utilsSlice.RandomCycle(nil, clients...)
|
|
}
|
|
}
|