Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,9 @@ Run all checks (lint, tests, audit, cross-compile): `./scripts/run-tests.sh`. Re
Your terminal -> shellroute CLI (local proxy) -> shellroute API -> Gateway -> Exit IP -> Internet
```

The CLI runs a local HTTP proxy on `127.0.0.1`. It sets `HTTP_PROXY`/`HTTPS_PROXY` so tools like curl, Python requests, and Node fetch route through it automatically. Traffic exits through residential or datacenter IPs in 120+ countries.
The CLI runs a local HTTP proxy on `127.0.0.1` and sets `HTTP_PROXY`/`HTTPS_PROXY` for the child process. Proxy-aware tools such as curl, Python Requests, and HTTPX inherit the route. Some clients need explicit configuration. See the [compatibility matrix](docs/compatibility.md) for tested versions and conditions.

Traffic exits through residential or datacenter IPs in 120+ countries.

## Important

Expand Down
84 changes: 84 additions & 0 deletions docs/compatibility.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# Compatibility Matrix

Last tested: 2026-08-11

Shellroute version: unreleased (post-0.1.0, includes auto NODE_USE_ENV_PROXY)
Platform: macOS 26.4 (darwin/arm64)

## How shellroute routes traffic

`shellroute run` and `shellroute` (interactive mode) start a local HTTP proxy and set these environment variables for the child process or session:

```
HTTP_PROXY
HTTPS_PROXY
http_proxy
https_proxy
```

A tool is routed only when it reads and uses these variables, or when it is explicitly configured to use the local proxy. Shellroute does not intercept arbitrary TCP, UDP, DNS, or raw-socket traffic.

## Matrix

Tested on macOS 26.4 (darwin/arm64). Non-Node rows tested 2026-07-27 with shellroute 0.1.0. Node rows tested 2026-08-11 with unreleased build (post-0.1.0). Each tested row verified: command exit code 0, exit IP differs from direct control, exit country = US, session ended cleanly.

| Client | Version | Outcome | Test command | Condition |
|---|---|---|---|---|
| curl | 8.7.1 | automatic | `shellroute run US -- curl -s https://ipinfo.io/json` | Reads proxy env vars by default. |
| wget | 1.25.0 | automatic | `shellroute run US -- wget -qO- https://ipinfo.io/json` | Reads proxy env vars by default. |
| Python Requests | 2.32.5 | automatic | `shellroute run US -- python3 -c "import requests; print(requests.get('https://ipinfo.io/json').text)"` | Reads proxy env vars by default. `Session.proxies` can override. |
| Python HTTPX (default) | 0.28.1 | automatic | `shellroute run US -- python3 -c "import httpx; print(httpx.get('https://ipinfo.io/json').text)"` | Default `trust_env=True`. |
| Python HTTPX (`trust_env=False`) | 0.28.1 | not transparent | `shellroute run US -- python3 -c "import httpx; print(httpx.get('https://ipinfo.io/json', trust_env=False).text)"` | Bypassed proxy. Returned direct IP. |
| Python urllib | 3.9.6 | automatic | `shellroute run US -- python3 -c "import urllib.request; print(urllib.request.urlopen('https://ipinfo.io/json').read().decode())"` | Default handlers read proxy env vars. |
| aiohttp (default) | 3.13.5 | not transparent | Tested internally: `aiohttp.ClientSession()` without `trust_env` | Did not use proxy env vars. Returned direct IP. |
| aiohttp (`trust_env=True`) | 3.13.5 | explicit configuration | Tested internally: `aiohttp.ClientSession(trust_env=True)` | Requires `trust_env=True` or explicit proxy. |
| Node fetch | v25.8.2 | automatic | `shellroute run US -- node -e "fetch('https://ipinfo.io/json').then(r=>r.json()).then(console.log)"` | Shellroute sets `NODE_USE_ENV_PROXY=1` automatically. Supported: Node 24.0+ and 22.21+. Not supported: Node 20, 21, 22.0–22.20, 23. Opt out: `NODE_USE_ENV_PROXY=0`. |
| Node fetch (`NODE_USE_ENV_PROXY=0`) | v25.8.2 | not transparent | `NODE_USE_ENV_PROXY=0 shellroute run US -- node -e "fetch('https://ipinfo.io/json').then(r=>r.json()).then(console.log)"` | User opt-out. Fetch goes direct, returns direct IP. |
| Node http/https | v25.8.2 | automatic | `shellroute run US -- node -e "const https=require('https'); https.get('https://ipinfo.io/json', r=>{let d=''; r.on('data',c=>d+=c); r.on('end',()=>console.log(d))})"` | Supported: Node 24.5+ and 22.21+. Not supported: Node 20, 21, 22.0–22.20, 23, 24.0–24.4. Uses default global agents; custom agents can bypass. |
| Go `http.Client` (default) | go1.26.2 | automatic | Tested internally: `http.Get(url)` with default transport | Default transport reads proxy env vars. |
| Go `http.Client` (custom) | go1.26.2 | not transparent | Tested internally: `Transport{Proxy: nil}` | Custom transport bypassed proxy. Returned direct IP. |
| Playwright | 1.60.0 | explicit configuration | Tested internally: `npx playwright test` with proxy in config | Requires `proxy: { server: process.env.HTTP_PROXY }` in playwright.config.ts. |
| Puppeteer | 25.1.0 | explicit configuration | Tested internally: Puppeteer with `--proxy-server` arg | Requires `--proxy-server=${process.env.HTTP_PROXY}` in launch args. |
| SSH | — | not tested | `/ssh user@host` in interactive mode | Direct `ssh` does not read proxy env vars. Use shellroute's `/ssh` helper. Not harness-testable (requires interactive mode + SSH server). |
| npm test runner | — | not tested | — | Conditional on the test suite's HTTP clients. Not harness-testable (no single representative fixture). |

