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
11 changes: 11 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,17 @@ jobs:
- run: GOOS=darwin GOARCH=amd64 go build ./cmd/shellroute
- run: GOOS=linux GOARCH=arm64 go build ./cmd/shellroute

upstreams:
# Layer 2 of the bring-your-own-proxy test plan: real proxy software in Docker.
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v7
with:
go-version-file: go.mod
- name: Proxy software in Docker (3proxy, gost, squid, ssh -D)
run: bash scripts/test-upstreams.sh

skills:
runs-on: ubuntu-latest
steps:
Expand Down
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,20 @@
# Changelog

## v0.2.0 (unreleased)

Bring your own proxy. Route a command or the interactive shell through a proxy you already have, with no Shellroute account.

- `shellroute upstream add <name> <url> [--note TEXT]`, `upstream list`, `upstream remove <name>`, `upstream test <name|url>`
- `shellroute run <name> -- <cmd>`, `shellroute run --proxy <url> -- <cmd>`, `SHELLROUTE_PROXY=<url>` for CI
- `shellroute proxy --upstream <name|url>` for a persistent local proxy over your own
- `/connect <name>` in the interactive shell; `/rotate` and the account commands answer "not applicable" on an own route
- `http`, `https` (TLS to the proxy), `socks5` and `socks5h` with optional username and password; SOCKS5 resolves names at the proxy
- The child command still sees only the local proxy on `127.0.0.1`; credentials never reach it. Passwords are stored in `config.toml` (mode 600) and shown as `***` everywhere else
- Starting an own route checks the tunnel once with one retry (10 s each), reports the exit IP and the country the exit IP service observes for it, and names the cause of a failure: cannot resolve host, connection refused, timeout, proxy rejected credentials, proxy returned a status. `--no-check` skips the probe
- A wrong password or three consecutive connection failures mark an own route dead and, in `run`, kill the command, as with a hosted route
- Own routes work with no login and with the Shellroute API unreachable; the version check is skipped for them
- Hosted routes are unchanged

## v0.1.0

Initial public release.
Expand Down
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ shellroute run DE -- curl https://ipinfo.io/json
| `shellroute run <country> -- <cmd>` | Run one command through the proxy |
| `shellroute proxy --country <code>` | Persistent proxy (blocks until Ctrl+C) |
| `shellroute proxy stop` | Stop running proxy sessions |
| `shellroute run your-proxy-name -- <cmd>` | Run one command through your own proxy (`your-proxy-name` is the alias you saved) |
| `shellroute proxy --upstream your-proxy-name` | Persistent local proxy over your own proxy |
| `shellroute upstream add\|list\|remove\|test` | Save, list, delete and check your own proxies |

#### Local proxy mode

Expand All @@ -79,6 +82,36 @@ shellroute proxy --country US
curl -x http://127.0.0.1:41900 https://ipinfo.io/ip
```

### Use your own proxy

Already have a proxy, from a provider or your own server? Save it under a name you choose and use that name wherever a country code goes. No Shellroute account is needed and nothing is billed.

`your-proxy-name` below is a placeholder: the alias you pick when saving the proxy, 3 to 32 characters of lowercase letters, digits, `_` or `-`. Replace `USER`, `PASSWORD`, `HOST` and `PORT` with what your provider gave you.

```bash
# 1. Save it. Schemes: http, https (TLS to the proxy), socks5, socks5h.
shellroute upstream add your-proxy-name http://USER:PASSWORD@HOST:PORT

# 2. Check it: one tunnel, exit IP, observed country, latency.
shellroute upstream test your-proxy-name
# ✓ via your-proxy-name · exit IP 91.198.0.12 · DE (observed) · 212 ms

# 3. Use it.
shellroute run your-proxy-name -- curl https://ipinfo.io/json # one command
shellroute # interactive shell, then: /connect your-proxy-name
shellroute proxy --upstream your-proxy-name # persistent local proxy on 127.0.0.1:41900

