Skip to content
Merged
Show file tree
Hide file tree
Changes from 24 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
81f4ede
feat: add `soaxreport` tool for ECH testing via SOAX proxies
jyyi1 Jan 20, 2026
5b5acd9
Refactor SOAX client into `internal/soax`
jyyi1 Jan 21, 2026
72f8229
Implement a reusable internal/curl package
jyyi1 Jan 21, 2026
e162e9e
implement performance stats collection via `curl -w`
jyyi1 Jan 27, 2026
413f1d8
implement ECH testing with SOAX proxy
jyyi1 Jan 27, 2026
1787668
Implement concurrent SOAX ECH testing
jyyi1 Jan 29, 2026
fa02c6f
Refactor to use CSV country list and include country names
jyyi1 Jan 29, 2026
ce7f872
Ensure same exit node for one ISP thru sticky session.
jyyi1 Jan 29, 2026
0888d55
Implement atomic progress tracking and simplify test func
jyyi1 Feb 2, 2026
79dd375
Separate input ISP and exit node ISP header into different columns.
jyyi1 Feb 2, 2026
f76ed9c
Update parallelism default value in README for soaxreport
jyyi1 Feb 4, 2026
7306193
Add initial analysis report for www.google.com
jyyi1 Feb 4, 2026
98df4fe
Add initial analysis report for mail.google.com
jyyi1 Feb 4, 2026
668f0bc
Add initial analysis report for www.youtube.com
jyyi1 Feb 4, 2026
eb8dc13
Update analysis report for www.google.com
jyyi1 Feb 4, 2026
0a9f9dd
Update divergence chart to ISP based
jyyi1 Feb 4, 2026
5f35f45
Force use main's refactored soaxreport and internal/soax files
jyyi1 Mar 31, 2026
0a5fb04
Restore soaxreport/report and untrack GEMINI.private.md
jyyi1 Mar 31, 2026
4908a8d
Remove redundant internal/curl package
jyyi1 Mar 31, 2026
4ea6723
feat: implement real exit IP discovery for soaxreport
jyyi1 Mar 31, 2026
07c07bc
feat: add geodb asn validation
jyyi1 Mar 31, 2026
7aa573c
docs: update soaxreport README with IP discovery and GeoDB ASN valida…
jyyi1 Mar 31, 2026
a2ee878
refactor(soaxreport): replace analyze.py with Jupyter notebook and up…
jyyi1 Apr 1, 2026
57383e7
docs(soaxreport): add instructions for generating the final report
jyyi1 Apr 1, 2026
0b1ad76
chore: rename soaxreport to ispreport
jyyi1 Apr 3, 2026
9ab2b5e
refactor(ispreport): move output to workspace/ispreport and fix docs
jyyi1 Apr 3, 2026
dd01cd3
feat: use curl json output to capture detailed proxy metrics
jyyi1 Apr 3, 2026
0cf1def
privacy(ispreport): remove raw IP addresses from CSV and struct
jyyi1 Apr 3, 2026
c315f39
docs, data: update ispreport README, AGENTS.md, and results CSVs to r…
jyyi1 Apr 6, 2026
3314c2a
Update report.ipynb with Cloudflare dataset results and enhanced visu…
jyyi1 Apr 6, 2026
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
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ go 1.24.8

require (
github.com/miekg/dns v1.1.70
github.com/oschwald/maxminddb-golang v1.13.1
golang.getoutline.org/sdk/x v0.1.0
golang.org/x/sync v0.19.0
)
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ github.com/onsi/ginkgo/v2 v2.12.0 h1:UIVDowFPwpg6yMUpPjGkYvf06K3RAiJXUhCxEwQVHRI
github.com/onsi/ginkgo/v2 v2.12.0/go.mod h1:ZNEzXISYlqpb8S36iN71ifqLi3vVD1rVJGvWRCJOUpQ=
github.com/onsi/gomega v1.27.10 h1:naR28SdDFlqrG6kScpT8VWpu1xWY5nJRCF3XaYyBjhI=
github.com/onsi/gomega v1.27.10/go.mod h1:RsS8tutOdbdgzbPtzzATp12yT7kM5I5aElG3evPbQ0M=
github.com/oschwald/maxminddb-golang v1.13.1 h1:G3wwjdN9JmIK2o/ermkHM+98oX5fS+k5MbwsmL4MRQE=
github.com/oschwald/maxminddb-golang v1.13.1/go.mod h1:K4pgV9N/GcK694KSTmVSDTODk4IsCNThNdTmnaBZ/F8=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
Expand Down
84 changes: 78 additions & 6 deletions soaxreport/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ You need to build the ECH-enabled `curl` and place it in the workspace directory

