diff --git a/README.md b/README.md index 6ca9ba2..7d539e9 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/compatibility.md b/docs/compatibility.md new file mode 100644 index 0000000..3fe530c --- /dev/null +++ b/docs/compatibility.md @@ -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 -- `. +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 -- ` against `https://ipinfo.io/json`. Compare the exit IP and country to a direct control. diff --git a/internal/cli/node_proxy_test.go b/internal/cli/node_proxy_test.go new file mode 100644 index 0000000..2a60ae5 --- /dev/null +++ b/internal/cli/node_proxy_test.go @@ -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) +} diff --git a/internal/cli/run.go b/internal/cli/run.go index 8873588..382f0eb 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -7,6 +7,7 @@ import ( "fmt" "os" "os/exec" + "strings" "sync/atomic" "syscall" "time" @@ -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() @@ -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') { diff --git a/internal/session/control.go b/internal/session/control.go index 2e3b662..fd5fe61 100644 --- a/internal/session/control.go +++ b/internal/session/control.go @@ -384,8 +384,13 @@ func (c *Controller) httpConnect(w http.ResponseWriter, r *http.Request) { fmt.Fprintf(w, "export HTTPS_PROXY=%s\n", proxyURL) fmt.Fprintf(w, "export http_proxy=%s\n", proxyURL) fmt.Fprintf(w, "export https_proxy=%s\n", proxyURL) - fmt.Fprintf(w, "export NO_PROXY=localhost,127.0.0.1\n") - fmt.Fprintf(w, "export no_proxy=localhost,127.0.0.1\n") + // Union NO_PROXY + no_proxy + loopback, assign identical result to both. + w.Write([]byte(NoProxyUnionScript + "\n")) + fmt.Fprintln(w, `_sr_np=$(_sr_union_no_proxy); export NO_PROXY="$_sr_np"; export no_proxy="$_sr_np"; unset _sr_np`) + // Enable Node.js built-in fetch/http proxy support (Node 24.0+ fetch, + // 24.5+ http/https, backported to 22.21+; older versions ignore it). + // Only set if user hasn't configured it; track ownership for cleanup. + fmt.Fprintln(w, NodeProxyOwnershipScript) fmt.Fprintf(w, "export SHELLROUTE_SESSION_ID=%s\n", sess.ID) fmt.Fprintf(w, "export SHELLROUTE_COUNTRY=%s\n", sess.Country) fmt.Fprintf(w, "export SHELLROUTE_COUNTRY_NAME=%s\n", shellQuote(c.countryName(sess.Country))) diff --git a/internal/session/shell_scripts.go b/internal/session/shell_scripts.go new file mode 100644 index 0000000..b481732 --- /dev/null +++ b/internal/session/shell_scripts.go @@ -0,0 +1,27 @@ +package session + +// NoProxyUnionScript is the shell function that unions NO_PROXY + no_proxy +// + loopback entries into a single deduplicated list. Both variables receive +// the identical result. Glob-safe (no unquoted expansion of *) and portable +// across bash and zsh. +const NoProxyUnionScript = `_sr_union_no_proxy() { + local seen="" result="" h + local combined="${NO_PROXY:+$NO_PROXY,}${no_proxy}" + while [ -n "$combined" ]; do + h="${combined%%,*}" + if [ "$h" = "$combined" ]; then combined=""; else combined="${combined#*,}"; fi + h="${h## }"; h="${h%% }" + [ -z "$h" ] && continue + case ",$seen," in *",$h,"*) continue ;; esac + seen="$seen,$h"; result="${result:+$result,}$h" + done + for h in localhost 127.0.0.1 ::1; do + case ",$seen," in *",$h,"*) continue ;; esac + result="${result:+$result,}$h" + done + printf '%s' "$result" +}` + +// NodeProxyOwnershipScript is the shell conditional that sets +// NODE_USE_ENV_PROXY=1 with an ownership marker if the user hasn't set it. +const NodeProxyOwnershipScript = `if [ -z "$NODE_USE_ENV_PROXY" ]; then export NODE_USE_ENV_PROXY=1; _SR_OWNS_NODE_PROXY=1; fi` diff --git a/internal/tui/node_proxy_test.go b/internal/tui/node_proxy_test.go new file mode 100644 index 0000000..e554d31 --- /dev/null +++ b/internal/tui/node_proxy_test.go @@ -0,0 +1,308 @@ +package tui + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/shellroute/shellroute-cli/internal/session" +) + +func writeShellFile(t *testing.T) string { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "shell.sh") + f, err := os.Create(path) + if err != nil { + t.Fatal(err) + } + writeCleanupHelper(f) + writeConnectFunc(f) + writeDisconnectFunc(f) + writeRotateFunc(f) + f.Close() + return path +} + +// runShell runs a script under the given shell (bash or zsh). +func execShell(t *testing.T, shell, script string) string { + t.Helper() + cmd := exec.Command(shell, "-c", script) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("%s failed: %v\nscript: %s\noutput: %s", shell, err, script, out) + } + return strings.TrimSpace(string(out)) +} + +func shells() []string { + s := []string{"bash"} + if _, err := exec.LookPath("zsh"); err == nil { + s = append(s, "zsh") + } + return s +} + +// --- Cleanup helper tests (production _sr_cleanup_session) --- + +func TestCleanup_OwnsAndRemoves(t *testing.T) { + sh := writeShellFile(t) + for _, shell := range shells() { + t.Run(shell, func(t *testing.T) { + out := execShell(t, shell, `source `+sh+` + export NODE_USE_ENV_PROXY=1; _SR_OWNS_NODE_PROXY=1 + _sr_cleanup_session + echo "${NODE_USE_ENV_PROXY:-UNSET}"`) + if out != "UNSET" { + t.Errorf("NODE_USE_ENV_PROXY=%q, want UNSET", out) + } + }) + } +} + +func TestCleanup_PreservesUserZero(t *testing.T) { + sh := writeShellFile(t) + for _, shell := range shells() { + t.Run(shell, func(t *testing.T) { + out := execShell(t, shell, `source `+sh+` + export NODE_USE_ENV_PROXY=0 + _sr_cleanup_session + echo "$NODE_USE_ENV_PROXY"`) + if out != "0" { + t.Errorf("NODE_USE_ENV_PROXY=%q, want 0", out) + } + }) + } +} + +func TestCleanup_PreservesUserOne(t *testing.T) { + sh := writeShellFile(t) + for _, shell := range shells() { + t.Run(shell, func(t *testing.T) { + out := execShell(t, shell, `source `+sh+` + export NODE_USE_ENV_PROXY=1 + _sr_cleanup_session + echo "$NODE_USE_ENV_PROXY"`) + if out != "1" { + t.Errorf("NODE_USE_ENV_PROXY=%q, want 1", out) + } + }) + } +} + +func TestCleanup_UserChangedOwnedToZero(t *testing.T) { + sh := writeShellFile(t) + for _, shell := range shells() { + t.Run(shell, func(t *testing.T) { + out := execShell(t, shell, `source `+sh+` + _SR_OWNS_NODE_PROXY=1; export NODE_USE_ENV_PROXY=0 + _sr_cleanup_session + echo "$NODE_USE_ENV_PROXY"`) + if out != "0" { + t.Errorf("NODE_USE_ENV_PROXY=%q, want 0 (user override survives)", out) + } + }) + } +} + +func TestCleanup_MarkerAlwaysCleared(t *testing.T) { + sh := writeShellFile(t) + for _, shell := range shells() { + t.Run(shell, func(t *testing.T) { + out := execShell(t, shell, `source `+sh+` + _SR_OWNS_NODE_PROXY=1; export NODE_USE_ENV_PROXY=1 + _sr_cleanup_session + echo "${_SR_OWNS_NODE_PROXY:-UNSET}"`) + if out != "UNSET" { + t.Errorf("_SR_OWNS_NODE_PROXY=%q, want UNSET", out) + } + }) + } +} + +// --- All cleanup paths call _sr_cleanup_session --- + +func TestDisconnect_CallsCleanup(t *testing.T) { + sh := writeShellFile(t) + out := execShell(t, "bash", `source `+sh+`; type /disconnect`) + if !strings.Contains(out, "_sr_cleanup_session") { + t.Error("/disconnect does not call _sr_cleanup_session") + } +} + +func TestConnectDisconnected_CallsCleanup(t *testing.T) { + sh := writeShellFile(t) + out := execShell(t, "bash", `source `+sh+`; type /connect`) + if !strings.Contains(out, "_sr_cleanup_session") { + t.Error("/connect DISCONNECTED path does not call _sr_cleanup_session") + } +} + +func TestRotateDisconnected_CallsCleanup(t *testing.T) { + sh := writeShellFile(t) + out := execShell(t, "bash", `source `+sh+`; type /rotate`) + if !strings.Contains(out, "_sr_cleanup_session") { + t.Error("/rotate DISCONNECTED path does not call _sr_cleanup_session") + } +} + +// --- Test production NO_PROXY union script (from session.NoProxyUnionScript) --- + +func TestNoProxyUnion_Star(t *testing.T) { + for _, shell := range shells() { + t.Run(shell, func(t *testing.T) { + out := execShell(t, shell, ` + export NO_PROXY="*" + unset no_proxy + `+session.NoProxyUnionScript+` + _sr_np=$(_sr_union_no_proxy) + echo "$_sr_np"`) + if !strings.HasPrefix(out, "*") { + t.Errorf("NO_PROXY=* lost: got %q", out) + } + }) + } +} + +func TestNoProxyUnion_WildcardDomain(t *testing.T) { + for _, shell := range shells() { + t.Run(shell, func(t *testing.T) { + out := execShell(t, shell, ` + export NO_PROXY=".corp.internal,*.test.local" + unset no_proxy + `+session.NoProxyUnionScript+` + _sr_np=$(_sr_union_no_proxy) + echo "$_sr_np"`) + if !strings.Contains(out, ".corp.internal") { + t.Errorf("lost .corp.internal: %q", out) + } + if !strings.Contains(out, "*.test.local") { + t.Errorf("lost *.test.local: %q", out) + } + if !strings.Contains(out, "127.0.0.1") { + t.Errorf("missing loopback: %q", out) + } + }) + } +} + +func TestNoProxyUnion_UpperOnly(t *testing.T) { + for _, shell := range shells() { + t.Run(shell, func(t *testing.T) { + out := execShell(t, shell, ` + export NO_PROXY="corp.internal" + unset no_proxy + `+session.NoProxyUnionScript+` + export NO_PROXY=$(_sr_union_no_proxy) + export no_proxy="$NO_PROXY" + echo "NO_PROXY=$NO_PROXY no_proxy=$no_proxy"`) + if !strings.Contains(out, "corp.internal") { + t.Errorf("lost corp.internal: %q", out) + } + parts := strings.SplitN(out, " ", 2) + if len(parts) == 2 { + upper := strings.TrimPrefix(parts[0], "NO_PROXY=") + lower := strings.TrimPrefix(parts[1], "no_proxy=") + if upper != lower { + t.Errorf("NO_PROXY=%q != no_proxy=%q", upper, lower) + } + } + }) + } +} + +func TestNoProxyUnion_LowerOnly(t *testing.T) { + for _, shell := range shells() { + t.Run(shell, func(t *testing.T) { + out := execShell(t, shell, ` + unset NO_PROXY + export no_proxy="corp.internal" + `+session.NoProxyUnionScript+` + _sr_np=$(_sr_union_no_proxy) + echo "$_sr_np"`) + if !strings.Contains(out, "corp.internal") { + t.Errorf("lost corp.internal: %q", out) + } + }) + } +} + +func TestNoProxyUnion_DifferingLists(t *testing.T) { + for _, shell := range shells() { + t.Run(shell, func(t *testing.T) { + out := execShell(t, shell, ` + export NO_PROXY="upper.host" + export no_proxy="lower.host" + `+session.NoProxyUnionScript+` + _sr_np=$(_sr_union_no_proxy) + echo "$_sr_np"`) + for _, host := range []string{"upper.host", "lower.host", "localhost", "127.0.0.1", "::1"} { + if !strings.Contains(out, host) { + t.Errorf("missing %s in %q", host, out) + } + } + }) + } +} + +func TestNoProxyUnion_Deduplicates(t *testing.T) { + for _, shell := range shells() { + t.Run(shell, func(t *testing.T) { + out := execShell(t, shell, ` + export NO_PROXY="localhost,myhost" + export no_proxy="localhost,myhost" + `+session.NoProxyUnionScript+` + _sr_np=$(_sr_union_no_proxy) + echo "$_sr_np"`) + if strings.Count(out, "localhost") != 1 { + t.Errorf("duplicate localhost: %q", out) + } + }) + } +} + +// --- Test production NODE_USE_ENV_PROXY script (from session.NodeProxyOwnershipScript) --- + +func TestNodeProxyOwnership_Absent(t *testing.T) { + for _, shell := range shells() { + t.Run(shell, func(t *testing.T) { + out := execShell(t, shell, ` + unset NODE_USE_ENV_PROXY + `+session.NodeProxyOwnershipScript+` + echo "val=$NODE_USE_ENV_PROXY marker=$_SR_OWNS_NODE_PROXY"`) + if out != "val=1 marker=1" { + t.Errorf("absent: %q, want val=1 marker=1", out) + } + }) + } +} + +func TestNodeProxyOwnership_PresetZero(t *testing.T) { + for _, shell := range shells() { + t.Run(shell, func(t *testing.T) { + out := execShell(t, shell, ` + export NODE_USE_ENV_PROXY=0 + `+session.NodeProxyOwnershipScript+` + echo "val=$NODE_USE_ENV_PROXY marker=${_SR_OWNS_NODE_PROXY:-none}"`) + if out != "val=0 marker=none" { + t.Errorf("preset 0: %q, want val=0 marker=none", out) + } + }) + } +} + +func TestNodeProxyOwnership_PresetOne(t *testing.T) { + for _, shell := range shells() { + t.Run(shell, func(t *testing.T) { + out := execShell(t, shell, ` + export NODE_USE_ENV_PROXY=1 + `+session.NodeProxyOwnershipScript+` + echo "val=$NODE_USE_ENV_PROXY marker=${_SR_OWNS_NODE_PROXY:-none}"`) + if out != "val=1 marker=none" { + t.Errorf("preset 1: %q, want val=1 marker=none", out) + } + }) + } +} diff --git a/internal/tui/tui_shell.go b/internal/tui/tui_shell.go index e76460a..9043e03 100644 --- a/internal/tui/tui_shell.go +++ b/internal/tui/tui_shell.go @@ -6,6 +6,7 @@ import ( ) func writeShellFunctions(f *os.File) { + writeCleanupHelper(f) writeSSHProxyHelper(f) writeConnectFunc(f) writeDisconnectFunc(f) @@ -16,6 +17,22 @@ func writeShellFunctions(f *os.File) { writeSettingsCommands(f) } +func writeCleanupHelper(f *os.File) { + // Shared cleanup for all session-end paths. + // Unsets NODE_USE_ENV_PROXY only if shellroute owns it AND value is still 1. + f.WriteString(` +_sr_cleanup_session() { + unset HTTP_PROXY HTTPS_PROXY http_proxy https_proxy NO_PROXY no_proxy + unset SHELLROUTE_SESSION_ID SHELLROUTE_COUNTRY SHELLROUTE_EXIT_IP SHELLROUTE_PORT SHELLROUTE_COUNTRY_NAME SHELLROUTE_CITY + if [ "$_SR_OWNS_NODE_PROXY" = "1" ] && [ "$NODE_USE_ENV_PROXY" = "1" ]; then + unset NODE_USE_ENV_PROXY + fi + unset _SR_OWNS_NODE_PROXY + unset _SR_KILLSWITCH_SHOWN _SR_HEALTH +} +`) +} + func writeSSHProxyHelper(f *os.File) { // Write HTTP CONNECT proxy helper for /ssh fmt.Fprint(f, ` @@ -95,8 +112,7 @@ func writeConnectFunc(f *os.File) { if echo "$result" | grep -q '^DISCONNECTED='; then local err=$(echo "$result" | grep '^ERROR ' | sed 's/^ERROR //') echo " ✗ ${err}" - unset HTTP_PROXY HTTPS_PROXY http_proxy https_proxy NO_PROXY no_proxy - unset SHELLROUTE_SESSION_ID SHELLROUTE_COUNTRY SHELLROUTE_EXIT_IP SHELLROUTE_PORT SHELLROUTE_COUNTRY_NAME SHELLROUTE_CITY + _sr_cleanup_session return 1 fi if [ $curl_rc -ne 0 ] || echo "$result" | grep -q "^ERROR "; then @@ -131,9 +147,7 @@ func writeDisconnectFunc(f *os.File) { echo " ✗ ${err}" return 1 fi - unset HTTP_PROXY HTTPS_PROXY http_proxy https_proxy NO_PROXY no_proxy - unset SHELLROUTE_SESSION_ID SHELLROUTE_COUNTRY SHELLROUTE_EXIT_IP SHELLROUTE_PORT SHELLROUTE_COUNTRY_NAME SHELLROUTE_CITY - unset _SR_KILLSWITCH_SHOWN _SR_HEALTH + _sr_cleanup_session echo "$result" echo "" } @@ -160,8 +174,7 @@ func writeRotateFunc(f *os.File) { if echo "$result" | grep -q '^DISCONNECTED='; then local err=$(echo "$result" | grep '^ERROR ' | sed 's/^ERROR //') echo " ✗ ${err}" - unset HTTP_PROXY HTTPS_PROXY http_proxy https_proxy NO_PROXY no_proxy - unset SHELLROUTE_SESSION_ID SHELLROUTE_COUNTRY SHELLROUTE_EXIT_IP SHELLROUTE_PORT SHELLROUTE_COUNTRY_NAME SHELLROUTE_CITY + _sr_cleanup_session return 1 fi if [ $curl_rc -ne 0 ] || echo "$result" | head -1 | grep -q "^ERROR "; then