## Outcome definitions

- **automatic**: the client uses the injected `HTTP_PROXY`/`HTTPS_PROXY` environment without additional shellroute-specific application configuration.
- **explicit configuration**: works only after documented client option or environment opt-in.
- **conditional**: default transports or child clients work, but the named umbrella command is not sufficient to predict routing.
- **not transparent**: shellroute's HTTP proxy environment does not route this protocol/client by itself.
- **not tested**: no current executable evidence. Expected outcome noted.

## Methodology

Each tested combination runs through this procedure:

1. Capture a direct control request (without shellroute) to `https://ipinfo.io/json`. The direct IP is redacted and not committed.
2. Run the same request inside `shellroute run <country> -- <command>`.
3. A passing result requires:
- The child command succeeded.
- The observed public exit IP differs from the redacted direct control.
- The endpoint reports the selected country.
- The session shuts down cleanly.
4. Negative tests (e.g., `trust_env=False`, `NODE_USE_ENV_PROXY=0`) verify the request bypasses the proxy without publishing the direct IP.
5. Provider failures are retried. A client is not labeled incompatible because of an upstream failure.

## Node library caveats

Shellroute sets `NODE_USE_ENV_PROXY=1` which makes Node's global HTTP agents read proxy env vars. This affects all libraries using the default agents:

- **Axios <=1.18.0**: can double-proxy because both Axios and Node process `HTTP_PROXY`/`HTTPS_PROXY`. Workaround: set `NODE_USE_ENV_PROXY=0` so only Axios handles the proxy.
- **Axios >=1.18.1**: defers env proxy handling to Node, avoiding the known double-proxy conflict. Not tested with shellroute.
- **Got**: default HTTP/1.1 path uses Node's global agent and routes correctly. Custom agents and HTTP/2 mode are not guaranteed to proxy.
- Not all Node HTTP libraries automatically work. Libraries that create their own sockets or agents may bypass the proxy.

