Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion transport/tls/stream_dialer.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,10 @@ func ToGoTLSConfig(cfg *ClientConfig) *tls.Config {

// toStdConfig creates a [tls.Config] based on the configured parameters.
func (cfg *ClientConfig) toStdConfig() *tls.Config {
certVerifier := cfg.CertVerifier
if certVerifier == nil {
certVerifier = &StandardCertVerifier{CertificateName: cfg.ServerName}
}
return &tls.Config{
ServerName: cfg.ServerName,
NextProtos: cfg.NextProtos,
Expand All @@ -120,7 +124,7 @@ func (cfg *ClientConfig) toStdConfig() *tls.Config {
// replacing. This will not disable VerifyConnection.
InsecureSkipVerify: true,
VerifyConnection: func(cs tls.ConnectionState) error {
return cfg.CertVerifier.VerifyCertificate(&CertVerificationContext{
return certVerifier.VerifyCertificate(&CertVerificationContext{
PeerCertificates: cs.PeerCertificates,
})
},
Expand Down
6 changes: 5 additions & 1 deletion x/httpconnect/transport.go
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,10 @@ func (rt proxyRT) Scheme() string {
// TODO: Replace with tls.ToGoTLSConfig call once outline-sdk dependency version for this module is bumped.
// It is basically a copy of the implementation ToGoTLSConfig
func toStdConfig(cfg tls.ClientConfig) *stdTLS.Config {
certVerifier := cfg.CertVerifier
if certVerifier == nil {
certVerifier = &tls.StandardCertVerifier{CertificateName: cfg.ServerName}
}
return &stdTLS.Config{
ServerName: cfg.ServerName,
NextProtos: cfg.NextProtos,
Expand All @@ -171,7 +175,7 @@ func toStdConfig(cfg tls.ClientConfig) *stdTLS.Config {
// replacing. This will not disable VerifyConnection.
InsecureSkipVerify: true,
VerifyConnection: func(cs stdTLS.ConnectionState) error {
return cfg.CertVerifier.VerifyCertificate(&tls.CertVerificationContext{
return certVerifier.VerifyCertificate(&tls.CertVerificationContext{
PeerCertificates: cs.PeerCertificates,
})
},
Expand Down
16 changes: 14 additions & 2 deletions x/soax/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ Request format:
https://api.soax.com/api/get-country-regions?api_key=<api_key>&package_key=<package_key>&country_iso=<country_iso>&conn_type=<conn_type>[&provider=<provider>]
```

The `conn_type` parameter specifies the connection type. It must be `wifi` for residential proxies and `mobile` for mobile proxies.
The `conn_type` parameter specifies the connection type. It must be `wifi` for Residential packages and `mobile` for Mobile packages.

Here is an example of listing regions in Iran:

Expand All @@ -123,4 +123,16 @@ Request format:
https://api.soax.com/api/get-country-cities?api_key=<api_key>&package_key=<package_key>&country_iso=<country_iso>&conn_type=<conn_type>[&provider=<provider_name>[&region=<region_name>]]
```

The `conn_type` parameter specifies the connection type. It must be `wifi` for residential proxies and `mobile` for mobile proxies.
The `conn_type` parameter specifies the connection type. It must be `wifi` for Residential packages and `mobile` for Mobile packages.

## Testing

Unit tests use a mock server and run with `go test ./...`.

Integration tests against the live SOAX API are in `api_integration_test.go` and require credentials:

```sh
SOAX_API_KEY=<api_key> SOAX_PACKAGE_KEY=<package_key> go test -run TestLiveAPI -v
```

Note that `TestLiveAPI_GetResidentialISPs` requires a Residential package and will fail with a Mobile package, and `TestLiveAPI_GetMobileISPs` requires a Mobile package and will fail with a Residential package.
17 changes: 9 additions & 8 deletions x/soax/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,6 @@ const (
type Client struct {
APIKey string
PackageKey string
// ConnType is the connection type.
ConnType ConnType
// HTTPClient is the client to use for API calls. If nil, a default client will be used.
HTTPClient *http.Client
// BaseURL for testing. If empty, "https://api.soax.com" is used. This can be a plain URL, or one
Expand Down Expand Up @@ -112,7 +110,8 @@ func (c *Client) doAndDecode(req *http.Request, result any) error {
}

// GetResidentialISPs returns the available ISPs for the given location.
// This is for Residential packages only. The official documentation refers to them as "WiFi ISPS".
// Requires a Residential package; returns an error with a Mobile package.
// The official documentation refers to residential ISPs as "WiFi ISPs".
// API reference: https://helpcenter.soax.com/en/articles/6228391-getting-a-list-of-wifi-isps
func (c *Client) GetResidentialISPs(ctx context.Context, countryCode, regionID, cityID string) ([]string, error) {
req, err := c.newRequest(ctx, "/api/get-country-isp", map[string]string{
Expand All @@ -131,7 +130,7 @@ func (c *Client) GetResidentialISPs(ctx context.Context, countryCode, regionID,
}

// GetMobileISPs returns the available mobile carriers for the given location.
// This is for Mobile packages only.
// Requires a Mobile package; returns an error with a Residential package.
// API reference: https://helpcenter.soax.com/en/articles/6228381-getting-a-list-of-mobile-carriers
func (c *Client) GetMobileISPs(ctx context.Context, countryCode, regionID, cityID string) ([]string, error) {
req, err := c.newRequest(ctx, "/api/get-country-operators", map[string]string{
Expand All @@ -150,11 +149,12 @@ func (c *Client) GetMobileISPs(ctx context.Context, countryCode, regionID, cityI
}

// GetRegions returns the available regions for the given country and ISP.
// connType must match the package type: [ConnTypeResidential] for Residential packages, [ConnTypeMobile] for Mobile packages.
// API reference: https://helpcenter.soax.com/en/articles/6227864-getting-a-list-of-regions
func (c *Client) GetRegions(ctx context.Context, countryCode, isp string) ([]string, error) {
func (c *Client) GetRegions(ctx context.Context, connType ConnType, countryCode, isp string) ([]string, error) {
req, err := c.newRequest(ctx, "/api/get-country-regions", map[string]string{
"country_iso": strings.ToLower(countryCode),
"conn_type": string(c.ConnType),
"conn_type": string(connType),
"provider": isp,
})
if err != nil {
Expand All @@ -168,11 +168,12 @@ func (c *Client) GetRegions(ctx context.Context, countryCode, isp string) ([]str
}

// GetCities returns the available cities for the given country, ISP, and region.
// connType must match the package type: [ConnTypeResidential] for Residential packages, [ConnTypeMobile] for Mobile packages.
// API reference: https://helpcenter.soax.com/en/articles/6228092-getting-a-list-of-cities
func (c *Client) GetCities(ctx context.Context, countryCode, isp, regionID string) ([]string, error) {
func (c *Client) GetCities(ctx context.Context, connType ConnType, countryCode, isp, regionID string) ([]string, error) {
req, err := c.newRequest(ctx, "/api/get-country-cities", map[string]string{
"country_iso": strings.ToLower(countryCode),
"conn_type": string(c.ConnType),
"conn_type": string(connType),
"provider": isp,
"region": regionID,
})
Expand Down
68 changes: 68 additions & 0 deletions x/soax/api_integration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// Copyright 2025 The Outline Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package soax

import (
"context"
"os"
"testing"

"github.com/stretchr/testify/require"
)

func newLiveClient(t *testing.T) *Client {
t.Helper()
apiKey := os.Getenv("SOAX_API_KEY")
packageKey := os.Getenv("SOAX_PACKAGE_KEY")
if apiKey == "" || packageKey == "" {
t.Skip("SOAX_API_KEY and SOAX_PACKAGE_KEY must be set to run live API tests")
}
return &Client{
APIKey: apiKey,
PackageKey: packageKey,
}
}

func TestLiveAPI_GetResidentialISPs(t *testing.T) {
client := newLiveClient(t)
isps, err := client.GetResidentialISPs(context.Background(), "US", "", "")
require.NoError(t, err)
require.NotEmpty(t, isps)
t.Logf("Residential ISPs in US (%v): %v", len(isps), isps)
}

func TestLiveAPI_GetMobileISPs(t *testing.T) {
client := newLiveClient(t)
isps, err := client.GetMobileISPs(context.Background(), "US", "", "")
require.NoError(t, err)
require.NotEmpty(t, isps)
t.Logf("Mobile ISPs in US (%v): %v", len(isps), isps)
}

func TestLiveAPI_GetRegions(t *testing.T) {
client := newLiveClient(t)
regions, err := client.GetRegions(context.Background(), ConnTypeMobile, "US", "")
require.NoError(t, err)
require.NotEmpty(t, regions)
t.Logf("Mobile regions in US (%v): %v", len(regions), regions)
}

func TestLiveAPI_GetCities(t *testing.T) {
client := newLiveClient(t)
cities, err := client.GetCities(context.Background(), ConnTypeMobile, "US", "", "")
require.NoError(t, err)
require.NotEmpty(t, cities)
t.Logf("Mobile cities in US (%v): %v", len(cities), cities)
}
6 changes: 2 additions & 4 deletions x/soax/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,15 +92,13 @@ func TestClient(t *testing.T) {
})

t.Run("GetRegions", func(t *testing.T) {
client.ConnType = ConnTypeMobile
regions, err := client.GetRegions(ctx, "FR", "orange")
regions, err := client.GetRegions(ctx, ConnTypeMobile, "FR", "orange")
require.NoError(t, err)
require.Equal(t, []string{"region1", "region2"}, regions)
})

t.Run("GetCities", func(t *testing.T) {
client.ConnType = ConnTypeResidential
cities, err := client.GetCities(ctx, "ES", "movistar", "md")
cities, err := client.GetCities(ctx, ConnTypeResidential, "ES", "movistar", "md")
require.NoError(t, err)
require.Equal(t, []string{"city1", "city2"}, cities)
})
Expand Down
11 changes: 5 additions & 6 deletions x/soax/proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ type ProxySessionConfig struct {

// ProxySession represents a session with unique SessionID, created from a SessionConfig.
type ProxySession struct {
config *ProxySessionConfig
config ProxySessionConfig
}

func (c *ProxySessionConfig) newUserPassword() *url.Userinfo {
Expand Down Expand Up @@ -109,8 +109,7 @@ func (c *ProxySessionConfig) newUserPassword() *url.Userinfo {

func (c *ProxySessionConfig) NewSession() *ProxySession {
session := new(ProxySession)
// Copy the config to not modify the original one.
session.config = c
session.config = *c
if session.config.Session != SessionNotPersistent {
if session.config.Session.ID == "" {
session.config.Session.ID = strconv.Itoa(int(time.Now().UnixMilli()))
Expand Down Expand Up @@ -145,10 +144,10 @@ func (c *ProxySession) NewSOCKS5Client() (*socks5.Client, error) {
return client, nil
}

// NewStreamDialer creates a [transport.StreamDialer] that connects through the SOAX proxy.
// NewWebProxyStreamDialer creates a [transport.StreamDialer] that connects through the SOAX proxy.
// It uses HTTP CONNECT, so it only supports TCP.
func (c *ProxySession) NewWebProxyStreamDialer() (transport.StreamDialer, error) {
rt, err := httpconnect.NewHTTPProxyTransport(&transport.TCPDialer{}, c.config.Endpoint)
func (c *ProxySession) NewWebProxyStreamDialer(opts ...httpconnect.TransportOption) (transport.StreamDialer, error) {
rt, err := httpconnect.NewHTTPProxyTransport(&transport.TCPDialer{}, c.config.Endpoint, opts...)
if err != nil {
return nil, err
}
Expand Down
106 changes: 106 additions & 0 deletions x/soax/proxy_integration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
// Copyright 2025 The Outline Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package soax

import (
"context"
"encoding/json"
"io"
"net"
"net/http"
"os"
"strconv"
"testing"

"github.com/stretchr/testify/require"
"golang.getoutline.org/sdk/transport"
)

const checkerURL = "https://checker.soax.com/api/ipinfo"

type ipInfoResponse struct {
Status bool `json:"status"`
Reason string `json:"reason"`
Data struct {
CountryCode string `json:"country_code"`
IP string `json:"ip"`
ISP string `json:"isp"`
Carrier string `json:"carrier"`
Region string `json:"region"`
City string `json:"city"`
} `json:"data"`
}

func newLiveProxyConfig(t *testing.T) ProxySessionConfig {
t.Helper()
packageIDStr := os.Getenv("SOAX_PACKAGE_ID")
packageKey := os.Getenv("SOAX_PACKAGE_KEY")
if packageIDStr == "" || packageKey == "" {
t.Skip("SOAX_PACKAGE_ID and SOAX_PACKAGE_KEY must be set to run live proxy tests")
}
packageID, err := strconv.Atoi(packageIDStr)
require.NoError(t, err)
return ProxySessionConfig{
Auth: ProxyAuthConfig{
PackageID: packageID,
PackageKey: packageKey,
},
Node: ProxyNodeConfig{
CountryCode: "US",
},
}
}

func fetchIPInfo(t *testing.T, dialer transport.StreamDialer) *ipInfoResponse {
t.Helper()
httpClient := &http.Client{
Transport: &http.Transport{
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
return dialer.DialStream(ctx, addr)
},
},
}
resp, err := httpClient.Get(checkerURL)
require.NoError(t, err)
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
var info ipInfoResponse
require.NoError(t, json.Unmarshal(body, &info))
require.True(t, info.Status, "checker returned status=false: %s", info.Reason)
return &info
}

func TestLiveProxy_SOCKS5(t *testing.T) {
config := newLiveProxyConfig(t)
session := config.NewSession()
client, err := session.NewSOCKS5Client()
require.NoError(t, err)

info := fetchIPInfo(t, client)
require.Equal(t, "US", info.Data.CountryCode)
t.Logf("SOCKS5: ip=%s isp=%s city=%s region=%s", info.Data.IP, info.Data.ISP, info.Data.City, info.Data.Region)
}

func TestLiveProxy_HTTPConnect(t *testing.T) {
config := newLiveProxyConfig(t)
session := config.NewSession()
dialer, err := session.NewWebProxyStreamDialer()
require.NoError(t, err)

info := fetchIPInfo(t, dialer)
require.Equal(t, "US", info.Data.CountryCode)
t.Logf("HTTP CONNECT: ip=%s isp=%s city=%s region=%s", info.Data.IP, info.Data.ISP, info.Data.City, info.Data.Region)
}
Loading
Loading