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 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") 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") 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.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") 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, } } // buildCAPool constructs the x509 cert pool used for all TLS verification. // When -cafile is given, only that file is loaded (useful for custom/corporate CAs). // Otherwise the bundled Mozilla NSS root store is used, which includes all // major roots and supports AddCertWithConstraint for name-constrained CAs — // strictly better than a plain PEM file. func buildCAPool(caFile string, logger *clog.CondLogger) (*x509.CertPool, int) { pool := x509.NewCertPool() if caFile != "" { certs, err := os.ReadFile(caFile) if err != nil { logger.Error("Can't load CA file: %v", err) return nil, 15 } if ok := pool.AppendCertsFromPEM(certs); !ok { logger.Error("Can't load certificates from CA file") return nil, 15 } return pool, 0 } for c := range bundle.Roots() { cert, err := x509.ParseCertificate(c.Certificate) if err != nil { logger.Error("Unable to parse bundled certificate: %v", err) return nil, 15 } if c.Constraint == nil { pool.AddCert(cert) } else { pool.AddCertWithConstraint(cert, c.Constraint) } } return pool, 0 } 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, exitCode := buildCAPool(args.caFile, mainLogger) if exitCode != 0 { return exitCode } 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 api-proxy URL: %v", err) return 6 } pxDialer, err := xproxy.FromURL(apiProxyURL, seclientDialer) if err != nil { mainLogger.Critical("Unable to instantiate api-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) } // TLS config for the API connection: SNI suppressed (or faked), cert // verification is skipped at the TLS layer because the API endpoint uses // a self-signed cert — actual peer verification happens in VerifyConnection // inside ProxyDialer for the proxy connections. tlsConfig := &tls.Config{ ServerName: args.fakeSNI, InsecureSkipVerify: true, } seclient, err := se.NewSEClient(args.apiLogin, args.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() if err := seclient.Login(reqCtx); 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() if err := seclient.DeviceGeneratePassword(reqCtx); 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 }