You also need to set the SOAX credentials as environment variables and provide a list of ISO country codes.

**(Optional) ASN Validation:** To independently verify the ASN of the proxy exit nodes, you can provide a local IP-to-ASN database in `.mmdb` format (such as [MaxMind GeoLite2](https://dev.maxmind.com/geoip/geolite2-free-geolocation-data) or [DB-IP ASN Lite](https://db-ip.com/db/download/ip-to-asn-lite)).

### Configuration

**SOAX Credentials (Environment Variables)**
Expand Down Expand Up @@ -41,18 +43,32 @@ You can download a complete list of country codes from [here](https://raw.github

## Running

To run the tool, ensure your environment variables are set, then use the `go run` command from the project root directory:
To run the tool, ensure your environment variables are set, then use the `go run` command from the project root directory.

**Basic Run:**

```sh
go run ./soaxreport --targetDomain www.google.com
```

**With Independent ASN Validation (Recommended):**
First, download a free IP-to-ASN `.mmdb` database (e.g., from DB-IP) to your workspace.
```sh
go run ./soaxreport --targetDomain www.google.com --asnDB workspace/dbip-asn-lite.mmdb
Comment thread
jyyi1 marked this conversation as resolved.
Resolved
```

**With Custom IP Check URL and Verbose Logging:**
```sh
go run ./soaxreport --targetDomain www.google.com --ipCheckURL https://ifconfig.me/ip --verbose
```

This will:

1. Load the SOAX credentials from the environment and the country list (`./workspace/countries.csv` by default).
2. For each country, fetch the list of available ISPs.
3. For each ISP, issue requests to the target domain via a SOAX proxy, once with ECH GREASE and once without.
4. Save the results to `./workspace/soax-results-<domain>-countries<N>.csv`.
3. For each ISP, discover the real proxy exit IP via the `ipCheckURL`.
4. Issue requests to the target domain via the SOAX proxy, once with ECH GREASE and once without.
5. Save the results to `./workspace/soax-results-<domain>-countries<N>.csv`.

### Parameters

Expand All @@ -63,6 +79,8 @@ This will:
* `-verbose`: Enable verbose logging.
* `-maxTime <duration>`: Maximum time per curl request. Defaults to `30s`.
* `-curl <path>`: Path to the ECH-enabled curl binary. Defaults to `./workspace/output/bin/curl`.
* `-ipCheckURL <url>`: URL used to discover the real external IP of the proxy. Defaults to `https://ipv4.icanhazip.com/`.
* `-asnDB <path>`: Optional path to a MaxMind or DB-IP `.mmdb` database file for independent ASN verification.

### Output Format

Expand All @@ -77,9 +95,14 @@ The CSV file contains the following columns:
* `country_code`: The 2-letter ISO country code.
* `country_name`: The full name of the country.
* `isp`: The ISP name of the proxy used.
* `asn`: The ASN of the proxy exit node.
* `exit_node_ip`: The IP address of the proxy exit node.
* `exit_node_isp`: The ISP name reported by the proxy exit node (from headers).
* `asn`: The ASN of the proxy exit node as reported by the SOAX proxy headers.
* `exit_node_ip`: The IP address of the proxy exit node as reported by the SOAX proxy headers.
* `exit_node_isp`: The ISP name reported by the SOAX proxy headers.
* `discovered_ip`: The actual public IP address of the proxy, discovered by querying `ipCheckURL`.
* `ip_match`: `true` if `exit_node_ip` equals `discovered_ip`, `false` otherwise.
* `geodb_asn`: The ASN corresponding to the `discovered_ip`, looked up in the `-asnDB` (if provided).
* `geodb_as_name`: The AS organization name corresponding to the `discovered_ip`, looked up in the `-asnDB` (if provided).
* `asn_match`: `true` if the SOAX-reported `asn` matches the `geodb_asn`, `false` otherwise.
* `ech_grease`: `true` if ECH GREASE was enabled for the request, `false` otherwise.
* `error`: Any error that occurred during the request.
* `curl_exit_code`: The exit code returned by the `curl` command.
Expand All @@ -90,3 +113,52 @@ The CSV file contains the following columns:
* `server_time_ms`: The time from the end of the TLS handshake to the first byte of the response.
* `total_time_ms`: The total duration of the request.
* `http_status`: The HTTP status code of the response.

## Generating the Final Report

After running the data collection tool, you can generate a visual report using the provided Jupyter notebook.

### 1. Organize the Data

The notebook expects data to be organized in subdirectories within `soaxreport/report/` named after the tested domain.
Comment thread
jyyi1 marked this conversation as resolved.
Resolved

1. Create a subdirectory for your results (e.g., for `www.google.com`):
```bash
mkdir -p soaxreport/report/www_google_com
```

2. Copy and rename the generated results from the `workspace` directory:
```bash
# Use the actual filename generated in your workspace
cp workspace/soax-results-www_google_com-countriesN.csv soaxreport/report/www_google_com/results.csv
cp workspace/soax-isps-audit.json soaxreport/report/www_google_com/isps-audit.json
```

### 2. Setup the Environment

Running the notebook requires Python 3 and several data analysis libraries.

```bash
# From the project root:
# 1. Create the virtual environment if it doesn't exist
python3 -m venv workspace/.venv

# 2. Activate the virtual environment
source workspace/.venv/bin/activate

# 3. Install required dependencies
pip install pandas numpy matplotlib seaborn ipywidgets jupyter
```

### 3. Run the Notebook

1. Navigate to the report directory and start Jupyter:
```bash
cd soaxreport/report
# If you didn't activate the venv yet, run: source ../../workspace/.venv/bin/activate
jupyter notebook report.ipynb
```

2. In the first code cell of the notebook, update the `DOMAIN` variable to match the name of the subdirectory you created (e.g., `DOMAIN = "www_google_com"`).

3. Run all cells in the notebook to generate the analysis and visualizations.
125 changes: 112 additions & 13 deletions soaxreport/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ import (
"flag"
"fmt"
"log/slog"
"net"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
Expand All @@ -32,17 +34,72 @@ import (
"github.com/Jigsaw-Code/ech-research/internal/echtest"
"github.com/Jigsaw-Code/ech-research/internal/soax"
"github.com/Jigsaw-Code/ech-research/internal/workspace"
"github.com/oschwald/maxminddb-golang"
"golang.org/x/sync/semaphore"
)

type TestResult struct {
echtest.TestResult
Country string
CountryName string
ISP string
ASN string
ExitNodeIP string
ExitNodeISP string
Country string
CountryName string
ISP string
ASN string
ExitNodeIP string
ExitNodeISP string
DiscoveredIP string
IPMatch string
GeoDBASN string
GeoDBASName string
ASNMatch string
}

func lookupASN(db *maxminddb.Reader, ipStr string) (string, string) {
if db == nil || ipStr == "" {
return "", ""
}
ip := net.ParseIP(ipStr)
if ip == nil {
return "", ""
}

var record struct {
AutonomousSystemNumber uint `maxminddb:"autonomous_system_number"`
AutonomousSystemOrganization string `maxminddb:"autonomous_system_organization"`
}

err := db.Lookup(ip, &record)
if err != nil {
return "", ""
}

asn := ""
if record.AutonomousSystemNumber > 0 {
asn = fmt.Sprintf("%d", record.AutonomousSystemNumber)
}
return asn, record.AutonomousSystemOrganization
}

func discoverIP(curlPath, proxyURL string, maxTime time.Duration, discoveryURL string) string {
args := []string{
"-s",
"-4",
"--max-time", strconv.FormatFloat(maxTime.Seconds(), 'f', -1, 64),
"--proxy", proxyURL,
discoveryURL,
}
cmd := exec.Command(curlPath, args...)

binDir := filepath.Dir(curlPath)
libDir := filepath.Join(filepath.Dir(binDir), "lib")
if libStat, err := os.Stat(libDir); err == nil && libStat.IsDir() {
cmd.Env = append(os.Environ(), "LD_LIBRARY_PATH="+libDir)
}

out, err := cmd.Output()
if err != nil {
return ""
}
return strings.TrimSpace(string(out))
}

func runSoaxTest(
Expand All @@ -54,15 +111,20 @@ func runSoaxTest(
proxyURL string,
echGrease bool,
maxTime time.Duration,
ipCheckURL string,
asnDB *maxminddb.Reader,
) TestResult {
discoveredIP := discoverIP(curlPath, proxyURL, maxTime, ipCheckURL)

headers := []string{"Respond-With: ip,isp,asn"}
res := echtest.Run(curlPath, domain, echGrease, maxTime, proxyURL, headers)

result := TestResult{
TestResult: res,
Country: country,
CountryName: countryName,
ISP: isp,
TestResult: res,
Country: country,
CountryName: countryName,
ISP: isp,
DiscoveredIP: discoveredIP,
}

// Parse metadata from Stderr (SOAX specific headers in CONNECT response)
Expand All @@ -87,6 +149,26 @@ func runSoaxTest(
}
}

if result.ExitNodeIP != "" && result.DiscoveredIP != "" {
if result.ExitNodeIP == result.DiscoveredIP {
result.IPMatch = "true"
} else {
result.IPMatch = "false"
}
}

if asnDB != nil && result.DiscoveredIP != "" {
result.GeoDBASN, result.GeoDBASName = lookupASN(asnDB, result.DiscoveredIP)
if result.ASN != "" && result.GeoDBASN != "" {
soaxASN := strings.TrimPrefix(strings.ToUpper(result.ASN), "AS")
if soaxASN == result.GeoDBASN {
result.ASNMatch = "true"
} else {
result.ASNMatch = "false"
}
}
}

return result
}

Expand Down Expand Up @@ -138,6 +220,8 @@ func main() {
maxTimeFlag = flag.Duration("maxTime", 30*time.Second, "Maximum time per curl request")
curlPathFlag = flag.String("curl", "", "Path to the ECH-enabled curl binary")
parallelismFlag = flag.Int("parallelism", 16, "Maximum number of parallel requests")
ipCheckURLFlag = flag.String("ipCheckURL", "https://ipv4.icanhazip.com/", "URL for checking the real exit IP")
asnDBPathFlag = flag.String("asnDB", "", "Optional: Path to a MaxMind/DB-IP ASN database (.mmdb)")
)
flag.Parse()

Expand All @@ -156,6 +240,19 @@ func main() {
curlPath = filepath.Join(workspaceDir, "output", "bin", "curl")
}

// Load ASN database if provided
var asnDB *maxminddb.Reader
if *asnDBPathFlag != "" {
var err error
asnDB, err = maxminddb.Open(*asnDBPathFlag)
if err != nil {
slog.Error("Failed to open ASN database", "path", *asnDBPathFlag, "error", err)
os.Exit(1)
}
defer asnDB.Close()
slog.Info("Using ASN database", "path", *asnDBPathFlag)
}

// Load SOAX config from environment variables
cfg, err := soax.NewConfig(
os.Getenv("SOAX_API_KEY"),
Expand Down Expand Up @@ -200,7 +297,8 @@ func main() {
defer csvWriter.Flush()

header := []string{
"domain", "country_code", "country_name", "isp", "asn", "exit_node_ip", "exit_node_isp", "ech_grease", "error",
"domain", "country_code", "country_name", "isp", "asn", "exit_node_ip", "exit_node_isp", "discovered_ip",
"ip_match", "geodb_asn", "geodb_as_name", "asn_match", "ech_grease", "error",
"curl_exit_code", "curl_error_name", "dns_lookup_ms", "tcp_connection_ms",
"tls_handshake_ms", "server_time_ms", "total_time_ms", "http_status",
}
Expand All @@ -210,7 +308,8 @@ func main() {

for r := range resultsCh {
record := []string{
r.Domain, r.Country, r.CountryName, r.ISP, r.ASN, r.ExitNodeIP, r.ExitNodeISP, strconv.FormatBool(r.ECHGrease), r.Error,
r.Domain, r.Country, r.CountryName, r.ISP, r.ASN, r.ExitNodeIP, r.ExitNodeISP, r.DiscoveredIP,
r.IPMatch, r.GeoDBASN, r.GeoDBASName, r.ASNMatch, strconv.FormatBool(r.ECHGrease), r.Error,
strconv.Itoa(r.CurlExitCode), r.CurlErrorName,
strconv.FormatInt(r.DNSLookup.Milliseconds(), 10),
strconv.FormatInt(r.TCPConnection.Milliseconds(), 10),
Expand Down Expand Up @@ -260,7 +359,7 @@ func main() {

proxyURL := soax.BuildWebProxyURL(cfg, c.Code, isp, sid)
slog.Debug("Testing ISP", "country", c.Code, "isp", isp, "ech_grease", ech, "session", sid)
resultsCh <- runSoaxTest(curlPath, domain, c.Code, c.Name, isp, proxyURL, ech, *maxTimeFlag)
resultsCh <- runSoaxTest(curlPath, domain, c.Code, c.Name, isp, proxyURL, ech, *maxTimeFlag, *ipCheckURLFlag, asnDB)
progress := fmt.Sprintf("%d/%d", finished.Add(1), total.Load())
slog.Info("Finished", "country", c.Code, "isp", isp, "progress", progress)
}
Expand Down
Loading