diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d5c55a3..0fa3037 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -2,8 +2,26 @@ name: build on: push: - tags: - - 'v*' + workflow_dispatch: + inputs: + create_release: + description: 'Create release? (yes/no)' + required: true + default: 'no' + type: choice + options: + - 'yes' + - 'no' + tag_name: + description: 'Release tag name (e.g., v1.2.3)' + required: false + default: '' + type: string + release_name: + description: 'Release name (optional, defaults to tag name)' + required: false + type: string + default: '' jobs: build: @@ -12,31 +30,48 @@ jobs: contents: write steps: - - - name: Checkout + - name: Checkout uses: actions/checkout@v6 with: fetch-depth: 0 - - - name: Setup Go + + - name: Setup Go uses: actions/setup-go@v6 with: - go-version: 'stable' - - - name: Read tag - id: tag - run: echo "tag=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT - - - name: Build + go-version: "stable" + + - name: Build run: >- make -j $(nproc) allplus NDK_CC_ARM64="$ANDROID_NDK/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android21-clang" NDK_CC_ARM="$ANDROID_NDK/toolchains/llvm/prebuilt/linux-x86_64/bin/armv7a-linux-androideabi21-clang" - VERSION=${{steps.tag.outputs.tag}} - - - name: Release + VERSION=v1.17.1 + + - name: Upload artifact for arm64 + uses: actions/upload-artifact@v7 + with: + path: bin/opera-proxy.android-arm64 + archive: false + + - name: Upload artifact for win amd64 + uses: actions/upload-artifact@v7 + with: + path: bin/opera-proxy.windows-amd64.exe + archive: false + + - name: Upload all artifact + uses: actions/upload-artifact@v7 + with: + path: bin/* + + - name: Create Release + if: github.event_name == 'workflow_dispatch' && github.event.inputs.create_release == 'yes' uses: softprops/action-gh-release@v3 with: - files: bin/* + tag_name: ${{ github.event.inputs.tag_name }} + name: ${{ github.event.inputs.release_name || github.event.inputs.tag_name }} + files: bin/**/* fail_on_unmatched_files: true generate_release_notes: true + draft: false + prerelease: false \ No newline at end of file diff --git a/main.go b/main.go index ede8a09..fdf48e1 100644 --- a/main.go +++ b/main.go @@ -120,9 +120,6 @@ type CLIArgs struct { 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 @@ -168,7 +165,7 @@ func parse_args() *CLIArgs { 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)") + "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] "+ @@ -176,13 +173,8 @@ func parse_args() *CLIArgs { 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.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", @@ -198,7 +190,7 @@ func parse_args() *CLIArgs { 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)") + "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") @@ -241,6 +233,40 @@ func buildAPITransport( } } +// 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 { @@ -267,31 +293,9 @@ func run() int { 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) - } - } + caPool, exitCode := buildCAPool(args.caFile, mainLogger) + if exitCode != 0 { + return exitCode } xproxy.RegisterDialerType("http", proxyFromURLWrapper) @@ -314,12 +318,12 @@ func run() int { if args.apiProxy != "" { apiProxyURL, err := url.Parse(args.apiProxy) if err != nil { - mainLogger.Critical("Unable to parse base proxy URL: %v", err) + 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 base proxy dialer: %v", err) + mainLogger.Critical("Unable to instantiate api-proxy dialer: %v", err) return 7 } seclientDialer = pxDialer.(dialer.ContextDialer) @@ -336,27 +340,16 @@ func run() int { 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. + // 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, } - // 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( + 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) @@ -460,9 +453,7 @@ func run() int { ss = dialer.NewFastestServerSelectionFunc( args.serverSelectionTestURL, args.serverSelectionDLLimit, - &tls.Config{ - RootCAs: caPool, - }, + &tls.Config{RootCAs: caPool}, ) default: panic("unhandled server selection value got past parsing") @@ -497,8 +488,7 @@ func run() int { mainLogger.Info("Refreshing login...") reqCtx, cl := context.WithTimeout(ctx, args.timeout) defer cl() - err := seclient.Login(reqCtx) - if err != nil { + if err := seclient.Login(reqCtx); err != nil { mainLogger.Error("Login refresh failed: %v", err) return err } @@ -507,8 +497,7 @@ func run() int { mainLogger.Info("Refreshing device password...") reqCtx, cl = context.WithTimeout(ctx, args.timeout) defer cl() - err = seclient.DeviceGeneratePassword(reqCtx) - if err != nil { + if err := seclient.DeviceGeneratePassword(reqCtx); err != nil { mainLogger.Error("Device password refresh failed: %v", err) return err } @@ -547,7 +536,6 @@ func printCountries(try func(string, func() error) error, logger *clog.CondLogge if err != nil { return 11 } - wr := csv.NewWriter(os.Stdout) defer wr.Flush() wr.Write([]string{"country code", "country name"}) @@ -591,10 +579,7 @@ func dpExport(ips []se.SEIPEntry, seclient *se.SEClient, sni string) int { u := url.URL{ Scheme: "https", User: creds, - Host: net.JoinHostPort( - ip.IP, - strconv.Itoa(int(ip.Ports[0])), - ), + 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)}, diff --git a/seclient/seclient.go b/seclient/seclient.go index bbf9c00..1451b81 100644 --- a/seclient/seclient.go +++ b/seclient/seclient.go @@ -15,14 +15,10 @@ import ( ) const ( - ANON_EMAIL_LOCALPART_BYTES = 32 - ANON_PASSWORD_BYTES = 20 - DEVICE_ID_BYTES = 20 - READ_LIMIT int64 = 128 * 1024 - - // Byte length for randomly generated API credential components. - RANDOM_API_LOGIN_BYTES = 16 - RANDOM_API_PASSWORD_BYTES = 24 + ANON_EMAIL_LOCALPART_BYTES = 32 + ANON_PASSWORD_BYTES = 20 + DEVICE_ID_BYTES = 20 + READ_LIMIT int64 = 128 * 1024 ) type SEEndpoints struct { @@ -76,24 +72,10 @@ type SEClient struct { type StrKV map[string]string -// GenerateRandomAPICreds returns a cryptographically-random (login, password) pair -// suitable for use as SurfEasy digest-auth credentials. Call this instead of -// using the hardcoded defaults when -random-api-creds is set. -func GenerateRandomAPICreds() (login, password string, err error) { - rng := rand.New(RandomSource) - login, err = randomCapitalHexString(rng, RANDOM_API_LOGIN_BYTES) - if err != nil { - return "", "", fmt.Errorf("generate random api login: %w", err) - } - password, err = randomCapitalHexString(rng, RANDOM_API_PASSWORD_BYTES) - if err != nil { - return "", "", fmt.Errorf("generate random api password: %w", err) - } - return login, password, nil -} - -// NewSEClient instantiates a SurfEasy client with default settings and given API keys. -// Optional transport parameter allows overriding the HTTP transport used for API calls. +// NewSEClient instantiates a SurfEasy client. +// apiUsername/apiSecret are the application-level Digest Auth credentials +// embedded in every Opera client — they are NOT per-user and must not be randomised. +// transport may be nil (uses http.DefaultTransport). func NewSEClient(apiUsername, apiSecret string, transport http.RoundTripper) (*SEClient, error) { if transport == nil { transport = http.DefaultTransport @@ -101,7 +83,7 @@ func NewSEClient(apiUsername, apiSecret string, transport http.RoundTripper) (*S rng := rand.New(RandomSource) - device_id, err := randomCapitalHexString(rng, DEVICE_ID_BYTES) + deviceID, err := randomCapitalHexString(rng, DEVICE_ID_BYTES) if err != nil { return nil, err } @@ -111,17 +93,15 @@ func NewSEClient(apiUsername, apiSecret string, transport http.RoundTripper) (*S return nil, err } - res := &SEClient{ + return &SEClient{ httpClient: &http.Client{ Jar: jar, Transport: dac.NewDigestTransport(apiUsername, apiSecret, transport), }, Settings: DefaultSESettings, rng: rng, - DeviceID: device_id, - } - - return res, nil + DeviceID: deviceID, + }, nil } func (c *SEClient) ResetCookies() error { @@ -142,7 +122,8 @@ func (c *SEClient) AnonRegister(ctx context.Context) error { if err != nil { return err } - + // Each run generates a fresh random subscriber identity — this is the + // actual anonymisation layer. The API-level credentials above are fixed. c.SubscriberEmail = fmt.Sprintf("%s@%s.best.vpn", localPart, c.Settings.ClientType) c.SubscriberPassword = capitalHexSHA1(c.SubscriberEmail) @@ -156,22 +137,19 @@ func (c *SEClient) Register(ctx context.Context) error { } func (c *SEClient) register(ctx context.Context) error { - err := c.resetCookies() - if err != nil { + if err := c.resetCookies(); err != nil { return err } - var regRes SERegisterSubscriberResponse - err = c.rpcCall(ctx, c.Settings.Endpoints.RegisterSubscriber, StrKV{ + err := c.rpcCall(ctx, c.Settings.Endpoints.RegisterSubscriber, StrKV{ "email": c.SubscriberEmail, "password": c.SubscriberPassword, }, ®Res) if err != nil { return err } - if regRes.Status.Code != SE_STATUS_OK { - return fmt.Errorf("API responded with error message: code=%d, msg=\"%s\"", + return fmt.Errorf("API responded with error message: code=%d, msg=%q", regRes.Status.Code, regRes.Status.Message) } return nil @@ -190,12 +168,10 @@ func (c *SEClient) RegisterDevice(ctx context.Context) error { if err != nil { return err } - if regRes.Status.Code != SE_STATUS_OK { - return fmt.Errorf("API responded with error message: code=%d, msg=\"%s\"", + return fmt.Errorf("API responded with error message: code=%d, msg=%q", regRes.Status.Code, regRes.Status.Message) } - c.AssignedDeviceID = regRes.Data.DeviceID c.DevicePassword = regRes.Data.DevicePassword c.AssignedDeviceIDHash = capitalHexSHA1(regRes.Data.DeviceID) @@ -213,12 +189,10 @@ func (c *SEClient) GeoList(ctx context.Context) ([]SEGeoEntry, error) { if err != nil { return nil, err } - if geoListRes.Status.Code != SE_STATUS_OK { - return nil, fmt.Errorf("API responded with error message: code=%d, msg=\"%s\"", + return nil, fmt.Errorf("API responded with error message: code=%d, msg=%q", geoListRes.Status.Code, geoListRes.Status.Message) } - return geoListRes.Data.Geos, nil } @@ -234,12 +208,10 @@ func (c *SEClient) Discover(ctx context.Context, requestedGeo string) ([]SEIPEnt if err != nil { return nil, err } - if discoverRes.Status.Code != SE_STATUS_OK { - return nil, fmt.Errorf("API responded with error message: code=%d, msg=\"%s\"", + return nil, fmt.Errorf("API responded with error message: code=%d, msg=%q", discoverRes.Status.Code, discoverRes.Status.Message) } - return discoverRes.Data.IPs, nil } @@ -247,13 +219,11 @@ func (c *SEClient) Login(ctx context.Context) error { c.Mux.Lock() defer c.Mux.Unlock() - err := c.resetCookies() - if err != nil { + if err := c.resetCookies(); err != nil { return err } - var loginRes SESubscriberLoginResponse - err = c.rpcCall(ctx, c.Settings.Endpoints.SubscriberLogin, StrKV{ + err := c.rpcCall(ctx, c.Settings.Endpoints.SubscriberLogin, StrKV{ "login": c.SubscriberEmail, "password": c.SubscriberPassword, "client_type": c.Settings.ClientType, @@ -261,9 +231,8 @@ func (c *SEClient) Login(ctx context.Context) error { if err != nil { return err } - if loginRes.Status.Code != SE_STATUS_OK { - return fmt.Errorf("API responded with error message: code=%d, msg=\"%s\"", + return fmt.Errorf("API responded with error message: code=%d, msg=%q", loginRes.Status.Code, loginRes.Status.Message) } return nil @@ -280,12 +249,10 @@ func (c *SEClient) DeviceGeneratePassword(ctx context.Context) error { if err != nil { return err } - if genRes.Status.Code != SE_STATUS_OK { - return fmt.Errorf("API responded with error message: code=%d, msg=\"%s\"", + return fmt.Errorf("API responded with error message: code=%d, msg=%q", genRes.Status.Code, genRes.Status.Message) } - c.DevicePassword = genRes.Data.DevicePassword return nil } @@ -293,7 +260,6 @@ func (c *SEClient) DeviceGeneratePassword(ctx context.Context) error { func (c *SEClient) GetProxyCredentials() (string, string) { c.Mux.Lock() defer c.Mux.Unlock() - return c.AssignedDeviceIDHash, c.DevicePassword } @@ -306,7 +272,6 @@ func (c *SEClient) populateRequest(req *http.Request) { func (c *SEClient) RpcCall(ctx context.Context, endpoint string, params map[string]string, res interface{}) error { c.Mux.Lock() defer c.Mux.Unlock() - return c.rpcCall(ctx, endpoint, params, res) } @@ -315,12 +280,8 @@ func (c *SEClient) rpcCall(ctx context.Context, endpoint string, params map[stri for k, v := range params { input[k] = []string{v} } - req, err := http.NewRequestWithContext( - ctx, - "POST", - endpoint, - strings.NewReader(input.Encode()), - ) + req, err := http.NewRequestWithContext(ctx, "POST", endpoint, + strings.NewReader(input.Encode())) if err != nil { return err } @@ -334,25 +295,17 @@ func (c *SEClient) rpcCall(ctx context.Context, endpoint string, params map[stri } if resp.StatusCode != http.StatusOK { + cleanupBody(resp.Body) return fmt.Errorf("bad http status: %s, headers: %#v", resp.Status, resp.Header) } - decoder := json.NewDecoder(resp.Body) - err = decoder.Decode(res) + err = json.NewDecoder(resp.Body).Decode(res) cleanupBody(resp.Body) - - if err != nil { - return err - } - - return nil + return err } // cleanupBody drains and closes an HTTP response body to allow connection reuse. func cleanupBody(body io.ReadCloser) { - io.Copy(io.Discard, &io.LimitedReader{ - R: body, - N: READ_LIMIT, - }) + io.Copy(io.Discard, &io.LimitedReader{R: body, N: READ_LIMIT}) body.Close() }