## Limitations

- Results apply to the tested versions on the tested platform. Other versions or platforms may differ.
- A successful proxy route proves the request used the expected exit IP. It does not prove target-side localized content, region selection, or anti-bot bypass.
- An HTTP CONNECT proxy tunnels TLS bytes. It does not replace the client's TLS fingerprint.

## Retesting

To retest, log in with `shellroute login`, then run each client through `shellroute run <country> -- <command>` against `https://ipinfo.io/json`. Compare the exit IP and country to a direct control.
197 changes: 197 additions & 0 deletions internal/cli/node_proxy_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
package cli

import (
"os"
"os/exec"
"strings"
"testing"
)

// Tests for buildProxyEnv (production function in run.go).

func TestBuildProxyEnv_NodeProxyAbsent(t *testing.T) {
base := filterEnv(os.Environ(), "NODE_USE_ENV_PROXY")
env := buildProxyEnv(base, "http://127.0.0.1:41900")
if v := envLookup(env, "NODE_USE_ENV_PROXY"); v != "1" {
t.Errorf("NODE_USE_ENV_PROXY = %q, want 1", v)
}
}

func TestBuildProxyEnv_NodeProxyZeroPreserved(t *testing.T) {
base := setEnv(os.Environ(), "NODE_USE_ENV_PROXY", "0")
env := buildProxyEnv(base, "http://127.0.0.1:41900")
if v := envLookup(env, "NODE_USE_ENV_PROXY"); v != "0" {
t.Errorf("NODE_USE_ENV_PROXY = %q, want 0 (user opt-out)", v)
}
}

func TestBuildProxyEnv_NodeProxyOneNoDuplicate(t *testing.T) {
base := setEnv(os.Environ(), "NODE_USE_ENV_PROXY", "1")
env := buildProxyEnv(base, "http://127.0.0.1:41900")
count := 0
for _, e := range env {
if strings.HasPrefix(e, "NODE_USE_ENV_PROXY=") {
count++
}
}
if count != 1 {
t.Errorf("NODE_USE_ENV_PROXY appears %d times, want 1", count)
}
}

func TestBuildProxyEnv_NoProxyAdded(t *testing.T) {
base := filterEnv(os.Environ(), "NO_PROXY", "no_proxy")
env := buildProxyEnv(base, "http://127.0.0.1:41900")
np := envLookup(env, "NO_PROXY")
for _, host := range []string{"localhost", "127.0.0.1", "::1"} {
if !strings.Contains(np, host) {
t.Errorf("NO_PROXY=%q missing %s", np, host)
}
}
}

func TestBuildProxyEnv_NoProxyPreservesUser(t *testing.T) {
base := setEnv(os.Environ(), "NO_PROXY", "myhost.local")
env := buildProxyEnv(base, "http://127.0.0.1:41900")
np := envLookup(env, "NO_PROXY")
if !strings.Contains(np, "myhost.local") {
t.Errorf("NO_PROXY=%q should contain user entry myhost.local", np)
}
if !strings.Contains(np, "127.0.0.1") {
t.Errorf("NO_PROXY=%q should contain 127.0.0.1", np)
}
}

func TestBuildProxyEnv_ProxyVarsSet(t *testing.T) {
env := buildProxyEnv(os.Environ(), "http://127.0.0.1:41900")
for _, key := range []string{"HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy"} {
if v := envLookup(env, key); v != "http://127.0.0.1:41900" {
t.Errorf("%s = %q, want proxy URL", key, v)
}
}
}

// Verify child process actually receives the vars
func TestBuildProxyEnv_ChildReceivesNodeProxy(t *testing.T) {
base := filterEnv(os.Environ(), "NODE_USE_ENV_PROXY")
cmd := exec.Command("sh", "-c", "echo $NODE_USE_ENV_PROXY")
cmd.Env = buildProxyEnv(base, "http://127.0.0.1:41900")
out, err := cmd.Output()
if err != nil {
t.Fatalf("sh: %v", err)
}
if v := strings.TrimSpace(string(out)); v != "1" {
t.Errorf("child saw NODE_USE_ENV_PROXY=%q, want 1", v)
}
}

