mirror of
https://github.com/Alexey71/opera-proxy.git
synced 2026-05-13 14:11:00 +00:00
1891 lines
56 KiB
Go
1891 lines
56 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"encoding/csv"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"io/ioutil"
|
|
"log"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"runtime/debug"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
xproxy "golang.org/x/net/proxy"
|
|
|
|
"github.com/Alexey71/opera-proxy/clock"
|
|
"github.com/Alexey71/opera-proxy/dialer"
|
|
"github.com/Alexey71/opera-proxy/handler"
|
|
clog "github.com/Alexey71/opera-proxy/log"
|
|
"github.com/Alexey71/opera-proxy/resolver"
|
|
se "github.com/Alexey71/opera-proxy/seclient"
|
|
|
|
_ "golang.org/x/crypto/x509roots/fallback"
|
|
"golang.org/x/crypto/x509roots/fallback/bundle"
|
|
)
|
|
|
|
const (
|
|
API_DOMAIN = "api2.sec-tunnel.com"
|
|
PROXY_SUFFIX = "sec-tunnel.com"
|
|
DefaultDiscoverCSVFallback = "proxies.csv"
|
|
DefaultProxyBypassFallback = "proxy-bypass.txt"
|
|
)
|
|
|
|
func perror(msg string) {
|
|
fmt.Fprintln(os.Stderr, "")
|
|
fmt.Fprintln(os.Stderr, msg)
|
|
}
|
|
|
|
func arg_fail(msg string) {
|
|
perror(msg)
|
|
perror("Usage:")
|
|
flag.PrintDefaults()
|
|
os.Exit(2)
|
|
}
|
|
|
|
type CSVArg struct {
|
|
values []string
|
|
}
|
|
|
|
func (a *CSVArg) String() string {
|
|
if len(a.values) == 0 {
|
|
return ""
|
|
}
|
|
buf := new(bytes.Buffer)
|
|
wr := csv.NewWriter(buf)
|
|
wr.Write(a.values)
|
|
wr.Flush()
|
|
return strings.TrimRight(buf.String(), "\n")
|
|
}
|
|
|
|
func (a *CSVArg) Set(line string) error {
|
|
rd := csv.NewReader(strings.NewReader(line))
|
|
rd.FieldsPerRecord = -1
|
|
rd.TrimLeadingSpace = true
|
|
values, err := rd.Read()
|
|
if err == io.EOF {
|
|
a.values = nil
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("unable to parse comma-separated argument: %w", err)
|
|
}
|
|
a.values = values
|
|
return nil
|
|
}
|
|
|
|
type serverSelectionArg struct {
|
|
value dialer.ServerSelection
|
|
}
|
|
|
|
func (a *serverSelectionArg) Set(s string) error {
|
|
v, err := dialer.ParseServerSelection(s)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
a.value = v
|
|
return nil
|
|
}
|
|
|
|
func (a *serverSelectionArg) String() string {
|
|
return a.value.String()
|
|
}
|
|
|
|
type CLIArgs struct {
|
|
country string
|
|
countryExplicit bool
|
|
discoverCSV string
|
|
listCountries bool
|
|
listProxies bool
|
|
listProxiesAll bool
|
|
listProxiesAllOut string
|
|
fetchFreeProxyOut string
|
|
fetchFreeProxyURL string
|
|
estimateProxySpeed bool
|
|
sortProxiesBy string
|
|
dpExport bool
|
|
discoverRepeat int
|
|
bindAddress string
|
|
socksMode bool
|
|
verbosity int
|
|
timeout time.Duration
|
|
showVersion bool
|
|
proxy string
|
|
apiLogin string
|
|
apiPassword string
|
|
apiAddress string
|
|
apiClientType string
|
|
apiClientVersion string
|
|
apiUserAgent string
|
|
apiProxy string
|
|
apiProxyFile string
|
|
apiProxyListURL string
|
|
apiProxyParallel int
|
|
bootstrapDNS *CSVArg
|
|
refresh time.Duration
|
|
refreshRetry time.Duration
|
|
initRetries int
|
|
initRetryInterval time.Duration
|
|
caFile string
|
|
fakeSNI string
|
|
proxyBypass *CSVArg
|
|
proxyBlacklistFile string
|
|
proxyBlacklist map[string]struct{}
|
|
overrideProxyAddress string
|
|
proxySpeedTestURL string
|
|
proxySpeedTimeout time.Duration
|
|
proxySpeedDLLimit int64
|
|
serverSelection serverSelectionArg
|
|
serverSelectionTimeout time.Duration
|
|
serverSelectionTestURL string
|
|
serverSelectionDLLimit int64
|
|
}
|
|
|
|
func parse_args() *CLIArgs {
|
|
args := &CLIArgs{
|
|
bootstrapDNS: &CSVArg{
|
|
values: []string{
|
|
"https://1.1.1.3/dns-query",
|
|
"https://8.8.8.8/dns-query",
|
|
"https://dns.google/dns-query",
|
|
"https://security.cloudflare-dns.com/dns-query",
|
|
"https://fidelity.vm-0.com/q",
|
|
"https://wikimedia-dns.org/dns-query",
|
|
"https://dns.adguard-dns.com/dns-query",
|
|
"https://dns.quad9.net/dns-query",
|
|
"https://doh.cleanbrowsing.org/doh/adult-filter/",
|
|
},
|
|
},
|
|
proxyBypass: &CSVArg{},
|
|
serverSelection: serverSelectionArg{dialer.ServerSelectionFastest},
|
|
}
|
|
flag.StringVar(&args.country, "country", "EU", "desired proxy location; for list-proxies-all modes supports comma-separated codes or ALL")
|
|
flag.StringVar(&args.discoverCSV, "discover-csv", "", "read proxy endpoints from CSV instead of SurfEasy discover API")
|
|
flag.BoolVar(&args.listCountries, "list-countries", false, "list available countries and exit")
|
|
flag.BoolVar(&args.listProxies, "list-proxies", false, "output proxy list and exit")
|
|
flag.BoolVar(&args.listProxiesAll, "list-proxies-all", false, "output proxy list for all countries and exit")
|
|
flag.StringVar(&args.listProxiesAllOut, "list-proxies-all-out", "", "write proxy list CSV to file")
|
|
flag.StringVar(&args.fetchFreeProxyOut, "fetch-freeproxy-out", "", "download proxy list from advanced.name/freeproxy and save it as a text file with one ip:port per line")
|
|
flag.StringVar(&args.fetchFreeProxyURL, "fetch-freeproxy-url", "https://advanced.name/freeproxy", "source URL for -fetch-freeproxy-out")
|
|
flag.BoolVar(&args.estimateProxySpeed, "estimate-proxy-speed", false, "measure proxy response time for proxy list output")
|
|
flag.StringVar(&args.sortProxiesBy, "sort-proxies-by", "speed", "proxy list sort order: speed, country, ip")
|
|
flag.BoolVar(&args.dpExport, "dp-export", false, "export configuration for dumbproxy")
|
|
flag.IntVar(&args.discoverRepeat, "discover-repeat", 1, "number of repeated discover requests to aggregate and deduplicate")
|
|
flag.StringVar(&args.bindAddress, "bind-address", "127.0.0.1:18080", "proxy listen address")
|
|
flag.BoolVar(&args.socksMode, "socks-mode", false, "listen for SOCKS requests instead of HTTP")
|
|
flag.IntVar(&args.verbosity, "verbosity", 20, "logging verbosity "+
|
|
"(10 - debug, 20 - info, 30 - warning, 40 - error, 50 - critical)")
|
|
flag.DurationVar(&args.timeout, "timeout", 10*time.Second, "timeout for network operations")
|
|
flag.BoolVar(&args.showVersion, "version", false, "show program version and exit")
|
|
flag.StringVar(&args.proxy, "proxy", "", "sets base proxy to use for all dial-outs. "+
|
|
"Format: <http|https|socks5|socks5h>://[login:password@]host[:port] "+
|
|
"Examples: http://user:password@192.168.1.1:3128, socks5://10.0.0.1:1080")
|
|
flag.Var(args.proxyBypass, "proxy-bypass", "comma-separated list of destination host or URL patterns that should bypass proxying and connect directly; matching is case-insensitive and supports * in hostnames")
|
|
flag.StringVar(&args.apiClientVersion, "api-client-version", se.DefaultSESettings.ClientVersion, "client version reported to SurfEasy API")
|
|
flag.StringVar(&args.apiClientType, "api-client-type", se.DefaultSESettings.ClientType, "client type reported to SurfEasy API")
|
|
flag.StringVar(&args.apiUserAgent, "api-user-agent", se.DefaultSESettings.UserAgent, "user agent reported to SurfEasy API")
|
|
flag.StringVar(&args.apiLogin, "api-login", "se0316", "SurfEasy API login")
|
|
flag.StringVar(&args.apiPassword, "api-password", "SILrMEPBmJuhomxWkfm3JalqHX2Eheg1YhlEZiMh8II", "SurfEasy API password")
|
|
flag.StringVar(&args.apiAddress, "api-address", "", fmt.Sprintf("override IP address of %s", API_DOMAIN))
|
|
flag.StringVar(&args.apiProxy, "api-proxy", "", "additional proxy server used to access SurfEasy API")
|
|
flag.StringVar(&args.apiProxyFile, "api-proxy-file", "", "path to text file with candidate proxy servers for SurfEasy API access, one per line; proxies are tried in order until init/discover succeeds")
|
|
flag.StringVar(&args.apiProxyListURL, "api-proxy-list-url", "", "URL of a text file with candidate proxy servers for SurfEasy API access; falls back to -api-proxy-file if download fails")
|
|
flag.IntVar(&args.apiProxyParallel, "api-proxy-parallel", 5, "number of API proxy candidates tested in parallel when -api-proxy-file is used")
|
|
flag.Var(args.bootstrapDNS, "bootstrap-dns",
|
|
"comma-separated list of DNS/DoH/DoT resolvers for initial discovery of SurfEasy API address. "+
|
|
"Supported schemes are: dns://, https://, tls://, tcp://. "+
|
|
"Examples: https://1.1.1.1/dns-query,tls://9.9.9.9:853")
|
|
flag.DurationVar(&args.refresh, "refresh", 4*time.Hour, "login refresh interval")
|
|
flag.DurationVar(&args.refreshRetry, "refresh-retry", 5*time.Second, "login refresh retry interval")
|
|
flag.IntVar(&args.initRetries, "init-retries", 0, "number of attempts for initialization steps, zero for unlimited retry")
|
|
flag.DurationVar(&args.initRetryInterval, "init-retry-interval", 5*time.Second, "delay between initialization retries")
|
|
flag.StringVar(&args.caFile, "cafile", "", "use custom CA certificate bundle file")
|
|
flag.StringVar(&args.fakeSNI, "fake-SNI", "", "domain name to use as SNI in outbound TLS and tunneled TLS ClientHello where possible")
|
|
flag.StringVar(&args.proxyBlacklistFile, "proxy-blacklist", "", "path to file with blacklisted proxy addresses, one host[:port] per line")
|
|
flag.StringVar(&args.overrideProxyAddress, "override-proxy-address", "", "use fixed proxy address instead of server address returned by SurfEasy API")
|
|
flag.StringVar(&args.proxySpeedTestURL, "proxy-speed-test-url", "https://ajax.googleapis.com/ajax/libs/angularjs/1.8.2/angular.min.js",
|
|
"URL used to measure proxy response time")
|
|
flag.DurationVar(&args.proxySpeedTimeout, "proxy-speed-timeout", 15*time.Second, "timeout for a single proxy speed measurement")
|
|
flag.Int64Var(&args.proxySpeedDLLimit, "proxy-speed-dl-limit", 262144, "limit of downloaded bytes for proxy speed measurement")
|
|
flag.Var(&args.serverSelection, "server-selection", "server selection policy (first/random/fastest)")
|
|
flag.DurationVar(&args.serverSelectionTimeout, "server-selection-timeout", 30*time.Second, "timeout given for server selection function to produce result")
|
|
flag.StringVar(&args.serverSelectionTestURL, "server-selection-test-url", "https://ajax.googleapis.com/ajax/libs/angularjs/1.8.2/angular.min.js",
|
|
"URL used for download benchmark by fastest server selection policy")
|
|
flag.Int64Var(&args.serverSelectionDLLimit, "server-selection-dl-limit", 0, "restrict amount of downloaded data per connection by fastest server selection")
|
|
flag.Func("config", "read configuration from file with space-separated keys and values", readConfig)
|
|
flag.Parse()
|
|
flag.Visit(func(f *flag.Flag) {
|
|
if f.Name == "country" {
|
|
args.countryExplicit = true
|
|
}
|
|
})
|
|
if args.country == "" {
|
|
arg_fail("Country can't be empty string.")
|
|
}
|
|
if args.discoverRepeat < 1 {
|
|
arg_fail("discover-repeat must be >= 1.")
|
|
}
|
|
if args.apiProxyParallel < 1 {
|
|
arg_fail("api-proxy-parallel must be >= 1.")
|
|
}
|
|
switch args.sortProxiesBy {
|
|
case "speed", "country", "ip":
|
|
default:
|
|
arg_fail("sort-proxies-by must be one of: speed, country, ip.")
|
|
}
|
|
if args.listProxiesAllOut != "" {
|
|
args.listProxiesAll = true
|
|
}
|
|
if args.listProxiesAll {
|
|
args.estimateProxySpeed = true
|
|
}
|
|
if args.listCountries && args.listProxies ||
|
|
args.listCountries && args.listProxiesAll ||
|
|
args.listCountries && args.dpExport ||
|
|
args.listCountries && args.fetchFreeProxyOut != "" ||
|
|
args.listProxies && args.listProxiesAll ||
|
|
args.listProxies && args.dpExport ||
|
|
args.listProxies && args.fetchFreeProxyOut != "" ||
|
|
args.listProxiesAll && args.dpExport ||
|
|
args.listProxiesAll && args.fetchFreeProxyOut != "" ||
|
|
args.dpExport && args.fetchFreeProxyOut != "" {
|
|
arg_fail("mutually exclusive output arguments were provided")
|
|
}
|
|
return args
|
|
}
|
|
|
|
func proxyFromURLWrapper(u *url.URL, next xproxy.Dialer) (xproxy.Dialer, error) {
|
|
cdialer, ok := next.(dialer.ContextDialer)
|
|
if !ok {
|
|
return nil, errors.New("only context dialers are accepted")
|
|
}
|
|
|
|
return dialer.ProxyDialerFromURL(u, cdialer)
|
|
}
|
|
|
|
func normalizeAPIProxy(raw string) (string, error) {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
return "", nil
|
|
}
|
|
if strings.Contains(raw, "://") {
|
|
return raw, nil
|
|
}
|
|
if strings.Contains(raw, "@") {
|
|
return "http://" + raw, nil
|
|
}
|
|
|
|
parts := strings.Split(raw, ":")
|
|
if len(parts) == 4 {
|
|
host := strings.TrimSpace(parts[0])
|
|
port := strings.TrimSpace(parts[1])
|
|
user := strings.TrimSpace(parts[2])
|
|
password := strings.TrimSpace(parts[3])
|
|
if host == "" || port == "" || user == "" {
|
|
return "", fmt.Errorf("invalid proxy entry %q", raw)
|
|
}
|
|
proxyURL := &url.URL{
|
|
Scheme: "http",
|
|
Host: net.JoinHostPort(host, port),
|
|
User: url.UserPassword(user, password),
|
|
}
|
|
return proxyURL.String(), nil
|
|
}
|
|
|
|
if len(parts) == 2 {
|
|
host := strings.TrimSpace(parts[0])
|
|
port := strings.TrimSpace(parts[1])
|
|
if host == "" || port == "" {
|
|
return "", fmt.Errorf("invalid proxy entry %q", raw)
|
|
}
|
|
return "http://" + net.JoinHostPort(host, port), nil
|
|
}
|
|
|
|
return "", fmt.Errorf("unsupported proxy entry format %q", raw)
|
|
}
|
|
|
|
func loadAPIProxyListFromReader(r io.Reader, source string) ([]string, error) {
|
|
scanner := bufio.NewScanner(r)
|
|
proxies := make([]string, 0)
|
|
seen := make(map[string]struct{})
|
|
for scanner.Scan() {
|
|
line := strings.TrimSpace(scanner.Text())
|
|
if line == "" || strings.HasPrefix(line, "#") {
|
|
continue
|
|
}
|
|
proxy, err := normalizeAPIProxy(line)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid API proxy entry %q from %s: %w", line, source, err)
|
|
}
|
|
if _, ok := seen[proxy]; ok {
|
|
continue
|
|
}
|
|
seen[proxy] = struct{}{}
|
|
proxies = append(proxies, proxy)
|
|
}
|
|
if err := scanner.Err(); err != nil {
|
|
return nil, fmt.Errorf("unable to read API proxy list from %s: %w", source, err)
|
|
}
|
|
if len(proxies) == 0 {
|
|
return nil, fmt.Errorf("no API proxies found in %s", source)
|
|
}
|
|
return proxies, nil
|
|
}
|
|
|
|
func loadAPIProxyList(filename string) ([]string, error) {
|
|
f, err := os.Open(filename)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("unable to open API proxy list %q: %w", filename, err)
|
|
}
|
|
defer f.Close()
|
|
return loadAPIProxyListFromReader(f, fmt.Sprintf("file %q", filename))
|
|
}
|
|
|
|
func loadProxyBypassList(filename string) ([]string, error) {
|
|
f, err := os.Open(filename)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer f.Close()
|
|
|
|
scanner := bufio.NewScanner(f)
|
|
seen := make(map[string]struct{})
|
|
patterns := make([]string, 0)
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
if idx := strings.Index(line, "#"); idx >= 0 {
|
|
line = line[:idx]
|
|
}
|
|
line = strings.TrimSpace(line)
|
|
if line == "" {
|
|
continue
|
|
}
|
|
|
|
var parsed CSVArg
|
|
if err := parsed.Set(line); err != nil {
|
|
return nil, fmt.Errorf("unable to parse proxy bypass entry %q from %s: %w", line, filename, err)
|
|
}
|
|
for _, value := range parsed.values {
|
|
pattern := strings.TrimSpace(value)
|
|
if pattern == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[pattern]; ok {
|
|
continue
|
|
}
|
|
seen[pattern] = struct{}{}
|
|
patterns = append(patterns, pattern)
|
|
}
|
|
}
|
|
if err := scanner.Err(); err != nil {
|
|
return nil, fmt.Errorf("unable to read proxy bypass list %q: %w", filename, err)
|
|
}
|
|
return patterns, nil
|
|
}
|
|
|
|
func loadAPIProxyListFromURL(listURL string, transport http.RoundTripper, timeout time.Duration) ([]string, error) {
|
|
client := &http.Client{
|
|
Transport: transport,
|
|
Timeout: timeout,
|
|
}
|
|
req, err := http.NewRequest(http.MethodGet, listURL, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("unable to create request for API proxy list URL %q: %w", listURL, err)
|
|
}
|
|
req.Header.Set("User-Agent", "opera-proxy api-proxy-list fetcher/1.0")
|
|
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("unable to download API proxy list from %q: %w", listURL, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("unable to download API proxy list from %q: unexpected HTTP status %s", listURL, resp.Status)
|
|
}
|
|
return loadAPIProxyListFromReader(resp.Body, fmt.Sprintf("URL %q", listURL))
|
|
}
|
|
|
|
func newSEClient(args *CLIArgs, baseDialer dialer.ContextDialer, caPool *x509.CertPool, apiProxy string) (*se.SEClient, error) {
|
|
seclientDialer := baseDialer
|
|
if apiProxy != "" {
|
|
apiProxyURL, err := url.Parse(apiProxy)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("unable to parse API proxy URL %q: %w", apiProxy, err)
|
|
}
|
|
pxDialer, err := xproxy.FromURL(apiProxyURL, seclientDialer)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("unable to instantiate API proxy dialer %q: %w", apiProxy, err)
|
|
}
|
|
seclientDialer = pxDialer.(dialer.ContextDialer)
|
|
}
|
|
if args.apiAddress != "" {
|
|
seclientDialer = dialer.NewFixedDialer(args.apiAddress, seclientDialer)
|
|
} else if len(args.bootstrapDNS.values) > 0 {
|
|
resolver, err := resolver.FastFromURLs(caPool, args.bootstrapDNS.values...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("unable to instantiate DNS resolver: %w", err)
|
|
}
|
|
seclientDialer = dialer.NewResolvingDialer(resolver, seclientDialer)
|
|
}
|
|
|
|
// Dialing w/o SNI, receiving self-signed certificate, so skip verification.
|
|
// Either way we'll validate certificate of actual proxy server.
|
|
tlsConfig := &tls.Config{
|
|
ServerName: args.fakeSNI,
|
|
InsecureSkipVerify: true,
|
|
}
|
|
seclient, err := se.NewSEClient(args.apiLogin, args.apiPassword, &http.Transport{
|
|
DialContext: seclientDialer.DialContext,
|
|
DialTLSContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
|
conn, err := seclientDialer.DialContext(ctx, network, addr)
|
|
if err != nil {
|
|
return conn, err
|
|
}
|
|
return tls.Client(conn, tlsConfig), nil
|
|
},
|
|
ForceAttemptHTTP2: true,
|
|
MaxIdleConns: 100,
|
|
IdleConnTimeout: 90 * time.Second,
|
|
TLSHandshakeTimeout: 10 * time.Second,
|
|
ExpectContinueTimeout: 1 * time.Second,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("unable to construct SEClient: %w", err)
|
|
}
|
|
seclient.Settings.ClientType = args.apiClientType
|
|
seclient.Settings.ClientVersion = args.apiClientVersion
|
|
seclient.Settings.UserAgent = args.apiUserAgent
|
|
return seclient, nil
|
|
}
|
|
|
|
type apiProxyCandidateResult struct {
|
|
candidate string
|
|
client *se.SEClient
|
|
ips []se.SEIPEntry
|
|
countries []se.SEGeoEntry
|
|
err error
|
|
}
|
|
|
|
func callWithTimeout(parent context.Context, timeout time.Duration, f func(context.Context) error) error {
|
|
ctx, cl := context.WithTimeout(parent, timeout)
|
|
defer cl()
|
|
return f(ctx)
|
|
}
|
|
|
|
func testAPIProxyCandidate(ctx context.Context, args *CLIArgs, baseDialer dialer.ContextDialer, caPool *x509.CertPool, candidate string, needDiscover bool, needCountries bool) apiProxyCandidateResult {
|
|
result := apiProxyCandidateResult{candidate: candidate}
|
|
|
|
client, err := newSEClient(args, baseDialer, caPool, candidate)
|
|
if err != nil {
|
|
result.err = err
|
|
return result
|
|
}
|
|
|
|
if err := callWithTimeout(ctx, args.timeout, client.AnonRegister); err != nil {
|
|
result.err = fmt.Errorf("anonymous registration failed: %w", err)
|
|
return result
|
|
}
|
|
|
|
if err := callWithTimeout(ctx, args.timeout, client.RegisterDevice); err != nil {
|
|
result.err = fmt.Errorf("device registration failed: %w", err)
|
|
return result
|
|
}
|
|
|
|
if needCountries {
|
|
var countries []se.SEGeoEntry
|
|
if err := callWithTimeout(ctx, args.timeout, func(reqCtx context.Context) error {
|
|
var geoErr error
|
|
countries, geoErr = client.GeoList(reqCtx)
|
|
return geoErr
|
|
}); err != nil {
|
|
result.err = fmt.Errorf("geo list request failed: %w", err)
|
|
return result
|
|
}
|
|
result.countries = countries
|
|
}
|
|
|
|
if needDiscover {
|
|
var ips []se.SEIPEntry
|
|
if args.listProxiesAll {
|
|
ips, err = discoverAllCountriesWithContext(ctx, args, client, nil, args.country)
|
|
} else {
|
|
ips, err = discoverCountryWithContext(ctx, args, client, nil, args.country)
|
|
}
|
|
if err != nil {
|
|
result.err = fmt.Errorf("discover request failed: %w", err)
|
|
return result
|
|
}
|
|
if len(ips) == 0 {
|
|
result.err = errors.New("discover request returned an empty endpoints list")
|
|
return result
|
|
}
|
|
result.ips = ips
|
|
}
|
|
|
|
result.client = client
|
|
return result
|
|
}
|
|
|
|
func selectAPIProxyCandidate(args *CLIArgs, baseDialer dialer.ContextDialer, caPool *x509.CertPool, logger *clog.CondLogger, candidates []string, needDiscover bool, needCountries bool) (apiProxyCandidateResult, error) {
|
|
if len(candidates) == 0 {
|
|
return apiProxyCandidateResult{}, errors.New("no API proxy candidates provided")
|
|
}
|
|
|
|
parallelism := args.apiProxyParallel
|
|
if parallelism > len(candidates) {
|
|
parallelism = len(candidates)
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
type candidateJob struct {
|
|
idx int
|
|
candidate string
|
|
}
|
|
|
|
jobs := make(chan candidateJob)
|
|
results := make(chan apiProxyCandidateResult, len(candidates))
|
|
var wg sync.WaitGroup
|
|
|
|
worker := func() {
|
|
defer wg.Done()
|
|
for job := range jobs {
|
|
if ctx.Err() != nil {
|
|
return
|
|
}
|
|
logger.Info("Trying API proxy candidate #%d/%d: %s", job.idx+1, len(candidates), job.candidate)
|
|
result := testAPIProxyCandidate(ctx, args, baseDialer, caPool, job.candidate, needDiscover, needCountries)
|
|
select {
|
|
case results <- result:
|
|
case <-ctx.Done():
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
wg.Add(parallelism)
|
|
for i := 0; i < parallelism; i++ {
|
|
go worker()
|
|
}
|
|
|
|
go func() {
|
|
defer close(jobs)
|
|
for idx, candidate := range candidates {
|
|
select {
|
|
case jobs <- candidateJob{idx: idx, candidate: candidate}:
|
|
case <-ctx.Done():
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
|
|
var lastErr error
|
|
for i := 0; i < len(candidates); i++ {
|
|
result := <-results
|
|
if result.err == nil {
|
|
logger.Info("Using API proxy candidate: %s", result.candidate)
|
|
cancel()
|
|
wg.Wait()
|
|
return result, nil
|
|
}
|
|
lastErr = result.err
|
|
logger.Warning("API proxy candidate %s failed: %v", result.candidate, result.err)
|
|
}
|
|
|
|
cancel()
|
|
wg.Wait()
|
|
if lastErr == nil {
|
|
lastErr = errors.New("all API proxy candidates failed")
|
|
}
|
|
return apiProxyCandidateResult{}, lastErr
|
|
}
|
|
|
|
func run() int {
|
|
args := parse_args()
|
|
if args.showVersion {
|
|
fmt.Println(version())
|
|
return 0
|
|
}
|
|
if args.fetchFreeProxyOut != "" {
|
|
count, err := fetchFreeProxyToFile(args.fetchFreeProxyURL, args.fetchFreeProxyOut, args.timeout)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "failed to fetch free proxy list: %v\n", err)
|
|
return 19
|
|
}
|
|
fmt.Printf("Saved %d proxies to %s\n", count, args.fetchFreeProxyOut)
|
|
return 0
|
|
}
|
|
|
|
logWriter := clog.NewLogWriter(os.Stderr)
|
|
defer logWriter.Close()
|
|
|
|
mainLogger := clog.NewCondLogger(log.New(logWriter, "MAIN : ",
|
|
log.LstdFlags|log.Lshortfile),
|
|
args.verbosity)
|
|
proxyLogger := clog.NewCondLogger(log.New(logWriter, "PROXY : ",
|
|
log.LstdFlags|log.Lshortfile),
|
|
args.verbosity)
|
|
socksLogger := log.New(logWriter, "SOCKS : ",
|
|
log.LstdFlags|log.Lshortfile)
|
|
|
|
mainLogger.Info("opera-proxy client version %s is starting...", version())
|
|
|
|
proxyBlacklist, err := loadProxyBlacklist(args.proxyBlacklistFile)
|
|
if err != nil {
|
|
mainLogger.Error("Can't load proxy blacklist file %q: %v", args.proxyBlacklistFile, err)
|
|
return 18
|
|
}
|
|
args.proxyBlacklist = proxyBlacklist
|
|
if len(args.proxyBlacklist) > 0 {
|
|
mainLogger.Info("Loaded %d blacklisted proxy endpoints from %s.", len(args.proxyBlacklist), args.proxyBlacklistFile)
|
|
}
|
|
|
|
var d dialer.ContextDialer = &net.Dialer{
|
|
Timeout: 30 * time.Second,
|
|
KeepAlive: 30 * time.Second,
|
|
}
|
|
|
|
caPool := x509.NewCertPool()
|
|
if args.caFile != "" {
|
|
certs, err := ioutil.ReadFile(args.caFile)
|
|
if err != nil {
|
|
mainLogger.Error("Can't load CA file: %v", err)
|
|
return 15
|
|
}
|
|
if ok := caPool.AppendCertsFromPEM(certs); !ok {
|
|
mainLogger.Error("Can't load certificates from CA file")
|
|
return 15
|
|
}
|
|
} else {
|
|
for c := range bundle.Roots() {
|
|
cert, err := x509.ParseCertificate(c.Certificate)
|
|
if err != nil {
|
|
mainLogger.Error("Unable to parse bundled certificate: %v", err)
|
|
return 15
|
|
}
|
|
if c.Constraint == nil {
|
|
caPool.AddCert(cert)
|
|
} else {
|
|
caPool.AddCertWithConstraint(cert, c.Constraint)
|
|
}
|
|
}
|
|
}
|
|
|
|
xproxy.RegisterDialerType("http", proxyFromURLWrapper)
|
|
xproxy.RegisterDialerType("https", proxyFromURLWrapper)
|
|
directDialer := d
|
|
if args.proxy != "" {
|
|
proxyURL, err := url.Parse(args.proxy)
|
|
if err != nil {
|
|
mainLogger.Critical("Unable to parse base proxy URL: %v", err)
|
|
return 6
|
|
}
|
|
pxDialer, err := xproxy.FromURL(proxyURL, d)
|
|
if err != nil {
|
|
mainLogger.Critical("Unable to instantiate base proxy dialer: %v", err)
|
|
return 7
|
|
}
|
|
d = pxDialer.(dialer.ContextDialer)
|
|
}
|
|
if len(args.proxyBypass.values) == 0 {
|
|
fallbackPatterns, err := loadProxyBypassList(proxyBypassPathForFallback())
|
|
if err == nil {
|
|
args.proxyBypass.values = fallbackPatterns
|
|
} else if !errors.Is(err, os.ErrNotExist) {
|
|
mainLogger.Critical("Unable to load proxy bypass list: %v", err)
|
|
return 7
|
|
}
|
|
}
|
|
mainLogger.Info("Proxy bypass loaded: %d rule(s).", len(args.proxyBypass.values))
|
|
|
|
try := retryPolicy(args.initRetries, args.initRetryInterval, mainLogger)
|
|
if args.apiAddress != "" {
|
|
mainLogger.Info("Using fixed API host address = %s", args.apiAddress)
|
|
}
|
|
|
|
var (
|
|
seclient *se.SEClient
|
|
ips []se.SEIPEntry
|
|
preloadedDiscovery bool
|
|
)
|
|
|
|
if args.apiProxyListURL != "" || args.apiProxyFile != "" {
|
|
var (
|
|
candidates []string
|
|
err error
|
|
)
|
|
if args.apiProxyListURL != "" {
|
|
downloadTransport := &http.Transport{
|
|
DialContext: d.DialContext,
|
|
ForceAttemptHTTP2: true,
|
|
MaxIdleConns: 100,
|
|
IdleConnTimeout: 90 * time.Second,
|
|
TLSHandshakeTimeout: 10 * time.Second,
|
|
ExpectContinueTimeout: 1 * time.Second,
|
|
}
|
|
candidates, err = loadAPIProxyListFromURL(args.apiProxyListURL, downloadTransport, args.timeout)
|
|
if err != nil {
|
|
if args.apiProxyFile == "" {
|
|
mainLogger.Critical("Unable to load API proxy list from URL: %v", err)
|
|
return 8
|
|
}
|
|
mainLogger.Warning("Unable to load API proxy list from URL %q, falling back to file %q: %v", args.apiProxyListURL, args.apiProxyFile, err)
|
|
}
|
|
}
|
|
if len(candidates) == 0 {
|
|
candidates, err = loadAPIProxyList(args.apiProxyFile)
|
|
if err != nil {
|
|
mainLogger.Critical("Unable to load API proxy list: %v", err)
|
|
return 8
|
|
}
|
|
}
|
|
if args.apiProxy != "" {
|
|
explicitProxy, err := normalizeAPIProxy(args.apiProxy)
|
|
if err != nil {
|
|
mainLogger.Critical("Unable to parse explicit API proxy: %v", err)
|
|
return 6
|
|
}
|
|
filtered := make([]string, 0, len(candidates)+1)
|
|
filtered = append(filtered, explicitProxy)
|
|
for _, candidate := range candidates {
|
|
if candidate == explicitProxy {
|
|
continue
|
|
}
|
|
filtered = append(filtered, candidate)
|
|
}
|
|
candidates = filtered
|
|
}
|
|
parallelism := args.apiProxyParallel
|
|
if parallelism > len(candidates) {
|
|
parallelism = len(candidates)
|
|
}
|
|
mainLogger.Info("Loaded %d API proxy candidates. Testing up to %d in parallel.", len(candidates), parallelism)
|
|
|
|
needDiscover := args.listProxies || args.listProxiesAll || args.dpExport || args.overrideProxyAddress == ""
|
|
result, err := selectAPIProxyCandidate(args, d, caPool, mainLogger, candidates, needDiscover, args.listCountries)
|
|
if err != nil {
|
|
mainLogger.Critical("All API proxy candidates failed. Last error: %v", err)
|
|
return 12
|
|
}
|
|
seclient = result.client
|
|
ips = result.ips
|
|
preloadedDiscovery = len(ips) > 0
|
|
args.apiProxy = result.candidate
|
|
if args.listCountries {
|
|
return printCountryList(result.countries)
|
|
}
|
|
} else {
|
|
seclientDialer := d
|
|
if args.apiProxy != "" {
|
|
apiProxyURL, err := url.Parse(args.apiProxy)
|
|
if err != nil {
|
|
mainLogger.Critical("Unable to parse base proxy URL: %v", err)
|
|
return 6
|
|
}
|
|
pxDialer, err := xproxy.FromURL(apiProxyURL, seclientDialer)
|
|
if err != nil {
|
|
mainLogger.Critical("Unable to instantiate base proxy dialer: %v", err)
|
|
return 7
|
|
}
|
|
seclientDialer = pxDialer.(dialer.ContextDialer)
|
|
}
|
|
if args.apiAddress != "" {
|
|
seclientDialer = dialer.NewFixedDialer(args.apiAddress, seclientDialer)
|
|
} else if len(args.bootstrapDNS.values) > 0 {
|
|
resolver, err := resolver.FastFromURLs(caPool, args.bootstrapDNS.values...)
|
|
if err != nil {
|
|
mainLogger.Critical("Unable to instantiate DNS resolver: %v", err)
|
|
return 4
|
|
}
|
|
seclientDialer = dialer.NewResolvingDialer(resolver, seclientDialer)
|
|
}
|
|
|
|
// Dialing w/o SNI, receiving self-signed certificate, so skip verification.
|
|
// Either way we'll validate certificate of actual proxy server.
|
|
tlsConfig := &tls.Config{
|
|
ServerName: args.fakeSNI,
|
|
InsecureSkipVerify: true,
|
|
}
|
|
seclient, err = se.NewSEClient(args.apiLogin, args.apiPassword, &http.Transport{
|
|
DialContext: seclientDialer.DialContext,
|
|
DialTLSContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
|
conn, err := seclientDialer.DialContext(ctx, network, addr)
|
|
if err != nil {
|
|
return conn, err
|
|
}
|
|
return tls.Client(conn, tlsConfig), nil
|
|
},
|
|
ForceAttemptHTTP2: true,
|
|
MaxIdleConns: 100,
|
|
IdleConnTimeout: 90 * time.Second,
|
|
TLSHandshakeTimeout: 10 * time.Second,
|
|
ExpectContinueTimeout: 1 * time.Second,
|
|
})
|
|
if err != nil {
|
|
mainLogger.Critical("Unable to construct SEClient: %v", err)
|
|
return 8
|
|
}
|
|
seclient.Settings.ClientType = args.apiClientType
|
|
seclient.Settings.ClientVersion = args.apiClientVersion
|
|
seclient.Settings.UserAgent = args.apiUserAgent
|
|
|
|
err = try("anonymous registration", func() error {
|
|
ctx, cl := context.WithTimeout(context.Background(), args.timeout)
|
|
defer cl()
|
|
return seclient.AnonRegister(ctx)
|
|
})
|
|
if err != nil {
|
|
return 9
|
|
}
|
|
|
|
err = try("device registration", func() error {
|
|
ctx, cl := context.WithTimeout(context.Background(), args.timeout)
|
|
defer cl()
|
|
return seclient.RegisterDevice(ctx)
|
|
})
|
|
if err != nil {
|
|
return 10
|
|
}
|
|
|
|
if args.listCountries {
|
|
return printCountries(try, mainLogger, args.timeout, seclient)
|
|
}
|
|
}
|
|
|
|
proxyTLSServerName := func(entry se.SEIPEntry) string {
|
|
if strings.TrimSpace(entry.Host) != "" {
|
|
return entry.Host
|
|
}
|
|
return fmt.Sprintf("%s0.%s", strings.ToLower(entry.Geo.CountryCode), PROXY_SUFFIX)
|
|
}
|
|
|
|
handlerDialerFactory := func(entry se.SEIPEntry, endpointAddr string) dialer.ContextDialer {
|
|
return dialer.NewProxyDialer(
|
|
dialer.WrapStringToCb(endpointAddr),
|
|
dialer.WrapStringToCb(proxyTLSServerName(entry)),
|
|
dialer.WrapStringToCb(args.fakeSNI),
|
|
func() (string, error) {
|
|
return dialer.BasicAuthHeader(seclient.GetProxyCredentials()), nil
|
|
},
|
|
caPool,
|
|
d)
|
|
}
|
|
|
|
if args.listProxies || args.listProxiesAll || args.dpExport {
|
|
if !preloadedDiscovery {
|
|
err = try("discover", func() error {
|
|
var discoverErr error
|
|
if args.listProxiesAll {
|
|
ips, discoverErr = discoverAllCountries(args, seclient, mainLogger)
|
|
} else {
|
|
ips, discoverErr = discoverCountry(args, seclient, mainLogger, args.country)
|
|
}
|
|
if discoverErr != nil {
|
|
return discoverErr
|
|
}
|
|
if len(ips) == 0 {
|
|
return errors.New("empty endpoints list!")
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return 12
|
|
}
|
|
}
|
|
if args.listProxies || args.listProxiesAll {
|
|
var speedResults map[proxyEndpointKey]proxySpeedResult
|
|
if args.estimateProxySpeed {
|
|
mainLogger.Info("Measuring proxy response time for %d endpoints using %q.", countProxyPorts(ips), args.proxySpeedTestURL)
|
|
speedResults = benchmarkProxyEndpoints(args, ips, caPool, mainLogger, handlerDialerFactory)
|
|
}
|
|
if args.listProxiesAllOut != "" {
|
|
if err := writeProxyCSV(args.listProxiesAllOut, ips, seclient, speedResults, args.sortProxiesBy); err != nil {
|
|
mainLogger.Critical("Unable to write proxy CSV: %v", err)
|
|
return 17
|
|
}
|
|
fmt.Printf("Proxy list saved to %s\n", args.listProxiesAllOut)
|
|
return 0
|
|
}
|
|
return printProxies(ips, seclient, speedResults, args.sortProxiesBy)
|
|
}
|
|
if args.dpExport {
|
|
return dpExport(ips, seclient, args.fakeSNI)
|
|
}
|
|
}
|
|
|
|
var handlerDialer dialer.ContextDialer
|
|
|
|
if args.overrideProxyAddress == "" {
|
|
if !preloadedDiscovery {
|
|
err = try("discover", func() error {
|
|
res, err := discoverCountry(args, seclient, mainLogger, args.country)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(res) == 0 {
|
|
return errors.New("empty endpoints list!")
|
|
}
|
|
ips = res
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return 12
|
|
}
|
|
}
|
|
|
|
mainLogger.Info("Discovered endpoints: %v. Starting server selection routine %q.", ips, args.serverSelection.value)
|
|
err = func() error {
|
|
var ss dialer.SelectionFunc
|
|
switch args.serverSelection.value {
|
|
case dialer.ServerSelectionFirst:
|
|
ss = dialer.SelectFirst
|
|
case dialer.ServerSelectionRandom:
|
|
ss = dialer.SelectRandom
|
|
case dialer.ServerSelectionFastest:
|
|
ss = dialer.NewFastestServerSelectionFunc(
|
|
args.serverSelectionTestURL,
|
|
args.serverSelectionDLLimit,
|
|
&tls.Config{
|
|
RootCAs: caPool,
|
|
},
|
|
)
|
|
default:
|
|
panic("unhandled server selection value got past parsing")
|
|
}
|
|
dialers := make([]dialer.ContextDialer, len(ips))
|
|
for i, ep := range ips {
|
|
dialers[i] = handlerDialerFactory(ep, ep.NetAddr())
|
|
}
|
|
ctx, cl := context.WithTimeout(context.Background(), args.serverSelectionTimeout)
|
|
defer cl()
|
|
handlerDialer, err = ss(ctx, dialers)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if addresser, ok := handlerDialer.(interface{ Address() (string, error) }); ok {
|
|
if epAddr, err := addresser.Address(); err == nil {
|
|
mainLogger.Info("Selected endpoint address: %s", epAddr)
|
|
}
|
|
}
|
|
return nil
|
|
}()
|
|
if err != nil {
|
|
return 12
|
|
}
|
|
} else {
|
|
sanitizedEndpoint := sanitizeFixedProxyAddress(args.overrideProxyAddress)
|
|
if _, ok := args.proxyBlacklist[sanitizedEndpoint]; ok {
|
|
mainLogger.Critical("Endpoint override %s is blacklisted.", sanitizedEndpoint)
|
|
return 12
|
|
}
|
|
handlerDialer = handlerDialerFactory(se.SEIPEntry{
|
|
Geo: se.SEGeoEntry{
|
|
CountryCode: args.country,
|
|
},
|
|
}, sanitizedEndpoint)
|
|
mainLogger.Info("Endpoint override: %s", sanitizedEndpoint)
|
|
}
|
|
if len(args.proxyBypass.values) > 0 {
|
|
bypassDialer, err := dialer.NewBypassDialer(args.proxyBypass.values, directDialer, handlerDialer)
|
|
if err != nil {
|
|
mainLogger.Critical("Unable to configure proxy bypass rules: %v", err)
|
|
return 7
|
|
}
|
|
handlerDialer = bypassDialer
|
|
}
|
|
|
|
clock.RunTicker(context.Background(), args.refresh, args.refreshRetry, func(ctx context.Context) error {
|
|
mainLogger.Info("Refreshing login...")
|
|
reqCtx, cl := context.WithTimeout(ctx, args.timeout)
|
|
defer cl()
|
|
err := seclient.Login(reqCtx)
|
|
if err != nil {
|
|
mainLogger.Error("Login refresh failed: %v", err)
|
|
return err
|
|
}
|
|
mainLogger.Info("Login refreshed.")
|
|
|
|
mainLogger.Info("Refreshing device password...")
|
|
reqCtx, cl = context.WithTimeout(ctx, args.timeout)
|
|
defer cl()
|
|
err = seclient.DeviceGeneratePassword(reqCtx)
|
|
if err != nil {
|
|
mainLogger.Error("Device password refresh failed: %v", err)
|
|
return err
|
|
}
|
|
mainLogger.Info("Device password refreshed.")
|
|
return nil
|
|
})
|
|
|
|
mainLogger.Info("Starting proxy server...")
|
|
if args.socksMode {
|
|
socks, initError := handler.NewSocksServer(handlerDialer, socksLogger, args.fakeSNI)
|
|
if initError != nil {
|
|
mainLogger.Critical("Failed to start: %v", err)
|
|
return 16
|
|
}
|
|
mainLogger.Info("Init complete.")
|
|
err = socks.ListenAndServe("tcp", args.bindAddress)
|
|
} else {
|
|
h := handler.NewProxyHandler(handlerDialer, proxyLogger, args.fakeSNI)
|
|
mainLogger.Info("Init complete.")
|
|
err = http.ListenAndServe(args.bindAddress, h)
|
|
}
|
|
mainLogger.Critical("Server terminated with a reason: %v", err)
|
|
mainLogger.Info("Shutting down...")
|
|
return 0
|
|
}
|
|
|
|
func printCountryList(list []se.SEGeoEntry) int {
|
|
wr := csv.NewWriter(os.Stdout)
|
|
defer wr.Flush()
|
|
if err := wr.Write([]string{"country code", "country name"}); err != nil {
|
|
fmt.Fprintf(os.Stderr, "failed to write country list: %v\n", err)
|
|
return 1
|
|
}
|
|
for _, country := range list {
|
|
if err := wr.Write([]string{country.CountryCode, country.Country}); err != nil {
|
|
fmt.Fprintf(os.Stderr, "failed to write country list: %v\n", err)
|
|
return 1
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func printCountries(try func(string, func() error) error, logger *clog.CondLogger, timeout time.Duration, seclient *se.SEClient) int {
|
|
var list []se.SEGeoEntry
|
|
err := try("geolist", func() error {
|
|
ctx, cl := context.WithTimeout(context.Background(), timeout)
|
|
defer cl()
|
|
l, err := seclient.GeoList(ctx)
|
|
list = l
|
|
return err
|
|
})
|
|
if err != nil {
|
|
return 11
|
|
}
|
|
return printCountryList(list)
|
|
}
|
|
|
|
func printProxies(ips []se.SEIPEntry, seclient *se.SEClient, speedResults map[proxyEndpointKey]proxySpeedResult, sortBy string) int {
|
|
login, password := seclient.GetProxyCredentials()
|
|
fmt.Println("Proxy login:", login)
|
|
fmt.Println("Proxy password:", password)
|
|
fmt.Println("Proxy-Authorization:", dialer.BasicAuthHeader(login, password))
|
|
fmt.Println("")
|
|
if err := emitProxyCSV(os.Stdout, ips, speedResults, sortBy); err != nil {
|
|
fmt.Fprintf(os.Stderr, "failed to write proxy CSV: %v\n", err)
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func writeProxyCSV(filename string, ips []se.SEIPEntry, seclient *se.SEClient, speedResults map[proxyEndpointKey]proxySpeedResult, sortBy string) error {
|
|
f, err := os.Create(filename)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer f.Close()
|
|
return emitProxyCSV(f, ips, speedResults, sortBy)
|
|
}
|
|
|
|
func emitProxyCSV(w io.Writer, ips []se.SEIPEntry, speedResults map[proxyEndpointKey]proxySpeedResult, sortBy string) error {
|
|
wr := csv.NewWriter(w)
|
|
defer wr.Flush()
|
|
header := []string{"country_code", "country_name", "host", "ip_address", "port"}
|
|
includeSpeed := speedResults != nil
|
|
if includeSpeed {
|
|
header = append(header, "speed_ms", "speed_status")
|
|
}
|
|
if err := wr.Write(header); err != nil {
|
|
return err
|
|
}
|
|
rows := buildProxyRows(ips, speedResults, sortBy)
|
|
for _, rowData := range rows {
|
|
row := []string{
|
|
rowData.CountryCode,
|
|
rowData.CountryName,
|
|
rowData.Host,
|
|
rowData.IP,
|
|
fmt.Sprintf("%d", rowData.Port),
|
|
}
|
|
if includeSpeed {
|
|
speedMs := ""
|
|
status := "not_tested"
|
|
if rowData.HasSpeed {
|
|
if rowData.Speed.Err == nil {
|
|
speedMs = fmt.Sprintf("%d", rowData.Speed.Duration.Milliseconds())
|
|
}
|
|
status = rowData.Speed.Status()
|
|
}
|
|
row = append(row, speedMs, status)
|
|
}
|
|
if err := wr.Write(row); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
wr.Flush()
|
|
return wr.Error()
|
|
}
|
|
|
|
func dpExport(ips []se.SEIPEntry, seclient *se.SEClient, sni string) int {
|
|
wr := csv.NewWriter(os.Stdout)
|
|
wr.Comma = ' '
|
|
defer wr.Flush()
|
|
creds := url.UserPassword(seclient.GetProxyCredentials())
|
|
var gotOne bool
|
|
for i, ip := range ips {
|
|
if len(ip.Ports) == 0 {
|
|
continue
|
|
}
|
|
u := url.URL{
|
|
Scheme: "https",
|
|
User: creds,
|
|
Host: net.JoinHostPort(
|
|
ip.IP,
|
|
strconv.Itoa(int(ip.Ports[0])),
|
|
),
|
|
RawQuery: url.Values{
|
|
"sni": []string{sni},
|
|
"peername": []string{fmt.Sprintf("%s%d.%s", strings.ToLower(ip.Geo.CountryCode), i, PROXY_SUFFIX)},
|
|
}.Encode(),
|
|
}
|
|
key := "proxy"
|
|
if gotOne {
|
|
key = "#proxy"
|
|
}
|
|
wr.Write([]string{
|
|
key,
|
|
u.String(),
|
|
})
|
|
gotOne = true
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func sanitizeFixedProxyAddress(addr string) string {
|
|
if _, _, err := net.SplitHostPort(addr); err == nil {
|
|
return addr
|
|
}
|
|
return net.JoinHostPort(addr, "443")
|
|
}
|
|
|
|
func loadProxyBlacklist(filename string) (map[string]struct{}, error) {
|
|
if filename == "" {
|
|
return nil, nil
|
|
}
|
|
|
|
f, err := os.Open(filename)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer f.Close()
|
|
|
|
blacklist := make(map[string]struct{})
|
|
scanner := bufio.NewScanner(f)
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
if idx := strings.Index(line, "#"); idx >= 0 {
|
|
line = line[:idx]
|
|
}
|
|
line = strings.TrimSpace(line)
|
|
if line == "" {
|
|
continue
|
|
}
|
|
blacklist[sanitizeFixedProxyAddress(line)] = struct{}{}
|
|
}
|
|
if err := scanner.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return blacklist, nil
|
|
}
|
|
|
|
func filterBlacklistedProxyEntries(entries []se.SEIPEntry, blacklist map[string]struct{}) ([]se.SEIPEntry, []string) {
|
|
if len(blacklist) == 0 {
|
|
return entries, nil
|
|
}
|
|
|
|
filtered := make([]se.SEIPEntry, 0, len(entries))
|
|
blocked := make([]string, 0)
|
|
blockedSeen := make(map[string]struct{})
|
|
appendBlocked := func(addr string) {
|
|
if _, ok := blockedSeen[addr]; ok {
|
|
return
|
|
}
|
|
blockedSeen[addr] = struct{}{}
|
|
blocked = append(blocked, addr)
|
|
}
|
|
|
|
for _, entry := range entries {
|
|
if len(entry.Ports) == 0 {
|
|
addr := net.JoinHostPort(entry.IP, "443")
|
|
if _, ok := blacklist[addr]; ok {
|
|
appendBlocked(addr)
|
|
continue
|
|
}
|
|
filtered = append(filtered, entry)
|
|
continue
|
|
}
|
|
|
|
ports := make([]uint16, 0, len(entry.Ports))
|
|
for _, port := range entry.Ports {
|
|
addr := net.JoinHostPort(entry.IP, strconv.Itoa(int(port)))
|
|
if _, ok := blacklist[addr]; ok {
|
|
appendBlocked(addr)
|
|
continue
|
|
}
|
|
ports = append(ports, port)
|
|
}
|
|
if len(ports) == 0 {
|
|
continue
|
|
}
|
|
filtered = append(filtered, se.SEIPEntry{
|
|
Geo: entry.Geo,
|
|
IP: entry.IP,
|
|
Ports: ports,
|
|
})
|
|
}
|
|
|
|
return filtered, blocked
|
|
}
|
|
|
|
type proxyEndpointKey struct {
|
|
countryCode string
|
|
ip string
|
|
port uint16
|
|
}
|
|
|
|
type proxySpeedResult struct {
|
|
Duration time.Duration
|
|
Err error
|
|
}
|
|
|
|
type proxyListRow struct {
|
|
CountryCode string
|
|
CountryName string
|
|
Host string
|
|
IP string
|
|
Port uint16
|
|
Speed proxySpeedResult
|
|
HasSpeed bool
|
|
}
|
|
|
|
func (r proxySpeedResult) Status() string {
|
|
if r.Err == nil {
|
|
return "ok"
|
|
}
|
|
return r.Err.Error()
|
|
}
|
|
|
|
func parseCountryFilters(raw string) ([]string, bool) {
|
|
parts := strings.Split(raw, ",")
|
|
res := make([]string, 0, len(parts))
|
|
seen := make(map[string]struct{})
|
|
for _, part := range parts {
|
|
country := strings.ToUpper(strings.TrimSpace(part))
|
|
if country == "" {
|
|
continue
|
|
}
|
|
if country == "ALL" || country == "*" {
|
|
return nil, true
|
|
}
|
|
if _, ok := seen[country]; ok {
|
|
continue
|
|
}
|
|
seen[country] = struct{}{}
|
|
res = append(res, country)
|
|
}
|
|
return res, false
|
|
}
|
|
|
|
func proxyEndpointAddrs(entries []se.SEIPEntry) []string {
|
|
addrs := make([]string, 0, countProxyPorts(entries))
|
|
for _, entry := range entries {
|
|
if len(entry.Ports) == 0 {
|
|
addrs = append(addrs, net.JoinHostPort(entry.IP, "443"))
|
|
continue
|
|
}
|
|
for _, port := range entry.Ports {
|
|
addrs = append(addrs, net.JoinHostPort(entry.IP, strconv.Itoa(int(port))))
|
|
}
|
|
}
|
|
return addrs
|
|
}
|
|
|
|
func discoverCountryWithContext(parent context.Context, args *CLIArgs, seclient *se.SEClient, logger *clog.CondLogger, countryCode string) ([]se.SEIPEntry, error) {
|
|
if args.discoverCSV != "" {
|
|
return loadProxyEntriesFromCSV(args.discoverCSV, countryCode, false, args.countryExplicit, args.proxyBlacklist)
|
|
}
|
|
|
|
seen := make(map[proxyEndpointKey]struct{})
|
|
aggregated := make([]se.SEIPEntry, 0)
|
|
requestedGeo := fmt.Sprintf("\"%s\",,", countryCode)
|
|
for attempt := 1; attempt <= args.discoverRepeat; attempt++ {
|
|
ctx, cl := context.WithTimeout(parent, args.timeout)
|
|
res, err := seclient.Discover(ctx, requestedGeo)
|
|
cl()
|
|
if err != nil {
|
|
if isSurfEasyDiscover801(err) {
|
|
fallbackCSV := discoverCSVPathForFallback(args)
|
|
if logger != nil {
|
|
logger.Warning("Discover API returned 801 for country %s, falling back to CSV %q.", countryCode, fallbackCSV)
|
|
}
|
|
res, csvErr := loadProxyEntriesFromCSV(fallbackCSV, countryCode, false, args.countryExplicit, args.proxyBlacklist)
|
|
if csvErr != nil {
|
|
return nil, fmt.Errorf("discover API returned 801 and CSV fallback %q failed: %w", fallbackCSV, csvErr)
|
|
}
|
|
return res, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
if logger != nil {
|
|
logger.Info("Discover for country %s returned %d endpoints on pass #%d: %v", countryCode, len(res), attempt, proxyEndpointAddrs(res))
|
|
}
|
|
res, blocked := filterBlacklistedProxyEntries(res, args.proxyBlacklist)
|
|
if logger != nil && len(blocked) > 0 {
|
|
logger.Info("Discover for country %s skipped %d blacklisted endpoints on pass #%d: %v", countryCode, len(blocked), attempt, blocked)
|
|
}
|
|
aggregated = appendUniqueProxies(aggregated, res, seen)
|
|
}
|
|
sortProxyEntries(aggregated)
|
|
return aggregated, nil
|
|
}
|
|
|
|
func discoverCountry(args *CLIArgs, seclient *se.SEClient, logger *clog.CondLogger, countryCode string) ([]se.SEIPEntry, error) {
|
|
return discoverCountryWithContext(context.Background(), args, seclient, logger, countryCode)
|
|
}
|
|
|
|
func discoverAllCountriesWithContext(parent context.Context, args *CLIArgs, seclient *se.SEClient, logger *clog.CondLogger, countryFilter string) ([]se.SEIPEntry, error) {
|
|
if args.discoverCSV != "" {
|
|
return loadProxyEntriesFromCSV(args.discoverCSV, countryFilter, true, args.countryExplicit, args.proxyBlacklist)
|
|
}
|
|
|
|
ctx, cl := context.WithTimeout(parent, args.timeout)
|
|
countries, err := seclient.GeoList(ctx)
|
|
cl()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
filters, allCountries := parseCountryFilters(countryFilter)
|
|
if !args.countryExplicit {
|
|
allCountries = true
|
|
filters = nil
|
|
}
|
|
allowed := make(map[string]struct{}, len(filters))
|
|
for _, country := range filters {
|
|
allowed[country] = struct{}{}
|
|
}
|
|
|
|
all := make([]se.SEIPEntry, 0)
|
|
seen := make(map[proxyEndpointKey]struct{})
|
|
for _, country := range countries {
|
|
if !allCountries {
|
|
if _, ok := allowed[strings.ToUpper(country.CountryCode)]; !ok {
|
|
continue
|
|
}
|
|
}
|
|
res, err := discoverCountryWithContext(parent, args, seclient, logger, country.CountryCode)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("discover failed for country %s: %w", country.CountryCode, err)
|
|
}
|
|
all = appendUniqueProxies(all, res, seen)
|
|
}
|
|
if len(all) == 0 && !allCountries {
|
|
return nil, fmt.Errorf("no countries matched filter %q", args.country)
|
|
}
|
|
sortProxyEntries(all)
|
|
return all, nil
|
|
}
|
|
|
|
func discoverAllCountries(args *CLIArgs, seclient *se.SEClient, logger *clog.CondLogger) ([]se.SEIPEntry, error) {
|
|
return discoverAllCountriesWithContext(context.Background(), args, seclient, logger, args.country)
|
|
}
|
|
|
|
func appendUniqueProxies(dst, src []se.SEIPEntry, seen map[proxyEndpointKey]struct{}) []se.SEIPEntry {
|
|
for _, entry := range src {
|
|
if len(entry.Ports) == 0 {
|
|
key := proxyEndpointKey{
|
|
countryCode: entry.Geo.CountryCode,
|
|
ip: entry.IP,
|
|
port: 443,
|
|
}
|
|
if _, ok := seen[key]; ok {
|
|
continue
|
|
}
|
|
seen[key] = struct{}{}
|
|
dst = append(dst, se.SEIPEntry{
|
|
Geo: entry.Geo,
|
|
Host: entry.Host,
|
|
IP: entry.IP,
|
|
Ports: []uint16{443},
|
|
})
|
|
continue
|
|
}
|
|
|
|
ports := make([]uint16, 0, len(entry.Ports))
|
|
for _, port := range entry.Ports {
|
|
key := proxyEndpointKey{
|
|
countryCode: entry.Geo.CountryCode,
|
|
ip: entry.IP,
|
|
port: port,
|
|
}
|
|
if _, ok := seen[key]; ok {
|
|
continue
|
|
}
|
|
seen[key] = struct{}{}
|
|
ports = append(ports, port)
|
|
}
|
|
if len(ports) == 0 {
|
|
continue
|
|
}
|
|
dst = append(dst, se.SEIPEntry{
|
|
Geo: entry.Geo,
|
|
Host: entry.Host,
|
|
IP: entry.IP,
|
|
Ports: ports,
|
|
})
|
|
}
|
|
return dst
|
|
}
|
|
|
|
func isSurfEasyDiscover801(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
return strings.Contains(err.Error(), "code=801")
|
|
}
|
|
|
|
func discoverCSVPathForFallback(args *CLIArgs) string {
|
|
if args.discoverCSV != "" {
|
|
return args.discoverCSV
|
|
}
|
|
return DefaultDiscoverCSVFallback
|
|
}
|
|
|
|
func proxyBypassPathForFallback() string {
|
|
return DefaultProxyBypassFallback
|
|
}
|
|
|
|
func loadProxyEntriesFromCSV(filename string, countryFilter string, allowAll bool, countryExplicit bool, blacklist map[string]struct{}) ([]se.SEIPEntry, error) {
|
|
f, err := os.Open(filename)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer f.Close()
|
|
|
|
reader := csv.NewReader(f)
|
|
reader.FieldsPerRecord = -1
|
|
reader.TrimLeadingSpace = true
|
|
|
|
header, err := reader.Read()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
indexByName := make(map[string]int, len(header))
|
|
for i, name := range header {
|
|
indexByName[strings.ToLower(strings.TrimSpace(name))] = i
|
|
}
|
|
|
|
requiredColumns := []string{"country_code", "ip_address", "port"}
|
|
for _, col := range requiredColumns {
|
|
if _, ok := indexByName[col]; !ok {
|
|
return nil, fmt.Errorf("proxy CSV %q is missing required column %q", filename, col)
|
|
}
|
|
}
|
|
|
|
filters, allCountries := parseCountryFilters(countryFilter)
|
|
if allowAll && !countryExplicit {
|
|
allCountries = true
|
|
filters = nil
|
|
}
|
|
allowedCountries := make(map[string]struct{}, len(filters))
|
|
for _, country := range filters {
|
|
allowedCountries[country] = struct{}{}
|
|
}
|
|
|
|
seen := make(map[proxyEndpointKey]struct{})
|
|
entries := make([]se.SEIPEntry, 0)
|
|
lineNo := 1
|
|
fieldValue := func(record []string, column string) (string, error) {
|
|
idx := indexByName[column]
|
|
if idx >= len(record) {
|
|
return "", fmt.Errorf("proxy CSV %q line %d is missing value for %s", filename, lineNo, column)
|
|
}
|
|
return strings.TrimSpace(record[idx]), nil
|
|
}
|
|
for {
|
|
record, err := reader.Read()
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read proxy CSV %q at line %d: %w", filename, lineNo+1, err)
|
|
}
|
|
lineNo++
|
|
|
|
countryCodeValue, err := fieldValue(record, "country_code")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
countryCode := strings.ToUpper(countryCodeValue)
|
|
if countryCode == "" {
|
|
return nil, fmt.Errorf("proxy CSV %q line %d has empty country_code", filename, lineNo)
|
|
}
|
|
if !allCountries {
|
|
if _, ok := allowedCountries[countryCode]; !ok {
|
|
continue
|
|
}
|
|
}
|
|
|
|
ipAddr, err := fieldValue(record, "ip_address")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if ipAddr == "" {
|
|
return nil, fmt.Errorf("proxy CSV %q line %d has empty ip_address", filename, lineNo)
|
|
}
|
|
if net.ParseIP(ipAddr) == nil {
|
|
return nil, fmt.Errorf("proxy CSV %q line %d has invalid ip_address %q", filename, lineNo, ipAddr)
|
|
}
|
|
|
|
portValue, err := fieldValue(record, "port")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
portNum, err := strconv.ParseUint(portValue, 10, 16)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("proxy CSV %q line %d has invalid port %q: %w", filename, lineNo, portValue, err)
|
|
}
|
|
|
|
countryName := ""
|
|
if idx, ok := indexByName["country_name"]; ok && idx < len(record) {
|
|
countryName = strings.TrimSpace(record[idx])
|
|
}
|
|
hostName := ""
|
|
if idx, ok := indexByName["host"]; ok && idx < len(record) {
|
|
hostName = strings.TrimSpace(record[idx])
|
|
}
|
|
|
|
entry := se.SEIPEntry{
|
|
Geo: se.SEGeoEntry{
|
|
CountryCode: countryCode,
|
|
Country: countryName,
|
|
},
|
|
Host: hostName,
|
|
IP: ipAddr,
|
|
Ports: []uint16{uint16(portNum)},
|
|
}
|
|
entries = appendUniqueProxies(entries, []se.SEIPEntry{entry}, seen)
|
|
}
|
|
|
|
entries, _ = filterBlacklistedProxyEntries(entries, blacklist)
|
|
if len(entries) == 0 {
|
|
if allowAll {
|
|
if allCountries {
|
|
return nil, fmt.Errorf("no proxy endpoints found in CSV %q", filename)
|
|
}
|
|
return nil, fmt.Errorf("no proxy endpoints from CSV %q matched country filter %q", filename, countryFilter)
|
|
}
|
|
return nil, fmt.Errorf("no proxy endpoints from CSV %q matched country %q", filename, countryFilter)
|
|
}
|
|
|
|
sortProxyEntries(entries)
|
|
return entries, nil
|
|
}
|
|
|
|
func sortProxyEntries(entries []se.SEIPEntry) {
|
|
for i := range entries {
|
|
sort.Slice(entries[i].Ports, func(a, b int) bool {
|
|
return entries[i].Ports[a] < entries[i].Ports[b]
|
|
})
|
|
}
|
|
sort.Slice(entries, func(i, j int) bool {
|
|
if entries[i].Geo.CountryCode != entries[j].Geo.CountryCode {
|
|
return entries[i].Geo.CountryCode < entries[j].Geo.CountryCode
|
|
}
|
|
if entries[i].IP != entries[j].IP {
|
|
return entries[i].IP < entries[j].IP
|
|
}
|
|
leftPort, rightPort := uint16(443), uint16(443)
|
|
if len(entries[i].Ports) > 0 {
|
|
leftPort = entries[i].Ports[0]
|
|
}
|
|
if len(entries[j].Ports) > 0 {
|
|
rightPort = entries[j].Ports[0]
|
|
}
|
|
return leftPort < rightPort
|
|
})
|
|
}
|
|
|
|
func countProxyPorts(entries []se.SEIPEntry) int {
|
|
total := 0
|
|
for _, entry := range entries {
|
|
if len(entry.Ports) == 0 {
|
|
total++
|
|
continue
|
|
}
|
|
total += len(entry.Ports)
|
|
}
|
|
return total
|
|
}
|
|
|
|
func buildProxyRows(ips []se.SEIPEntry, speedResults map[proxyEndpointKey]proxySpeedResult, sortBy string) []proxyListRow {
|
|
rows := make([]proxyListRow, 0, countProxyPorts(ips))
|
|
for i, ip := range ips {
|
|
ports := ip.Ports
|
|
if len(ports) == 0 {
|
|
ports = []uint16{443}
|
|
}
|
|
for _, port := range ports {
|
|
row := proxyListRow{
|
|
CountryCode: ip.Geo.CountryCode,
|
|
CountryName: ip.Geo.Country,
|
|
Host: ip.Host,
|
|
IP: ip.IP,
|
|
Port: port,
|
|
}
|
|
if row.Host == "" {
|
|
row.Host = fmt.Sprintf("%s%d.%s", strings.ToLower(ip.Geo.CountryCode), i, PROXY_SUFFIX)
|
|
}
|
|
if speedResults != nil {
|
|
result, ok := speedResults[proxyEndpointKey{
|
|
countryCode: ip.Geo.CountryCode,
|
|
ip: ip.IP,
|
|
port: port,
|
|
}]
|
|
row.HasSpeed = ok
|
|
if ok {
|
|
row.Speed = result
|
|
}
|
|
}
|
|
rows = append(rows, row)
|
|
}
|
|
}
|
|
|
|
sortProxyRows(rows, sortBy)
|
|
|
|
return rows
|
|
}
|
|
|
|
func sortProxyRows(rows []proxyListRow, sortBy string) {
|
|
sort.SliceStable(rows, func(i, j int) bool {
|
|
left, right := rows[i], rows[j]
|
|
switch sortBy {
|
|
case "country":
|
|
if left.CountryCode != right.CountryCode {
|
|
return left.CountryCode < right.CountryCode
|
|
}
|
|
if left.CountryName != right.CountryName {
|
|
return left.CountryName < right.CountryName
|
|
}
|
|
if left.IP != right.IP {
|
|
return left.IP < right.IP
|
|
}
|
|
return left.Port < right.Port
|
|
case "ip":
|
|
if left.IP != right.IP {
|
|
return left.IP < right.IP
|
|
}
|
|
if left.Port != right.Port {
|
|
return left.Port < right.Port
|
|
}
|
|
return left.CountryCode < right.CountryCode
|
|
default:
|
|
leftOK := left.HasSpeed && left.Speed.Err == nil
|
|
rightOK := right.HasSpeed && right.Speed.Err == nil
|
|
if leftOK != rightOK {
|
|
return leftOK
|
|
}
|
|
if leftOK && rightOK && left.Speed.Duration != right.Speed.Duration {
|
|
return left.Speed.Duration < right.Speed.Duration
|
|
}
|
|
if left.CountryCode != right.CountryCode {
|
|
return left.CountryCode < right.CountryCode
|
|
}
|
|
if left.IP != right.IP {
|
|
return left.IP < right.IP
|
|
}
|
|
return left.Port < right.Port
|
|
}
|
|
})
|
|
}
|
|
|
|
func benchmarkProxyEndpoints(args *CLIArgs, ips []se.SEIPEntry, caPool *x509.CertPool, logger *clog.CondLogger, dialerFactory func(se.SEIPEntry, string) dialer.ContextDialer) map[proxyEndpointKey]proxySpeedResult {
|
|
results := make(map[proxyEndpointKey]proxySpeedResult)
|
|
var mu sync.Mutex
|
|
var wg sync.WaitGroup
|
|
sem := make(chan struct{}, 8)
|
|
|
|
for _, entry := range ips {
|
|
ports := entry.Ports
|
|
if len(ports) == 0 {
|
|
ports = []uint16{443}
|
|
}
|
|
for _, port := range ports {
|
|
key := proxyEndpointKey{
|
|
countryCode: entry.Geo.CountryCode,
|
|
ip: entry.IP,
|
|
port: port,
|
|
}
|
|
endpoint := net.JoinHostPort(entry.IP, strconv.Itoa(int(port)))
|
|
wg.Add(1)
|
|
go func(entry se.SEIPEntry, key proxyEndpointKey, countryCode, endpoint string) {
|
|
defer wg.Done()
|
|
sem <- struct{}{}
|
|
defer func() { <-sem }()
|
|
|
|
start := time.Now()
|
|
ctx, cl := context.WithTimeout(context.Background(), args.proxySpeedTimeout)
|
|
err := probeProxyEndpoint(ctx, dialerFactory(entry, endpoint), args.proxySpeedTestURL, args.proxySpeedDLLimit, &tls.Config{
|
|
RootCAs: caPool,
|
|
})
|
|
cl()
|
|
|
|
result := proxySpeedResult{
|
|
Duration: time.Since(start),
|
|
Err: err,
|
|
}
|
|
|
|
mu.Lock()
|
|
results[key] = result
|
|
mu.Unlock()
|
|
|
|
if err == nil {
|
|
logger.Info("Speed probe for %s via %s completed in %d ms.", countryCode, endpoint, result.Duration.Milliseconds())
|
|
} else {
|
|
logger.Warning("Speed probe for %s via %s failed: %v", countryCode, endpoint, err)
|
|
}
|
|
}(entry, key, entry.Geo.CountryCode, endpoint)
|
|
}
|
|
}
|
|
|
|
wg.Wait()
|
|
return results
|
|
}
|
|
|
|
func probeProxyEndpoint(ctx context.Context, upstream dialer.ContextDialer, targetURL string, dlLimit int64, tlsClientConfig *tls.Config) error {
|
|
httpClient := http.Client{
|
|
Transport: &http.Transport{
|
|
MaxIdleConns: 100,
|
|
IdleConnTimeout: 90 * time.Second,
|
|
TLSHandshakeTimeout: 10 * time.Second,
|
|
ExpectContinueTimeout: 1 * time.Second,
|
|
DialContext: upstream.DialContext,
|
|
TLSClientConfig: tlsClientConfig,
|
|
ForceAttemptHTTP2: true,
|
|
},
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, targetURL, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
resp, err := httpClient.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return fmt.Errorf("http_%d", resp.StatusCode)
|
|
}
|
|
|
|
var reader io.Reader = resp.Body
|
|
if dlLimit > 0 {
|
|
reader = io.LimitReader(reader, dlLimit)
|
|
}
|
|
_, err = io.Copy(io.Discard, reader)
|
|
return err
|
|
}
|
|
|
|
func main() {
|
|
os.Exit(run())
|
|
}
|
|
|
|
func retryPolicy(retries int, retryInterval time.Duration, logger *clog.CondLogger) func(string, func() error) error {
|
|
return func(name string, f func() error) error {
|
|
var err error
|
|
for i := 1; retries <= 0 || i <= retries; i++ {
|
|
if i > 1 {
|
|
logger.Warning("Retrying action %q in %v...", name, retryInterval)
|
|
time.Sleep(retryInterval)
|
|
}
|
|
logger.Info("Attempting action %q, attempt #%d...", name, i)
|
|
err = f()
|
|
if err == nil {
|
|
logger.Info("Action %q succeeded on attempt #%d", name, i)
|
|
return nil
|
|
}
|
|
logger.Warning("Action %q failed: %v", name, err)
|
|
}
|
|
logger.Critical("All attempts for action %q have failed. Last error: %v", name, err)
|
|
return err
|
|
}
|
|
}
|
|
|
|
func readConfig(filename string) error {
|
|
f, err := os.Open(filename)
|
|
if err != nil {
|
|
return fmt.Errorf("unable to open config file %q: %w", filename, err)
|
|
}
|
|
defer f.Close()
|
|
r := csv.NewReader(f)
|
|
r.Comma = ' '
|
|
r.Comment = '#'
|
|
r.FieldsPerRecord = -1
|
|
r.TrimLeadingSpace = true
|
|
r.ReuseRecord = true
|
|
for {
|
|
record, err := r.Read()
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("configuration file parsing failed: %w", err)
|
|
}
|
|
switch len(record) {
|
|
case 0:
|
|
continue
|
|
case 1:
|
|
if err := flag.Set(record[0], "true"); err != nil {
|
|
line, _ := r.FieldPos(0)
|
|
return fmt.Errorf("error parsing config file %q at line %d (%#v): %w", filename, line, record, err)
|
|
}
|
|
case 2:
|
|
if err := flag.Set(record[0], record[1]); err != nil {
|
|
line, _ := r.FieldPos(0)
|
|
return fmt.Errorf("error parsing config file %q at line %d (%#v): %w", filename, line, record, err)
|
|
}
|
|
default:
|
|
unified := strings.Join(record[1:], " ")
|
|
if err := flag.Set(record[0], unified); err != nil {
|
|
line, _ := r.FieldPos(0)
|
|
return fmt.Errorf("error parsing config file %q at line %d (%#v): %w", filename, line, record, err)
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func version() string {
|
|
bi, ok := debug.ReadBuildInfo()
|
|
if !ok {
|
|
return "unknown"
|
|
}
|
|
return bi.Main.Version
|
|
}
|