This commit is contained in:
xteamlyer
2026-04-26 23:22:13 +03:00
committed by Alexey71
parent 1f29e12cf0
commit 677c32bba1
3 changed files with 134 additions and 161 deletions
+52 -17
View File
@@ -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
+52 -67
View File
@@ -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: <http|https|socks5|socks5h>://[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)},
+25 -72
View File
@@ -19,10 +19,6 @@ const (
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
)
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,
}, &regRes)
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
}
// 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()
}