func TestBuildProxyEnv_ChildSeesZero(t *testing.T) {
base := setEnv(os.Environ(), "NODE_USE_ENV_PROXY", "0")
cmd := exec.Command("sh", "-c", "echo $NODE_USE_ENV_PROXY")
cmd.Env = buildProxyEnv(base, "http://127.0.0.1:41900")
out, err := cmd.Output()
if err != nil {
t.Fatalf("sh: %v", err)
}
if v := strings.TrimSpace(string(out)); v != "0" {
t.Errorf("child saw NODE_USE_ENV_PROXY=%q, want 0", v)
}
}

func TestUnionNoProxy_BothEmpty(t *testing.T) {
got := unionNoProxy("", "")
if got != defaultNoProxy {
t.Errorf("unionNoProxy('','') = %q, want %q", got, defaultNoProxy)
}
}

func TestUnionNoProxy_AlreadyComplete(t *testing.T) {
got := unionNoProxy("localhost,127.0.0.1,::1", "")
for _, h := range []string{"localhost", "127.0.0.1", "::1"} {
if !strings.Contains(got, h) {
t.Errorf("missing %s in %q", h, got)
}
}
// No duplicates
if strings.Count(got, "localhost") != 1 {
t.Errorf("duplicate localhost in %q", got)
}
}

func TestUnionNoProxy_UppercaseOnly(t *testing.T) {
// User only set NO_PROXY (uppercase), no_proxy is empty
base := setEnv(filterEnv(os.Environ(), "NO_PROXY", "no_proxy"), "NO_PROXY", "corp.internal")
env := buildProxyEnv(base, "http://127.0.0.1:41900")
np := envLookup(env, "no_proxy")
if !strings.Contains(np, "corp.internal") {
t.Errorf("no_proxy=%q missing corp.internal from NO_PROXY", np)
}
if !strings.Contains(np, "127.0.0.1") {
t.Errorf("no_proxy=%q missing loopback", np)
}
}

func TestUnionNoProxy_LowercaseOnly(t *testing.T) {
// User only set no_proxy (lowercase), NO_PROXY is empty
base := setEnv(filterEnv(os.Environ(), "NO_PROXY", "no_proxy"), "no_proxy", "corp.internal")
env := buildProxyEnv(base, "http://127.0.0.1:41900")
np := envLookup(env, "NO_PROXY")
if !strings.Contains(np, "corp.internal") {
t.Errorf("NO_PROXY=%q missing corp.internal from no_proxy", np)
}
if !strings.Contains(np, "127.0.0.1") {
t.Errorf("NO_PROXY=%q missing loopback", np)
}
}

func TestUnionNoProxy_BothSet(t *testing.T) {
// Both set with different entries
base := setEnv(
setEnv(filterEnv(os.Environ(), "NO_PROXY", "no_proxy"), "NO_PROXY", "upper.host"),
"no_proxy", "lower.host",
)
env := buildProxyEnv(base, "http://127.0.0.1:41900")
np := envLookup(env, "NO_PROXY")
for _, host := range []string{"upper.host", "lower.host", "localhost", "127.0.0.1", "::1"} {
if !strings.Contains(np, host) {
t.Errorf("NO_PROXY=%q missing %s", np, host)
}
}
// Both vars should be identical
if envLookup(env, "NO_PROXY") != envLookup(env, "no_proxy") {
t.Error("NO_PROXY and no_proxy should be identical")
}
}

func TestUnionNoProxy_Deduplicates(t *testing.T) {
got := unionNoProxy("localhost,myhost", "localhost,myhost")
if strings.Count(got, "localhost") != 1 {
t.Errorf("duplicate localhost in %q", got)
}
if strings.Count(got, "myhost") != 1 {
t.Errorf("duplicate myhost in %q", got)
}
}