# Without saving, for example in CI:
shellroute run --proxy socks5://USER:PASSWORD@HOST:PORT -- curl https://ipinfo.io/json
SHELLROUTE_PROXY=http://USER:PASSWORD@HOST:PORT shellroute run -- curl https://ipinfo.io/json

# Manage saved proxies. Passwords are shown as ***.
shellroute upstream list
shellroute upstream remove your-proxy-name
```

The command only ever sees the local proxy on `127.0.0.1`, so your proxy's credentials never reach it, your shell history or your CI logs. Where the proxy exits is observed when the route starts, not declared; a different exit per request is normal on rotating plans. Tested against 3proxy, gost, squid, SSH tunnels and the providers listed in [docs/upstream-compatibility.md](docs/upstream-compatibility.md). [Details.](docs/bring-your-own-proxy.md)

### Info

| Command | Description |
Expand Down
107 changes: 107 additions & 0 deletions docs/bring-your-own-proxy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# Bring your own proxy

Route a command or an interactive shell through a proxy you already have. No Shellroute account is needed: nothing is logged in, metered or billed. The hosted routes stay the zero-setup option; your own proxy is the free one.

## Save a proxy and use it

`your-proxy-name` in every example is a placeholder for the alias you choose when saving the proxy. Pick any name of 3 to 32 characters: lowercase letters, digits, `_` or `-`. `USER`, `PASSWORD`, `HOST` and `PORT` are the values your provider gives you.

```bash
# Save it once. The URL carries the credentials.
shellroute upstream add your-proxy-name http://USER:PASSWORD@HOST:PORT

# Check it: opens one tunnel and reports the exit IP and where it appears to be.
shellroute upstream test your-proxy-name
# ✓ via your-proxy-name · exit IP 91.198.0.12 · DE (observed) · 212 ms

# Use the alias wherever a country goes.
shellroute run your-proxy-name -- curl https://ipinfo.io/json
shellroute proxy --upstream your-proxy-name # persistent local proxy on 127.0.0.1:41900
shellroute # interactive shell, then: /connect your-proxy-name

shellroute upstream list # passwords shown as ***
shellroute upstream remove your-proxy-name
```

Without saving:

```bash
shellroute run --proxy socks5://127.0.0.1:1080 -- curl https://ipinfo.io/json
SHELLROUTE_PROXY=http://USER:PASSWORD@HOST:PORT shellroute run -- curl https://ipinfo.io/json # CI
shellroute upstream test https://USER:PASSWORD@HOST:443
```

## Proxy URLs

| Scheme | Meaning |
|---|---|
| `http://host:port` | HTTP proxy; HTTPS traffic goes through `CONNECT` |
| `https://host:port` | the same, with TLS to the proxy itself. The proxy's certificate must be trusted by your system |
| `socks5://host:port` | SOCKS5. Target names are resolved by the proxy |
| `socks5h://host:port` | the same as `socks5://` |

- The port is required.
- Credentials go in the URL: `scheme://user:pass@host:port`. URL-encode special characters: `@` becomes `%40`, `:` becomes `%3A`, `/` becomes `%2F`, `#` becomes `%23`.
- IPv6 hosts go in brackets: `http://[2001:db8::1]:8080`.
- Your provider's documentation gives the hostname, port and username format. Many providers select the country or a sticky session through the username; paste it into the URL as given, URL-encoded.

Saved names are 3 to 32 characters: lowercase letters, digits, `_` or `-`. A name can never look like a two-letter country code, so `shellroute run your-proxy-name` and `shellroute run DE` never collide.

## How `shellroute run` picks the route

In this order:

1. `--proxy <url>`: your own proxy, this run only.
2. `--country CC`: a hosted route.
3. A two-letter word before `--`: a hosted country, as before.
4. Any other word before `--`: one of your saved upstreams.
5. Nothing given: `SHELLROUTE_PROXY` from the environment if set, else `default_country` from the config.

