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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

Dates are the npm publish dates. The same notes are at https://shellroute.com/changelog.

## v0.2.1

- Proxy credentials are taken exactly as written: a password with `/`, `#`, `?`, `%`, spaces or `@` no longer needs percent-encoding. Entries saved by 0.2.0 keep working.
- When a proxy rejects the login, or the shell splits an unquoted URL, the error says what to do about it.

## v0.2.0 (2026-10-21)

Bring your own proxy. Route a command or the interactive shell through a proxy you already have, with no Shellroute account.
Expand Down
8 changes: 5 additions & 3 deletions docs/bring-your-own-proxy.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,10 @@ shellroute upstream test https://USER:PASSWORD@HOST:443
| `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`.
- Credentials go in the URL: `scheme://user:password@host:port`. Username and password are taken exactly as written, any character. Wrap the URL in single quotes when it contains spaces or shell characters such as `$`, `!`, `&`, `;` or `#`: `shellroute upstream add your-proxy-name 'http://USER:PASSWORD@HOST:PORT'`.
- Entries saved by versions before 0.2.1 hold percent-encoded passwords and keep working.
- 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.
- 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.

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.

Expand Down Expand Up @@ -102,6 +103,7 @@ A `4xx` from the proxy for one target is forwarded to the client and does not co