// helpers

func filterEnv(env []string, keys ...string) []string {
var out []string
for _, e := range env {
skip := false
for _, k := range keys {
if strings.HasPrefix(e, k+"=") {
skip = true
break
}
}
if !skip {
out = append(out, e)
}
}
return out
}

func setEnv(env []string, key, val string) []string {
return append(filterEnv(env, key), key+"="+val)
}
72 changes: 66 additions & 6 deletions internal/cli/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"fmt"
"os"
"os/exec"
"strings"
"sync/atomic"
"syscall"
"time"
Expand Down Expand Up @@ -136,12 +137,7 @@ func runRun(cmd *cobra.Command, args []string) error {
childCmd.Stdout = os.Stdout
childCmd.Stderr = os.Stderr
childCmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} // own process group
childCmd.Env = append(os.Environ(),
"HTTP_PROXY="+sess.ProxyURL(),
"HTTPS_PROXY="+sess.ProxyURL(),
"http_proxy="+sess.ProxyURL(),
"https_proxy="+sess.ProxyURL(),
)
childCmd.Env = buildProxyEnv(os.Environ(), sess.ProxyURL())

if err := childCmd.Start(); err != nil {
sess.Stop()
Expand Down Expand Up @@ -202,6 +198,70 @@ func runRun(cmd *cobra.Command, args []string) error {
return nil
}

const defaultNoProxy = "localhost,127.0.0.1,::1"

// buildProxyEnv creates the child environment with proxy vars, NO_PROXY bypass,
// and NODE_USE_ENV_PROXY for Node.js support. Preserves user-set values.
func buildProxyEnv(base []string, proxyURL string) []string {
env := append(base,
"HTTP_PROXY="+proxyURL,
"HTTPS_PROXY="+proxyURL,
"http_proxy="+proxyURL,
"https_proxy="+proxyURL,
)

// Union NO_PROXY + no_proxy + loopback, assign identical result to both.
// Node gives lowercase precedence; merging both prevents lost entries.
noProxy := unionNoProxy(envLookup(base, "NO_PROXY"), envLookup(base, "no_proxy"))
env = append(env, "NO_PROXY="+noProxy, "no_proxy="+noProxy)

// Node.js proxy support (fetch Node 24.0+, http/https 24.5+, backported to 22.21+).
// Older versions ignore it. Only set if user hasn't configured it.
if envLookup(base, "NODE_USE_ENV_PROXY") == "" {
env = append(env, "NODE_USE_ENV_PROXY=1")
}

return env
}

// unionNoProxy merges uppercase NO_PROXY, lowercase no_proxy, and required
// loopback entries into one deduplicated list. Node gives lowercase precedence,
// so both variables must contain the same complete set.
func unionNoProxy(upper, lower string) string {
seen := make(map[string]bool)
var parts []string
for _, src := range []string{upper, lower} {
for _, p := range strings.Split(src, ",") {
p = strings.TrimSpace(p)
if p != "" && !seen[p] {
seen[p] = true
parts = append(parts, p)
}
}
}
for _, required := range []string{"localhost", "127.0.0.1", "::1"} {
if !seen[required] {
parts = append(parts, required)
}
}
if len(parts) == 0 {
return defaultNoProxy
}
return strings.Join(parts, ",")
}

// envLookup finds the last value for a key in an env slice (matches exec.Command behavior).
func envLookup(env []string, key string) string {
prefix := key + "="
val := ""
for _, e := range env {
if strings.HasPrefix(e, prefix) {
val = e[len(prefix):]
}
}
return val
}

func isAlpha(s string) bool {
for _, c := range s {
if (c < 'a' || c > 'z') && (c < 'A' || c > 'Z') {
Expand Down
Loading