There is no country setting for an own proxy. Shellroute cannot know where a third-party proxy exits, so it looks instead: when a route starts, the exit IP service that returns the IP also reports a country for it, and that is shown as `DE (observed)` in `upstream test`, `run`, `status` and the shell prompt. It is a measurement from a public geo-IP database, usually right and occasionally wrong, and it is empty when the service does not report one.

## What stays the same

The child command only ever sees the local proxy on `127.0.0.1`, the same as with a hosted route. `HTTP_PROXY`, `HTTPS_PROXY`, their lowercase twins, `NO_PROXY` and `NODE_USE_ENV_PROXY` are set the same way, so the [compatibility matrix](compatibility.md) and the Playwright and Puppeteer guidance apply unchanged.

Your proxy's password never reaches the child process, its environment, your shell history or your CI logs. It is stored in `~/.shellroute/config.toml` (mode 600) and shown as `user:***` everywhere else: `upstream list`, `status`, the session file and error messages.

## What is different

- No login, no balance, no cost line. The end-of-run summary shows duration and data only.
- The exit IP and its observed country come from a probe through your proxy (ipinfo.io, then api.ipify.org). `--no-check` skips it; the route then starts without an exit IP line. On a rotating provider later requests may leave from other exits; the probe shows the first one only.
- Rotation is your provider's job. In the shell, `/rotate` says so and does nothing; `/iptype`, `/sticky`, `/cities`, `/countries` and `/balance` answer "not applicable to your own upstream".
- An `https://` proxy must present a certificate your system trusts. A self-signed certificate is refused; on Linux you can point `SSL_CERT_FILE` at the CA file for the run, on macOS the system keychain decides.

## Failure behavior

Starting a route makes one connection attempt with a 10 second timeout and one retry, then fails with the cause:

| Message | Meaning |
|---|---|
| `cannot resolve host proxy.example.com` | the proxy's hostname does not resolve |
| `connection refused` | nothing listens on that host and port |
| `timeout` | the proxy did not answer within 10 seconds |
| `proxy rejected credentials` | wrong username or password (HTTP 407 or SOCKS5 authentication failure) |
| `proxy requires a username and password` | the SOCKS5 proxy wants credentials and the URL has none |
| `proxy returned 403 Forbidden for CONNECT` | the proxy refused the target; its status is forwarded |
| `proxy returned general failure (check the username and password)` | a SOCKS5 proxy that accepted the login exchange refused the connection; some providers answer a wrong password this way |
| `not a SOCKS5 proxy` | the port speaks something else, often HTTP |
| `proxy did not answer the CONNECT request` | the port speaks something else, often SOCKS |

A wrong password is final and not retried. If the tunnel opens but the exit IP service does not answer, the route starts with a warning.

While a route runs, each client request opens its own tunnel through your proxy. One failed connection answers that client with `502` and nothing else changes. A credentials error, or three consecutive connection failures with no success in between, marks the route dead:

- `shellroute run`: active connections are closed and the command is killed, with "Connection lost during execution. Command killed.", exactly as with a hosted route. There is no retry that could make a half-run script look successful.
- `shellroute proxy --upstream`: clients get `503` until you restart it.
- the interactive shell: the prompt shows "Connection lost through your-proxy-name. Run /connect." and blocks commands until you reconnect.

A `4xx` from the proxy for one target is forwarded to the client and does not count towards route death.

## Troubleshooting

- `unknown upstream "your-proxy-name"`: nothing is saved under that alias. `shellroute upstream list` shows what is; `shellroute upstream add your-proxy-name <url>` saves it.
- `proxy URL needs a port`: add `:8080`, `:1080` or whatever your provider gives.
- `proxy URL is not valid`: URL-encode special characters in the username or password (see above).
- `upstream test` passes but a program is not routed: the program does not read the proxy environment variables. See the [compatibility matrix](compatibility.md).
- `upstream test` reports an unexpected country: the exit is the provider's choice. Check the username or port format in the provider's documentation. Geo-IP databases also misplace some addresses.
2 changes: 2 additions & 0 deletions docs/compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ 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.

