package main import ( "bytes" "context" "crypto/tls" "crypto/x509" "encoding/csv" "errors" "flag" "fmt" "io" "log" "net" "net/http" "net/url" "os" "runtime/debug" "strconv" "strings" "time" xproxy "golang.org/x/net/proxy" "github.com/xteamlyer/opera-proxy/clock" "github.com/xteamlyer/opera-proxy/dialer" "github.com/xteamlyer/opera-proxy/handler" clog "github.com/xteamlyer/opera-proxy/log" "github.com/xteamlyer/opera-proxy/resolver" se "github.com/xteamlyer/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" // Default timeouts increased to reduce premature API errors on slow networks. DEFAULT_TIMEOUT = 30 * time.Second DEFAULT_SERVER_SELECTION_TIMEOUT = 60 * time.Second // Reduced idle connection pool to lower resource usage on embedded/low-RAM hosts. HTTP_MAX_IDLE_CONNS = 10 HTTP_MAX_IDLE_CONNS_PER_HOST = 3 HTTP_IDLE_CONN_TIMEOUT = 60 * time.Second ) 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 listCountries bool listProxies bool dpExport bool bindAddress string socksMode bool verbosity int timeout time.Duration showVersion bool proxy string apiLogin string apiPassword string // randomAPICreds: when true, ignore --api-login/--api-password and // generate a fresh cryptographically-random credential pair on every run. randomAPICreds bool apiAddress string apiClientType string apiClientVersion string apiUserAgent string apiProxy string bootstrapDNS *CSVArg refresh time.Duration refreshRetry time.Duration initRetries int initRetryInterval time.Duration caFile string fakeSNI string overrideProxyAddress string 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/", }, }, serverSelection: serverSelectionArg{dialer.ServerSelectionFastest}, } flag.StringVar(&args.country, "country", "EU", "desired proxy location") 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.dpExport, "dp-export", false, "export configuration for dumbproxy") 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", DEFAULT_TIMEOUT, "timeout for network operations (default raised to 30s for better API reliability)") 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: ://[login:password@]host[:port] "+ "Examples: http://user:password@192.168.1.1:3128, socks5://10.0.0.1:1080") 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 (ignored when -random-api-creds is set)") flag.StringVar(&args.apiPassword, "api-password", "SILrMEPBmJuhomxWkfm3JalqHX2Eheg1YhlEZiMh8II", "SurfEasy API password (ignored when -random-api-creds is set)") flag.BoolVar(&args.randomAPICreds, "random-api-creds", false, "generate a random API login/password pair on every run instead of using -api-login/-api-password; "+ "reduces fingerprinting and avoids credential-reuse bans") 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.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 communications with servers") flag.StringVar(&args.overrideProxyAddress, "override-proxy-address", "", "use fixed proxy address instead of server address returned by SurfEasy API") flag.Var(&args.serverSelection, "server-selection", "server selection policy (first/random/fastest)") flag.DurationVar(&args.serverSelectionTimeout, "server-selection-timeout", DEFAULT_SERVER_SELECTION_TIMEOUT, "timeout given for server selection function to produce result (default raised to 60s)") 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() if args.country == "" { arg_fail("Country can't be empty string.") } if args.listCountries && args.listProxies || args.listCountries && args.dpExport || args.listProxies && args.dpExport { 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) } // buildAPITransport returns an http.Transport tuned for infrequent API calls: // reduced idle pool (saves goroutines/sockets), no forced HTTP/2. func buildAPITransport( dialCtx func(context.Context, string, string) (net.Conn, error), dialTLSCtx func(context.Context, string, string) (net.Conn, error), ) *http.Transport { return &http.Transport{ DialContext: dialCtx, DialTLSContext: dialTLSCtx, ForceAttemptHTTP2: false, MaxIdleConns: HTTP_MAX_IDLE_CONNS, MaxIdleConnsPerHost: HTTP_MAX_IDLE_CONNS_PER_HOST, IdleConnTimeout: HTTP_IDLE_CONN_TIMEOUT, TLSHandshakeTimeout: 10 * time.Second, ExpectContinueTimeout: 1 * time.Second, } } func run() int { args := parse_args() if args.showVersion { fmt.Println(version()) 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()) var d dialer.ContextDialer = &net.Dialer{ Timeout: 30 * time.Second, KeepAlive: 30 * time.Second, } caPool := x509.NewCertPool() if args.caFile != "" { // os.ReadFile replaces deprecated ioutil.ReadFile certs, err := os.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) 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) } 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 != "" { mainLogger.Info("Using fixed API host address = %s", args.apiAddress) seclientDialer = dialer.NewFixedDialer(args.apiAddress, seclientDialer) } else if len(args.bootstrapDNS.values) > 0 { res, err := resolver.FastFromURLs(caPool, args.bootstrapDNS.values...) if err != nil { mainLogger.Critical("Unable to instantiate DNS resolver: %v", err) return 4 } seclientDialer = dialer.NewResolvingDialer(res, seclientDialer) } // Dialing w/o SNI, receiving self-signed certificate, so skip verification. // Either way we validate the certificate of the actual proxy server. tlsConfig := &tls.Config{ ServerName: args.fakeSNI, InsecureSkipVerify: true, } // Resolve which API credentials to use. apiLogin := args.apiLogin apiPassword := args.apiPassword if args.randomAPICreds { var err error apiLogin, apiPassword, err = se.GenerateRandomAPICreds() if err != nil { mainLogger.Critical("Failed to generate random API credentials: %v", err) return 17 } mainLogger.Info("Using randomly generated API credentials (login prefix: %.8s...)", apiLogin) } seclient, err := se.NewSEClient(apiLogin, apiPassword, buildAPITransport( seclientDialer.DialContext, 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 }, )) 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 try := retryPolicy(args.initRetries, args.initRetryInterval, mainLogger) 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) } var ips []se.SEIPEntry if args.listProxies || args.dpExport { err = try("discover", func() error { ctx, cl := context.WithTimeout(context.Background(), args.timeout) defer cl() ips, err = seclient.Discover(ctx, fmt.Sprintf("\"%s\",,", args.country)) if err != nil { return err } if len(ips) == 0 { return errors.New("empty endpoints list!") } return nil }) if err != nil { return 12 } if args.listProxies { return printProxies(ips, seclient) } if args.dpExport { return dpExport(ips, seclient, args.fakeSNI) } } handlerDialerFactory := func(endpointAddr string) dialer.ContextDialer { return dialer.NewProxyDialer( dialer.WrapStringToCb(endpointAddr), dialer.WrapStringToCb(fmt.Sprintf("%s0.%s", args.country, PROXY_SUFFIX)), dialer.WrapStringToCb(args.fakeSNI), func() (string, error) { return dialer.BasicAuthHeader(seclient.GetProxyCredentials()), nil }, caPool, d) } var handlerDialer dialer.ContextDialer if args.overrideProxyAddress == "" { err = try("discover", func() error { ctx, cl := context.WithTimeout(context.Background(), args.timeout) defer cl() res, err := seclient.Discover(ctx, fmt.Sprintf("\"%s\",,", args.country)) if err != nil { return err } if len(res) == 0 { return errors.New("empty endpoints list!") } mainLogger.Info("Discovered endpoints: %v. Starting server selection routine %q.", res, args.serverSelection.value) 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(res)) for i, ep := range res { dialers[i] = handlerDialerFactory(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) handlerDialer = handlerDialerFactory(sanitizedEndpoint) mainLogger.Info("Endpoint override: %s", sanitizedEndpoint) } 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) if initError != nil { mainLogger.Critical("Failed to start: %v", initError) return 16 } mainLogger.Info("Init complete.") err = socks.ListenAndServe("tcp", args.bindAddress) } else { h := handler.NewProxyHandler(handlerDialer, proxyLogger) 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 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 } wr := csv.NewWriter(os.Stdout) defer wr.Flush() wr.Write([]string{"country code", "country name"}) for _, country := range list { wr.Write([]string{country.CountryCode, country.Country}) } return 0 } func printProxies(ips []se.SEIPEntry, seclient *se.SEClient) int { wr := csv.NewWriter(os.Stdout) defer wr.Flush() login, password := seclient.GetProxyCredentials() fmt.Println("Proxy login:", login) fmt.Println("Proxy password:", password) fmt.Println("Proxy-Authorization:", dialer.BasicAuthHeader(login, password)) fmt.Println("") wr.Write([]string{"host", "ip_address", "port"}) for i, ip := range ips { for _, port := range ip.Ports { wr.Write([]string{ fmt.Sprintf("%s%d.%s", strings.ToLower(ip.Geo.CountryCode), i, PROXY_SUFFIX), ip.IP, fmt.Sprintf("%d", port), }) } } return 0 } 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 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 }