- `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).
- `proxy URL is not valid`: the host part is wrong. The format is `scheme://user:password@host:port`. If the shell split the URL at a space or a symbol, wrap it in single quotes.
- the proxy rejects the username or password: compare them with what your provider gave you. A password percent-encoded for an older version is now taken as written; type the real characters.
- `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.
12 changes: 12 additions & 0 deletions internal/cli/run_exit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import (
"syscall"
"testing"
"time"

"github.com/shellroute/shellroute-cli/internal/proxy"
)

// exitAsChild and exitFromSignal end the process, so each case runs in a
Expand Down Expand Up @@ -57,6 +59,16 @@ func TestHelperProcess(t *testing.T) {
os.Exit(98)
}
os.Exit(0)
case "run-own-url":
// `shellroute run --proxy <url> -- sh -c 'exit 0'` through a fake proxy the
// parent test started; the exit IP probe goes to the parent's echo server.
proxy.ExitInfoEndpoints = []string{os.Getenv("SR_TEST_EXIT_IP_URL")}
rootCmd.SetArgs([]string{"run", "--proxy", os.Getenv("SR_TEST_PROXY_URL"), "--no-stat",
"--", "sh", "-c", "exit 0"})
if err := rootCmd.Execute(); err != nil {
fmt.Println(err)
os.Exit(98)
}
case "run-own-env":
// SHELLROUTE_PROXY selects the route; the command must not see it.
rootCmd.SetArgs([]string{"run", "--no-check", "--no-stat",
Expand Down
12 changes: 8 additions & 4 deletions internal/cli/run_upstream.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,11 @@ func resolveRunTarget(cfg *config.Config, args []string, dashIdx int, proxyFlag,

if proxyFlag != "" {
if first != "" {
return runTarget{}, nil, errors.New("--proxy cannot be combined with a country or upstream name")
if _, saved := cfg.FindUpstream(first); isCountryWord(first) || saved {
return runTarget{}, nil, errors.New("--proxy cannot be combined with a country or upstream name")
}
// Any other word before -- is the tail of a URL the shell split.
return runTarget{}, nil, fmt.Errorf("--proxy: %v", proxy.ErrURLFormat)
}
up, err := proxy.ParseUpstreamURL(proxyFlag)
if err != nil {
Expand All @@ -100,7 +104,7 @@ func resolveRunTarget(cfg *config.Config, args []string, dashIdx int, proxyFlag,
if !ok {
return runTarget{}, nil, fmt.Errorf("unknown upstream %q. Save it with `shellroute upstream add %s <url>` or list saved ones with `shellroute upstream list`", first, first)
}
up, err := proxy.ParseUpstreamURL(entry.URL)
up, err := proxy.ParseSavedUpstreamURL(entry.URL, entry.Literal)
if err != nil {
return runTarget{}, nil, fmt.Errorf("saved upstream %q has an invalid URL (%v). Remove it and add it again", first, err)
}
Expand Down Expand Up @@ -128,7 +132,7 @@ func resolveUpstreamRef(cfg *config.Config, ref string) (runTarget, error) {
return runTarget{}, errors.New("upstream required")
}
if entry, ok := cfg.FindUpstream(ref); ok {
up, err := proxy.ParseUpstreamURL(entry.URL)
up, err := proxy.ParseSavedUpstreamURL(entry.URL, entry.Literal)
if err != nil {
return runTarget{}, fmt.Errorf("saved upstream %q has an invalid URL (%v). Remove it and add it again", ref, err)
}
Expand Down Expand Up @@ -188,7 +192,7 @@ func viaLine(sess *session.Session) string {
// reportOwnStartError explains why the user's own proxy could not be used.
func reportOwnStartError(t runTarget, err error) error {
if proxy.TunnelFailed(err) {
display.Error("Cannot connect through %s: %s", t.describe(), proxy.Cause(err))
display.Error("Cannot connect through %s: %s", t.describe(), proxy.Explain(err))
} else {
display.Error("Cannot start the local proxy. Try again.")
}
Expand Down
27 changes: 20 additions & 7 deletions internal/cli/upstream.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,15 +29,17 @@ alias wherever a country code goes:
Aliases are 3 to 32 characters: lowercase letters, digits, _ or -.

URL schemes: http, https (TLS to the proxy), socks5, socks5h. The port is
required. Credentials go in the URL, URL-encoded. The child command only ever
sees the local proxy on 127.0.0.1; your proxy's password never reaches it.
Where the proxy exits is observed when a route starts, not declared.`,
required. Username and password go in the URL exactly as your provider gave
them; wrap the URL in single quotes when they contain spaces or symbols. The
child command only ever sees the local proxy on 127.0.0.1; your proxy's
password never reaches it. Where the proxy exits is observed when a route
starts, not declared.`,
}

var upstreamAddCmd = &cobra.Command{
Use: "add <name> <url>",
Short: "Save your own proxy under a name",
Args: cobra.ExactArgs(2),
Args: exactArgsQuoted(2, "shellroute upstream add your-proxy-name 'scheme://user:password@host:port'"),
RunE: runUpstreamAdd,
}

Expand All @@ -58,10 +60,21 @@ var upstreamRemoveCmd = &cobra.Command{
var upstreamTestCmd = &cobra.Command{
Use: "test <name|url>",
Short: "Open one tunnel through a proxy and report its exit IP",
Args: cobra.ExactArgs(1),
Args: exactArgsQuoted(1, "shellroute upstream test 'scheme://user:password@host:port'"),
RunE: runUpstreamTest,
}

// exactArgsQuoted is cobra.ExactArgs(n) that, given too many arguments,
// explains the likely cause: the shell split an unquoted URL.
func exactArgsQuoted(n int, example string) cobra.PositionalArgs {
return func(cmd *cobra.Command, args []string) error {
if len(args) > n {
return fmt.Errorf("too many arguments. %s: %s", proxy.QuoteHint, example)
}
return cobra.ExactArgs(n)(cmd, args)
}
}

func init() {
upstreamAddCmd.Flags().StringVar(&upstreamAddNote, "note", "", "Free-text note")
upstreamCmd.AddCommand(upstreamAddCmd, upstreamListCmd, upstreamRemoveCmd, upstreamTestCmd)
Expand All @@ -79,7 +92,7 @@ func runUpstreamAdd(cmd *cobra.Command, args []string) error {
if err != nil {
return err
}
replaced := cfg.AddUpstream(config.Upstream{Name: name, URL: strings.TrimSpace(rawURL), Note: upstreamAddNote})
replaced := cfg.AddUpstream(config.Upstream{Name: name, URL: strings.TrimSpace(rawURL), Note: upstreamAddNote, Literal: true})
if err := config.Save(cfg); err != nil {
return err
}
Expand Down Expand Up @@ -165,7 +178,7 @@ func runUpstreamTest(cmd *cobra.Command, args []string) error {
display.Success("via %s · exit IP %s%s · %d ms", t.describe(), res.ExitIP, where, res.Latency.Milliseconds())
return nil
case proxy.TunnelFailed(err):
display.Error("Cannot connect through %s: %s", t.describe(), proxy.Cause(err))
display.Error("Cannot connect through %s: %s", t.describe(), proxy.Explain(err))
return errors.New("upstream unreachable")
default:
display.Warn("Tunnel open via %s, but the exit IP could not be read: %s", t.describe(), proxy.Cause(err))
Expand Down
180 changes: 180 additions & 0 deletions internal/cli/upstream_literal_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
package cli

import (
"io"
"os"
"strings"
"testing"

"github.com/shellroute/shellroute-cli/internal/config"
"github.com/shellroute/shellroute-cli/internal/proxy/proxytest"
)

// literalPassword has every character the old rules made people encode.
const literalPassword = "p@ss/w#rd?100% x"

const exitIPBody = `{"ip": "203.0.113.5", "country": "LT"}`

// captureStderr returns what f wrote to stderr.
func captureStderr(t *testing.T, f func()) string {
t.Helper()
r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
old := os.Stderr
os.Stderr = w
f()
w.Close()
os.Stderr = old
b, _ := io.ReadAll(r)
return string(b)
}

func TestUpstreamTest_LiteralPasswordHTTP(t *testing.T) {
withOwnConfig(t)
p := proxytest.StartHTTPProxy(t, "alice", literalPassword)
withExitIPEndpoints(t, ipEcho(t, exitIPBody).URL)
if _, err := execCLI(t, "upstream", "add", "work", "http://alice:"+literalPassword+"@"+p.Addr); err != nil {
t.Fatalf("add: %v", err)
}
if _, err := execCLI(t, "upstream", "test", "work"); err != nil {
t.Fatalf("test: %v", err)
}
if p.Requests.Load() == 0 || p.Rejected.Load() != 0 {
t.Errorf("proxy saw %d authenticated and %d rejected requests", p.Requests.Load(), p.Rejected.Load())
}
}

func TestUpstreamTest_LiteralPasswordSOCKS5ByURL(t *testing.T) {
withOwnConfig(t)
s := proxytest.StartSOCKS5(t, "alice", literalPassword)
withExitIPEndpoints(t, ipEcho(t, exitIPBody).URL)
if _, err := execCLI(t, "upstream", "test", "socks5://alice:"+literalPassword+"@"+s.Addr); err != nil {
t.Fatalf("test: %v", err)
}
if s.Connects.Load() == 0 || s.Rejected.Load() != 0 {
t.Errorf("proxy saw %d connects and %d rejected logins", s.Connects.Load(), s.Rejected.Load())
}
}

func TestUpstreamTest_LegacyEncodedEntryStillWorks(t *testing.T) {
withOwnConfig(t)
p := proxytest.StartHTTPProxy(t, "alice", "p@ss")
withExitIPEndpoints(t, ipEcho(t, exitIPBody).URL)
cfg, err := loadConfig()
if err != nil {
t.Fatal(err)
}
// Written by 0.2.0: percent-encoded, no literal marker.
cfg.AddUpstream(config.Upstream{Name: "old", URL: "http://alice:p%40ss@" + p.Addr})
if err := config.Save(cfg); err != nil {
t.Fatal(err)
}
if _, err := execCLI(t, "upstream", "test", "old"); err != nil {
t.Fatalf("legacy entry: %v", err)
}
if p.Requests.Load() == 0 {
t.Error("the decoded password should have authenticated")
}
}

func TestUpstreamAdd_SavesLiteralMarker(t *testing.T) {
withOwnConfig(t)
p := proxytest.StartHTTPProxy(t, "alice", "p%40ss")
withExitIPEndpoints(t, ipEcho(t, exitIPBody).URL)
if _, err := execCLI(t, "upstream", "add", "new", "http://alice:p%40ss@"+p.Addr); err != nil {
t.Fatal(err)
}
cfg, err := loadConfig()
if err != nil {
t.Fatal(err)
}
entry, ok := cfg.FindUpstream("new")
if !ok || !entry.Literal {
t.Fatalf("saved entry = %+v, want Literal true", entry)
}
if _, err := execCLI(t, "upstream", "test", "new"); err != nil {
t.Fatalf("literal %%40 password: %v", err)
}
if p.Requests.Load() == 0 {
t.Error("the literal password should have authenticated")
}
}

func TestUpstreamAdd_TooManyArgsHintsQuoting(t *testing.T) {
withOwnConfig(t)
_, err := execCLI(t, "upstream", "add", "work", "http://u:pa", "ss@h:1")
if err == nil || !strings.Contains(err.Error(), "single quotes") {
t.Errorf("err = %v, want the quoting hint", err)
}
_, err = execCLI(t, "upstream", "test", "http://u:pa", "ss@h:1")
if err == nil || !strings.Contains(err.Error(), "single quotes") {
t.Errorf("test: err = %v, want the quoting hint", err)
}
}

func TestRun_ProxyFlagSplitByShellHintsQuoting(t *testing.T) {
withOwnConfig(t)
_, err := execCLI(t, "run", "--proxy", "http://u:pa", "ss@h:1", "--", "echo")
if err == nil || !strings.Contains(err.Error(), "single quotes") {
t.Errorf("err = %v, want the quoting hint", err)
}
}

func TestUpstreamTest_RejectedCredentialsShowHints(t *testing.T) {
withOwnConfig(t)
p := proxytest.StartHTTPProxy(t, "alice", "right")
withExitIPEndpoints(t, "http://127.0.0.1:1/ip")
if _, err := execCLI(t, "upstream", "add", "work", "http://alice:wr%41ng@"+p.Addr); err != nil {
t.Fatal(err)
}
out := captureStderr(t, func() {
if _, err := execCLI(t, "upstream", "test", "work"); err == nil {
t.Error("expected a failure")
}
})
for _, want := range []string{"rejected credentials", "single quotes", "percent-encoded"} {
if !strings.Contains(out, want) {
t.Errorf("stderr lacks %q:\n%s", want, out)
}
}
if strings.Contains(out, "wr%41ng") {
t.Errorf("stderr shows the password:\n%s", out)
}
}

func TestRunOwn_LiteralPasswordEndToEnd(t *testing.T) {
for _, scheme := range []string{"http", "socks5"} {
t.Run(scheme, func(t *testing.T) {
var addr string
var authenticated func() int32
if scheme == "http" {
p := proxytest.StartHTTPProxy(t, "alice", literalPassword)
addr, authenticated = p.Addr, p.Requests.Load
} else {
s := proxytest.StartSOCKS5(t, "alice", literalPassword)
addr, authenticated = s.Addr, s.Connects.Load
}
echo := ipEcho(t, exitIPBody)
res := runHelper(t, false,
"SR_TEST_HELPER=run-own-url",
"SHELLROUTE_HOME="+t.TempDir(),
"SHELLROUTE_API_KEY=",
"SHELLROUTE_PROXY=",
"SHELLROUTE_API_URL=http://127.0.0.1:1",
"SR_TEST_PROXY_URL="+scheme+"://alice:"+literalPassword+"@"+addr,
"SR_TEST_EXIT_IP_URL="+echo.URL,
)
if res.sig != 0 || res.code != 0 {
t.Fatalf("exit %d, signal %v:\n%s", res.code, res.sig, res.out)
}
if authenticated() == 0 {
t.Error("the proxy never saw an authenticated request")
}
if strings.Contains(res.out, literalPassword) {
t.Errorf("output shows the password:\n%s", res.out)
}
})
}
}
4 changes: 4 additions & 0 deletions internal/config/upstream.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ type Upstream struct {
Name string `toml:"name"`
URL string `toml:"url"`
Note string `toml:"note,omitempty"`
// Literal marks an entry saved by 0.2.1 or later: the username and
// password in URL are exactly as typed. Older entries hold them
// percent-encoded and are decoded when read.
Literal bool `toml:"literal,omitempty"`
}

// Upstream names are 3 to 32 characters of lowercase letters, digits,
Expand Down
11 changes: 11 additions & 0 deletions internal/proxy/upstream.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ type DialError struct {
Status int
Msg string
Err error
Hint string // what the user can do about it; set for own-proxy credential failures
}

func (e *DialError) Error() string {
Expand Down Expand Up @@ -121,6 +122,16 @@ func Cause(err error) string {
}
}

// Explain is Cause followed by the hint attached to the error, if any.
func Explain(err error) string {
cause := Cause(err)
var de *DialError
if errors.As(err, &de) && de.Hint != "" {
return cause + ". " + de.Hint
}
return cause
}

// netCause names the network reason behind a dial failure.
func netCause(err error) string {
if err == nil {
Expand Down
Loading
Loading