The matrix applies to both route kinds: the hosted routes and [your own proxy](bring-your-own-proxy.md). The child side is identical; only where the local proxy forwards to differs.

## 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.
Expand Down
28 changes: 28 additions & 0 deletions docs/upstream-compatibility.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Own proxy providers: what has been tested

Results of running the bring-your-own-proxy commands against real proxy providers. Each row is one provider endpoint and scheme, tested with the same five commands: `upstream add`, `upstream test`, `run … curl https://ipinfo.io/json`, `run … curl -I https://store.google.com/`, and a wrong-password run that must be refused before the command starts. A provider missing here has not been tested, which says nothing about whether it works.

Usernames, passwords, plans and account details are not recorded, and neither are exit IPs or dates.

| Provider | Scheme | Endpoint | Result | Notes |
|---|---|---|---|---|
| Webshare | http | an individual proxy address from the dashboard list, port as listed | pass | Fixed exit: the probe and every request leave from the same IP. A wrong password is refused before the command starts. If the shared gateway address rejects your credentials, use the per-proxy addresses from the dashboard. |
| Evomi | http | `dcp.evomi.com:2000` | pass | Rotating pool: each connection may leave from a different exit and country. A wrong password is refused before the command starts. |
| Evomi | https | `dcp.evomi-proxy.com:2001` | pass | TLS to the proxy with its public certificate, no extra setup. Rotating pool as above. |
| Evomi | socks5 | `dcp.evomi.com:2002` | pass | Rotating pool as above. A wrong password comes back as "proxy returned general failure (check the username and password)" and the command does not start. |

## How to read the rotation note

With a rotating pool every connection may leave from a different exit, and often a different country. `upstream test` and the `via …` line at the start of `run` show the first exit only. If you need one country, select it the way the provider documents, usually through the username or a dedicated port, and check the result with `curl https://ipinfo.io/json` through the route.

## Retesting

```bash
shellroute upstream add your-proxy-name <provider url> # your-proxy-name: any alias you choose
shellroute upstream test your-proxy-name
shellroute run your-proxy-name -- curl -sS https://ipinfo.io/json
shellroute run your-proxy-name -- curl -sS -I https://store.google.com/
shellroute run --proxy <same url with a wrong password> -- true # must be refused
```

Add a row with the provider, scheme and endpoint. Record what happened, never the credentials, the plan or the exit IPs.
11 changes: 10 additions & 1 deletion internal/cli/commands_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -350,7 +350,7 @@ func TestRevealKeyRegistered(t *testing.T) {
// --- command existence ---

func TestAllCommandsRegistered(t *testing.T) {
expected := []string{"login", "logout", "proxy", "status", "balance", "countries", "cities", "run", "reveal-key"}
expected := []string{"login", "logout", "proxy", "status", "balance", "countries", "cities", "run", "reveal-key", "upstream"}
commands := make(map[string]bool)
for _, cmd := range rootCmd.Commands() {
commands[cmd.Name()] = true
Expand Down Expand Up @@ -959,6 +959,7 @@ func TestNoUnexpectedCommands(t *testing.T) {
"login": true, "logout": true, "proxy": true,
"status": true, "balance": true, "countries": true, "cities": true,
"run": true, "reveal-key": true, "help": true, "completion": true,
"upstream": true,
}
for _, cmd := range rootCmd.Commands() {
if !allowed[cmd.Name()] {
Expand All @@ -973,6 +974,14 @@ func TestNoUnexpectedCommands(t *testing.T) {
t.Errorf("unexpected proxy subcommand %q", sub.Name())
}
}

// Check upstream subcommands
allowedUpstreamSubs := map[string]bool{"add": true, "list": true, "remove": true, "test": true}
for _, sub := range upstreamCmd.Commands() {
if !allowedUpstreamSubs[sub.Name()] {
t.Errorf("unexpected upstream subcommand %q", sub.Name())
}
}
}

// --- Direct login tests ---
Expand Down
Loading
Loading