From c11e5d5e1df5a97eae8fa685a4f75f62500c0856 Mon Sep 17 00:00:00 2001 From: contra Date: Wed, 14 Oct 2026 20:33:17 +0300 Subject: [PATCH 01/13] feat(proxy): upstream abstraction with hosted, HTTP(S) and SOCKS5 implementations The local proxy now talks to an Upstream interface instead of the gateway fields. Three implementations: the hosted gateway (unchanged semantics for X-Exit-IP, 402, 407, X-Route-Dead), a user's own HTTP or HTTPS proxy with optional basic auth, and a hand-written SOCKS5 client with username/password auth and remote name resolution. Plain HTTP over SOCKS5 is rewritten to origin form. A user's own proxy never reports route death, so a credentials error marks the route dead at once and three consecutive connection failures mark it dead; a success resets the count. The hosted gateway keeps its explicit signalling and no counter. ParseUpstreamURL builds an upstream from http, https, socks5 or socks5h URLs with a required port; RedactURL and Describe never show the password. Probe and Check open one tunnel (one retry, 10 s each) and read the exit IP through the upstream, classifying failures as resolve, refused, timeout, credentials or proxy status. session.StartOwn starts the local proxy over an own upstream with no API call and no billing; Stop returns duration and bytes only. proxytest holds in-process HTTP, HTTPS and SOCKS5 fakes for tests. Signed-off-by: contra --- internal/proxy/httpform.go | 72 ++ internal/proxy/probe.go | 95 +++ internal/proxy/proxytest/fakes.go | 346 +++++++++ internal/proxy/relay.go | 99 +++ internal/proxy/server.go | 396 ++++------ internal/proxy/server_route_state_test.go | 50 +- internal/proxy/server_test.go | 93 +-- internal/proxy/upstream.go | 190 +++++ internal/proxy/upstream_http.go | 128 ++++ internal/proxy/upstream_socks5.go | 199 +++++ internal/proxy/upstream_test.go | 873 ++++++++++++++++++++++ internal/proxy/upstream_url.go | 79 ++ internal/session/control_test.go | 7 +- internal/session/own_test.go | 173 +++++ internal/session/session.go | 222 ++++-- internal/session/session_test.go | 9 +- 16 files changed, 2646 insertions(+), 385 deletions(-) create mode 100644 internal/proxy/httpform.go create mode 100644 internal/proxy/probe.go create mode 100644 internal/proxy/proxytest/fakes.go create mode 100644 internal/proxy/relay.go create mode 100644 internal/proxy/upstream.go create mode 100644 internal/proxy/upstream_http.go create mode 100644 internal/proxy/upstream_socks5.go create mode 100644 internal/proxy/upstream_test.go create mode 100644 internal/proxy/upstream_url.go create mode 100644 internal/session/own_test.go diff --git a/internal/proxy/httpform.go b/internal/proxy/httpform.go new file mode 100644 index 0000000..4b6132c --- /dev/null +++ b/internal/proxy/httpform.go @@ -0,0 +1,72 @@ +package proxy + +import ( + "bytes" + "errors" + "net" + "net/url" + "strings" +) + +// originForm rewrites the header block of a proxy-style (absolute-form) HTTP +// request into the origin form a target server expects and returns the +// target host:port. Hop-by-hop proxy headers are dropped. Only the bytes in +// data are rewritten; the caller relays the rest of the request unchanged. +func originForm(data []byte) (hostPort string, out []byte, err error) { + headEnd := bytes.Index(data, []byte("\r\n\r\n")) + if headEnd == -1 { + return "", nil, errors.New("request headers incomplete") + } + lines := strings.Split(string(data[:headEnd]), "\r\n") + body := data[headEnd+4:] + + parts := strings.SplitN(lines[0], " ", 3) + if len(parts) != 3 { + return "", nil, errors.New("malformed request line") + } + method, target, version := parts[0], parts[1], parts[2] + + var hostHeader string + for _, l := range lines[1:] { + if len(l) > 5 && strings.EqualFold(l[:5], "host:") { + hostHeader = strings.TrimSpace(l[5:]) + } + } + + path := target + host := hostHeader + defaultPort := "80" + if strings.Contains(target, "://") { + u, err := url.Parse(target) + if err != nil || u.Host == "" { + return "", nil, errors.New("malformed request target") + } + host = u.Host + path = u.RequestURI() + if strings.EqualFold(u.Scheme, "https") { + defaultPort = "443" + } + } + if host == "" { + return "", nil, errors.New("request has no host") + } + if _, _, err := net.SplitHostPort(host); err != nil { + host = net.JoinHostPort(strings.Trim(host, "[]"), defaultPort) + } + + var b strings.Builder + b.WriteString(method + " " + path + " " + version + "\r\n") + if hostHeader == "" { + b.WriteString("Host: " + host + "\r\n") + } + for _, l := range lines[1:] { + lower := strings.ToLower(l) + if strings.HasPrefix(lower, "proxy-connection:") || strings.HasPrefix(lower, "proxy-authorization:") { + continue + } + b.WriteString(l + "\r\n") + } + b.WriteString("\r\n") + out = append([]byte(b.String()), body...) + return host, out, nil +} diff --git a/internal/proxy/probe.go b/internal/proxy/probe.go new file mode 100644 index 0000000..155bf45 --- /dev/null +++ b/internal/proxy/probe.go @@ -0,0 +1,95 @@ +package proxy + +import ( + "context" + "errors" + "fmt" + "io" + "net" + "net/http" + "strings" + "time" +) + +// ExitIPEndpoints are the services asked for the public IP of a route. +var ExitIPEndpoints = []string{ + "https://ipinfo.io/ip", + "https://api.ipify.org", +} + +// ProbeResult is what Probe learned about an upstream. +type ProbeResult struct { + ExitIP string + Latency time.Duration +} + +// Probe opens a tunnel through the upstream and asks an IP echo service for +// the exit IP. A *DialError means the tunnel could not be opened; any other +// error means the tunnel opened but no service returned a usable IP. +func Probe(ctx context.Context, up Upstream, timeout time.Duration) (*ProbeResult, error) { + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + tr := &http.Transport{ + Proxy: nil, + DialContext: func(ctx context.Context, _, addr string) (net.Conn, error) { + return up.DialTarget(ctx, addr) + }, + DisableKeepAlives: true, + } + defer tr.CloseIdleConnections() + client := &http.Client{Transport: tr} + + start := time.Now() + var lastErr error + for _, endpoint := range ExitIPEndpoints { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + lastErr = err + continue + } + resp, err := client.Do(req) + if err != nil { + var de *DialError + if errors.As(err, &de) { + return nil, de + } + lastErr = err + continue + } + body, _ := io.ReadAll(io.LimitReader(resp.Body, 256)) + resp.Body.Close() + ip := strings.TrimSpace(string(body)) + if net.ParseIP(ip) != nil { + return &ProbeResult{ExitIP: ip, Latency: time.Since(start)}, nil + } + lastErr = fmt.Errorf("unexpected reply from %s", endpoint) + } + if lastErr == nil { + lastErr = errors.New("no exit IP service configured") + } + return nil, lastErr +} + +// Check verifies that an upstream can open a tunnel: one attempt and one +// retry, each bounded by the dial timeout. A credentials or proxy-status +// failure is final and not retried. A non-*DialError result means the tunnel +// opened but the exit IP could not be read. +func Check(ctx context.Context, up Upstream) (*ProbeResult, error) { + var lastErr error + for attempt := 0; attempt < 2; attempt++ { + res, err := Probe(ctx, up, dialTimeout) + if err == nil { + return res, nil + } + lastErr = err + var de *DialError + if !errors.As(err, &de) { + return nil, err + } + if de.Kind == KindCredentials || de.Kind == KindProxyStatus || de.Kind == KindDepleted || ctx.Err() != nil { + break + } + } + return nil, lastErr +} diff --git a/internal/proxy/proxytest/fakes.go b/internal/proxy/proxytest/fakes.go new file mode 100644 index 0000000..9453335 --- /dev/null +++ b/internal/proxy/proxytest/fakes.go @@ -0,0 +1,346 @@ +// Package proxytest provides in-process proxy servers for tests: a forward +// HTTP proxy with optional basic auth (plain or TLS) and a SOCKS5 server with +// optional username/password auth. Everything stays on loopback. +package proxytest + +import ( + "bufio" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/base64" + "encoding/binary" + "io" + "math/big" + "net" + "net/http" + "sync" + "sync/atomic" + "testing" + "time" +) + +// HTTPProxy is a forward HTTP proxy. It answers CONNECT with a tunnel and +// forwards absolute-form requests to their target. +type HTTPProxy struct { + Addr string + Requests atomic.Int32 // requests that passed authentication + Rejected atomic.Int32 // requests answered with 407 + + ln net.Listener + auth string // expected Proxy-Authorization value, "" for none +} + +// StartHTTPProxy starts a plain HTTP proxy. Empty user and pass disable auth. +func StartHTTPProxy(t testing.TB, user, pass string) *HTTPProxy { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + return startHTTPProxy(t, ln, user, pass) +} + +// StartTLSHTTPProxy starts an HTTPS proxy (TLS to the proxy itself) with a +// self-signed certificate and returns the pool that trusts it. +func StartTLSHTTPProxy(t testing.TB, user, pass string) (*HTTPProxy, *x509.CertPool) { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + cert, pool := selfSigned(t) + tlsLn := tls.NewListener(ln, &tls.Config{Certificates: []tls.Certificate{cert}}) + return startHTTPProxy(t, tlsLn, user, pass), pool +} + +func startHTTPProxy(t testing.TB, ln net.Listener, user, pass string) *HTTPProxy { + p := &HTTPProxy{Addr: ln.Addr().String(), ln: ln} + if user != "" || pass != "" { + p.auth = "Basic " + base64.StdEncoding.EncodeToString([]byte(user+":"+pass)) + } + t.Cleanup(func() { ln.Close() }) + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + go p.handle(conn) + } + }() + return p +} + +func (p *HTTPProxy) handle(conn net.Conn) { + defer conn.Close() + br := bufio.NewReader(conn) + req, err := http.ReadRequest(br) + if err != nil { + return + } + if p.auth != "" && req.Header.Get("Proxy-Authorization") != p.auth { + p.Rejected.Add(1) + io.WriteString(conn, "HTTP/1.1 407 Proxy Authentication Required\r\nProxy-Authenticate: Basic realm=\"test\"\r\nContent-Length: 0\r\n\r\n") + return + } + p.Requests.Add(1) + + if req.Method == http.MethodConnect { + target, err := net.DialTimeout("tcp", req.Host, 5*time.Second) + if err != nil { + io.WriteString(conn, "HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\n\r\n") + return + } + defer target.Close() + io.WriteString(conn, "HTTP/1.1 200 Connection Established\r\n\r\n") + relay(conn, br, target) + return + } + + // Absolute-form request: forward it to the origin. + req.RequestURI = "" + req.Header.Del("Proxy-Authorization") + req.Header.Del("Proxy-Connection") + tr := &http.Transport{Proxy: nil} + resp, err := tr.RoundTrip(req) + if err != nil { + io.WriteString(conn, "HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\n\r\n") + return + } + defer resp.Body.Close() + resp.Write(conn) +} + +// SOCKS5Server is a SOCKS5 proxy with optional username/password auth. +type SOCKS5Server struct { + Addr string + Connects atomic.Int32 // successful CONNECT commands + Rejected atomic.Int32 // failed authentications + + ln net.Listener + user string + pass string +} + +// StartSOCKS5 starts a SOCKS5 server. Empty user and pass disable auth. +func StartSOCKS5(t testing.TB, user, pass string) *SOCKS5Server { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + s := &SOCKS5Server{Addr: ln.Addr().String(), ln: ln, user: user, pass: pass} + t.Cleanup(func() { ln.Close() }) + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + go s.handle(conn) + } + }() + return s +} + +func (s *SOCKS5Server) handle(conn net.Conn) { + defer conn.Close() + conn.SetDeadline(time.Now().Add(10 * time.Second)) + + var head [2]byte + if _, err := io.ReadFull(conn, head[:]); err != nil || head[0] != 0x05 { + return + } + methods := make([]byte, int(head[1])) + if _, err := io.ReadFull(conn, methods); err != nil { + return + } + needAuth := s.user != "" || s.pass != "" + if needAuth { + offered := false + for _, m := range methods { + if m == 0x02 { + offered = true + } + } + if !offered { + conn.Write([]byte{0x05, 0xFF}) + return + } + conn.Write([]byte{0x05, 0x02}) + var ah [2]byte + if _, err := io.ReadFull(conn, ah[:]); err != nil || ah[0] != 0x01 { + return + } + user := make([]byte, int(ah[1])) + if _, err := io.ReadFull(conn, user); err != nil { + return + } + var pl [1]byte + if _, err := io.ReadFull(conn, pl[:]); err != nil { + return + } + pass := make([]byte, int(pl[0])) + if _, err := io.ReadFull(conn, pass); err != nil { + return + } + if string(user) != s.user || string(pass) != s.pass { + s.Rejected.Add(1) + conn.Write([]byte{0x01, 0x01}) + return + } + conn.Write([]byte{0x01, 0x00}) + } else { + conn.Write([]byte{0x05, 0x00}) + } + + var req [4]byte + if _, err := io.ReadFull(conn, req[:]); err != nil || req[0] != 0x05 { + return + } + var host string + switch req[3] { + case 0x01: + var ip [4]byte + if _, err := io.ReadFull(conn, ip[:]); err != nil { + return + } + host = net.IP(ip[:]).String() + case 0x04: + var ip [16]byte + if _, err := io.ReadFull(conn, ip[:]); err != nil { + return + } + host = net.IP(ip[:]).String() + case 0x03: + var l [1]byte + if _, err := io.ReadFull(conn, l[:]); err != nil { + return + } + name := make([]byte, int(l[0])) + if _, err := io.ReadFull(conn, name); err != nil { + return + } + host = string(name) + default: + conn.Write([]byte{0x05, 0x08, 0x00, 0x01, 0, 0, 0, 0, 0, 0}) + return + } + var portB [2]byte + if _, err := io.ReadFull(conn, portB[:]); err != nil { + return + } + port := binary.BigEndian.Uint16(portB[:]) + if req[1] != 0x01 { + conn.Write([]byte{0x05, 0x07, 0x00, 0x01, 0, 0, 0, 0, 0, 0}) + return + } + target, err := net.DialTimeout("tcp", net.JoinHostPort(host, itoa(int(port))), 5*time.Second) + if err != nil { + conn.Write([]byte{0x05, 0x05, 0x00, 0x01, 0, 0, 0, 0, 0, 0}) + return + } + defer target.Close() + s.Connects.Add(1) + conn.SetDeadline(time.Time{}) + conn.Write([]byte{0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0}) + relay(conn, nil, target) +} + +// StartEcho starts a TCP server that writes back whatever it reads. +func StartEcho(t testing.TB) string { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { ln.Close() }) + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + go func(c net.Conn) { + defer c.Close() + io.Copy(c, c) + }(conn) + } + }() + return ln.Addr().String() +} + +// relay copies bytes both ways until one side closes. buffered, when not +// nil, holds client bytes already read ahead of the raw connection. +func relay(client net.Conn, buffered *bufio.Reader, target net.Conn) { + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + var src io.Reader = client + if buffered != nil { + src = buffered + } + io.Copy(target, src) + if tc, ok := target.(*net.TCPConn); ok { + tc.CloseWrite() + } + }() + go func() { + defer wg.Done() + io.Copy(client, target) + if tc, ok := client.(*net.TCPConn); ok { + tc.CloseWrite() + } + }() + wg.Wait() +} + +func selfSigned(t testing.TB) (tls.Certificate, *x509.CertPool) { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "proxytest"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment | x509.KeyUsageCertSign, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, + DNSNames: []string{"localhost"}, + IsCA: true, + BasicConstraintsValid: true, + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) + if err != nil { + t.Fatal(err) + } + leaf, err := x509.ParseCertificate(der) + if err != nil { + t.Fatal(err) + } + pool := x509.NewCertPool() + pool.AddCert(leaf) + return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key, Leaf: leaf}, pool +} + +func itoa(n int) string { + if n == 0 { + return "0" + } + var b [8]byte + i := len(b) + for n > 0 { + i-- + b[i] = byte('0' + n%10) + n /= 10 + } + return string(b[i:]) +} diff --git a/internal/proxy/relay.go b/internal/proxy/relay.go new file mode 100644 index 0000000..632b047 --- /dev/null +++ b/internal/proxy/relay.go @@ -0,0 +1,99 @@ +package proxy + +import ( + "bufio" + "context" + "io" + "net" + "time" + + "github.com/shellroute/shellroute-cli/internal/meter" +) + +// relay copies data bidirectionally between two connections, metering bytes. +func (s *Server) relay(ctx context.Context, client, remote net.Conn) { + done := make(chan struct{}, 2) + + // Idle timeout: if no data flows in either direction for 60 seconds, + // close both sides. Without this, a dead tunnel leaves the relay stuck + // indefinitely (especially over TLS where half-close doesn't propagate + // cleanly). + idleTimeout := 60 * time.Second + timer := time.AfterFunc(idleTimeout, func() { + client.Close() + remote.Close() + }) + extend := func() { + timer.Reset(idleTimeout) + } + + go func() { + copyWithIdleReset(&meteredWriter{remote, s.meter, true}, client, extend) + if tc, ok := remote.(*net.TCPConn); ok { + tc.CloseWrite() + } + done <- struct{}{} + }() + + go func() { + copyWithIdleReset(&meteredWriter{client, s.meter, false}, remote, extend) + if tc, ok := client.(*net.TCPConn); ok { + tc.CloseWrite() + } + done <- struct{}{} + }() + + select { + case <-done: + timer.Stop() + case <-ctx.Done(): + timer.Stop() + } +} + +// copyWithIdleReset copies from src to dst, calling onData after each chunk. +func copyWithIdleReset(dst io.Writer, src io.Reader, onData func()) { + buf := make([]byte, 32*1024) + for { + n, err := src.Read(buf) + if n > 0 { + onData() + if _, wErr := dst.Write(buf[:n]); wErr != nil { + return + } + } + if err != nil { + return + } + } +} + +// meteredWriter wraps a writer and counts bytes. +type meteredWriter struct { + w io.Writer + meter *meter.Meter + isUp bool +} + +func (mw *meteredWriter) Write(p []byte) (int, error) { + n, err := mw.w.Write(p) + if n > 0 { + if mw.isUp { + mw.meter.AddUp(int64(n)) + } else { + mw.meter.AddDown(int64(n)) + } + } + return n, err +} + +// bufferedConn wraps a net.Conn with a bufio.Reader, so that bytes already +// buffered from HTTP response parsing are not lost during relay. +type bufferedConn struct { + net.Conn + r *bufio.Reader +} + +func (bc *bufferedConn) Read(p []byte) (int, error) { + return bc.r.Read(p) +} diff --git a/internal/proxy/server.go b/internal/proxy/server.go index 90802ea..5a96b6d 100644 --- a/internal/proxy/server.go +++ b/internal/proxy/server.go @@ -1,19 +1,15 @@ package proxy import ( - "bufio" "context" - "crypto/tls" - "encoding/base64" + "errors" "fmt" - "io" "log" "net" "net/http" "strings" "sync" "sync/atomic" - "time" "github.com/shellroute/shellroute-cli/internal/meter" ) @@ -26,24 +22,28 @@ const ( RouteDepleted int32 = 3 ) -// Server is a local HTTP proxy that forwards traffic to a remote gateway. +// Server is a local HTTP proxy that forwards traffic to an upstream: the +// hosted gateway or a user's own proxy. type Server struct { listenAddr string - gatewayAddr string - gatewayTLS bool // true = tls.Dial, false = net.Dial - gatewayAuth string // base64-encoded "user:pass" + mu sync.Mutex + upstream Upstream meter *meter.Meter listener net.Listener - mu sync.Mutex activeConns int OnUpstreamDead func(reason string) // called on upstream failure - OnExitIPChanged func(ip string) // called when gateway reports a different exit IP + OnExitIPChanged func(ip string) // called when the gateway reports a different exit IP OnRelayDone func() // called after each relay ends budget atomic.Int64 // byte budget from API (-1 = unlimited) // Route health gate — checked before every CONNECT/HTTP routeState atomic.Int32 // RouteHealthy, RouteDead, RouteRotating, RouteDepleted + // A user's own proxy never reports route death, so consecutive failures + // are counted instead. 0 disables the counter (hosted gateway). + failureThreshold int + failures atomic.Int32 + // Tracked relay connections for run-mode force-close trackedMu sync.Mutex trackedConns map[net.Conn]struct{} @@ -53,35 +53,68 @@ type Server struct { // Config holds proxy server configuration. type Config struct { ListenPort int - GatewayAddr string // host:port of the gateway - GatewayTLS bool // use TLS when connecting to gateway - Token string // gateway session token + Upstream Upstream Meter *meter.Meter ByteBudget int64 // max bytes allowed (-1 = unlimited, 0 = blocked) TrackRelays bool // true = track active relay conns for force-close (run mode) + // FailureThreshold marks the route dead after this many consecutive + // upstream failures. 0 disables the counter; the hosted gateway reports + // death explicitly instead. + FailureThreshold int } func NewServer(cfg *Config) *Server { - auth := base64.StdEncoding.EncodeToString( - []byte("_:" + cfg.Token), - ) budget := cfg.ByteBudget if budget == 0 { budget = -1 } s := &Server{ - listenAddr: fmt.Sprintf("127.0.0.1:%d", cfg.ListenPort), - gatewayAddr: cfg.GatewayAddr, - gatewayTLS: cfg.GatewayTLS, - gatewayAuth: auth, - meter: cfg.Meter, - trackRelays: cfg.TrackRelays, - trackedConns: make(map[net.Conn]struct{}), + listenAddr: fmt.Sprintf("127.0.0.1:%d", cfg.ListenPort), + meter: cfg.Meter, + trackRelays: cfg.TrackRelays, + trackedConns: make(map[net.Conn]struct{}), + failureThreshold: cfg.FailureThreshold, } s.budget.Store(budget) + s.SetUpstream(cfg.Upstream) return s } +// exitIPNotifier is implemented by upstreams that learn the exit IP from the +// gateway on each tunnel. +type exitIPNotifier interface { + setExitIPCallback(func(string)) +} + +// SetUpstream replaces the upstream with no listener gap and marks the route +// healthy. Same port, new destination. +func (s *Server) SetUpstream(u Upstream) { + if n, ok := u.(exitIPNotifier); ok { + n.setExitIPCallback(func(ip string) { + if s.OnExitIPChanged != nil { + s.OnExitIPChanged(ip) + } + }) + } + s.mu.Lock() + s.upstream = u + s.mu.Unlock() + s.failures.Store(0) + s.routeState.Store(RouteHealthy) +} + +// SwapUpstream replaces the hosted gateway credentials for rotation. +func (s *Server) SwapUpstream(gatewayAddr string, gatewayTLS bool, token string) { + s.SetUpstream(NewHostedUpstream(gatewayAddr, gatewayTLS, token)) +} + +// Upstream returns the current upstream. +func (s *Server) Upstream() Upstream { + s.mu.Lock() + defer s.mu.Unlock() + return s.upstream +} + // RouteState returns the current route state. func (s *Server) RouteState() int32 { return s.routeState.Load() @@ -131,18 +164,6 @@ func (s *Server) SetRouteRotating() { s.routeState.Store(RouteRotating) } -// SwapUpstream atomically replaces the gateway credentials for rotation. -// No listener gap — same port, new upstream. Resets route state to healthy. -func (s *Server) SwapUpstream(gatewayAddr string, gatewayTLS bool, token string) { - auth := base64.StdEncoding.EncodeToString([]byte("_:" + token)) - s.mu.Lock() - s.gatewayAddr = gatewayAddr - s.gatewayTLS = gatewayTLS - s.gatewayAuth = auth - s.mu.Unlock() - s.routeState.Store(RouteHealthy) -} - // closeTrackedConns closes all active relay connections (run mode force-close). func (s *Server) closeTrackedConns() { s.trackedMu.Lock() @@ -277,93 +298,82 @@ func (s *Server) checkBudget(clientConn net.Conn) bool { return false } -// dialAndSendConnect dials the gateway and sends a CONNECT request for the target host. -// Returns the gateway connection on success, or nil + handles error response to client. -func (s *Server) dialAndSendConnect(clientConn net.Conn, targetHost string) net.Conn { - s.mu.Lock() - gwAddr := s.gatewayAddr - gwTLS := s.gatewayTLS - gwAuth := s.gatewayAuth - s.mu.Unlock() - - gatewayConn, err := s.dialGatewayWith(gwAddr, gwTLS) - if err != nil { - // Gateway dial failed — return 502 but do NOT mark route dead. - // The gateway is a Docker container and rarely goes down. Transient - // TCP failures (busy, timeout under load) should not poison the - // entire route for all subsequent requests. Only X-Route-Dead from - // the gateway (explicit tunnel death) should set RouteDead. +// dialAndSendConnect opens a tunnel to targetHost through the current +// upstream. On failure it answers the client and returns nil. +func (s *Server) dialAndSendConnect(ctx context.Context, clientConn net.Conn, targetHost string) net.Conn { + up := s.Upstream() + if up == nil { clientConn.Write([]byte("HTTP/1.1 502 Bad Gateway\r\n\r\n")) return nil } - - connectReq := fmt.Sprintf( - "CONNECT %s HTTP/1.1\r\nHost: %s\r\nProxy-Authorization: Basic %s\r\n\r\n", - targetHost, targetHost, gwAuth, - ) - if _, err := gatewayConn.Write([]byte(connectReq)); err != nil { - gatewayConn.Close() - clientConn.Write([]byte("HTTP/1.1 502 Bad Gateway\r\n\r\n")) - return nil - } - - // Parse gateway response. Use bufio.Reader so we don't lose any tunnel - // data that arrives right after the HTTP response headers. - gwBuf := bufio.NewReader(gatewayConn) - resp, err := http.ReadResponse(gwBuf, nil) + conn, err := up.DialTarget(ctx, targetHost) if err != nil { - gatewayConn.Close() - clientConn.Write([]byte("HTTP/1.1 502 Bad Gateway\r\n\r\n")) + s.handleDialError(clientConn, err) return nil } + s.failures.Store(0) + return conn +} - switch { - case resp.StatusCode == http.StatusOK: - // Success — track exit IP - if ip := resp.Header.Get("X-Exit-IP"); ip != "" && s.OnExitIPChanged != nil { - s.OnExitIPChanged(ip) - } - // If the bufio.Reader buffered tunnel data beyond the HTTP headers, - // wrap the connection so relay reads from the buffer first. - if gwBuf.Buffered() > 0 { - return &bufferedConn{Conn: gatewayConn, r: gwBuf} - } - return gatewayConn - - case resp.StatusCode == 402: - // Balance depleted +// handleDialError answers the client and updates route state after an +// upstream failure. Hosted replies keep their meaning (402 depleted, 407 +// token invalid, X-Route-Dead); a user's own proxy is marked dead on a +// credentials error or after failureThreshold consecutive failures. +func (s *Server) handleDialError(clientConn net.Conn, err error) { + var de *DialError + if !errors.As(err, &de) { + de = &DialError{Kind: KindDial, Err: err} + } + switch de.Kind { + case KindDepleted: s.routeState.Store(RouteDepleted) clientConn.Write([]byte("HTTP/1.1 402 Payment Required\r\n\r\nbalance_depleted")) - gatewayConn.Close() s.upstreamDeadWithReason("depleted") - return nil - - case resp.StatusCode == 407: - // Auth failure — session token invalid. Do NOT mark route dead. + case KindCredentials: clientConn.Write([]byte("HTTP/1.1 407 Proxy Authentication Required\r\n\r\n")) - gatewayConn.Close() + if s.failureThreshold > 0 { + // Retrying cannot fix a wrong password. + s.SetRouteDead(s.trackRelays) + } s.upstreamDeadWithReason("auth_invalid") - return nil - - case resp.Header.Get("X-Route-Dead") == "true": - // Gateway says route/tunnel is dead + case KindRouteDead: s.routeState.Store(RouteDead) clientConn.Write([]byte("HTTP/1.1 502 Bad Gateway\r\n\r\n")) - gatewayConn.Close() s.upstreamDeadWithReason("connection_failed") - return nil - - case resp.StatusCode >= 500: - // Transient gateway error — do NOT mark dead + case KindProxyStatus: + switch { + case de.Status >= 500: + // Transient upstream error for one request. + clientConn.Write([]byte("HTTP/1.1 502 Bad Gateway\r\n\r\n")) + s.noteFailure() + case de.Status >= 100: + // The proxy refused this target; forward its answer. + clientConn.Write([]byte(fmt.Sprintf("HTTP/1.1 %s\r\n\r\n", de.Msg))) + case de.Status == 2: + // SOCKS: connection not allowed by ruleset. + clientConn.Write([]byte("HTTP/1.1 403 Forbidden\r\n\r\n")) + default: + // SOCKS: target unreachable or refused. + clientConn.Write([]byte("HTTP/1.1 502 Bad Gateway\r\n\r\n")) + } + default: + // The proxy could not be reached or did not speak the protocol. + // For the hosted gateway this is transient and never poisons the + // route; only an explicit X-Route-Dead does. clientConn.Write([]byte("HTTP/1.1 502 Bad Gateway\r\n\r\n")) - gatewayConn.Close() - return nil + s.noteFailure() + } +} - default: - // Other non-200 — forward to client, don't mark dead - clientConn.Write([]byte(fmt.Sprintf("HTTP/1.1 %s\r\n\r\n", resp.Status))) - gatewayConn.Close() - return nil +// noteFailure counts a consecutive upstream failure for a user's own proxy +// and marks the route dead at the threshold. +func (s *Server) noteFailure() { + if s.failureThreshold <= 0 { + return + } + if int(s.failures.Add(1)) >= s.failureThreshold { + s.SetRouteDead(s.trackRelays) + s.upstreamDeadWithReason("connection_failed") } } @@ -383,28 +393,30 @@ func (s *Server) handleConnect(ctx context.Context, clientConn net.Conn, initial parts := strings.SplitN(firstLine, " ", 3) targetHost := parts[1] - gatewayConn := s.dialAndSendConnect(clientConn, targetHost) - if gatewayConn == nil { + upstreamConn := s.dialAndSendConnect(ctx, clientConn, targetHost) + if upstreamConn == nil { return } - defer gatewayConn.Close() + defer upstreamConn.Close() clientConn.Write([]byte("HTTP/1.1 200 Connection Established\r\n\r\n")) // Track relay connection for run-mode force-close - s.trackConn(gatewayConn) - s.relay(ctx, clientConn, gatewayConn) - s.untrackConn(gatewayConn) + s.trackConn(upstreamConn) + s.relay(ctx, clientConn, upstreamConn) + s.untrackConn(upstreamConn) if s.OnRelayDone != nil { s.OnRelayDone() } } -// handleHTTP handles plain HTTP requests by forwarding to gateway. +// handleHTTP handles plain HTTP requests. An HTTP proxy upstream takes the +// request as-is in absolute form with its own Proxy-Authorization; any other +// upstream gets a tunnel to the target and the request in origin form. // No explicit body size limit: the relay meters all bytes (billing is correct) -// and the gateway's meteredConn enforces the byte budget. The idle timeout -// (60s) bounds slow-drip attacks. +// and the gateway enforces the byte budget. The idle timeout (60s) bounds +// slow-drip attacks. func (s *Server) handleHTTP(ctx context.Context, clientConn net.Conn, initialData []byte) { if s.checkRouteState(clientConn) { return @@ -413,107 +425,55 @@ func (s *Server) handleHTTP(ctx context.Context, clientConn net.Conn, initialDat return } - s.mu.Lock() - gwAuth := s.gatewayAuth - s.mu.Unlock() - - gatewayConn, err := s.dialGateway() - if err != nil { - // Don't mark route dead on transient dial failure — matches handleConnect behavior. - clientConn.Write([]byte("HTTP/1.1 502 Bad Gateway\r\n\r\n")) - return - } - defer gatewayConn.Close() - - req := string(initialData) - authHeader := fmt.Sprintf("Proxy-Authorization: Basic %s\r\n", gwAuth) - - if idx := strings.Index(req, "\r\n"); idx != -1 { - req = req[:idx+2] + authHeader + req[idx+2:] - } - - s.meter.AddUp(int64(len(req))) - if _, err := gatewayConn.Write([]byte(req)); err != nil { + up := s.Upstream() + if up == nil { clientConn.Write([]byte("HTTP/1.1 502 Bad Gateway\r\n\r\n")) return } - s.trackConn(gatewayConn) - s.relay(ctx, clientConn, gatewayConn) - s.untrackConn(gatewayConn) -} - -// relay copies data bidirectionally between two connections, metering bytes. -func (s *Server) relay(ctx context.Context, client, remote net.Conn) { - done := make(chan struct{}, 2) - - // Idle timeout: if no data flows in either direction for 60 seconds, - // close both sides. Without this, a dead WireGuard tunnel leaves the - // relay stuck indefinitely (especially over TLS where half-close doesn't - // propagate cleanly). - idleTimeout := 60 * time.Second - timer := time.AfterFunc(idleTimeout, func() { - client.Close() - remote.Close() - }) - extend := func() { - timer.Reset(idleTimeout) - } - - go func() { - copyWithIdleReset(&meteredWriter{remote, s.meter, true}, client, extend) - if tc, ok := remote.(*net.TCPConn); ok { - tc.CloseWrite() - } - done <- struct{}{} - }() - - go func() { - copyWithIdleReset(&meteredWriter{client, s.meter, false}, remote, extend) - if tc, ok := client.(*net.TCPConn); ok { - tc.CloseWrite() + var upstreamConn net.Conn + var req []byte + if hp, ok := up.(HTTPProxyUpstream); ok { + conn, err := hp.DialProxy(ctx) + if err != nil { + s.handleDialError(clientConn, err) + return } - done <- struct{}{} - }() - - select { - case <-done: - timer.Stop() - case <-ctx.Done(): - timer.Stop() - } -} - -// copyWithIdleReset copies from src to dst, calling onData after each chunk. -func copyWithIdleReset(dst io.Writer, src io.Reader, onData func()) { - buf := make([]byte, 32*1024) - for { - n, err := src.Read(buf) - if n > 0 { - onData() - if _, wErr := dst.Write(buf[:n]); wErr != nil { - return + upstreamConn = conn + req = initialData + if auth := hp.ProxyAuthorization(); auth != "" { + text := string(initialData) + if idx := strings.Index(text, "\r\n"); idx != -1 { + text = text[:idx+2] + "Proxy-Authorization: " + auth + "\r\n" + text[idx+2:] } + req = []byte(text) + } + } else { + target, origin, err := originForm(initialData) + if err != nil { + clientConn.Write([]byte("HTTP/1.1 400 Bad Request\r\n\r\n")) + return } + conn, err := up.DialTarget(ctx, target) if err != nil { + s.handleDialError(clientConn, err) return } + upstreamConn = conn + req = origin } -} + defer upstreamConn.Close() + s.failures.Store(0) -func (s *Server) dialGateway() (net.Conn, error) { - s.mu.Lock() - addr := s.gatewayAddr - tls := s.gatewayTLS - s.mu.Unlock() - return s.dialGatewayWith(addr, tls) -} - -func (s *Server) dialGatewayWith(addr string, useTLS bool) (net.Conn, error) { - if useTLS { - return tls.DialWithDialer(&net.Dialer{Timeout: 10 * time.Second}, "tcp", addr, &tls.Config{}) + s.meter.AddUp(int64(len(req))) + if _, err := upstreamConn.Write(req); err != nil { + clientConn.Write([]byte("HTTP/1.1 502 Bad Gateway\r\n\r\n")) + return } - return net.DialTimeout("tcp", addr, 10*time.Second) + + s.trackConn(upstreamConn) + s.relay(ctx, clientConn, upstreamConn) + s.untrackConn(upstreamConn) } func (s *Server) upstreamDeadWithReason(reason string) { @@ -521,33 +481,3 @@ func (s *Server) upstreamDeadWithReason(reason string) { s.OnUpstreamDead(reason) } } - -// meteredWriter wraps a writer and counts bytes. -type meteredWriter struct { - w io.Writer - meter *meter.Meter - isUp bool -} - -// bufferedConn wraps a net.Conn with a bufio.Reader, so that bytes already -// buffered from HTTP response parsing are not lost during relay. -type bufferedConn struct { - net.Conn - r *bufio.Reader -} - -func (bc *bufferedConn) Read(p []byte) (int, error) { - return bc.r.Read(p) -} - -func (mw *meteredWriter) Write(p []byte) (int, error) { - n, err := mw.w.Write(p) - if n > 0 { - if mw.isUp { - mw.meter.AddUp(int64(n)) - } else { - mw.meter.AddDown(int64(n)) - } - } - return n, err -} diff --git a/internal/proxy/server_route_state_test.go b/internal/proxy/server_route_state_test.go index 6a3036a..01fca09 100644 --- a/internal/proxy/server_route_state_test.go +++ b/internal/proxy/server_route_state_test.go @@ -19,12 +19,10 @@ import ( func newTestServer(t *testing.T, gatewayAddr string) *Server { t.Helper() s := NewServer(&Config{ - ListenPort: 0, - GatewayAddr: gatewayAddr, - GatewayTLS: false, - Token: "test-token", - Meter: meter.New(), - ByteBudget: -1, + ListenPort: 0, + Upstream: NewHostedUpstream(gatewayAddr, false, "test-token"), + Meter: meter.New(), + ByteBudget: -1, }) return s } @@ -164,11 +162,9 @@ func TestSwapUpstream(t *testing.T) { } // Gateway addr should be updated - s.mu.Lock() - addr := s.gatewayAddr - s.mu.Unlock() - if addr != "new-gateway:8080" { - t.Errorf("gatewayAddr = %q, want new-gateway:8080", addr) + up, ok := s.Upstream().(*HTTPUpstream) + if !ok || up.addr != "new-gateway:8080" { + t.Errorf("upstream = %#v, want hosted new-gateway:8080", s.Upstream()) } } @@ -177,8 +173,7 @@ func TestSwapUpstream(t *testing.T) { func TestTrackedConnsCloseOnDead(t *testing.T) { s := NewServer(&Config{ ListenPort: 0, - GatewayAddr: "localhost:0", - Token: "test", + Upstream: NewHostedUpstream("localhost:0", false, "test"), Meter: meter.New(), ByteBudget: -1, TrackRelays: true, @@ -218,8 +213,7 @@ func TestTrackedConnsCloseOnDead(t *testing.T) { func TestTrackedConnsNotClosedOnDeadFalse(t *testing.T) { s := NewServer(&Config{ ListenPort: 0, - GatewayAddr: "localhost:0", - Token: "test", + Upstream: NewHostedUpstream("localhost:0", false, "test"), Meter: meter.New(), ByteBudget: -1, TrackRelays: true, @@ -246,8 +240,7 @@ func TestTrackedConnsNotClosedOnDeadFalse(t *testing.T) { func TestTrackUntrack(t *testing.T) { s := NewServer(&Config{ ListenPort: 0, - GatewayAddr: "localhost:0", - Token: "test", + Upstream: NewHostedUpstream("localhost:0", false, "test"), Meter: meter.New(), ByteBudget: -1, TrackRelays: true, @@ -275,8 +268,7 @@ func TestTrackUntrack(t *testing.T) { func TestNoTrackingWhenDisabled(t *testing.T) { s := NewServer(&Config{ ListenPort: 0, - GatewayAddr: "localhost:0", - Token: "test", + Upstream: NewHostedUpstream("localhost:0", false, "test"), Meter: meter.New(), ByteBudget: -1, TrackRelays: false, // disabled @@ -320,7 +312,7 @@ func TestDeadStateSetBeforeClientResponse(t *testing.T) { defer client.Close() go func() { - result := s.dialAndSendConnect(server, "example.com:443") + result := s.dialAndSendConnect(context.Background(), server, "example.com:443") if result != nil { result.Close() } @@ -376,7 +368,7 @@ func TestXRouteDeadHeaderSetsRouteDead(t *testing.T) { defer client.Close() go func() { - result := s.dialAndSendConnect(server, "example.com:443") + result := s.dialAndSendConnect(context.Background(), server, "example.com:443") if result != nil { result.Close() } @@ -417,7 +409,7 @@ func TestDialFailureDoesNotPoisonRoute(t *testing.T) { // First request — dial fails, returns 502 c1, s1 := net.Pipe() go func() { - s.dialAndSendConnect(s1, "example.com:443") + s.dialAndSendConnect(context.Background(), s1, "example.com:443") s1.Close() }() resp, _ := http.ReadResponse(bufio.NewReader(c1), nil) @@ -522,8 +514,7 @@ func TestRelayNormalCloseDoesNotTriggerDeath(t *testing.T) { func TestRelayNormalCloseDoesNotTriggerDeathRunMode(t *testing.T) { s := NewServer(&Config{ ListenPort: 0, - GatewayAddr: "localhost:0", - Token: "test", + Upstream: NewHostedUpstream("localhost:0", false, "test"), Meter: meter.New(), ByteBudget: -1, TrackRelays: true, // run mode @@ -572,11 +563,10 @@ func TestRelayNormalCloseDoesNotTriggerDeathRunMode(t *testing.T) { func TestCheckBudgetSetsDepletedBeforeResponse(t *testing.T) { s := NewServer(&Config{ - ListenPort: 0, - GatewayAddr: "localhost:0", - Token: "test", - Meter: meter.New(), - ByteBudget: 100, // very small budget + ListenPort: 0, + Upstream: NewHostedUpstream("localhost:0", false, "test"), + Meter: meter.New(), + ByteBudget: 100, // very small budget }) // Add bytes to exceed budget @@ -660,7 +650,7 @@ func TestRelayIdleTimeout_ClosesOnStall(t *testing.T) { relayDone := make(chan struct{}) go func() { - s.handleConnect(context.Background(), server, "CONNECT example.com:443 HTTP/1.1\r\nHost: example.com\r\nProxy-Authorization: Basic "+s.gatewayAuth+"\r\n\r\n") + s.handleConnect(context.Background(), server, "CONNECT example.com:443 HTTP/1.1\r\nHost: example.com\r\nProxy-Authorization: Basic "+s.Upstream().(*HTTPUpstream).auth+"\r\n\r\n") server.Close() close(relayDone) }() diff --git a/internal/proxy/server_test.go b/internal/proxy/server_test.go index 7212dc8..953b746 100644 --- a/internal/proxy/server_test.go +++ b/internal/proxy/server_test.go @@ -1,6 +1,7 @@ package proxy import ( + "context" "net" "strings" "sync" @@ -42,27 +43,32 @@ func fakeGateway(t *testing.T, response string) (string, func()) { return ln.Addr().String(), func() { ln.Close() } } +// hostedServer builds a Server over a hosted gateway at gwAddr, as session.Start does. +func hostedServer(gwAddr string) *Server { + return NewServer(&Config{ + Upstream: NewHostedUpstream(gwAddr, false, "test-token"), + Meter: meter.New(), + }) +} + func TestDialAndSendConnect_ParsesExitIPHeader(t *testing.T) { gwAddr, cleanup := fakeGateway(t, "HTTP/1.1 200 OK\r\nX-Exit-IP: 5.6.7.8\r\n\r\n") defer cleanup() var got string var mu sync.Mutex - s := &Server{ - gatewayAddr: gwAddr, - - OnExitIPChanged: func(ip string) { - mu.Lock() - got = ip - mu.Unlock() - }, + s := hostedServer(gwAddr) + s.OnExitIPChanged = func(ip string) { + mu.Lock() + got = ip + mu.Unlock() } client, server := net.Pipe() defer client.Close() defer server.Close() - result := s.dialAndSendConnect(server, "example.com:443") + result := s.dialAndSendConnect(context.Background(), server, "example.com:443") if result == nil { t.Fatal("dialAndSendConnect returned nil — gateway should have returned 200") } @@ -80,19 +86,16 @@ func TestDialAndSendConnect_NoHeaderNoCallback(t *testing.T) { defer cleanup() called := false - s := &Server{ - gatewayAddr: gwAddr, - - OnExitIPChanged: func(ip string) { - called = true - }, + s := hostedServer(gwAddr) + s.OnExitIPChanged = func(ip string) { + called = true } client, server := net.Pipe() defer client.Close() defer server.Close() - result := s.dialAndSendConnect(server, "example.com:443") + result := s.dialAndSendConnect(context.Background(), server, "example.com:443") if result == nil { t.Fatal("dialAndSendConnect returned nil") } @@ -140,20 +143,17 @@ func TestDialAndSendConnect_IPChangeMidSession(t *testing.T) { var receivedIPs []string var ipMu sync.Mutex - s := &Server{ - gatewayAddr: ln.Addr().String(), - - OnExitIPChanged: func(ip string) { - ipMu.Lock() - receivedIPs = append(receivedIPs, ip) - ipMu.Unlock() - }, + s := hostedServer(ln.Addr().String()) + s.OnExitIPChanged = func(ip string) { + ipMu.Lock() + receivedIPs = append(receivedIPs, ip) + ipMu.Unlock() } // First connect c1, s1 := net.Pipe() defer c1.Close() - r1 := s.dialAndSendConnect(s1, "example.com:443") + r1 := s.dialAndSendConnect(context.Background(), s1, "example.com:443") s1.Close() if r1 != nil { r1.Close() @@ -162,7 +162,7 @@ func TestDialAndSendConnect_IPChangeMidSession(t *testing.T) { // Second connect — different IP c2, s2 := net.Pipe() defer c2.Close() - r2 := s.dialAndSendConnect(s2, "example.com:443") + r2 := s.dialAndSendConnect(context.Background(), s2, "example.com:443") s2.Close() if r2 != nil { r2.Close() @@ -185,18 +185,14 @@ func TestDialAndSendConnect_NilCallbackDoesNotPanic(t *testing.T) { gwAddr, cleanup := fakeGateway(t, "HTTP/1.1 200 OK\r\nX-Exit-IP: 9.9.9.9\r\n\r\n") defer cleanup() - s := &Server{ - gatewayAddr: gwAddr, - - OnExitIPChanged: nil, // not set - } + s := hostedServer(gwAddr) client, server := net.Pipe() defer client.Close() defer server.Close() // Should not panic - result := s.dialAndSendConnect(server, "example.com:443") + result := s.dialAndSendConnect(context.Background(), server, "example.com:443") if result != nil { result.Close() } @@ -208,12 +204,9 @@ func TestDialAndSendConnect_NonOKDoesNotKillRoute(t *testing.T) { defer cleanup() deadCalled := false - s := &Server{ - gatewayAddr: gwAddr, - - OnUpstreamDead: func(reason string) { - deadCalled = true - }, + s := hostedServer(gwAddr) + s.OnUpstreamDead = func(reason string) { + deadCalled = true } client, server := net.Pipe() @@ -228,7 +221,7 @@ func TestDialAndSendConnect_NonOKDoesNotKillRoute(t *testing.T) { } }() - result := s.dialAndSendConnect(server, "example.com:443") + result := s.dialAndSendConnect(context.Background(), server, "example.com:443") server.Close() if result != nil { t.Error("502 should return nil connection") @@ -244,12 +237,9 @@ func TestDialAndSendConnect_402TriggersDepleted(t *testing.T) { defer cleanup() var reason string - s := &Server{ - gatewayAddr: gwAddr, - - OnUpstreamDead: func(r string) { - reason = r - }, + s := hostedServer(gwAddr) + s.OnUpstreamDead = func(r string) { + reason = r } client, server := net.Pipe() @@ -263,7 +253,7 @@ func TestDialAndSendConnect_402TriggersDepleted(t *testing.T) { } }() - result := s.dialAndSendConnect(server, "example.com:443") + result := s.dialAndSendConnect(context.Background(), server, "example.com:443") server.Close() if result != nil { t.Error("402 should return nil") @@ -277,12 +267,9 @@ func TestDialAndSendConnect_402TriggersDepleted(t *testing.T) { func TestDialAndSendConnect_DialFailureDoesNotKillRoute(t *testing.T) { // Gateway unreachable — should NOT trigger OnUpstreamDead (transient) deadCalled := false - s := &Server{ - gatewayAddr: "127.0.0.1:1", // nothing listening - - OnUpstreamDead: func(r string) { - deadCalled = true - }, + s := hostedServer("127.0.0.1:1") + s.OnUpstreamDead = func(r string) { + deadCalled = true } client, server := net.Pipe() @@ -296,7 +283,7 @@ func TestDialAndSendConnect_DialFailureDoesNotKillRoute(t *testing.T) { } }() - result := s.dialAndSendConnect(server, "example.com:443") + result := s.dialAndSendConnect(context.Background(), server, "example.com:443") server.Close() if result != nil { t.Error("dial failure should return nil") diff --git a/internal/proxy/upstream.go b/internal/proxy/upstream.go new file mode 100644 index 0000000..3cc1fc7 --- /dev/null +++ b/internal/proxy/upstream.go @@ -0,0 +1,190 @@ +package proxy + +import ( + "context" + "crypto/tls" + "errors" + "fmt" + "net" + "os" + "syscall" + "time" +) + +// Upstream is where the local proxy sends traffic. The hosted gateway and a +// user's own proxy both implement it. The child process never sees the +// difference: it only ever talks to 127.0.0.1. +type Upstream interface { + // DialTarget returns a connection that is already tunnelled to hostPort. + // Failures are returned as *DialError so the server can map them to a + // client response and a route state. + DialTarget(ctx context.Context, hostPort string) (net.Conn, error) + // Describe returns a label for status output. It never contains a password. + Describe() string +} + +// HTTPProxyUpstream is implemented by upstreams that are HTTP proxies +// themselves and can take a plain HTTP request in absolute form directly. +type HTTPProxyUpstream interface { + Upstream + // DialProxy opens a raw connection to the proxy itself. + DialProxy(ctx context.Context) (net.Conn, error) + // ProxyAuthorization returns the Proxy-Authorization header value, or "" + // when the proxy takes no credentials. + ProxyAuthorization() string +} + +// OwnFailureThreshold is how many consecutive upstream failures mark a +// user's own proxy dead. The hosted gateway reports death explicitly and +// uses no threshold. +const OwnFailureThreshold = 3 + +// dialTimeout bounds one connection attempt to a user's own proxy. A +// variable so tests can shorten it. +var dialTimeout = 10 * time.Second + +// hostedHandshakeTimeout bounds the CONNECT exchange with the hosted +// gateway, which may build a tunnel on first use and needs longer. +const hostedHandshakeTimeout = 45 * time.Second + +// DialErrorKind classifies why an upstream could not reach the target. +type DialErrorKind int + +const ( + // KindDial: the proxy itself could not be reached (resolve, refused, timeout). + KindDial DialErrorKind = iota + // KindHandshake: the proxy answered, but not with the expected protocol. + KindHandshake + // KindCredentials: the proxy rejected the username or password. + KindCredentials + // KindProxyStatus: the proxy refused the target. Status carries the HTTP + // status or the SOCKS reply code, Msg the text to show. + KindProxyStatus + // KindDepleted: hosted only. The account balance is used up. + KindDepleted + // KindRouteDead: hosted only. The gateway declared the route dead. + KindRouteDead +) + +// DialError reports why an upstream could not open a tunnel. +type DialError struct { + Kind DialErrorKind + Status int + Msg string + Err error +} + +func (e *DialError) Error() string { + if e.Msg != "" { + return e.Msg + } + if e.Err != nil { + return e.Err.Error() + } + return fmt.Sprintf("upstream error (kind %d)", e.Kind) +} + +func (e *DialError) Unwrap() error { return e.Err } + +// Cause returns a short explanation of a dial failure for the user. +func Cause(err error) string { + var de *DialError + if !errors.As(err, &de) { + return netCause(err) + } + switch de.Kind { + case KindCredentials: + if de.Msg != "" { + return de.Msg + } + return "proxy rejected credentials" + case KindProxyStatus: + if de.Msg != "" { + return "proxy returned " + de.Msg + } + return fmt.Sprintf("proxy returned %d for CONNECT", de.Status) + case KindHandshake: + if de.Msg != "" { + return de.Msg + } + return "proxy did not answer with the expected protocol" + case KindDepleted: + return "balance depleted" + case KindRouteDead: + return "route is dead" + default: + return netCause(de.Err) + } +} + +// netCause names the network reason behind a dial failure. +func netCause(err error) string { + if err == nil { + return "connection failed" + } + var dnsErr *net.DNSError + if errors.As(err, &dnsErr) { + return "cannot resolve host " + dnsErr.Name + } + if errors.Is(err, syscall.ECONNREFUSED) { + return "connection refused" + } + if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, os.ErrDeadlineExceeded) { + return "timeout" + } + var ne net.Error + if errors.As(err, &ne) && ne.Timeout() { + return "timeout" + } + return err.Error() +} + +// handshakeError classifies a failed protocol exchange with the proxy: a +// timeout is a reachability problem (KindDial), anything else means the +// proxy did not speak the expected protocol (KindHandshake). +func handshakeError(msg string, err error) *DialError { + var ne net.Error + if errors.Is(err, os.ErrDeadlineExceeded) || (errors.As(err, &ne) && ne.Timeout()) { + return &DialError{Kind: KindDial, Err: err} + } + return &DialError{Kind: KindHandshake, Msg: msg, Err: err} +} + +// TunnelFailed reports whether err means the tunnel could not be opened, as +// opposed to a later step (such as the exit IP service) failing. +func TunnelFailed(err error) bool { + var de *DialError + return errors.As(err, &de) +} + +// dialProxyConn opens a TCP connection to addr, wrapped in TLS when useTLS is +// set. tlsCfg may be nil for default verification against system roots. +func dialProxyConn(ctx context.Context, addr string, useTLS bool, tlsCfg *tls.Config) (net.Conn, error) { + d := &net.Dialer{Timeout: dialTimeout} + if !useTLS { + return d.DialContext(ctx, "tcp", addr) + } + cfg := &tls.Config{} + if tlsCfg != nil { + cfg = tlsCfg.Clone() + } + if cfg.ServerName == "" { + host, _, err := net.SplitHostPort(addr) + if err != nil { + host = addr + } + cfg.ServerName = host + } + td := &tls.Dialer{NetDialer: d, Config: cfg} + return td.DialContext(ctx, "tcp", addr) +} + +// handshakeDeadline is the deadline for a proxy handshake: the context's +// deadline when it is sooner, otherwise now plus timeout. +func handshakeDeadline(ctx context.Context, timeout time.Duration) time.Time { + deadline := time.Now().Add(timeout) + if d, ok := ctx.Deadline(); ok && d.Before(deadline) { + return d + } + return deadline +} diff --git a/internal/proxy/upstream_http.go b/internal/proxy/upstream_http.go new file mode 100644 index 0000000..5c43463 --- /dev/null +++ b/internal/proxy/upstream_http.go @@ -0,0 +1,128 @@ +package proxy + +import ( + "bufio" + "context" + "crypto/tls" + "encoding/base64" + "fmt" + "net" + "net/http" + "time" +) + +// HTTPUpstream is an HTTP proxy reached with CONNECT: the hosted gateway or a +// user's own HTTP or HTTPS proxy. +type HTTPUpstream struct { + addr string + useTLS bool + tlsConfig *tls.Config // nil: verify against system roots + auth string // base64 "user:pass", "" when the proxy takes no credentials + label string + hosted bool // interpret the hosted gateway's extra replies + timeout time.Duration + + onExitIP func(ip string) // hosted: the gateway reports the exit IP on each tunnel +} + +// NewHostedUpstream returns the upstream for a hosted gateway session. +func NewHostedUpstream(addr string, useTLS bool, token string) *HTTPUpstream { + return &HTTPUpstream{ + addr: addr, + useTLS: useTLS, + auth: base64.StdEncoding.EncodeToString([]byte("_:" + token)), + label: "shellroute", + hosted: true, + timeout: hostedHandshakeTimeout, + } +} + +// NewHTTPUpstream returns an upstream for a user's own HTTP (useTLS false) or +// HTTPS (useTLS true) proxy. label is shown in status output and must not +// contain the password. +func NewHTTPUpstream(addr string, useTLS bool, username, password, label string) *HTTPUpstream { + u := &HTTPUpstream{addr: addr, useTLS: useTLS, label: label, timeout: dialTimeout} + if username != "" || password != "" { + u.auth = base64.StdEncoding.EncodeToString([]byte(username + ":" + password)) + } + return u +} + +// Describe returns the redacted label. +func (u *HTTPUpstream) Describe() string { return u.label } + +// ProxyAuthorization returns the header value sent to the proxy, or "". +func (u *HTTPUpstream) ProxyAuthorization() string { + if u.auth == "" { + return "" + } + return "Basic " + u.auth +} + +func (u *HTTPUpstream) setExitIPCallback(f func(string)) { u.onExitIP = f } + +// setTLSConfig overrides TLS verification; used by tests with a private CA. +func (u *HTTPUpstream) setTLSConfig(cfg *tls.Config) { u.tlsConfig = cfg } + +// DialProxy opens a raw connection to the proxy itself. +func (u *HTTPUpstream) DialProxy(ctx context.Context) (net.Conn, error) { + conn, err := dialProxyConn(ctx, u.addr, u.useTLS, u.tlsConfig) + if err != nil { + return nil, &DialError{Kind: KindDial, Err: err} + } + return conn, nil +} + +// DialTarget opens a CONNECT tunnel to hostPort through the proxy. +func (u *HTTPUpstream) DialTarget(ctx context.Context, hostPort string) (net.Conn, error) { + conn, err := u.DialProxy(ctx) + if err != nil { + return nil, err + } + + req := fmt.Sprintf("CONNECT %s HTTP/1.1\r\nHost: %s\r\n", hostPort, hostPort) + if a := u.ProxyAuthorization(); a != "" { + req += "Proxy-Authorization: " + a + "\r\n" + } + req += "\r\n" + + conn.SetDeadline(handshakeDeadline(ctx, u.timeout)) + if _, err := conn.Write([]byte(req)); err != nil { + conn.Close() + return nil, &DialError{Kind: KindDial, Err: err} + } + + // bufio.Reader so tunnel bytes arriving right after the headers are kept. + br := bufio.NewReader(conn) + resp, err := http.ReadResponse(br, nil) + if err != nil { + conn.Close() + return nil, handshakeError("proxy did not answer the CONNECT request", err) + } + conn.SetDeadline(time.Time{}) + + switch { + case resp.StatusCode == http.StatusOK: + if u.hosted && u.onExitIP != nil { + if ip := resp.Header.Get("X-Exit-IP"); ip != "" { + u.onExitIP(ip) + } + } + if br.Buffered() > 0 { + return &bufferedConn{Conn: conn, r: br}, nil + } + return conn, nil + case u.hosted && resp.StatusCode == http.StatusPaymentRequired: + conn.Close() + return nil, &DialError{Kind: KindDepleted, Status: resp.StatusCode} + case resp.StatusCode == http.StatusProxyAuthRequired: + conn.Close() + return nil, &DialError{Kind: KindCredentials, Status: resp.StatusCode} + case u.hosted && resp.Header.Get("X-Route-Dead") == "true": + conn.Close() + return nil, &DialError{Kind: KindRouteDead, Status: resp.StatusCode} + default: + conn.Close() + return nil, &DialError{Kind: KindProxyStatus, Status: resp.StatusCode, Msg: resp.Status} + } +} diff --git a/internal/proxy/upstream_socks5.go b/internal/proxy/upstream_socks5.go new file mode 100644 index 0000000..07eb740 --- /dev/null +++ b/internal/proxy/upstream_socks5.go @@ -0,0 +1,199 @@ +package proxy + +import ( + "context" + "encoding/binary" + "fmt" + "io" + "net" + "strconv" + "time" +) + +const ( + socksVersion = 0x05 + socksNoAuth = 0x00 + socksUserPass = 0x02 + socksNoAcceptable = 0xFF + socksCmdConnect = 0x01 + socksAtypIPv4 = 0x01 + socksAtypDomain = 0x03 + socksAtypIPv6 = 0x04 +) + +// SOCKS5Upstream is a user's own SOCKS5 proxy (RFC 1928) with optional +// username/password authentication (RFC 1929). Target names are sent to the +// proxy for resolution, so socks5:// and socks5h:// behave the same. +type SOCKS5Upstream struct { + addr string + username string + password string + label string +} + +// NewSOCKS5Upstream returns an upstream for a SOCKS5 proxy. label is shown in +// status output and must not contain the password. +func NewSOCKS5Upstream(addr, username, password, label string) *SOCKS5Upstream { + return &SOCKS5Upstream{addr: addr, username: username, password: password, label: label} +} + +// Describe returns the redacted label. +func (u *SOCKS5Upstream) Describe() string { return u.label } + +// DialTarget connects to the proxy and asks it to open a TCP connection to hostPort. +func (u *SOCKS5Upstream) DialTarget(ctx context.Context, hostPort string) (net.Conn, error) { + host, portStr, err := net.SplitHostPort(hostPort) + if err != nil { + return nil, &DialError{Kind: KindHandshake, Msg: "invalid target address", Err: err} + } + port, err := strconv.Atoi(portStr) + if err != nil || port < 1 || port > 65535 { + return nil, &DialError{Kind: KindHandshake, Msg: "invalid target port"} + } + + conn, err := dialProxyConn(ctx, u.addr, false, nil) + if err != nil { + return nil, &DialError{Kind: KindDial, Err: err} + } + conn.SetDeadline(handshakeDeadline(ctx, dialTimeout)) + if err := u.handshake(conn, host, port); err != nil { + conn.Close() + return nil, err + } + conn.SetDeadline(time.Time{}) + return conn, nil +} + +func (u *SOCKS5Upstream) handshake(conn net.Conn, host string, port int) error { + wantAuth := u.username != "" || u.password != "" + methods := []byte{socksNoAuth} + if wantAuth { + methods = append(methods, socksUserPass) + } + greeting := append([]byte{socksVersion, byte(len(methods))}, methods...) + if _, err := conn.Write(greeting); err != nil { + return &DialError{Kind: KindDial, Err: err} + } + + var reply [2]byte + if _, err := io.ReadFull(conn, reply[:]); err != nil { + return handshakeError("proxy did not answer the SOCKS5 greeting", err) + } + if reply[0] != socksVersion { + return &DialError{Kind: KindHandshake, Msg: "not a SOCKS5 proxy"} + } + switch reply[1] { + case socksNoAuth: + case socksUserPass: + if !wantAuth { + return &DialError{Kind: KindCredentials, Msg: "proxy requires a username and password"} + } + if err := u.authenticate(conn); err != nil { + return err + } + case socksNoAcceptable: + if wantAuth { + return &DialError{Kind: KindHandshake, Msg: "proxy accepts neither anonymous nor username/password authentication"} + } + return &DialError{Kind: KindCredentials, Msg: "proxy requires a username and password"} + default: + return &DialError{Kind: KindHandshake, Msg: fmt.Sprintf("proxy chose unsupported authentication method %d", reply[1])} + } + + req := []byte{socksVersion, socksCmdConnect, 0x00} + if ip := net.ParseIP(host); ip != nil { + if ip4 := ip.To4(); ip4 != nil { + req = append(req, socksAtypIPv4) + req = append(req, ip4...) + } else { + req = append(req, socksAtypIPv6) + req = append(req, ip.To16()...) + } + } else { + if len(host) > 255 { + return &DialError{Kind: KindHandshake, Msg: "target host name longer than 255 bytes"} + } + req = append(req, socksAtypDomain, byte(len(host))) + req = append(req, host...) + } + req = binary.BigEndian.AppendUint16(req, uint16(port)) + if _, err := conn.Write(req); err != nil { + return &DialError{Kind: KindDial, Err: err} + } + + var head [4]byte + if _, err := io.ReadFull(conn, head[:]); err != nil { + return handshakeError("proxy did not answer the CONNECT request", err) + } + if head[0] != socksVersion { + return &DialError{Kind: KindHandshake, Msg: "not a SOCKS5 proxy"} + } + if head[1] != 0x00 { + return &DialError{Kind: KindProxyStatus, Status: int(head[1]), Msg: socksReplyText(head[1])} + } + + // Skip the bound address and port. + var skip int + switch head[3] { + case socksAtypIPv4: + skip = net.IPv4len + case socksAtypIPv6: + skip = net.IPv6len + case socksAtypDomain: + var l [1]byte + if _, err := io.ReadFull(conn, l[:]); err != nil { + return handshakeError("proxy sent a truncated CONNECT reply", err) + } + skip = int(l[0]) + default: + return &DialError{Kind: KindHandshake, Msg: "proxy sent an unknown address type"} + } + if _, err := io.CopyN(io.Discard, conn, int64(skip+2)); err != nil { + return handshakeError("proxy sent a truncated CONNECT reply", err) + } + return nil +} + +func (u *SOCKS5Upstream) authenticate(conn net.Conn) error { + if len(u.username) > 255 || len(u.password) > 255 { + return &DialError{Kind: KindCredentials, Msg: "username or password longer than 255 bytes"} + } + msg := []byte{0x01, byte(len(u.username))} + msg = append(msg, u.username...) + msg = append(msg, byte(len(u.password))) + msg = append(msg, u.password...) + if _, err := conn.Write(msg); err != nil { + return &DialError{Kind: KindDial, Err: err} + } + var reply [2]byte + if _, err := io.ReadFull(conn, reply[:]); err != nil { + return handshakeError("proxy did not answer the authentication request", err) + } + if reply[1] != 0x00 { + return &DialError{Kind: KindCredentials, Status: int(reply[1])} + } + return nil +} + +func socksReplyText(code byte) string { + switch code { + case 1: + return "general failure" + case 2: + return "connection not allowed by ruleset" + case 3: + return "network unreachable" + case 4: + return "host unreachable" + case 5: + return "connection refused" + case 6: + return "TTL expired" + case 7: + return "command not supported" + case 8: + return "address type not supported" + default: + return fmt.Sprintf("SOCKS reply %d", code) + } +} diff --git a/internal/proxy/upstream_test.go b/internal/proxy/upstream_test.go new file mode 100644 index 0000000..e9afdca --- /dev/null +++ b/internal/proxy/upstream_test.go @@ -0,0 +1,873 @@ +package proxy + +import ( + "bufio" + "context" + "crypto/tls" + "crypto/x509" + "errors" + "io" + "net" + "net/http" + "net/http/httptest" + neturl "net/url" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/shellroute/shellroute-cli/internal/meter" + "github.com/shellroute/shellroute-cli/internal/proxy/proxytest" +) + +// roundTrip writes msg through a tunnelled connection to an echo server and +// returns what came back. +func roundTrip(t *testing.T, conn net.Conn, msg string) string { + t.Helper() + conn.SetDeadline(time.Now().Add(5 * time.Second)) + if _, err := conn.Write([]byte(msg)); err != nil { + t.Fatalf("write: %v", err) + } + buf := make([]byte, len(msg)) + if _, err := io.ReadFull(conn, buf); err != nil { + t.Fatalf("read: %v", err) + } + return string(buf) +} + +func dialErr(t *testing.T, err error) *DialError { + t.Helper() + var de *DialError + if !errors.As(err, &de) { + t.Fatalf("error %v (%T) is not a *DialError", err, err) + } + return de +} + +// --- HTTP and HTTPS upstreams --- + +func TestHTTPUpstream_ConnectRelays(t *testing.T) { + p := proxytest.StartHTTPProxy(t, "", "") + echo := proxytest.StartEcho(t) + up := NewHTTPUpstream(p.Addr, false, "", "", "http://"+p.Addr) + + conn, err := up.DialTarget(context.Background(), echo) + if err != nil { + t.Fatalf("DialTarget: %v", err) + } + defer conn.Close() + if got := roundTrip(t, conn, "hello through http"); got != "hello through http" { + t.Fatalf("echo = %q", got) + } + if up.ProxyAuthorization() != "" { + t.Error("no credentials should mean no Proxy-Authorization") + } +} + +func TestHTTPUpstream_BasicAuthAccepted(t *testing.T) { + p := proxytest.StartHTTPProxy(t, "alice", "s3cret") + echo := proxytest.StartEcho(t) + up := NewHTTPUpstream(p.Addr, false, "alice", "s3cret", "label") + + conn, err := up.DialTarget(context.Background(), echo) + if err != nil { + t.Fatalf("DialTarget: %v", err) + } + defer conn.Close() + if got := roundTrip(t, conn, "auth ok"); got != "auth ok" { + t.Fatalf("echo = %q", got) + } + if p.Rejected.Load() != 0 { + t.Errorf("proxy rejected %d requests", p.Rejected.Load()) + } +} + +func TestHTTPUpstream_WrongPasswordIsCredentialsError(t *testing.T) { + p := proxytest.StartHTTPProxy(t, "alice", "s3cret") + up := NewHTTPUpstream(p.Addr, false, "alice", "wrong", "label") + + _, err := up.DialTarget(context.Background(), "example.com:443") + de := dialErr(t, err) + if de.Kind != KindCredentials { + t.Fatalf("kind = %d, want KindCredentials", de.Kind) + } + if Cause(err) != "proxy rejected credentials" { + t.Errorf("Cause = %q", Cause(err)) + } + if p.Rejected.Load() != 1 { + t.Errorf("proxy rejected %d, want 1", p.Rejected.Load()) + } +} + +func TestHTTPUpstream_ProxyStatusForwarded(t *testing.T) { + // A proxy that refuses every target with 403. + gwAddr, cleanup := fakeGateway(t, "HTTP/1.1 403 Forbidden\r\n\r\n") + defer cleanup() + up := NewHTTPUpstream(gwAddr, false, "", "", "label") + + _, err := up.DialTarget(context.Background(), "example.com:443") + de := dialErr(t, err) + if de.Kind != KindProxyStatus || de.Status != 403 { + t.Fatalf("got kind %d status %d, want KindProxyStatus 403", de.Kind, de.Status) + } + if Cause(err) != "proxy returned 403 Forbidden" { + t.Errorf("Cause = %q", Cause(err)) + } +} + +func TestHTTPSUpstream_TLSToProxy(t *testing.T) { + p, pool := proxytest.StartTLSHTTPProxy(t, "bob", "pw") + echo := proxytest.StartEcho(t) + up := NewHTTPUpstream(p.Addr, true, "bob", "pw", "https://bob:***@"+p.Addr) + up.setTLSConfig(&tls.Config{RootCAs: pool}) + + conn, err := up.DialTarget(context.Background(), echo) + if err != nil { + t.Fatalf("DialTarget over TLS: %v", err) + } + defer conn.Close() + if got := roundTrip(t, conn, "tls proxy"); got != "tls proxy" { + t.Fatalf("echo = %q", got) + } +} + +func TestHTTPSUpstream_UntrustedCertificateFails(t *testing.T) { + p, _ := proxytest.StartTLSHTTPProxy(t, "", "") + up := NewHTTPUpstream(p.Addr, true, "", "", "label") + + _, err := up.DialTarget(context.Background(), "example.com:443") + de := dialErr(t, err) + if de.Kind != KindDial { + t.Fatalf("kind = %d, want KindDial for a TLS verification failure", de.Kind) + } +} + +func TestHostedUpstream_KeepsGatewaySemantics(t *testing.T) { + cases := []struct { + name string + response string + kind DialErrorKind + }{ + {"402 depleted", "HTTP/1.1 402 Payment Required\r\n\r\n", KindDepleted}, + {"407 token invalid", "HTTP/1.1 407 Proxy Authentication Required\r\n\r\n", KindCredentials}, + {"X-Route-Dead", "HTTP/1.1 502 Bad Gateway\r\nX-Route-Dead: true\r\n\r\n", KindRouteDead}, + {"5xx transient", "HTTP/1.1 503 Service Unavailable\r\n\r\n", KindProxyStatus}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + gwAddr, cleanup := fakeGateway(t, tc.response) + defer cleanup() + up := NewHostedUpstream(gwAddr, false, "tok") + _, err := up.DialTarget(context.Background(), "example.com:443") + if de := dialErr(t, err); de.Kind != tc.kind { + t.Errorf("kind = %d, want %d", de.Kind, tc.kind) + } + }) + } +} + +func TestOwnHTTPUpstream_402IsPlainProxyStatus(t *testing.T) { + // Only the hosted gateway means "balance depleted" by 402. + gwAddr, cleanup := fakeGateway(t, "HTTP/1.1 402 Payment Required\r\n\r\n") + defer cleanup() + up := NewHTTPUpstream(gwAddr, false, "", "", "label") + _, err := up.DialTarget(context.Background(), "example.com:443") + if de := dialErr(t, err); de.Kind != KindProxyStatus { + t.Errorf("kind = %d, want KindProxyStatus", de.Kind) + } +} + +// --- SOCKS5 upstream --- + +func TestSOCKS5Upstream_NoAuthRelays(t *testing.T) { + s := proxytest.StartSOCKS5(t, "", "") + echo := proxytest.StartEcho(t) + up := NewSOCKS5Upstream(s.Addr, "", "", "socks5://"+s.Addr) + + conn, err := up.DialTarget(context.Background(), echo) + if err != nil { + t.Fatalf("DialTarget: %v", err) + } + defer conn.Close() + if got := roundTrip(t, conn, "socks hello"); got != "socks hello" { + t.Fatalf("echo = %q", got) + } + if s.Connects.Load() != 1 { + t.Errorf("connects = %d, want 1", s.Connects.Load()) + } +} + +func TestSOCKS5Upstream_DomainNameSentToProxy(t *testing.T) { + // "localhost" must be resolved by the proxy, not by us. + s := proxytest.StartSOCKS5(t, "", "") + echo := proxytest.StartEcho(t) + _, port, _ := net.SplitHostPort(echo) + up := NewSOCKS5Upstream(s.Addr, "", "", "label") + + conn, err := up.DialTarget(context.Background(), "localhost:"+port) + if err != nil { + t.Fatalf("DialTarget by name: %v", err) + } + conn.Close() +} + +func TestSOCKS5Upstream_UserPassAccepted(t *testing.T) { + s := proxytest.StartSOCKS5(t, "carol", "pw") + echo := proxytest.StartEcho(t) + up := NewSOCKS5Upstream(s.Addr, "carol", "pw", "label") + + conn, err := up.DialTarget(context.Background(), echo) + if err != nil { + t.Fatalf("DialTarget: %v", err) + } + defer conn.Close() + if got := roundTrip(t, conn, "socks auth"); got != "socks auth" { + t.Fatalf("echo = %q", got) + } +} + +func TestSOCKS5Upstream_WrongPasswordIsCredentialsError(t *testing.T) { + s := proxytest.StartSOCKS5(t, "carol", "pw") + up := NewSOCKS5Upstream(s.Addr, "carol", "nope", "label") + + _, err := up.DialTarget(context.Background(), "example.com:443") + if de := dialErr(t, err); de.Kind != KindCredentials { + t.Fatalf("kind = %d, want KindCredentials", de.Kind) + } + if Cause(err) != "proxy rejected credentials" { + t.Errorf("Cause = %q", Cause(err)) + } +} + +func TestSOCKS5Upstream_AuthRequiredButNoneGiven(t *testing.T) { + s := proxytest.StartSOCKS5(t, "carol", "pw") + up := NewSOCKS5Upstream(s.Addr, "", "", "label") + + _, err := up.DialTarget(context.Background(), "example.com:443") + de := dialErr(t, err) + if de.Kind != KindCredentials { + t.Fatalf("kind = %d, want KindCredentials", de.Kind) + } + if !strings.Contains(Cause(err), "requires a username and password") { + t.Errorf("Cause = %q", Cause(err)) + } +} + +func TestSOCKS5Upstream_TargetRefusedIsProxyStatus(t *testing.T) { + s := proxytest.StartSOCKS5(t, "", "") + up := NewSOCKS5Upstream(s.Addr, "", "", "label") + + _, err := up.DialTarget(context.Background(), "127.0.0.1:1") + de := dialErr(t, err) + if de.Kind != KindProxyStatus { + t.Fatalf("kind = %d, want KindProxyStatus", de.Kind) + } + if Cause(err) != "proxy returned connection refused" { + t.Errorf("Cause = %q", Cause(err)) + } +} + +func TestSOCKS5Upstream_NotASOCKSServer(t *testing.T) { + // A server that answers every byte stream with HTTP text. + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer ln.Close() + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + go func(c net.Conn) { + defer c.Close() + buf := make([]byte, 64) + c.Read(buf) + io.WriteString(c, "HTTP/1.1 400 Bad Request\r\n\r\n") + }(conn) + } + }() + up := NewSOCKS5Upstream(ln.Addr().String(), "", "", "label") + + _, err = up.DialTarget(context.Background(), "example.com:443") + if de := dialErr(t, err); de.Kind != KindHandshake { + t.Fatalf("kind = %d, want KindHandshake", de.Kind) + } + if Cause(err) != "not a SOCKS5 proxy" { + t.Errorf("Cause = %q", Cause(err)) + } +} + +// --- Network failures and causes --- + +func TestUpstream_ConnectionRefused(t *testing.T) { + up := NewHTTPUpstream("127.0.0.1:1", false, "", "", "label") + _, err := up.DialTarget(context.Background(), "example.com:443") + if de := dialErr(t, err); de.Kind != KindDial { + t.Fatalf("kind = %d, want KindDial", de.Kind) + } + if Cause(err) != "connection refused" { + t.Errorf("Cause = %q", Cause(err)) + } +} + +func TestUpstream_TimeoutIsBounded(t *testing.T) { + // A proxy that accepts and never answers. + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer ln.Close() + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + defer conn.Close() + } + }() + + old := dialTimeout + dialTimeout = 300 * time.Millisecond + defer func() { dialTimeout = old }() + + for _, up := range []Upstream{ + NewHTTPUpstream(ln.Addr().String(), false, "", "", "http"), + NewSOCKS5Upstream(ln.Addr().String(), "", "", "socks5"), + } { + start := time.Now() + _, err := up.DialTarget(context.Background(), "example.com:443") + if err == nil { + t.Fatalf("%s: expected an error", up.Describe()) + } + if Cause(err) != "timeout" { + t.Errorf("%s: Cause = %q, want timeout", up.Describe(), Cause(err)) + } + if d := time.Since(start); d > 4*dialTimeout { + t.Errorf("%s: took %v, want about %v", up.Describe(), d, dialTimeout) + } + } +} + +func TestCause_NamesDNSFailure(t *testing.T) { + err := &DialError{Kind: KindDial, Err: &net.DNSError{Name: "proxy.example", IsNotFound: true}} + if Cause(err) != "cannot resolve host proxy.example" { + t.Errorf("Cause = %q", Cause(err)) + } +} + +func TestCause_PlainErrorPassesThrough(t *testing.T) { + if Cause(errors.New("boom")) != "boom" { + t.Error("non-DialError should pass through") + } + if TunnelFailed(errors.New("boom")) { + t.Error("plain error is not a tunnel failure") + } + if !TunnelFailed(&DialError{Kind: KindDial}) { + t.Error("DialError is a tunnel failure") + } +} + +// --- URL parsing --- + +func TestParseUpstreamURL(t *testing.T) { + cases := []struct { + raw string + wantType string + wantErr string + }{ + {"http://user:pass@proxy.example:8080", "*proxy.HTTPUpstream", ""}, + {"HTTPS://proxy.example:443", "*proxy.HTTPUpstream", ""}, + {"socks5://proxy.example:1080", "*proxy.SOCKS5Upstream", ""}, + {"socks5h://u:p@[::1]:1080", "*proxy.SOCKS5Upstream", ""}, + {"", "", "empty"}, + {"proxy.example:8080", "", "needs a scheme"}, + {"ftp://proxy.example:21", "", "unsupported proxy scheme"}, + {"http://proxy.example", "", "needs a port"}, + {"http://:8080", "", "needs a host"}, + {"http://proxy.example:8080/path", "", "must not have a path"}, + {"http://proxy.example:8080?x=1", "", "must not have a path"}, + {"http://user:pa ss@host:1", "", "not valid"}, + } + for _, tc := range cases { + t.Run(tc.raw, func(t *testing.T) { + up, err := ParseUpstreamURL(tc.raw) + if tc.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("err = %v, want containing %q", err, tc.wantErr) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got := typeName(up); got != tc.wantType { + t.Errorf("type = %s, want %s", got, tc.wantType) + } + }) + } +} + +func TestParseUpstreamURL_DecodesCredentialsAndRedactsLabel(t *testing.T) { + up, err := ParseUpstreamURL("http://al%40ice:p%3Ass@proxy.example:8080") + if err != nil { + t.Fatal(err) + } + h := up.(*HTTPUpstream) + if h.addr != "proxy.example:8080" { + t.Errorf("addr = %q", h.addr) + } + if h.ProxyAuthorization() != "Basic YWxAaWNlOnA6c3M=" { // base64("al@ice:p:ss") + t.Errorf("auth = %q", h.ProxyAuthorization()) + } + if strings.Contains(up.Describe(), "ss") || !strings.Contains(up.Describe(), "***") { + t.Errorf("Describe leaks the password: %q", up.Describe()) + } +} + +func TestParseUpstreamURL_ErrorsNeverContainPassword(t *testing.T) { + for _, raw := range []string{ + "ftp://user:topsecret@h:1", + "http://user:topsecret@h", + "http://user:topsecret@h:1/path", + } { + _, err := ParseUpstreamURL(raw) + if err == nil { + t.Fatalf("%s: expected error", raw) + } + if strings.Contains(err.Error(), "topsecret") { + t.Errorf("error leaks password: %v", err) + } + } +} + +func TestRedactURL(t *testing.T) { + cases := map[string]string{ + "http://user:secret@h:8080": "http://user:***@h:8080", + "socks5://h:1080": "socks5://h:1080", + "http://user@h:8080": "http://user@h:8080", + "https://u:p@[::1]:443": "https://u:***@[::1]:443", + "not a url": "(invalid proxy URL)", + "http://user:se%40cret@h:80": "http://user:***@h:80", + } + for in, want := range cases { + if got := RedactURL(in); got != want { + t.Errorf("RedactURL(%q) = %q, want %q", in, got, want) + } + } +} + +// --- Origin-form rewrite for SOCKS5 plain HTTP --- + +func TestOriginForm(t *testing.T) { + in := []byte("GET http://example.com/path?q=1 HTTP/1.1\r\nHost: example.com\r\nProxy-Connection: keep-alive\r\nUser-Agent: curl\r\n\r\nbody") + host, out, err := originForm(in) + if err != nil { + t.Fatal(err) + } + if host != "example.com:80" { + t.Errorf("host = %q", host) + } + want := "GET /path?q=1 HTTP/1.1\r\nHost: example.com\r\nUser-Agent: curl\r\n\r\nbody" + if string(out) != want { + t.Errorf("out = %q\nwant %q", out, want) + } +} + +func TestOriginForm_PortAndMissingHostHeader(t *testing.T) { + host, out, err := originForm([]byte("GET http://example.com:8081 HTTP/1.0\r\n\r\n")) + if err != nil { + t.Fatal(err) + } + if host != "example.com:8081" { + t.Errorf("host = %q", host) + } + if !strings.HasPrefix(string(out), "GET / HTTP/1.0\r\nHost: example.com:8081\r\n") { + t.Errorf("out = %q", out) + } +} + +func TestOriginForm_OriginFormInputUsesHostHeader(t *testing.T) { + host, out, err := originForm([]byte("POST /submit HTTP/1.1\r\nHost: api.example:9000\r\nContent-Length: 2\r\n\r\nhi")) + if err != nil { + t.Fatal(err) + } + if host != "api.example:9000" { + t.Errorf("host = %q", host) + } + if !strings.HasSuffix(string(out), "\r\n\r\nhi") { + t.Errorf("body lost: %q", out) + } +} + +func TestOriginForm_Rejects(t *testing.T) { + for _, in := range []string{"GET /x HTTP/1.1\r\n\r\n", "GARBAGE\r\n\r\n", "GET http://x/ HTTP/1.1\r\nHost: x"} { + if _, _, err := originForm([]byte(in)); err == nil { + t.Errorf("%q: expected error", in) + } + } +} + +// --- Server over own upstreams --- + +// freePort reserves a loopback port for a Server, as session.Start does. +func freePort(t *testing.T) int { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + port := ln.Addr().(*net.TCPAddr).Port + ln.Close() + return port +} + +// startServer runs s until the test ends and returns once it accepts connections. +func startServer(t *testing.T, s *Server) string { + t.Helper() + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + go s.Start(ctx) + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + if c, err := net.DialTimeout("tcp", s.listenAddr, 100*time.Millisecond); err == nil { + c.Close() + return s.listenAddr + } + time.Sleep(5 * time.Millisecond) + } + t.Fatal("server did not start") + return "" +} + +func viaLocalProxy(t *testing.T, proxyAddr string, tlsCfg *tls.Config) *http.Client { + t.Helper() + pu, _ := neturl.Parse("http://" + proxyAddr) + return &http.Client{ + Timeout: 5 * time.Second, + Transport: &http.Transport{ + Proxy: http.ProxyURL(pu), + TLSClientConfig: tlsCfg, + }, + } +} + +func TestServer_HTTPSViaConnectThroughEachUpstream(t *testing.T) { + target := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + io.WriteString(w, "secure hello") + })) + defer target.Close() + tlsCfg := &tls.Config{RootCAs: certPoolFor(t, target)} + + hp := proxytest.StartHTTPProxy(t, "u", "p") + sp := proxytest.StartSOCKS5(t, "u", "p") + for name, up := range map[string]Upstream{ + "http": NewHTTPUpstream(hp.Addr, false, "u", "p", "http"), + "socks5": NewSOCKS5Upstream(sp.Addr, "u", "p", "socks5"), + } { + t.Run(name, func(t *testing.T) { + s := NewServer(&Config{ListenPort: freePort(t), Upstream: up, Meter: meter.New(), FailureThreshold: OwnFailureThreshold}) + addr := startServer(t, s) + resp, err := viaLocalProxy(t, addr, tlsCfg).Get(target.URL) + if err != nil { + t.Fatalf("GET through local proxy: %v", err) + } + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + if string(body) != "secure hello" { + t.Errorf("body = %q", body) + } + if s.BytesTotal() == 0 { + t.Error("relay metered no bytes") + } + }) + } +} + +func TestServer_PlainHTTPThroughEachUpstream(t *testing.T) { + var gotPath, gotHost atomic.Value + target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath.Store(r.URL.RequestURI()) + gotHost.Store(r.Host) + io.WriteString(w, "plain hello") + })) + defer target.Close() + + hp := proxytest.StartHTTPProxy(t, "", "") + sp := proxytest.StartSOCKS5(t, "", "") + for name, up := range map[string]Upstream{ + "http": NewHTTPUpstream(hp.Addr, false, "", "", "http"), + "socks5": NewSOCKS5Upstream(sp.Addr, "", "", "socks5"), + } { + t.Run(name, func(t *testing.T) { + s := NewServer(&Config{ListenPort: freePort(t), Upstream: up, Meter: meter.New(), FailureThreshold: OwnFailureThreshold}) + addr := startServer(t, s) + resp, err := viaLocalProxy(t, addr, nil).Get(target.URL + "/hello?x=1") + if err != nil { + t.Fatalf("GET through local proxy: %v", err) + } + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + if string(body) != "plain hello" { + t.Errorf("body = %q", body) + } + if gotPath.Load() != "/hello?x=1" { + t.Errorf("target saw path %v, want /hello?x=1 (origin form)", gotPath.Load()) + } + if gotHost.Load() != strings.TrimPrefix(target.URL, "http://") { + t.Errorf("target saw Host %v", gotHost.Load()) + } + }) + } +} + +// scriptedUpstream returns the queued results in order. +type scriptedUpstream struct { + mu sync.Mutex + errs []error + calls int +} + +func (u *scriptedUpstream) DialTarget(ctx context.Context, hostPort string) (net.Conn, error) { + u.mu.Lock() + defer u.mu.Unlock() + var err error + if u.calls < len(u.errs) { + err = u.errs[u.calls] + } + u.calls++ + if err != nil { + return nil, err + } + a, b := net.Pipe() + go func() { io.Copy(io.Discard, b); b.Close() }() + return a, nil +} + +func (u *scriptedUpstream) Describe() string { return "scripted" } + +func drainedPipe(t *testing.T) (client, server net.Conn) { + t.Helper() + client, server = net.Pipe() + go io.Copy(io.Discard, client) + t.Cleanup(func() { client.Close(); server.Close() }) + return client, server +} + +func TestServer_OwnUpstreamDeadAfterConsecutiveFailures(t *testing.T) { + refused := &DialError{Kind: KindDial, Err: errors.New("refused")} + up := &scriptedUpstream{errs: []error{refused, refused, refused}} + var reasons []string + var mu sync.Mutex + s := NewServer(&Config{Upstream: up, Meter: meter.New(), FailureThreshold: 3}) + s.OnUpstreamDead = func(r string) { mu.Lock(); reasons = append(reasons, r); mu.Unlock() } + + for i := 0; i < 2; i++ { + _, server := drainedPipe(t) + s.dialAndSendConnect(context.Background(), server, "x:443") + if s.RouteState() != RouteHealthy { + t.Fatalf("after %d failures state = %d, want healthy", i+1, s.RouteState()) + } + } + _, server := drainedPipe(t) + s.dialAndSendConnect(context.Background(), server, "x:443") + if s.RouteState() != RouteDead { + t.Fatalf("after 3 failures state = %d, want RouteDead", s.RouteState()) + } + mu.Lock() + defer mu.Unlock() + if len(reasons) != 1 || reasons[0] != "connection_failed" { + t.Errorf("OnUpstreamDead reasons = %v, want [connection_failed]", reasons) + } +} + +func TestServer_OwnUpstreamSuccessResetsFailureCount(t *testing.T) { + refused := &DialError{Kind: KindDial, Err: errors.New("refused")} + up := &scriptedUpstream{errs: []error{refused, refused, nil, refused, refused}} + s := NewServer(&Config{Upstream: up, Meter: meter.New(), FailureThreshold: 3}) + + for i := 0; i < 5; i++ { + _, server := drainedPipe(t) + if c := s.dialAndSendConnect(context.Background(), server, "x:443"); c != nil { + c.Close() + } + } + if s.RouteState() != RouteHealthy { + t.Fatalf("state = %d, want healthy: a success must reset the counter", s.RouteState()) + } + if s.failures.Load() != 2 { + t.Errorf("failures = %d, want 2", s.failures.Load()) + } +} + +func TestServer_OwnUpstreamCredentialsErrorIsImmediatelyDead(t *testing.T) { + up := &scriptedUpstream{errs: []error{&DialError{Kind: KindCredentials}}} + var reason string + s := NewServer(&Config{Upstream: up, Meter: meter.New(), FailureThreshold: 3}) + s.OnUpstreamDead = func(r string) { reason = r } + + client, server := net.Pipe() + defer client.Close() + done := make(chan struct{}) + go func() { s.dialAndSendConnect(context.Background(), server, "x:443"); server.Close(); close(done) }() + resp, err := http.ReadResponse(bufio.NewReader(client), nil) + if err != nil || resp.StatusCode != 407 { + t.Fatalf("client got %v %v, want 407", resp, err) + } + <-done + if s.RouteState() != RouteDead { + t.Errorf("state = %d, want RouteDead", s.RouteState()) + } + if reason != "auth_invalid" { + t.Errorf("reason = %q, want auth_invalid", reason) + } +} + +func TestServer_HostedUpstreamNeverCountsFailures(t *testing.T) { + refused := &DialError{Kind: KindDial, Err: errors.New("refused")} + up := &scriptedUpstream{errs: []error{refused, refused, refused, refused}} + s := NewServer(&Config{Upstream: up, Meter: meter.New()}) // threshold 0: hosted semantics + for i := 0; i < 4; i++ { + _, server := drainedPipe(t) + s.dialAndSendConnect(context.Background(), server, "x:443") + } + if s.RouteState() != RouteHealthy { + t.Errorf("state = %d, want healthy: hosted dial failures are transient", s.RouteState()) + } +} + +func TestServer_SOCKSRulesetRefusalIs403NotFailure(t *testing.T) { + up := &scriptedUpstream{errs: []error{&DialError{Kind: KindProxyStatus, Status: 2, Msg: "connection not allowed by ruleset"}}} + s := NewServer(&Config{Upstream: up, Meter: meter.New(), FailureThreshold: 1}) + client, server := net.Pipe() + defer client.Close() + done := make(chan struct{}) + go func() { s.dialAndSendConnect(context.Background(), server, "x:443"); server.Close(); close(done) }() + resp, err := http.ReadResponse(bufio.NewReader(client), nil) + if err != nil || resp.StatusCode != 403 { + t.Fatalf("client got %v %v, want 403", resp, err) + } + <-done + if s.RouteState() != RouteHealthy { + t.Error("a per-target refusal must not mark the route dead") + } +} + +func TestServer_SetUpstreamResetsState(t *testing.T) { + s := NewServer(&Config{Upstream: &scriptedUpstream{}, Meter: meter.New(), FailureThreshold: 3}) + s.failures.Store(2) + s.SetRouteDead(false) + s.SetUpstream(&scriptedUpstream{}) + if s.RouteState() != RouteHealthy || s.failures.Load() != 0 { + t.Error("SetUpstream must reset route state and failure count") + } +} + +// --- Probe and Check --- + +// directUpstream dials the target itself; it stands in for a working proxy. +type directUpstream struct{ calls atomic.Int32 } + +func (d *directUpstream) DialTarget(ctx context.Context, hostPort string) (net.Conn, error) { + d.calls.Add(1) + return (&net.Dialer{}).DialContext(ctx, "tcp", hostPort) +} +func (d *directUpstream) Describe() string { return "direct" } + +func withExitIPEndpoints(t *testing.T, endpoints ...string) { + t.Helper() + old := ExitIPEndpoints + ExitIPEndpoints = endpoints + t.Cleanup(func() { ExitIPEndpoints = old }) +} + +func TestProbe_ReadsExitIPThroughUpstream(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + io.WriteString(w, "203.0.113.7\n") + })) + defer ts.Close() + withExitIPEndpoints(t, ts.URL) + + res, err := Probe(context.Background(), &directUpstream{}, 5*time.Second) + if err != nil { + t.Fatal(err) + } + if res.ExitIP != "203.0.113.7" { + t.Errorf("ExitIP = %q", res.ExitIP) + } + if res.Latency <= 0 { + t.Error("latency should be measured") + } +} + +func TestProbe_FallsBackToSecondEndpoint(t *testing.T) { + bad := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "") })) + defer bad.Close() + good := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "198.51.100.2") })) + defer good.Close() + withExitIPEndpoints(t, bad.URL, good.URL) + + res, err := Probe(context.Background(), &directUpstream{}, 5*time.Second) + if err != nil || res.ExitIP != "198.51.100.2" { + t.Fatalf("res=%v err=%v", res, err) + } +} + +func TestProbe_TunnelOKButNoIPIsNotTunnelFailure(t *testing.T) { + bad := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "nope") })) + defer bad.Close() + withExitIPEndpoints(t, bad.URL) + + _, err := Check(context.Background(), &directUpstream{}) + if err == nil { + t.Fatal("expected error") + } + if TunnelFailed(err) { + t.Error("a bad echo reply is not a tunnel failure") + } +} + +func TestCheck_RetriesDialFailureOnce(t *testing.T) { + withExitIPEndpoints(t, "http://127.0.0.1:1/ip") + refused := &DialError{Kind: KindDial, Err: errors.New("refused")} + up := &scriptedUpstream{errs: []error{refused, refused, refused}} + + _, err := Check(context.Background(), up) + if !TunnelFailed(err) { + t.Fatalf("err = %v, want tunnel failure", err) + } + if up.calls != 2 { + t.Errorf("DialTarget called %d times, want 2 (one attempt, one retry)", up.calls) + } +} + +func TestCheck_DoesNotRetryCredentials(t *testing.T) { + withExitIPEndpoints(t, "http://127.0.0.1:1/ip") + up := &scriptedUpstream{errs: []error{&DialError{Kind: KindCredentials}, &DialError{Kind: KindCredentials}}} + + _, err := Check(context.Background(), up) + if de := dialErr(t, err); de.Kind != KindCredentials { + t.Fatalf("kind = %d", de.Kind) + } + if up.calls != 1 { + t.Errorf("DialTarget called %d times, want 1", up.calls) + } +} + +func certPoolFor(t *testing.T, ts *httptest.Server) *x509.CertPool { + t.Helper() + return ts.Client().Transport.(*http.Transport).TLSClientConfig.RootCAs +} + +func typeName(v interface{}) string { + switch v.(type) { + case *HTTPUpstream: + return "*proxy.HTTPUpstream" + case *SOCKS5Upstream: + return "*proxy.SOCKS5Upstream" + } + return "?" +} diff --git a/internal/proxy/upstream_url.go b/internal/proxy/upstream_url.go new file mode 100644 index 0000000..54e6877 --- /dev/null +++ b/internal/proxy/upstream_url.go @@ -0,0 +1,79 @@ +package proxy + +import ( + "errors" + "fmt" + "net" + "net/url" + "strings" +) + +// ParseUpstreamURL builds an Upstream from a proxy URL such as +// http://user:pass@host:8080, https://host:443 or socks5://user:pass@host:1080. +// The port is required. Credentials go in the userinfo part, URL-encoded. +// Returned errors never contain the password. +func ParseUpstreamURL(raw string) (Upstream, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, errors.New("proxy URL is empty") + } + if !strings.Contains(raw, "://") { + return nil, errors.New("proxy URL needs a scheme: http, https, socks5 or socks5h") + } + u, err := url.Parse(raw) + if err != nil { + return nil, errors.New("proxy URL is not valid; URL-encode special characters in the username and password") + } + scheme := strings.ToLower(u.Scheme) + switch scheme { + case "http", "https", "socks5", "socks5h": + default: + return nil, fmt.Errorf("unsupported proxy scheme %q: use http, https, socks5 or socks5h", u.Scheme) + } + host := u.Hostname() + port := u.Port() + if host == "" { + return nil, errors.New("proxy URL needs a host") + } + if port == "" { + return nil, errors.New("proxy URL needs a port, for example http://proxy.example.com:8080") + } + if (u.Path != "" && u.Path != "/") || u.RawQuery != "" || u.Fragment != "" { + return nil, errors.New("proxy URL must not have a path, query or fragment") + } + + user := u.User.Username() + pass, _ := u.User.Password() + addr := net.JoinHostPort(host, port) + label := RedactURL(raw) + + switch scheme { + case "http": + return NewHTTPUpstream(addr, false, user, pass, label), nil + case "https": + return NewHTTPUpstream(addr, true, user, pass, label), nil + default: + return NewSOCKS5Upstream(addr, user, pass, label), nil + } +} + +// RedactURL returns scheme://user:***@host:port for a proxy URL, hiding the +// password. A URL that does not parse is replaced entirely so nothing leaks. +func RedactURL(raw string) string { + u, err := url.Parse(strings.TrimSpace(raw)) + if err != nil || u.Scheme == "" || u.Host == "" { + return "(invalid proxy URL)" + } + var b strings.Builder + b.WriteString(strings.ToLower(u.Scheme)) + b.WriteString("://") + if u.User != nil { + b.WriteString(url.User(u.User.Username()).String()) + if _, has := u.User.Password(); has { + b.WriteString(":***") + } + b.WriteString("@") + } + b.WriteString(u.Host) + return b.String() +} diff --git a/internal/session/control_test.go b/internal/session/control_test.go index 009b02b..4696b34 100644 --- a/internal/session/control_test.go +++ b/internal/session/control_test.go @@ -59,10 +59,9 @@ func fakeSession(t *testing.T) *Session { Country: "US", Port: port, proxy: proxy.NewServer(&proxy.Config{ - ListenPort: port, - GatewayAddr: "localhost:0", - Token: "test", - Meter: &meter.Meter{}, + ListenPort: port, + Upstream: proxy.NewHostedUpstream("localhost:0", false, "test"), + Meter: &meter.Meter{}, }), } } diff --git a/internal/session/own_test.go b/internal/session/own_test.go new file mode 100644 index 0000000..e6e69d7 --- /dev/null +++ b/internal/session/own_test.go @@ -0,0 +1,173 @@ +package session + +import ( + "context" + "errors" + "io" + "net" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + + "github.com/shellroute/shellroute-cli/internal/proxy" +) + +// directUpstream dials the target itself; it stands in for a working proxy. +type directUpstream struct{ calls atomic.Int32 } + +func (d *directUpstream) DialTarget(ctx context.Context, hostPort string) (net.Conn, error) { + d.calls.Add(1) + return (&net.Dialer{}).DialContext(ctx, "tcp", hostPort) +} +func (d *directUpstream) Describe() string { return "http://user:***@proxy.test:8080" } + +// failingUpstream never opens a tunnel. +type failingUpstream struct{ kind proxy.DialErrorKind } + +func (f *failingUpstream) DialTarget(ctx context.Context, hostPort string) (net.Conn, error) { + return nil, &proxy.DialError{Kind: f.kind, Err: errors.New("down")} +} +func (f *failingUpstream) Describe() string { return "failing" } + +func withExitIPEndpoints(t *testing.T, endpoints ...string) { + t.Helper() + old := proxy.ExitIPEndpoints + proxy.ExitIPEndpoints = endpoints + t.Cleanup(func() { proxy.ExitIPEndpoints = old }) +} + +func echoIP(t *testing.T, body string) *httptest.Server { + t.Helper() + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + io.WriteString(w, body) + })) + t.Cleanup(ts.Close) + return ts +} + +func TestStartOwn_NoAPIAndStop(t *testing.T) { + dir := t.TempDir() + t.Setenv("SHELLROUTE_HOME", dir) + withExitIPEndpoints(t, echoIP(t, "203.0.113.9\n").URL) + + up := &directUpstream{} + sess, err := StartOwn(context.Background(), up, "work", "DE", 0, StartOpts{Mode: "run", TrackRelays: true}) + if err != nil { + t.Fatalf("StartOwn: %v", err) + } + if !sess.Own || sess.UpstreamName != "work" || sess.Country != "DE" { + t.Errorf("session = %+v", sess) + } + if sess.GetExitIP() != "203.0.113.9" { + t.Errorf("exit IP = %q", sess.GetExitIP()) + } + if sess.Via != up.Describe() { + t.Errorf("Via = %q", sess.Via) + } + if sess.client != nil { + t.Error("own session must not hold an API client") + } + + data, err := os.ReadFile(filepath.Join(dir, sess.sessionFileName())) + if err != nil { + t.Fatalf("session file: %v", err) + } + for _, want := range []string{`"upstream": "work"`, `"via": "http://user:***@proxy.test:8080"`, `"country": "DE"`} { + if !strings.Contains(string(data), want) { + t.Errorf("session file lacks %s:\n%s", want, data) + } + } + if strings.Contains(string(data), "pass") { + t.Error("session file must never carry the URL password") + } + + resp, err := sess.Stop() + if err != nil { + t.Fatalf("Stop: %v", err) + } + if resp.CostUSD != 0 || resp.BalanceUSD != 0 { + t.Error("own session has no cost or balance") + } + if _, err := os.Stat(filepath.Join(dir, sess.sessionFileName())); !os.IsNotExist(err) { + t.Error("session file should be removed on Stop") + } +} + +func TestStartOwn_TunnelFailureReturnsDialError(t *testing.T) { + dir := t.TempDir() + t.Setenv("SHELLROUTE_HOME", dir) + withExitIPEndpoints(t, "http://127.0.0.1:1/ip") + + _, err := StartOwn(context.Background(), &failingUpstream{kind: proxy.KindDial}, "work", "", 0, StartOpts{}) + if !proxy.TunnelFailed(err) { + t.Fatalf("err = %v, want tunnel failure", err) + } + entries, _ := filepath.Glob(filepath.Join(dir, "session-*.json")) + if len(entries) != 0 { + t.Errorf("session file left behind: %v", entries) + } +} + +func TestStartOwn_ProbeFailureStillStartsWithWarning(t *testing.T) { + t.Setenv("SHELLROUTE_HOME", t.TempDir()) + withExitIPEndpoints(t, echoIP(t, "not an ip").URL) + + sess, err := StartOwn(context.Background(), &directUpstream{}, "", "", 0, StartOpts{}) + if err != nil { + t.Fatalf("StartOwn should succeed when only the echo service misbehaves: %v", err) + } + defer sess.Stop() + if sess.ProbeWarning == "" { + t.Error("ProbeWarning should explain the missing exit IP") + } + if sess.GetExitIP() != "" { + t.Error("no exit IP should be set") + } + if sess.ID != "own-url" { + t.Errorf("ad-hoc URL session ID = %q", sess.ID) + } +} + +func TestStartOwn_SkipExitIPMakesNoProbe(t *testing.T) { + t.Setenv("SHELLROUTE_HOME", t.TempDir()) + withExitIPEndpoints(t, "http://127.0.0.1:1/ip") + + up := &directUpstream{} + sess, err := StartOwn(context.Background(), up, "work", "DE", 0, StartOpts{SkipExitIP: true}) + if err != nil { + t.Fatal(err) + } + defer sess.Stop() + if up.calls.Load() != 0 { + t.Errorf("DialTarget called %d times, want 0", up.calls.Load()) + } +} + +func TestStartOwn_ProxyRoutesThroughUpstream(t *testing.T) { + t.Setenv("SHELLROUTE_HOME", t.TempDir()) + target := echoIP(t, "198.51.100.1") + withExitIPEndpoints(t, target.URL) + + up := &directUpstream{} + sess, err := StartOwn(context.Background(), up, "work", "", 0, StartOpts{}) + if err != nil { + t.Fatal(err) + } + defer sess.Stop() + + // A request through the local proxy must go out through the upstream. + before := up.calls.Load() + if ip := detectExitIP(context.Background(), sess.Port); ip != "198.51.100.1" { + t.Fatalf("exit IP via local proxy = %q", ip) + } + if up.calls.Load() <= before { + t.Error("local proxy did not use the upstream") + } + if sess.Meter().BytesTotal() == 0 { + t.Error("bytes were not metered") + } +} diff --git a/internal/session/session.go b/internal/session/session.go index e72a1d9..4fa722f 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -34,6 +34,14 @@ type Session struct { BalanceUSD float64 LowBalance bool + // Own is set when the route is the user's own proxy: no API calls, no + // billing, Country holds the user's label. + Own bool + UpstreamName string // saved upstream name, "" for an ad-hoc URL + Via string // redacted upstream description for status output + ProbeWarning string // set when the tunnel opened but no exit IP could be read + ConnectMs int // time to the first successful exit IP probe (own sessions) + exitIPMu sync.Mutex exitIP string @@ -42,7 +50,7 @@ type Session struct { proxy *proxy.Server cancel context.CancelFunc - OnUpstreamDead func(reason string) // "depleted" or "connection_failed" + OnUpstreamDead func(reason string) // "depleted", "auth_invalid" or "connection_failed" OnExitIPUpdate func(ip string) // called by proxy when X-Exit-IP changes OnRelayDone func() // called after each relay ends } @@ -71,28 +79,26 @@ type Info struct { ExitIP string `json:"exit_ip"` Port int `json:"port"` StartTime string `json:"start_time"` - Mode string `json:"mode,omitempty"` // "proxy", "run", or "" (interactive) + Mode string `json:"mode,omitempty"` // "proxy", "run", or "" (interactive) + Upstream string `json:"upstream,omitempty"` // own route: saved upstream name + Via string `json:"via,omitempty"` // own route: redacted upstream description } -// StartOpts holds optional parameters for Start. +// StartOpts holds optional parameters for Start and StartOwn. type StartOpts struct { TrackRelays bool // true for run mode — enables force-close of active relay conns ConnectTimeout time.Duration // override exit IP detection timeout (default 45s) Mode string // "proxy", "run", or "" (interactive) + SkipExitIP bool // own routes: do not probe the exit IP } // Start creates a new session via the API and starts the local proxy. func Start(ctx context.Context, client *api.Client, req *api.SessionCreateRequest, port int, opts ...StartOpts) (*Session, error) { // Bind port before API call — fail fast if port unavailable - ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port)) - if err != nil && port != 0 { - ln, err = net.Listen("tcp", "127.0.0.1:0") - } + port, err := reservePort(port) if err != nil { - return nil, fmt.Errorf("no available port: %w", err) + return nil, err } - port = ln.Addr().(*net.TCPAddr).Port - ln.Close() resp, err := client.CreateSession(req) if err != nil { @@ -106,35 +112,38 @@ func Start(ctx context.Context, client *api.Client, req *api.SessionCreateReques return startWithResponse(ctx, client, resp, port, sOpts) } -// startWithResponse is the shared implementation for session start. +// reservePort checks that port is free and returns it, falling back to any +// free port when the requested one is taken. 0 means any free port. +func reservePort(port int) (int, error) { + ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port)) + if err != nil && port != 0 { + ln, err = net.Listen("tcp", "127.0.0.1:0") + } + if err != nil { + return 0, fmt.Errorf("no available port: %w", err) + } + port = ln.Addr().(*net.TCPAddr).Port + ln.Close() + return port, nil +} + +// startWithResponse is the shared implementation for hosted session start. func startWithResponse(ctx context.Context, client *api.Client, resp *api.SessionCreateResponse, port int, opts StartOpts) (*Session, error) { - // If port not yet bound, bind now - if port == 0 { - ln, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - return nil, fmt.Errorf("no available port: %w", err) - } - port = ln.Addr().(*net.TCPAddr).Port - ln.Close() - } else { - // Verify port is available (Start already closed its listener) - ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port)) - if err != nil { - ln, err = net.Listen("tcp", "127.0.0.1:0") - if err != nil { - return nil, fmt.Errorf("no available port: %w", err) - } - port = ln.Addr().(*net.TCPAddr).Port - } - ln.Close() + // Re-check the port: the API call took time and another process may + // have taken it. Start already closed its listener. + port, err := reservePort(port) + if err != nil { + return nil, err } m := meter.New() proxyServer := proxy.NewServer(&proxy.Config{ - ListenPort: port, - GatewayAddr: resp.Gateway.Endpoint, - GatewayTLS: gatewayNeedsTLS(resp.Gateway.Endpoint), - Token: resp.Gateway.Token, + ListenPort: port, + Upstream: proxy.NewHostedUpstream( + resp.Gateway.Endpoint, + gatewayNeedsTLS(resp.Gateway.Endpoint), + resp.Gateway.Token, + ), Meter: m, ByteBudget: resp.ByteBudget, TrackRelays: opts.TrackRelays, @@ -159,26 +168,7 @@ func startWithResponse(ctx context.Context, client *api.Client, resp *api.Sessio proxy: proxyServer, cancel: cancel, } - - // Wire callbacks - proxyServer.OnUpstreamDead = func(reason string) { - if s.OnUpstreamDead != nil { - s.OnUpstreamDead(reason) - } - } - proxyServer.OnExitIPChanged = func(ip string) { - if s.OnExitIPUpdate != nil { - s.OnExitIPUpdate(ip) - } else { - s.SetExitIP(ip) - } - } - - proxyServer.OnRelayDone = func() { - if s.OnRelayDone != nil { - s.OnRelayDone() - } - } + s.wireProxy() if err := s.writeInfoFile(); err != nil { cancel() @@ -216,11 +206,121 @@ func startWithResponse(ctx context.Context, client *api.Client, resp *api.Sessio return s, nil } +// StartOwn starts the local proxy over the user's own upstream. No account, +// no API call, no billing. name is the saved upstream name ("" for an ad-hoc +// URL); label is the user's country label for display, which Shellroute +// cannot verify. +// +// Unless opts.SkipExitIP is set, the upstream is checked first: one tunnel +// attempt and one retry, each bounded by the dial timeout. A tunnel failure +// returns the *proxy.DialError; when the tunnel opened but no exit IP could be +// read, the session starts and ProbeWarning says why. +func StartOwn(ctx context.Context, up proxy.Upstream, name, label string, port int, opts StartOpts) (*Session, error) { + port, err := reservePort(port) + if err != nil { + return nil, err + } + + m := meter.New() + proxyServer := proxy.NewServer(&proxy.Config{ + ListenPort: port, + Upstream: up, + Meter: m, + ByteBudget: -1, + TrackRelays: opts.TrackRelays, + FailureThreshold: proxy.OwnFailureThreshold, + }) + + ctx, cancel := context.WithCancel(ctx) + + id := name + if id == "" { + id = "url" + } + s := &Session{ + ID: "own-" + id, + Country: label, + Mode: opts.Mode, + Own: true, + UpstreamName: name, + Via: up.Describe(), + Port: port, + StartTime: time.Now(), + meter: m, + proxy: proxyServer, + cancel: cancel, + } + s.wireProxy() + + if err := s.writeInfoFile(); err != nil { + cancel() + return nil, fmt.Errorf("write session file: %w", err) + } + + go func() { + if err := proxyServer.Start(ctx); err != nil { + _ = err + s.removeInfoFile() + } + }() + + if !opts.SkipExitIP { + res, err := proxy.Check(ctx, up) + switch { + case err == nil: + s.SetExitIP(res.ExitIP) + s.ConnectMs = int(res.Latency.Milliseconds()) + case proxy.TunnelFailed(err): + cancel() + s.removeInfoFile() + return nil, err + default: + s.ProbeWarning = err.Error() + } + } + + return s, nil +} + +// wireProxy forwards proxy callbacks to the session's own hooks. +func (s *Session) wireProxy() { + s.proxy.OnUpstreamDead = func(reason string) { + if s.OnUpstreamDead != nil { + s.OnUpstreamDead(reason) + } + } + s.proxy.OnExitIPChanged = func(ip string) { + if s.OnExitIPUpdate != nil { + s.OnExitIPUpdate(ip) + } else { + s.SetExitIP(ip) + } + } + s.proxy.OnRelayDone = func() { + if s.OnRelayDone != nil { + s.OnRelayDone() + } + } +} + // Stop gracefully shuts down the session. -// Does NOT report usage — gateway is the authoritative meter. +// Does NOT report usage — gateway is the authoritative meter. Own sessions +// have no server side; they return duration and bytes only. func (s *Session) Stop() (*api.SessionEndResponse, error) { s.cancel() + if s.Own { + s.removeInfoFile() + resp := &api.SessionEndResponse{ + SessionID: s.ID, + DurationSec: int(s.Duration().Seconds()), + } + if s.meter != nil { + resp.BytesTotal = s.meter.BytesTotal() + } + return resp, nil + } + // End session with backend (triggers outbox cleanup + min charge) resp, err := s.client.EndSession(s.ID) @@ -283,6 +383,10 @@ func (s *Session) writeInfoFile() error { StartTime: s.StartTime.Format(time.RFC3339), Mode: s.Mode, } + if s.Own { + info.Upstream = s.UpstreamName + info.Via = s.Via + } data, err := json.MarshalIndent(info, "", " ") if err != nil { @@ -375,11 +479,7 @@ func detectExitIPRetry(ctx context.Context, port int, timeout time.Duration) str return "" } -var ipDetectEndpoints = []string{ - "https://ipinfo.io/ip", - "https://api.ipify.org", -} - +// detectExitIP asks the exit IP services through the local proxy. func detectExitIP(ctx context.Context, port int) string { proxyURL := fmt.Sprintf("http://127.0.0.1:%d", port) transport := &http.Transport{ @@ -392,7 +492,7 @@ func detectExitIP(ctx context.Context, port int) string { Timeout: 10 * time.Second, } - for _, endpoint := range ipDetectEndpoints { + for _, endpoint := range proxy.ExitIPEndpoints { req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) if err != nil { continue diff --git a/internal/session/session_test.go b/internal/session/session_test.go index d8f6773..ff6c8bd 100644 --- a/internal/session/session_test.go +++ b/internal/session/session_test.go @@ -1,6 +1,7 @@ package session import ( + "github.com/shellroute/shellroute-cli/internal/proxy" "os" "path/filepath" "sync" @@ -86,12 +87,12 @@ func TestGetExitIP_ReturnsSetValue(t *testing.T) { // --- IP detection endpoints --- func TestIPDetectEndpoints_HasFallback(t *testing.T) { - if len(ipDetectEndpoints) < 2 { - t.Errorf("expected at least 2 IP detection endpoints, got %d", len(ipDetectEndpoints)) + if len(proxy.ExitIPEndpoints) < 2 { + t.Errorf("expected at least 2 IP detection endpoints, got %d", len(proxy.ExitIPEndpoints)) } // First should be ipinfo.io (primary) - if ipDetectEndpoints[0] != "https://ipinfo.io/ip" { - t.Errorf("first endpoint should be ipinfo.io, got %q", ipDetectEndpoints[0]) + if proxy.ExitIPEndpoints[0] != "https://ipinfo.io/ip" { + t.Errorf("first endpoint should be ipinfo.io, got %q", proxy.ExitIPEndpoints[0]) } } From ab50e1ba439780810443de72402529e5aa4eecee Mon Sep 17 00:00:00 2001 From: contra Date: Wed, 14 Oct 2026 20:47:42 +0300 Subject: [PATCH 02/13] feat(cli): bring your own proxy with upstream add/list/remove/test, run , --proxy, proxy --upstream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Saved upstreams live in config.toml as [[upstream]] tables (name, url, country label, note); names are 3 to 32 characters of [a-z0-9_-] so they never collide with a country code. The URL password is stored in the 0600 file and never printed: list, status and the session file show user:***. shellroute run resolves its target in this order: --proxy , --country, a two-letter country before --, a saved upstream name before --, then SHELLROUTE_PROXY from the environment, then default_country. Own routes need no account: no login check, no API call, no version check, and they work with the API unreachable. The start of an own route checks the tunnel once with one retry (10 s each) and prints 'via work · exit IP … · N ms'; a failure names the cause (resolve, refused, timeout, credentials, proxy status). --no-check skips the probe. The end-of-run summary shows duration and bytes only. shellroute proxy --upstream runs the persistent local proxy over an own upstream; proxy stop and the singleton guard show the upstream name. The interactive shell starts without login when saved upstreams exist. Signed-off-by: contra --- internal/cli/commands_test.go | 11 +- internal/cli/connect.go | 82 ++++++++++- internal/cli/root.go | 15 +- internal/cli/run.go | 95 +++++++++--- internal/cli/run_exit_test.go | 10 ++ internal/cli/run_upstream.go | 197 +++++++++++++++++++++++++ internal/cli/run_upstream_test.go | 205 ++++++++++++++++++++++++++ internal/cli/upstream.go | 181 +++++++++++++++++++++++ internal/cli/upstream_test.go | 234 ++++++++++++++++++++++++++++++ internal/config/config.go | 3 + internal/config/upstream.go | 64 ++++++++ internal/config/upstream_test.go | 118 +++++++++++++++ internal/display/display.go | 17 +++ 13 files changed, 1201 insertions(+), 31 deletions(-) create mode 100644 internal/cli/run_upstream.go create mode 100644 internal/cli/run_upstream_test.go create mode 100644 internal/cli/upstream.go create mode 100644 internal/cli/upstream_test.go create mode 100644 internal/config/upstream.go create mode 100644 internal/config/upstream_test.go diff --git a/internal/cli/commands_test.go b/internal/cli/commands_test.go index 2718097..7eefd94 100644 --- a/internal/cli/commands_test.go +++ b/internal/cli/commands_test.go @@ -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 @@ -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()] { @@ -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 --- diff --git a/internal/cli/connect.go b/internal/cli/connect.go index 5abd740..afa58b3 100644 --- a/internal/cli/connect.go +++ b/internal/cli/connect.go @@ -44,6 +44,8 @@ func init() { f.StringVar(&connectType, "iptype", "", "IP type: residential (default), datacenter, or mix") f.StringVar(&connectFormat, "format", "json", "Output format: json, env") f.BoolVar(&connectSticky, "sticky", false, "Keep same IP on reconnect") + f.StringVar(&connectUpstream, "upstream", "", "Use your own proxy: a saved upstream name or a proxy URL") + f.BoolVar(&connectNoCheck, "no-check", false, "Own proxy: skip the exit IP check") connectCmd.AddCommand(proxyStopCmd) } @@ -52,10 +54,6 @@ func runConnect(cmd *cobra.Command, args []string) error { if err != nil { return err } - if !cfg.HasAuth() { - display.Error("Not authenticated. Run `shellroute login` or use `--api-key`.") - return fmt.Errorf("not authenticated") - } // Validate format (json or env only — interactive mode is via `shellroute` + `/connect`) if err := config.ValidateFormat(connectFormat, "json", "env"); err != nil { @@ -75,10 +73,14 @@ func runConnect(cmd *cobra.Command, args []string) error { display.Error("Another proxy session is already running:") for _, s := range proxySessions { dur := sessionDuration(s.StartTime) + who := s.Country + if s.Upstream != "" { + who = s.Upstream + } if s.ExitIP != "" { - display.Error(" PID %d · %s · %s · port %d · %s", s.PID, s.Country, s.ExitIP, s.Port, dur) + display.Error(" PID %d · %s · %s · port %d · %s", s.PID, who, s.ExitIP, s.Port, dur) } else { - display.Error(" PID %d · %s · port %d · %s", s.PID, s.Country, s.Port, dur) + display.Error(" PID %d · %s · port %d · %s", s.PID, who, s.Port, dur) } } display.Error("Run `shellroute proxy stop` first.") @@ -86,6 +88,15 @@ func runConnect(cmd *cobra.Command, args []string) error { } } + if connectUpstream != "" { + return runConnectOwn(cfg) + } + + if !cfg.HasAuth() { + display.Error("Not authenticated. Run `shellroute login` or use `--api-key`.") + return fmt.Errorf("not authenticated") + } + // Apply defaults country := connectCountry if country == "" { @@ -113,6 +124,45 @@ func runConnect(cmd *cobra.Command, args []string) error { return runConnectHeadless(cfg, country) } +// runConnectOwn starts the persistent local proxy over the user's own proxy. +func runConnectOwn(cfg *config.Config) error { + target, err := resolveUpstreamRef(cfg, connectUpstream) + if err != nil { + return err + } + port := connectPort + if port == 0 { + port = cfg.DefaultPort + } + + ctx, cancel := context.WithCancel(context.Background()) + sigs := NewSignalHandler(cancel, syscall.SIGINT, syscall.SIGTERM) + + sess, err := session.StartOwn(ctx, target.upstream, target.name, target.label, port, + session.StartOpts{Mode: "proxy", SkipExitIP: connectNoCheck}) + if err != nil { + sigs.Stop() + return reportOwnStartError(target, err) + } + + if sigs.StartupSignal() != 0 { + return waitAndDisconnect(sess, sigs) + } + + if sess.ProbeWarning != "" { + display.Warn("Exit IP unknown (%s). Continuing.", sess.ProbeWarning) + } + + switch connectFormat { + case "json": + outputJSON(sess) + case "env": + outputEnv(sess) + } + + return waitAndDisconnect(sess, sigs) +} + func runConnectHeadless(cfg *config.Config, country string) error { ipType := connectType if ipType == "" { @@ -182,6 +232,10 @@ func waitAndDisconnect(sess *session.Session, sigs *SignalHandler) error { return err } + if sess.Own { + display.OwnSessionSummary("Disconnected.", resp.DurationSec, resp.BytesTotal) + return nil + } display.SessionSummary("Disconnected.", resp.DurationSec, resp.BytesTotal, resp.CostUSD, resp.BalanceUSD) return nil } @@ -194,6 +248,14 @@ func outputJSON(sess *session.Session) { "city": sess.City, "type": sess.Type, } + if sess.Own { + who := sess.UpstreamName + if who == "" { + who = sess.Via + } + data["upstream"] = who + data["type"] = "own" + } enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") enc.Encode(data) @@ -247,10 +309,14 @@ func runProxyStop(cmd *cobra.Command, args []string) error { continue } dur := sessionDuration(s.StartTime) + who := s.Country + if s.Upstream != "" { + who = s.Upstream + } if s.ExitIP != "" { - display.Info("Stopped PID %d · %s · %s · %s", s.PID, s.Country, s.ExitIP, dur) + display.Info("Stopped PID %d · %s · %s · %s", s.PID, who, s.ExitIP, dur) } else { - display.Info("Stopped PID %d · %s · %s", s.PID, s.Country, dur) + display.Info("Stopped PID %d · %s · %s", s.PID, who, dur) } stopped++ } diff --git a/internal/cli/root.go b/internal/cli/root.go index 654cd9a..e7d6aeb 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -31,6 +31,10 @@ var rootCmd = &cobra.Command{ if name == "version" || name == "help" || name == "completion" { return nil } + // Own routes need no account and must work with the API unreachable. + if ownRouteSelected(cmd, args) { + return nil + } cfg, _ := loadConfig() if cfg != nil && !checkVersion(cfg) { return fmt.Errorf("version too old") @@ -53,8 +57,11 @@ var rootCmd = &cobra.Command{ return err } if !cfg.HasAuth() { - display.Error("Not authenticated. Run `shellroute login`.") - return fmt.Errorf("not authenticated") + if len(cfg.Upstreams) == 0 { + display.Error("Not authenticated. Run `shellroute login`, or save your own proxy with `shellroute upstream add`.") + return fmt.Errorf("not authenticated") + } + display.Warn("Not logged in. /connect uses your saved upstreams; run `shellroute login` for Shellroute routes.") } tui.Version = Version() return tui.Run(cfg) @@ -78,6 +85,7 @@ func init() { rootCmd.AddCommand(citiesCmd) rootCmd.AddCommand(runCmd) rootCmd.AddCommand(revealKeyCmd) + rootCmd.AddCommand(upstreamCmd) } // loadConfig loads config and applies the --api-key flag override. @@ -112,7 +120,8 @@ func isHandledError(err error) bool { case "not authenticated", "email required", "code required", "country required", "nested session", "api-key not supported in interactive mode", - "version too old", "proxy already running": + "version too old", "proxy already running", + "upstream unreachable", "upstream required": return true } return false diff --git a/internal/cli/run.go b/internal/cli/run.go index a7bff04..b8be26d 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -29,14 +29,19 @@ var ( ) var runCmd = &cobra.Command{ - Use: "run [country] [flags] -- [args...]", + Use: "run [country|upstream] [flags] -- [args...]", Short: "Run a command through the proxy", Long: `Creates a proxy session, runs the command with HTTP_PROXY/HTTPS_PROXY set, then tears down. Examples: shellroute run US -- curl https://httpbin.org/ip shellroute run --country DE -- python scraper.py - shellroute run GB -- wget https://example.com`, + shellroute run GB -- wget https://example.com + +Your own proxy (no account needed): + shellroute run work -- curl https://httpbin.org/ip # saved with 'shellroute upstream add' + shellroute run --proxy socks5://127.0.0.1:1080 -- curl https://httpbin.org/ip + SHELLROUTE_PROXY=http://user:pass@proxy:8080 shellroute run -- curl https://httpbin.org/ip`, Args: cobra.MinimumNArgs(1), RunE: runRun, } @@ -48,6 +53,8 @@ func init() { f.StringVar(&runIPType, "iptype", "", "IP type: residential (default), datacenter, or mix") f.BoolVar(&runSticky, "sticky", false, "Keep same IP on reconnect") f.BoolVar(&runNoStat, "no-stat", false, "Suppress session summary (clean output)") + f.StringVar(&runProxyURL, "proxy", "", "Route through your own proxy URL (http, https, socks5, socks5h)") + f.BoolVar(&runNoCheck, "no-check", false, "Own proxy: skip the exit IP check") } func runRun(cmd *cobra.Command, args []string) error { @@ -55,30 +62,30 @@ func runRun(cmd *cobra.Command, args []string) error { if err != nil { return err } - if !cfg.HasAuth() { - display.Error("Not authenticated. Run `shellroute login` or use `--api-key`.") - return fmt.Errorf("not authenticated") - } - country := runCountry - // If first arg looks like a country code (2 letters, all alpha), use it - if country == "" && len(args) >= 1 && len(args[0]) == 2 && isAlpha(args[0]) { - country = args[0] - args = args[1:] - } - if country == "" { - country = cfg.DefaultCountry - } - if country == "" { + target, args, err := resolveRunTarget(cfg, args, cmd.ArgsLenAtDash(), runProxyURL, runCountry, os.Getenv(envProxyVar)) + if err == errCountryRequired { display.Error("Country required. Use: shellroute run US -- ") return fmt.Errorf("country required") } + if err != nil { + return err + } if len(args) == 0 { - display.Error("Command required after --. Use: shellroute run %s -- ", country) + display.Error("Command required after --. Use: shellroute run %s -- ", target.describeOrCountry()) return fmt.Errorf("command required") } - country = config.NormalizeCountry(country) + if target.own() { + return runOwn(cfg, target, args) + } + + if !cfg.HasAuth() { + display.Error("Not authenticated. Run `shellroute login` or use `--api-key`.") + return fmt.Errorf("not authenticated") + } + + country := config.NormalizeCountry(target.country) if err := config.ValidateCountry(country); err != nil { display.Error("%s", err) return err @@ -142,8 +149,53 @@ func runRun(cmd *cobra.Command, args []string) error { // detectExitIPRetry already verified the tunnel passes traffic (got an exit IP). // No need for a second route health check — it just adds a failure window // where the tunnel can die between the two checks, causing spurious failures. + return execChild(sess, sigs, args) +} - // Run the child command with proxy env vars +// describeOrCountry names the target for usage messages. +func (t runTarget) describeOrCountry() string { + if t.own() { + return t.describe() + } + return t.country +} + +// runOwn routes the command through the user's own proxy: no account, no API +// call, no billing. The child sees the same local proxy as a hosted route. +func runOwn(cfg *config.Config, target runTarget, args []string) error { + port := runPort + if port == 0 { + port = cfg.DefaultPort + } + + ctx, cancel := context.WithCancel(context.Background()) + sigs := NewSignalHandler(cancel, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP) + + sess, err := session.StartOwn(ctx, target.upstream, target.name, target.label, port, + session.StartOpts{TrackRelays: true, Mode: "run", SkipExitIP: runNoCheck}) + if err != nil { + sigs.Stop() + return reportOwnStartError(target, err) + } + + if sig := sigs.StartupSignal(); sig != 0 { + sigs.Stop() + endRunSession(sess) + exitFromSignal(sig) + } + + if !runNoStat { + display.Info("%s", viaLine(sess)) + if sess.ProbeWarning != "" { + display.Warn("Exit IP unknown (%s). Continuing.", sess.ProbeWarning) + } + } + return execChild(sess, sigs, args) +} + +// execChild runs the command with the proxy environment, kills it if the +// upstream dies, and exits the way the child exited. +func execChild(sess *session.Session, sigs *SignalHandler, args []string) error { childCmd := exec.Command(args[0], args[1:]...) childCmd.Stdin = os.Stdin childCmd.Stdout = os.Stdout @@ -203,6 +255,7 @@ func runRun(cmd *cobra.Command, args []string) error { } // endRunSession ends the session and prints the summary unless --no-stat. +// An own route has nothing to bill: duration and bytes only. func endRunSession(sess *session.Session) { resp, err := sess.Stop() if runNoStat { @@ -212,6 +265,10 @@ func endRunSession(sess *session.Session) { display.Error("Failed to end session. Try again.") return } + if sess.Own { + display.OwnSessionSummary("shellroute session ended.", resp.DurationSec, resp.BytesTotal) + return + } display.SessionSummary("shellroute session ended.", resp.DurationSec, resp.BytesTotal, resp.CostUSD, resp.BalanceUSD) if resp.BalanceUSD <= 0.001 { display.Warn("Balance depleted. Top up at https://console.shellroute.com, then reconnect.") diff --git a/internal/cli/run_exit_test.go b/internal/cli/run_exit_test.go index b3b03da..5ad65ed 100644 --- a/internal/cli/run_exit_test.go +++ b/internal/cli/run_exit_test.go @@ -47,6 +47,16 @@ func TestHelperProcess(t *testing.T) { os.Exit(0) case "exit-from-signal": exitFromSignal(signalByName(os.Getenv("SR_TEST_SIG"))) + case "run-own": + // `shellroute run --proxy ... -- sh -c 'exit N'` with no account, no + // config file and the API URL pointing at a closed port. + rootCmd.SetArgs([]string{"run", "--proxy", "http://127.0.0.1:1", "--no-check", "--no-stat", + "--", "sh", "-c", "exit " + os.Getenv("SR_TEST_CHILD_EXIT")}) + if err := rootCmd.Execute(); err != nil { + fmt.Println(err) + os.Exit(98) + } + os.Exit(0) case "ignored-hup": helperIgnoredHUP() } diff --git a/internal/cli/run_upstream.go b/internal/cli/run_upstream.go new file mode 100644 index 0000000..7cae0be --- /dev/null +++ b/internal/cli/run_upstream.go @@ -0,0 +1,197 @@ +package cli + +import ( + "errors" + "fmt" + "os" + "strings" + + "github.com/shellroute/shellroute-cli/internal/config" + "github.com/shellroute/shellroute-cli/internal/display" + "github.com/shellroute/shellroute-cli/internal/proxy" + "github.com/shellroute/shellroute-cli/internal/session" + "github.com/spf13/cobra" +) + +var ( + runProxyURL string // run --proxy + runNoCheck bool // run --no-check + connectUpstream string // proxy --upstream + connectNoCheck bool // proxy --no-check +) + +// envProxyVar names the environment variable that selects an own proxy for +// `shellroute run` when no country or upstream is given, for CI. +const envProxyVar = "SHELLROUTE_PROXY" + +// runTarget is what `shellroute run` resolved from its arguments: a hosted +// country, or the user's own upstream. +type runTarget struct { + country string // hosted route; "" for an own route + upstream proxy.Upstream // own route + name string // saved upstream name; "" for --proxy or the environment + label string // own route: country label from the saved entry, unverified +} + +func (t runTarget) own() bool { return t.upstream != nil } + +// describe names the own route for messages: the saved name, or the redacted URL. +func (t runTarget) describe() string { + if t.name != "" { + return t.name + } + return t.upstream.Describe() +} + +var errCountryRequired = errors.New("country required") + +// isCountryWord reports whether s has the shape of a country code. +func isCountryWord(s string) bool { + return len(s) == 2 && isAlpha(s) +} + +// resolveRunTarget decides where `shellroute run` routes and returns the +// remaining command arguments. +// +// --proxy own, ad hoc +// --country CC hosted (as before) +// before -- hosted country, two letters +// before -- own, a saved upstream +// nothing SHELLROUTE_PROXY if set, else default_country +// +// dashIdx is cmd.ArgsLenAtDash(): the number of arguments before "--", or +// -1 when "--" was not given. Without "--", the first word is a target only +// when it is a country code or a saved upstream name, as before. +func resolveRunTarget(cfg *config.Config, args []string, dashIdx int, proxyFlag, countryFlag, envProxy string) (runTarget, []string, error) { + first, rest := "", args + switch { + case dashIdx > 1: + return runTarget{}, nil, errors.New("give one country or upstream name before --") + case dashIdx == 1: + first, rest = args[0], args[1:] + case dashIdx == -1 && len(args) > 0: + if _, saved := cfg.FindUpstream(args[0]); isCountryWord(args[0]) || saved { + first, rest = args[0], args[1:] + } + } + + if proxyFlag != "" { + if first != "" { + return runTarget{}, nil, errors.New("--proxy cannot be combined with a country or upstream name") + } + up, err := proxy.ParseUpstreamURL(proxyFlag) + if err != nil { + return runTarget{}, nil, fmt.Errorf("--proxy: %v", err) + } + return runTarget{upstream: up}, rest, nil + } + + if countryFlag != "" { + if first != "" { + return runTarget{}, nil, errors.New("--country cannot be combined with a country or upstream name before --") + } + return runTarget{country: countryFlag}, rest, nil + } + + if first != "" { + if isCountryWord(first) { + return runTarget{country: first}, rest, nil + } + entry, ok := cfg.FindUpstream(first) + if !ok { + return runTarget{}, nil, fmt.Errorf("unknown upstream %q. Save it with `shellroute upstream add %s ` or list saved ones with `shellroute upstream list`", first, first) + } + up, err := proxy.ParseUpstreamURL(entry.URL) + if err != nil { + return runTarget{}, nil, fmt.Errorf("saved upstream %q has an invalid URL (%v). Remove it and add it again", first, err) + } + return runTarget{upstream: up, name: first, label: entry.Country}, rest, nil + } + + if envProxy != "" { + up, err := proxy.ParseUpstreamURL(envProxy) + if err != nil { + return runTarget{}, nil, fmt.Errorf("%s: %v", envProxyVar, err) + } + return runTarget{upstream: up}, rest, nil + } + + if cfg.DefaultCountry != "" { + return runTarget{country: cfg.DefaultCountry}, rest, nil + } + return runTarget{}, rest, errCountryRequired +} + +// resolveUpstreamRef accepts a saved upstream name or a proxy URL. +func resolveUpstreamRef(cfg *config.Config, ref string) (runTarget, error) { + ref = strings.TrimSpace(ref) + if ref == "" { + return runTarget{}, errors.New("upstream required") + } + if entry, ok := cfg.FindUpstream(ref); ok { + up, err := proxy.ParseUpstreamURL(entry.URL) + if err != nil { + return runTarget{}, fmt.Errorf("saved upstream %q has an invalid URL (%v). Remove it and add it again", ref, err) + } + return runTarget{upstream: up, name: ref, label: entry.Country}, nil + } + if strings.Contains(ref, "://") { + up, err := proxy.ParseUpstreamURL(ref) + if err != nil { + return runTarget{}, err + } + return runTarget{upstream: up}, nil + } + return runTarget{}, fmt.Errorf("unknown upstream %q. Save it with `shellroute upstream add %s ` or give a proxy URL", ref, ref) +} + +// ownRouteSelected reports whether cmd will route through the user's own +// proxy, so commands that need no account can skip the version check (an +// API call) and work with the API unreachable. +func ownRouteSelected(cmd *cobra.Command, args []string) bool { + if cmd.Name() == "upstream" || (cmd.Parent() != nil && cmd.Parent().Name() == "upstream") { + return true + } + switch cmd.Name() { + case "run": + cfg, _ := loadConfig() + if cfg == nil { + cfg = config.Default() + } + t, _, err := resolveRunTarget(cfg, args, cmd.ArgsLenAtDash(), runProxyURL, runCountry, os.Getenv(envProxyVar)) + return err == nil && t.own() + case "proxy": + return connectUpstream != "" + } + return false +} + +// viaLine formats the own-route status line: via work · exit IP 1.2.3.4 (DE) · 212 ms. +func viaLine(sess *session.Session) string { + who := sess.UpstreamName + if who == "" { + who = sess.Via + } + parts := []string{"via " + who} + if ip := sess.GetExitIP(); ip != "" { + s := "exit IP " + ip + if sess.Country != "" { + s += " (" + sess.Country + ")" + } + parts = append(parts, s) + } + if sess.ConnectMs > 0 { + parts = append(parts, fmt.Sprintf("%d ms", sess.ConnectMs)) + } + return strings.Join(parts, " · ") +} + +// 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)) + } else { + display.Error("Cannot start the local proxy: %s", err) + } + return errors.New("upstream unreachable") +} diff --git a/internal/cli/run_upstream_test.go b/internal/cli/run_upstream_test.go new file mode 100644 index 0000000..1c42c53 --- /dev/null +++ b/internal/cli/run_upstream_test.go @@ -0,0 +1,205 @@ +//go:build !windows + +package cli + +import ( + "strings" + "testing" + "time" + + "github.com/shellroute/shellroute-cli/internal/config" + "github.com/shellroute/shellroute-cli/internal/session" +) + +func cfgWithUpstream() *config.Config { + cfg := config.Default() + cfg.AddUpstream(config.Upstream{Name: "work", URL: "http://u:p@proxy.example:8080", Country: "DE"}) + cfg.AddUpstream(config.Upstream{Name: "broken", URL: "ftp://x:1"}) + return cfg +} + +func TestResolveRunTarget(t *testing.T) { + cfg := cfgWithUpstream() + withDefault := cfgWithUpstream() + withDefault.DefaultCountry = "US" + + cases := []struct { + name string + cfg *config.Config + args []string + dash int + proxy string + country string + env string + wantCountry string + wantOwn bool + wantName string + wantLabel string + wantRest []string + wantErr string + }{ + {name: "positional country", cfg: cfg, args: []string{"US", "curl", "x"}, dash: 1, wantCountry: "US", wantRest: []string{"curl", "x"}}, + {name: "positional country without dash", cfg: cfg, args: []string{"US", "curl"}, dash: -1, wantCountry: "US", wantRest: []string{"curl"}}, + {name: "country flag", cfg: cfg, args: []string{"curl"}, dash: 0, country: "DE", wantCountry: "DE", wantRest: []string{"curl"}}, + {name: "saved name", cfg: cfg, args: []string{"work", "curl"}, dash: 1, wantOwn: true, wantName: "work", wantLabel: "DE", wantRest: []string{"curl"}}, + {name: "saved name without dash", cfg: cfg, args: []string{"work", "curl"}, dash: -1, wantOwn: true, wantName: "work", wantLabel: "DE", wantRest: []string{"curl"}}, + {name: "unknown word without dash is the command", cfg: cfg, args: []string{"curl", "x"}, dash: -1, wantErr: "country required"}, + {name: "unknown name before dash", cfg: cfg, args: []string{"nope", "curl"}, dash: 1, wantErr: "unknown upstream \"nope\""}, + {name: "saved name with invalid URL", cfg: cfg, args: []string{"broken", "curl"}, dash: 1, wantErr: "invalid URL"}, + {name: "--proxy", cfg: cfg, args: []string{"curl"}, dash: 0, proxy: "socks5://h:1080", wantOwn: true, wantRest: []string{"curl"}}, + {name: "--proxy beats a saved name conflict", cfg: cfg, args: []string{"work", "curl"}, dash: 1, proxy: "socks5://h:1080", wantErr: "cannot be combined"}, + {name: "--proxy invalid", cfg: cfg, args: []string{"curl"}, dash: 0, proxy: "h:1080", wantErr: "--proxy"}, + {name: "--country with positional conflict", cfg: cfg, args: []string{"US", "curl"}, dash: 1, country: "DE", wantErr: "cannot be combined"}, + {name: "env proxy when nothing given", cfg: cfg, args: []string{"curl"}, dash: 0, env: "http://h:8080", wantOwn: true, wantRest: []string{"curl"}}, + {name: "explicit country beats env", cfg: cfg, args: []string{"US", "curl"}, dash: 1, env: "http://h:8080", wantCountry: "US", wantRest: []string{"curl"}}, + {name: "env invalid", cfg: cfg, args: []string{"curl"}, dash: 0, env: "nonsense", wantErr: "SHELLROUTE_PROXY"}, + {name: "default country", cfg: withDefault, args: []string{"curl"}, dash: 0, wantCountry: "US", wantRest: []string{"curl"}}, + {name: "nothing at all", cfg: cfg, args: []string{"curl"}, dash: 0, wantErr: "country required"}, + {name: "two words before dash", cfg: cfg, args: []string{"US", "DE", "curl"}, dash: 2, wantErr: "one country or upstream name"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, rest, err := resolveRunTarget(tc.cfg, tc.args, tc.dash, tc.proxy, tc.country, tc.env) + if tc.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("err = %v, want containing %q", err, tc.wantErr) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.own() != tc.wantOwn || got.country != tc.wantCountry || got.name != tc.wantName || got.label != tc.wantLabel { + t.Errorf("target = own:%v country:%q name:%q label:%q", got.own(), got.country, got.name, got.label) + } + if strings.Join(rest, " ") != strings.Join(tc.wantRest, " ") { + t.Errorf("rest = %v, want %v", rest, tc.wantRest) + } + }) + } +} + +func TestResolveUpstreamRef(t *testing.T) { + cfg := cfgWithUpstream() + if got, err := resolveUpstreamRef(cfg, "work"); err != nil || got.name != "work" || got.label != "DE" { + t.Errorf("saved name: %+v %v", got, err) + } + if got, err := resolveUpstreamRef(cfg, "https://h:443"); err != nil || got.name != "" || !got.own() { + t.Errorf("url: %+v %v", got, err) + } + if _, err := resolveUpstreamRef(cfg, "nope"); err == nil || !strings.Contains(err.Error(), "unknown upstream") { + t.Errorf("unknown: %v", err) + } + if _, err := resolveUpstreamRef(cfg, ""); err == nil { + t.Error("empty ref should fail") + } +} + +func TestOwnRouteSelected(t *testing.T) { + withOwnConfig(t) + // ownRouteSelected runs after cobra parsed the flags, so parse for real: + // pflag records where "--" was. + parseRun := func(argv ...string) []string { + resetFlags(runCmd) + if err := runCmd.ParseFlags(argv); err != nil { + t.Fatal(err) + } + return runCmd.Flags().Args() + } + if ownRouteSelected(runCmd, parseRun("US", "--", "echo")) { + t.Error("hosted country must keep the version check") + } + if !ownRouteSelected(runCmd, parseRun("--proxy", "http://h:8080", "--", "echo")) { + t.Error("--proxy selects an own route") + } + t.Setenv(envProxyVar, "http://h:8080") + if !ownRouteSelected(runCmd, parseRun("--", "echo")) { + t.Error("SHELLROUTE_PROXY selects an own route") + } + if ownRouteSelected(runCmd, parseRun("US", "--", "echo")) { + t.Error("an explicit country beats SHELLROUTE_PROXY") + } + t.Setenv(envProxyVar, "") + resetFlags(runCmd) + + connectUpstream = "work" + if !ownRouteSelected(connectCmd, nil) { + t.Error("proxy --upstream selects an own route") + } + connectUpstream = "" + if ownRouteSelected(connectCmd, nil) || ownRouteSelected(statusCmd, nil) || ownRouteSelected(proxyStopCmd, nil) { + t.Error("hosted commands must keep the version check") + } + if !ownRouteSelected(upstreamCmd, nil) || !ownRouteSelected(upstreamAddCmd, nil) { + t.Error("upstream commands need no account") + } +} + +func TestViaLine(t *testing.T) { + s := &session.Session{Own: true, UpstreamName: "work", Country: "DE", ConnectMs: 212} + s.SetExitIP("91.198.0.1") + if got := viaLine(s); got != "via work · exit IP 91.198.0.1 (DE) · 212 ms" { + t.Errorf("viaLine = %q", got) + } + s2 := &session.Session{Own: true, Via: "socks5://h:1080"} + if got := viaLine(s2); got != "via socks5://h:1080" { + t.Errorf("viaLine without IP = %q", got) + } +} + +func TestRunOwn_ExitCodeMatchesChild_NoAccount(t *testing.T) { + dir := t.TempDir() + env := []string{ + "SR_TEST_HELPER=run-own", + "SHELLROUTE_HOME=" + dir, + "SHELLROUTE_API_KEY=", + "SHELLROUTE_PROXY=", + "SHELLROUTE_API_URL=http://127.0.0.1:1", + } + for _, code := range []string{"3", "0"} { + start := time.Now() + res := runHelper(t, false, append(env, "SR_TEST_CHILD_EXIT="+code)...) + if res.sig != 0 { + t.Fatalf("exit %s: killed by %v: %s", code, res.sig, res.out) + } + want := 3 + if code == "0" { + want = 0 + } + if res.code != want { + t.Errorf("exit %s: shellroute exited %d, want %d\n%s", code, res.code, want, res.out) + } + if strings.Contains(res.out, "Not authenticated") { + t.Errorf("own route must not require an account:\n%s", res.out) + } + if d := time.Since(start); d > 10*time.Second { + t.Errorf("took %v: own route must not wait for the API", d) + } + } +} + +func TestRun_UnknownUpstreamNameFailsBeforeAnySession(t *testing.T) { + withOwnConfig(t) + _, err := execCLI(t, "run", "nope", "--", "echo") + if err == nil || !strings.Contains(err.Error(), "unknown upstream") { + t.Errorf("err = %v", err) + } +} + +func TestRun_ProxyFlagConflict(t *testing.T) { + withOwnConfig(t) + _, err := execCLI(t, "run", "--proxy", "http://h:8080", "US", "--", "echo") + if err == nil || !strings.Contains(err.Error(), "cannot be combined") { + t.Errorf("err = %v", err) + } +} + +func TestRun_EnvProxyIsUsed(t *testing.T) { + withOwnConfig(t) + t.Setenv(envProxyVar, "http://127.0.0.1:1") // nothing listening: the check fails fast + withExitIPEndpoints(t, "http://127.0.0.1:1/ip") + _, err := execCLI(t, "run", "--", "sh", "-c", "exit 0") + if err == nil || err.Error() != "upstream unreachable" { + t.Errorf("err = %v, want upstream unreachable (proves the env proxy was tried)", err) + } +} diff --git a/internal/cli/upstream.go b/internal/cli/upstream.go new file mode 100644 index 0000000..14104a4 --- /dev/null +++ b/internal/cli/upstream.go @@ -0,0 +1,181 @@ +package cli + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/shellroute/shellroute-cli/internal/config" + "github.com/shellroute/shellroute-cli/internal/display" + "github.com/shellroute/shellroute-cli/internal/proxy" + "github.com/spf13/cobra" +) + +var ( + upstreamAddCountry string + upstreamAddNote string +) + +var upstreamCmd = &cobra.Command{ + Use: "upstream", + Short: "Use your own proxies", + Long: `Save proxies you already have and route through them with no Shellroute account. + +Save one, then use its name wherever a country goes: + shellroute upstream add work http://user:pass@proxy.example.com:8080 --country DE + shellroute run work -- curl https://ipinfo.io/ip + shellroute proxy --upstream work + shellroute # then /connect work + +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.`, +} + +var upstreamAddCmd = &cobra.Command{ + Use: "add ", + Short: "Save your own proxy under a name", + Args: cobra.ExactArgs(2), + RunE: runUpstreamAdd, +} + +var upstreamListCmd = &cobra.Command{ + Use: "list", + Short: "List saved upstreams (passwords hidden)", + Args: cobra.NoArgs, + RunE: runUpstreamList, +} + +var upstreamRemoveCmd = &cobra.Command{ + Use: "remove ", + Short: "Delete a saved upstream", + Args: cobra.ExactArgs(1), + RunE: runUpstreamRemove, +} + +var upstreamTestCmd = &cobra.Command{ + Use: "test ", + Short: "Open one tunnel through a proxy and report its exit IP", + Args: cobra.ExactArgs(1), + RunE: runUpstreamTest, +} + +func init() { + upstreamAddCmd.Flags().StringVar(&upstreamAddCountry, "country", "", "Country label shown in status (not verified)") + upstreamAddCmd.Flags().StringVar(&upstreamAddNote, "note", "", "Free-text note") + upstreamCmd.AddCommand(upstreamAddCmd, upstreamListCmd, upstreamRemoveCmd, upstreamTestCmd) +} + +func runUpstreamAdd(cmd *cobra.Command, args []string) error { + name, rawURL := args[0], args[1] + if err := config.ValidateUpstreamName(name); err != nil { + return err + } + if _, err := proxy.ParseUpstreamURL(rawURL); err != nil { + return err + } + country := "" + if upstreamAddCountry != "" { + country = config.NormalizeCountry(upstreamAddCountry) + if err := config.ValidateCountry(country); err != nil { + return err + } + } + cfg, err := loadConfig() + if err != nil { + return err + } + replaced := cfg.AddUpstream(config.Upstream{Name: name, URL: strings.TrimSpace(rawURL), Country: country, Note: upstreamAddNote}) + if err := config.Save(cfg); err != nil { + return err + } + verb := "Saved" + if replaced { + verb = "Updated" + } + display.Success("%s upstream %s: %s", verb, name, proxy.RedactURL(rawURL)) + return nil +} + +func runUpstreamList(cmd *cobra.Command, args []string) error { + cfg, err := loadConfig() + if err != nil { + return err + } + if len(cfg.Upstreams) == 0 { + display.Info("No saved upstreams. Add one: shellroute upstream add ") + return nil + } + rows := make([][]string, 0, len(cfg.Upstreams)) + for _, u := range cfg.Upstreams { + rows = append(rows, []string{u.Name, proxy.RedactURL(u.URL), u.Country, u.Note}) + } + headers := []string{"NAME", "URL", "COUNTRY", "NOTE"} + widths := make([]int, len(headers)) + for i, h := range headers { + widths[i] = len(h) + } + for _, r := range rows { + for i, c := range r { + if len(c) > widths[i] { + widths[i] = len(c) + } + } + } + out := cmd.OutOrStdout() + line := func(cells []string) { + parts := make([]string, len(cells)) + for i, c := range cells { + parts[i] = fmt.Sprintf("%-*s", widths[i], c) + } + fmt.Fprintln(out, strings.TrimRight(strings.Join(parts, " "), " ")) + } + line(headers) + for _, r := range rows { + line(r) + } + return nil +} + +func runUpstreamRemove(cmd *cobra.Command, args []string) error { + cfg, err := loadConfig() + if err != nil { + return err + } + if !cfg.RemoveUpstream(args[0]) { + return fmt.Errorf("unknown upstream %q. List saved ones with `shellroute upstream list`", args[0]) + } + if err := config.Save(cfg); err != nil { + return err + } + display.Success("Removed upstream %s", args[0]) + return nil +} + +func runUpstreamTest(cmd *cobra.Command, args []string) error { + cfg, err := loadConfig() + if err != nil { + return err + } + t, err := resolveUpstreamRef(cfg, args[0]) + if err != nil { + return err + } + res, err := proxy.Check(context.Background(), t.upstream) + switch { + case err == nil: + where := "" + if t.label != "" { + where = " (" + t.label + ")" + } + 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)) + return errors.New("upstream unreachable") + default: + display.Warn("Tunnel open via %s, but the exit IP could not be read: %v", t.describe(), err) + return nil + } +} diff --git a/internal/cli/upstream_test.go b/internal/cli/upstream_test.go new file mode 100644 index 0000000..ebc5991 --- /dev/null +++ b/internal/cli/upstream_test.go @@ -0,0 +1,234 @@ +package cli + +import ( + "bytes" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/shellroute/shellroute-cli/internal/proxy" + "github.com/shellroute/shellroute-cli/internal/proxy/proxytest" +) + +// withOwnConfig points the CLI at an empty temporary config with no account. +func withOwnConfig(t *testing.T) string { + t.Helper() + dir := t.TempDir() + t.Setenv("SHELLROUTE_HOME", dir) + t.Setenv("SHELLROUTE_API_KEY", "") + t.Setenv(envProxyVar, "") + return dir +} + +// execCLI runs the root command with args and returns what it wrote to stdout. +func execCLI(t *testing.T, args ...string) (string, error) { + t.Helper() + resetAllFlags() + for _, c := range upstreamCmd.Commands() { + resetFlags(c) + } + var out bytes.Buffer + rootCmd.SetOut(&out) + defer rootCmd.SetOut(nil) + rootCmd.SetArgs(args) + err := rootCmd.Execute() + return out.String(), err +} + +func withExitIPEndpoints(t *testing.T, endpoints ...string) { + t.Helper() + old := proxy.ExitIPEndpoints + proxy.ExitIPEndpoints = endpoints + t.Cleanup(func() { proxy.ExitIPEndpoints = old }) +} + +func ipEcho(t *testing.T, body string) *httptest.Server { + t.Helper() + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + io.WriteString(w, body) + })) + t.Cleanup(ts.Close) + return ts +} + +func TestUpstreamSubcommandsRegistered(t *testing.T) { + got := map[string]bool{} + for _, c := range upstreamCmd.Commands() { + got[c.Name()] = true + } + for _, want := range []string{"add", "list", "remove", "test"} { + if !got[want] { + t.Errorf("upstream %s not registered", want) + } + } +} + +func TestUpstreamAdd_RejectsBadNames(t *testing.T) { + withOwnConfig(t) + cases := map[string]string{ + "de": "country code", + "US": "country code", + "Work": "3 to 32", + "a": "3 to 32", + "my proxy": "3 to 32", + "proxy.one": "3 to 32", + } + for name, want := range cases { + _, err := execCLI(t, "upstream", "add", name, "http://proxy.example:8080") + if err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("add %q: err = %v, want containing %q", name, err, want) + } + } +} + +func TestUpstreamAdd_RejectsBadURLs(t *testing.T) { + withOwnConfig(t) + cases := map[string]string{ + "ftp://proxy.example:21": "unsupported proxy scheme", + "http://proxy.example": "needs a port", + "proxy.example:8080": "needs a scheme", + "http://proxy.example:1/x": "must not have a path", + "socks4://proxy.example:1080": "unsupported proxy scheme", + } + for raw, want := range cases { + _, err := execCLI(t, "upstream", "add", "work", raw) + if err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("add %q: err = %v, want containing %q", raw, err, want) + } + } +} + +func TestUpstreamAdd_RejectsBadCountryLabel(t *testing.T) { + withOwnConfig(t) + _, err := execCLI(t, "upstream", "add", "work", "http://proxy.example:8080", "--country", "XYZ") + if err == nil || !strings.Contains(err.Error(), "2 letters") { + t.Errorf("err = %v", err) + } +} + +func TestUpstreamAddListRemove(t *testing.T) { + dir := withOwnConfig(t) + + if _, err := execCLI(t, "upstream", "add", "work", "http://alice:s3cret@proxy.example:8080", "--country", "de", "--note", "office"); err != nil { + t.Fatalf("add: %v", err) + } + if _, err := execCLI(t, "upstream", "add", "home", "socks5://home.example:1080"); err != nil { + t.Fatalf("add home: %v", err) + } + + out, err := execCLI(t, "upstream", "list") + if err != nil { + t.Fatalf("list: %v", err) + } + for _, want := range []string{"NAME", "work", "http://alice:***@proxy.example:8080", "DE", "office", "home", "socks5://home.example:1080"} { + if !strings.Contains(out, want) { + t.Errorf("list output lacks %q:\n%s", want, out) + } + } + if strings.Contains(out, "s3cret") { + t.Errorf("list leaks the password:\n%s", out) + } + + raw, err := os.ReadFile(filepath.Join(dir, "config.toml")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(raw), "s3cret") { + t.Error("config file should store the full URL") + } + info, _ := os.Stat(filepath.Join(dir, "config.toml")) + if info.Mode().Perm() != 0600 { + t.Errorf("config mode = %o, want 0600", info.Mode().Perm()) + } + + // Same name again updates in place. + if _, err := execCLI(t, "upstream", "add", "work", "http://alice:new@proxy.example:8080"); err != nil { + t.Fatalf("update: %v", err) + } + out, _ = execCLI(t, "upstream", "list") + if strings.Count(out, "work") != 1 || strings.Contains(out, "office") { + t.Errorf("update should replace the entry:\n%s", out) + } + + if _, err := execCLI(t, "upstream", "remove", "work"); err != nil { + t.Fatalf("remove: %v", err) + } + if _, err := execCLI(t, "upstream", "remove", "work"); err == nil || !strings.Contains(err.Error(), "unknown upstream") { + t.Errorf("second remove: err = %v", err) + } + out, _ = execCLI(t, "upstream", "list") + if strings.Contains(out, "work") || !strings.Contains(out, "home") { + t.Errorf("after remove:\n%s", out) + } +} + +func TestUpstreamList_EmptyWritesNothingToStdout(t *testing.T) { + withOwnConfig(t) + out, err := execCLI(t, "upstream", "list") + if err != nil || out != "" { + t.Errorf("out=%q err=%v", out, err) + } +} + +func TestUpstreamTest_UnknownName(t *testing.T) { + withOwnConfig(t) + _, err := execCLI(t, "upstream", "test", "nope") + if err == nil || !strings.Contains(err.Error(), "unknown upstream") { + t.Errorf("err = %v", err) + } +} + +func TestUpstreamTest_CredentialsError(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:wrong@"+p.Addr); err != nil { + t.Fatal(err) + } + _, err := execCLI(t, "upstream", "test", "work") + if err == nil || err.Error() != "upstream unreachable" { + t.Errorf("err = %v, want upstream unreachable", err) + } + if p.Rejected.Load() == 0 { + t.Error("the proxy should have seen the bad credentials") + } +} + +func TestUpstreamTest_URLSuccess(t *testing.T) { + withOwnConfig(t) + p := proxytest.StartHTTPProxy(t, "", "") + withExitIPEndpoints(t, ipEcho(t, "203.0.113.5").URL) + if _, err := execCLI(t, "upstream", "test", "http://"+p.Addr); err != nil { + t.Errorf("test by URL: %v", err) + } + if p.Requests.Load() == 0 { + t.Error("the probe should have gone through the proxy") + } +} + +func TestUpstreamTest_ExitIPUnknownIsNotAnError(t *testing.T) { + withOwnConfig(t) + p := proxytest.StartSOCKS5(t, "", "") + withExitIPEndpoints(t, ipEcho(t, "").URL) + if _, err := execCLI(t, "upstream", "test", "socks5://"+p.Addr); err != nil { + t.Errorf("tunnel open but no IP should warn, not fail: %v", err) + } +} + +func TestUpstreamCommands_WorkWithAPIUnreachable(t *testing.T) { + withOwnConfig(t) + t.Setenv("SHELLROUTE_API_URL", "http://127.0.0.1:1") + if !ownRouteSelected(upstreamListCmd, nil) { + t.Error("upstream commands must skip the version check") + } + if _, err := execCLI(t, "upstream", "add", "work", "http://proxy.example:8080"); err != nil { + t.Errorf("add with API down: %v", err) + } + if _, err := execCLI(t, "upstream", "list"); err != nil { + t.Errorf("list with API down: %v", err) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index ef55198..1bc7ce7 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -27,6 +27,9 @@ type Config struct { LogLevel string `toml:"log_level"` UsageWarnings bool `toml:"usage_warnings"` AutoDisconnect int `toml:"auto_disconnect_at"` + + // Upstreams are the user's own proxies, saved by `shellroute upstream add`. + Upstreams []Upstream `toml:"upstream,omitempty"` } // IsLocalMode returns true if ~/.shellroute-mode contains "local". diff --git a/internal/config/upstream.go b/internal/config/upstream.go new file mode 100644 index 0000000..ab2d492 --- /dev/null +++ b/internal/config/upstream.go @@ -0,0 +1,64 @@ +package config + +import ( + "fmt" + "regexp" +) + +// Upstream is a saved proxy of the user's own, stored in config.toml as a +// [[upstream]] table. URL carries the credentials; the file is mode 0600. +type Upstream struct { + Name string `toml:"name"` + URL string `toml:"url"` + Country string `toml:"country,omitempty"` // display label only; not verified + Note string `toml:"note,omitempty"` +} + +// Upstream names are 3 to 32 characters of lowercase letters, digits, +// underscore or hyphen, so a name can never look like a two-letter country. +var upstreamNameRe = regexp.MustCompile(`^[a-z0-9_-]{3,32}$`) + +// ValidateUpstreamName checks a saved upstream name. +func ValidateUpstreamName(name string) error { + if len(name) == 2 { + return fmt.Errorf("upstream name %q looks like a country code; use 3 to 32 characters", name) + } + if !upstreamNameRe.MatchString(name) { + return fmt.Errorf("upstream name must be 3 to 32 characters: lowercase letters, digits, _ or -") + } + return nil +} + +// FindUpstream returns the saved upstream with the given name. +func (c *Config) FindUpstream(name string) (*Upstream, bool) { + for i := range c.Upstreams { + if c.Upstreams[i].Name == name { + return &c.Upstreams[i], true + } + } + return nil, false +} + +// AddUpstream saves an upstream, replacing one with the same name. +// Returns true when an existing entry was replaced. +func (c *Config) AddUpstream(u Upstream) bool { + for i := range c.Upstreams { + if c.Upstreams[i].Name == u.Name { + c.Upstreams[i] = u + return true + } + } + c.Upstreams = append(c.Upstreams, u) + return false +} + +// RemoveUpstream deletes a saved upstream. Returns false when it did not exist. +func (c *Config) RemoveUpstream(name string) bool { + for i := range c.Upstreams { + if c.Upstreams[i].Name == name { + c.Upstreams = append(c.Upstreams[:i], c.Upstreams[i+1:]...) + return true + } + } + return false +} diff --git a/internal/config/upstream_test.go b/internal/config/upstream_test.go new file mode 100644 index 0000000..501d744 --- /dev/null +++ b/internal/config/upstream_test.go @@ -0,0 +1,118 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestValidateUpstreamName(t *testing.T) { + good := []string{"work", "my-proxy", "home_1", "abc", strings.Repeat("a", 32)} + for _, n := range good { + if err := ValidateUpstreamName(n); err != nil { + t.Errorf("%q should be valid: %v", n, err) + } + } + bad := map[string]string{ + "DE": "country code", + "us": "country code", + "ab": "country code", + "a": "3 to 32", + "Work": "3 to 32", + "my proxy": "3 to 32", + "proxy.one": "3 to 32", + strings.Repeat("a", 33): "3 to 32", + "": "3 to 32", + "user:pass@host": "3 to 32", + } + for n, want := range bad { + err := ValidateUpstreamName(n) + if err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("%q: err = %v, want containing %q", n, err, want) + } + } +} + +func TestUpstreamAddFindRemove(t *testing.T) { + cfg := Default() + if replaced := cfg.AddUpstream(Upstream{Name: "work", URL: "http://u:p@h:8080", Country: "DE"}); replaced { + t.Error("first add must not report a replacement") + } + if replaced := cfg.AddUpstream(Upstream{Name: "home", URL: "socks5://h:1080"}); replaced { + t.Error("second name must not report a replacement") + } + if replaced := cfg.AddUpstream(Upstream{Name: "work", URL: "http://u:new@h:8080"}); !replaced { + t.Error("same name must replace") + } + if len(cfg.Upstreams) != 2 { + t.Fatalf("len = %d, want 2", len(cfg.Upstreams)) + } + u, ok := cfg.FindUpstream("work") + if !ok || u.URL != "http://u:new@h:8080" || u.Country != "" { + t.Errorf("FindUpstream(work) = %+v, %v", u, ok) + } + if _, ok := cfg.FindUpstream("nope"); ok { + t.Error("unknown name should not be found") + } + if !cfg.RemoveUpstream("home") || cfg.RemoveUpstream("home") { + t.Error("remove should succeed once, then fail") + } + if len(cfg.Upstreams) != 1 || cfg.Upstreams[0].Name != "work" { + t.Errorf("after remove: %+v", cfg.Upstreams) + } +} + +func TestUpstreamsRoundTripThroughConfigFile(t *testing.T) { + dir := t.TempDir() + t.Setenv("SHELLROUTE_HOME", dir) + t.Setenv("SHELLROUTE_API_KEY", "") + + cfg := Default() + cfg.AddUpstream(Upstream{Name: "work", URL: "http://alice:s3cret@proxy.example:8080", Country: "DE", Note: "office"}) + cfg.AddUpstream(Upstream{Name: "home", URL: "socks5h://home.example:1080"}) + if err := Save(cfg); err != nil { + t.Fatal(err) + } + + path := filepath.Join(dir, "config.toml") + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0600 { + t.Errorf("config mode = %o, want 0600", info.Mode().Perm()) + } + raw, _ := os.ReadFile(path) + if !strings.Contains(string(raw), "[[upstream]]") { + t.Errorf("config should use [[upstream]] tables:\n%s", raw) + } + + loaded, err := Load() + if err != nil { + t.Fatal(err) + } + if len(loaded.Upstreams) != 2 { + t.Fatalf("loaded %d upstreams, want 2:\n%s", len(loaded.Upstreams), raw) + } + w, ok := loaded.FindUpstream("work") + if !ok || w.URL != "http://alice:s3cret@proxy.example:8080" || w.Country != "DE" || w.Note != "office" { + t.Errorf("work = %+v", w) + } + h, _ := loaded.FindUpstream("home") + if h == nil || h.Country != "" || h.Note != "" { + t.Errorf("home = %+v", h) + } +} + +func TestNoUpstreamsLeavesConfigFileClean(t *testing.T) { + dir := t.TempDir() + t.Setenv("SHELLROUTE_HOME", dir) + if err := Save(Default()); err != nil { + t.Fatal(err) + } + raw, _ := os.ReadFile(filepath.Join(dir, "config.toml")) + if strings.Contains(string(raw), "upstream") { + t.Errorf("empty upstream list should not appear in the file:\n%s", raw) + } +} diff --git a/internal/display/display.go b/internal/display/display.go index d0c3d19..937eeeb 100644 --- a/internal/display/display.go +++ b/internal/display/display.go @@ -81,6 +81,23 @@ func WriteSessionSummary(w io.Writer, title string, durationSec int, bytesTotal fmt.Fprintln(w) } +// OwnSessionSummary prints the end-of-session summary for a route through +// the user's own proxy: duration and data only, nothing is billed. +func OwnSessionSummary(title string, durationSec int, bytesTotal int64) { + WriteOwnSessionSummary(os.Stderr, title, durationSec, bytesTotal) +} + +// WriteOwnSessionSummary writes the own-proxy session summary to w. +func WriteOwnSessionSummary(w io.Writer, title string, durationSec int, bytesTotal int64) { + fmt.Fprintln(w) + fmt.Fprintf(w, "%s %s\n", successMark, title) + fmt.Fprintln(w) + fmt.Fprintf(w, " %s\n", Bold("Session:")) + fmt.Fprintf(w, " %s %s\n", labelStyle.Render("Duration:"), FormatDuration(durationSec)) + fmt.Fprintf(w, " %s %s\n", labelStyle.Render("Data used:"), FormatBytes(bytesTotal)) + fmt.Fprintln(w) +} + func Blank() { fmt.Fprintln(os.Stderr) } From ffea4b7f4925633002072553f4ca57afb3c107d6 Mon Sep 17 00:00:00 2001 From: contra Date: Wed, 14 Oct 2026 20:54:04 +0300 Subject: [PATCH 03/13] feat(tui): /connect routes the interactive shell through an own upstream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The control port accepts /connect?upstream=, starts the session with session.StartOwn and exports SHELLROUTE_UPSTREAM next to the usual variables; switching back to a hosted route clears it. Route checks for an own session use the local route state only, so a dead or unreachable Shellroute API never blocks the shell. Rotation is the provider's job: /rotate says so and the reconnect-on-death path reports the loss instead of rotating. /balance, /countries, /cities, /iptype and /sticky answer 'not applicable' while an own route is active. The prompt shows 'work (DE) · ip'; status shows the upstream and its redacted URL; disconnect prints duration and bytes only. The shell starts without a login when upstreams are saved. Signed-off-by: contra --- internal/session/control.go | 73 ++++++----- internal/session/control_monitor.go | 2 +- internal/session/control_own.go | 100 ++++++++++++++ internal/session/control_own_test.go | 188 +++++++++++++++++++++++++++ internal/session/control_test.go | 2 +- internal/tui/own_shell_test.go | 180 +++++++++++++++++++++++++ internal/tui/tui.go | 57 ++++++-- internal/tui/tui_shell.go | 68 +++++++++- 8 files changed, 618 insertions(+), 52 deletions(-) create mode 100644 internal/session/control_own.go create mode 100644 internal/session/control_own_test.go create mode 100644 internal/tui/own_shell_test.go diff --git a/internal/session/control.go b/internal/session/control.go index 80f9cf1..fdfb414 100644 --- a/internal/session/control.go +++ b/internal/session/control.go @@ -117,6 +117,10 @@ func (c *Controller) autoRotateWithContext(ctx context.Context) { c.mu.Unlock() return } + if c.sess.Own { + c.mu.Unlock() + return + } oldSessionID := c.sess.ID c.mu.Unlock() @@ -247,8 +251,9 @@ func (c *Controller) Stop() { } // StopAndDisconnect stops the controller and returns the final session -// summary with accumulated stats from rotations. Returns nil if no session was active. -func (c *Controller) StopAndDisconnect() *api.SessionEndResponse { +// summary with accumulated stats from rotations, and whether the route was +// the user's own proxy. Returns nil if no session was active. +func (c *Controller) StopAndDisconnect() (*api.SessionEndResponse, bool) { c.Stop() c.mu.Lock() @@ -263,7 +268,7 @@ func (c *Controller) StopAndDisconnect() *api.SessionEndResponse { c.mu.Unlock() if sess == nil { - return nil + return nil, false } resp, _ := sess.Stop() @@ -272,7 +277,7 @@ func (c *Controller) StopAndDisconnect() *api.SessionEndResponse { resp.BytesTotal += accumBytes resp.CostUSD += accumCost } - return resp + return resp, sess.Own } // Session returns the current session (nil if disconnected). @@ -286,6 +291,10 @@ func (c *Controller) Session() *Session { // /connect?country=XX — returns env exports for eval func (c *Controller) httpConnect(w http.ResponseWriter, r *http.Request) { + if name := r.URL.Query().Get("upstream"); name != "" { + c.httpConnectOwn(w, name) + return + } country := config.NormalizeCountry(r.URL.Query().Get("country")) if country == "" { w.WriteHeader(400) @@ -368,35 +377,8 @@ func (c *Controller) httpConnect(w http.ResponseWriter, r *http.Request) { // during setup are expected and should not trigger the kill switch. c.wireUpstreamCallback(sess) - c.mu.Lock() - c.sess = sess - c.healthy = true - c.notifiedDead = false - c.successCount = 0 - c.lowBalanceWarned = false - c.accumDuration = 0 - c.accumBytes = 0 - c.accumCost = 0 - c.mu.Unlock() - - proxyURL := sess.ProxyURL() - fmt.Fprintf(w, "export HTTP_PROXY=%s\n", proxyURL) - 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) - // 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))) - fmt.Fprintf(w, "export SHELLROUTE_CITY=%s\n", shellQuote(sess.City)) - fmt.Fprintf(w, "export SHELLROUTE_EXIT_IP=%s\n", sess.GetExitIP()) - fmt.Fprintf(w, "export SHELLROUTE_PORT=%d\n", sess.Port) + c.adopt(sess) + c.writeConnectExports(w, sess) if sess.LowBalance { fmt.Fprintf(w, "LOW_BALANCE=%.2f\n", math.Floor(sess.BalanceUSD*100)/100) } @@ -432,6 +414,10 @@ func (c *Controller) httpDisconnect(w http.ResponseWriter, r *http.Request) { c.accumCost = 0 c.mu.Unlock() + if sess.Own { + display.WriteOwnSessionSummary(w, "Disconnected.", totalDuration, totalBytes) + return + } display.WriteSessionSummary(w, "Disconnected.", totalDuration, totalBytes, totalCost, resp.BalanceUSD) } @@ -444,6 +430,11 @@ func (c *Controller) httpRotate(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "Not connected.") return } + if c.sess.Own { + c.mu.Unlock() + fmt.Fprintln(w, "NO_OTHER_IPS=Rotate is only for Shellroute routes; your provider controls rotation") + return + } if !c.healthy { c.mu.Unlock() fmt.Fprintln(w, "DISCONNECTED=1") @@ -644,7 +635,8 @@ func (c *Controller) httpRouteCheck(w http.ResponseWriter, r *http.Request) { // Verify session via API when bytes changed or relay just ended. // Only clear needsRouteVerify and update lastCheckedBytes after // a successful API response — failed calls must retry next time. - if sess.Proxy() != nil { + // An own route has no server side: the local route state is all there is. + if sess.Proxy() != nil && !sess.Own { currentBytes := sess.Proxy().BytesTotal() c.mu.Lock() needsVerify := c.needsRouteVerify || currentBytes != c.lastCheckedBytes @@ -696,7 +688,7 @@ func (c *Controller) httpRotateAndWait(w http.ResponseWriter, r *http.Request) { } c.mu.Lock() - if c.sess == nil { + if c.sess == nil || c.sess.Own { c.mu.Unlock() fmt.Fprint(w, "failed") return @@ -767,7 +759,16 @@ func (c *Controller) httpStatus(w http.ResponseWriter, r *http.Request) { } uptime := time.Since(c.sess.StartTime).Truncate(time.Second) - fmt.Fprintf(w, " Country: %s\n", c.sess.Country) + if c.sess.Own { + label := c.sess.UpstreamName + if c.sess.Country != "" { + label += " (" + c.sess.Country + ")" + } + fmt.Fprintf(w, " Upstream: %s\n", label) + fmt.Fprintf(w, " Via: %s\n", c.sess.Via) + } else { + fmt.Fprintf(w, " Country: %s\n", c.sess.Country) + } fmt.Fprintf(w, " Exit IP: %s\n", c.sess.GetExitIP()) fmt.Fprintf(w, " Health: %s\n", health) fmt.Fprintf(w, " Uptime: %s\n", uptime) diff --git a/internal/session/control_monitor.go b/internal/session/control_monitor.go index 6d473ba..834af98 100644 --- a/internal/session/control_monitor.go +++ b/internal/session/control_monitor.go @@ -128,7 +128,7 @@ func (c *Controller) balanceMonitor(ctx context.Context) { isDepleted := c.deadReason == "depleted" && !c.healthy c.mu.Unlock() - if sess == nil { + if sess == nil || sess.Own { continue } diff --git a/internal/session/control_own.go b/internal/session/control_own.go new file mode 100644 index 0000000..e0b8dc5 --- /dev/null +++ b/internal/session/control_own.go @@ -0,0 +1,100 @@ +package session + +import ( + "context" + "fmt" + "net/http" + + "github.com/shellroute/shellroute-cli/internal/proxy" +) + +// httpConnectOwn handles /connect?upstream=: route the shell through +// one of the user's saved proxies. No account, no API call, no billing. +func (c *Controller) httpConnectOwn(w http.ResponseWriter, name string) { + entry, ok := c.cfg.FindUpstream(name) + if !ok { + w.WriteHeader(400) + fmt.Fprintf(w, "ERROR Unknown upstream %q. Save it with: shellroute upstream add %s \n", name, name) + return + } + up, err := proxy.ParseUpstreamURL(entry.URL) + if err != nil { + w.WriteHeader(400) + fmt.Fprintf(w, "ERROR Saved upstream %q has an invalid URL. Remove it and add it again.\n", name) + return + } + + c.mu.Lock() + oldSess := c.sess + c.mu.Unlock() + + // Start on a temp port; the old session keeps running until the new one works. + sess, err := StartOwn(context.Background(), up, name, entry.Country, 0, StartOpts{}) + if err != nil { + if proxy.TunnelFailed(err) { + fmt.Fprintf(w, "ERROR Cannot connect through %s: %s\n", name, proxy.Cause(err)) + } else { + fmt.Fprintln(w, "ERROR Cannot start the local proxy. Try again.") + } + return + } + + if oldSess != nil { + c.mu.Lock() + c.sess = nil + c.mu.Unlock() + oldSess.Stop() + } + + c.wireUpstreamCallback(sess) + c.adopt(sess) + c.writeConnectExports(w, sess) + if sess.ProbeWarning != "" { + // Filtered out by the shell before eval and shown as a warning. + fmt.Fprintf(w, "PROBE_WARNING=%s\n", sess.ProbeWarning) + } +} + +// adopt makes sess the current session and resets per-session state. +func (c *Controller) adopt(sess *Session) { + c.mu.Lock() + c.sess = sess + c.healthy = true + c.notifiedDead = false + c.deadReason = "" + c.successCount = 0 + c.lowBalanceWarned = false + c.accumDuration = 0 + c.accumBytes = 0 + c.accumCost = 0 + c.mu.Unlock() +} + +// writeConnectExports writes the shell exports for a connected session: the +// proxy environment the child tools read, then the SHELLROUTE_* variables +// the prompt and slash commands use. +func (c *Controller) writeConnectExports(w http.ResponseWriter, sess *Session) { + proxyURL := sess.ProxyURL() + fmt.Fprintf(w, "export HTTP_PROXY=%s\n", proxyURL) + 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) + // 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))) + fmt.Fprintf(w, "export SHELLROUTE_CITY=%s\n", shellQuote(sess.City)) + fmt.Fprintf(w, "export SHELLROUTE_EXIT_IP=%s\n", sess.GetExitIP()) + fmt.Fprintf(w, "export SHELLROUTE_PORT=%d\n", sess.Port) + if sess.Own { + fmt.Fprintf(w, "export SHELLROUTE_UPSTREAM=%s\n", sess.UpstreamName) + } else { + fmt.Fprintln(w, "unset SHELLROUTE_UPSTREAM") + } +} diff --git a/internal/session/control_own_test.go b/internal/session/control_own_test.go new file mode 100644 index 0000000..eb5eacc --- /dev/null +++ b/internal/session/control_own_test.go @@ -0,0 +1,188 @@ +package session + +import ( + "net/http/httptest" + "strings" + "testing" + + "github.com/shellroute/shellroute-cli/internal/api" + "github.com/shellroute/shellroute-cli/internal/config" + "github.com/shellroute/shellroute-cli/internal/proxy/proxytest" +) + +// ownController returns a controller whose config holds two saved upstreams: +// "work" (a fake HTTP proxy with auth) and "dead" (nothing listening). The +// API client points at a closed port: any API call would fail. +func ownController(t *testing.T) (*Controller, *proxytest.HTTPProxy) { + t.Helper() + t.Setenv("SHELLROUTE_HOME", t.TempDir()) + p := proxytest.StartHTTPProxy(t, "u", "p") + cfg := &config.Config{} + cfg.AddUpstream(config.Upstream{Name: "work", URL: "http://u:p@" + p.Addr, Country: "DE"}) + cfg.AddUpstream(config.Upstream{Name: "dead", URL: "http://127.0.0.1:1"}) + ctrl := NewController(api.New("http://127.0.0.1:1", ""), cfg) + withExitIPEndpoints(t, echoIP(t, "203.0.113.4").URL) + t.Cleanup(func() { ctrl.StopAndDisconnect() }) + return ctrl, p +} + +func connectOwn(t *testing.T, ctrl *Controller, name string) (int, string) { + t.Helper() + w := httptest.NewRecorder() + ctrl.httpConnect(w, httptest.NewRequest("GET", "/connect?upstream="+name, nil)) + return w.Code, w.Body.String() +} + +func TestConnectOwn_ExportsWithoutAPI(t *testing.T) { + ctrl, p := ownController(t) + code, body := connectOwn(t, ctrl, "work") + if code != 200 { + t.Fatalf("code %d: %s", code, body) + } + for _, want := range []string{ + "export HTTP_PROXY=http://127.0.0.1:", + "export HTTPS_PROXY=http://127.0.0.1:", + "export SHELLROUTE_SESSION_ID=own-work", + "export SHELLROUTE_UPSTREAM=work", + "export SHELLROUTE_COUNTRY=DE", + "export SHELLROUTE_EXIT_IP=203.0.113.4", + "export SHELLROUTE_PORT=", + "NODE_USE_ENV_PROXY", + } { + if !strings.Contains(body, want) { + t.Errorf("exports lack %q:\n%s", want, body) + } + } + for _, never := range []string{"LOW_BALANCE", "u:p@", "ERROR"} { + if strings.Contains(body, never) { + t.Errorf("exports must not contain %q:\n%s", never, body) + } + } + sess := ctrl.Session() + if sess == nil || !sess.Own || sess.UpstreamName != "work" { + t.Fatalf("session = %+v", sess) + } + if p.Requests.Load() == 0 { + t.Error("the exit IP probe should have gone through the proxy") + } +} + +func TestConnectOwn_UnknownName(t *testing.T) { + ctrl, _ := ownController(t) + code, body := connectOwn(t, ctrl, "nope") + if code != 400 || !strings.Contains(body, "ERROR Unknown upstream \"nope\"") { + t.Errorf("code %d body %q", code, body) + } + if ctrl.Session() != nil { + t.Error("no session should exist") + } +} + +func TestConnectOwn_TunnelFailureKeepsOldSession(t *testing.T) { + ctrl, _ := ownController(t) + if code, body := connectOwn(t, ctrl, "work"); code != 200 { + t.Fatalf("first connect: %d %s", code, body) + } + _, body := connectOwn(t, ctrl, "dead") + if !strings.Contains(body, "ERROR Cannot connect through dead: connection refused") { + t.Errorf("body = %q", body) + } + if s := ctrl.Session(); s == nil || s.UpstreamName != "work" { + t.Error("the working session must survive a failed switch") + } +} + +func TestHostedExports_ClearUpstreamVariable(t *testing.T) { + ctrl, _ := newTestController() + w := httptest.NewRecorder() + ctrl.writeConnectExports(w, fakeSession(t)) + if !strings.Contains(w.Body.String(), "unset SHELLROUTE_UPSTREAM") { + t.Error("switching to a hosted route must clear SHELLROUTE_UPSTREAM") + } +} + +func TestRotateOwn_ProviderControlsRotation(t *testing.T) { + ctrl, _ := ownController(t) + connectOwn(t, ctrl, "work") + w := httptest.NewRecorder() + ctrl.httpRotate(w, httptest.NewRequest("GET", "/rotate", nil)) + if !strings.Contains(w.Body.String(), "NO_OTHER_IPS=Rotate is only for Shellroute routes") { + t.Errorf("body = %q", w.Body.String()) + } + if ctrl.Session() == nil || !ctrl.Session().Own { + t.Error("rotate must leave the own session in place") + } +} + +func TestRotateAndWaitOwn_Fails(t *testing.T) { + ctrl, _ := ownController(t) + connectOwn(t, ctrl, "work") + w := httptest.NewRecorder() + ctrl.httpRotateAndWait(w, httptest.NewRequest("POST", "/rotate-and-wait", nil)) + if w.Body.String() != "failed" { + t.Errorf("body = %q", w.Body.String()) + } +} + +func TestRouteCheckOwn_UsesLocalStateOnly(t *testing.T) { + ctrl, _ := ownController(t) + connectOwn(t, ctrl, "work") + ctrl.mu.Lock() + ctrl.needsRouteVerify = true // would trigger an API call for a hosted route + ctrl.mu.Unlock() + + w := httptest.NewRecorder() + ctrl.httpRouteCheck(w, httptest.NewRequest("GET", "/route-check", nil)) + if w.Body.String() != "ok 203.0.113.4" { + t.Errorf("route-check = %q, want ok with the exit IP (the API is unreachable)", w.Body.String()) + } + + ctrl.Session().Proxy().SetRouteDead(false) + w = httptest.NewRecorder() + ctrl.httpRouteCheck(w, httptest.NewRequest("GET", "/route-check", nil)) + if w.Body.String() != "dead" { + t.Errorf("route-check after local death = %q, want dead", w.Body.String()) + } +} + +func TestStatusOwn_ShowsUpstreamNotPassword(t *testing.T) { + ctrl, _ := ownController(t) + connectOwn(t, ctrl, "work") + w := httptest.NewRecorder() + ctrl.httpStatus(w, httptest.NewRequest("GET", "/status", nil)) + body := w.Body.String() + for _, want := range []string{"Upstream: work (DE)", "Via: http://u:***@", "Exit IP: 203.0.113.4", "Health: healthy"} { + if !strings.Contains(body, want) { + t.Errorf("status lacks %q:\n%s", want, body) + } + } + if strings.Contains(body, "u:p@") || strings.Contains(body, "Country:") { + t.Errorf("status wrong:\n%s", body) + } +} + +func TestDisconnectOwn_NoCostLines(t *testing.T) { + ctrl, _ := ownController(t) + connectOwn(t, ctrl, "work") + w := httptest.NewRecorder() + ctrl.httpDisconnect(w, httptest.NewRequest("GET", "/disconnect", nil)) + body := w.Body.String() + if !strings.Contains(body, "Duration:") || !strings.Contains(body, "Data used:") { + t.Errorf("summary lacks duration/data:\n%s", body) + } + if strings.Contains(body, "Cost:") || strings.Contains(body, "Balance:") { + t.Errorf("own route has nothing to bill:\n%s", body) + } + if ctrl.Session() != nil { + t.Error("session should be cleared") + } +} + +func TestStopAndDisconnectOwn_ReportsOwn(t *testing.T) { + ctrl, _ := ownController(t) + connectOwn(t, ctrl, "work") + resp, own := ctrl.StopAndDisconnect() + if resp == nil || !own { + t.Errorf("resp=%v own=%v", resp, own) + } +} diff --git a/internal/session/control_test.go b/internal/session/control_test.go index 4696b34..677743b 100644 --- a/internal/session/control_test.go +++ b/internal/session/control_test.go @@ -1558,7 +1558,7 @@ func TestStopAndDisconnect_IncludesAccumulated(t *testing.T) { ctrl.accumCost = 0.20 ctrl.mu.Unlock() - resp := ctrl.StopAndDisconnect() + resp, _ := ctrl.StopAndDisconnect() if resp == nil { t.Fatal("expected response") } diff --git a/internal/tui/own_shell_test.go b/internal/tui/own_shell_test.go new file mode 100644 index 0000000..ee13482 --- /dev/null +++ b/internal/tui/own_shell_test.go @@ -0,0 +1,180 @@ +package tui + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// writeOwnShellFile writes the shell functions an own-route session uses. +func writeOwnShellFile(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) + writeConnectUpstreamFunc(f) + writeRotateFunc(f) + writeSimpleCommands(f) + writeSettingsCommands(f) + f.Close() + return path +} + +// fakeCurl records every URL and answers like the control port does for an +// own route. Extra lines are appended verbatim. +func fakeCurl(log string, extra string) string { + return `curl() { for a in "$@"; do case "$a" in http://*) echo "$a" >> "` + log + `";; esac; done + printf 'export SHELLROUTE_UPSTREAM=work\nexport SHELLROUTE_COUNTRY=DE\nexport SHELLROUTE_EXIT_IP=1.2.3.4\nexport SHELLROUTE_SESSION_ID=own-work\nexport SHELLROUTE_PORT=1\n` + extra + `'; } +` +} + +func readLog(t *testing.T, path string) string { + data, _ := os.ReadFile(path) + return string(data) +} + +func TestConnectName_UsesUpstreamEndpoint(t *testing.T) { + sh := writeOwnShellFile(t) + for _, shell := range shells() { + t.Run(shell, func(t *testing.T) { + log := filepath.Join(t.TempDir(), "curl.log") + out := execShell(t, shell, `source `+sh+` + `+fakeCurl(log, "")+` + export SHELLROUTE_CTRL=1 + /connect work + echo "U=$SHELLROUTE_UPSTREAM C=$SHELLROUTE_COUNTRY IP=$SHELLROUTE_EXIT_IP"`) + if !strings.Contains(out, "Connected through work (DE) — exit IP: 1.2.3.4") { + t.Errorf("output:\n%s", out) + } + if !strings.Contains(out, "U=work C=DE IP=1.2.3.4") { + t.Errorf("exports not applied:\n%s", out) + } + if !strings.Contains(readLog(t, log), "/connect?upstream=work") { + t.Errorf("curl log: %q", readLog(t, log)) + } + }) + } +} + +func TestConnectCountry_StillUsesCountryEndpoint(t *testing.T) { + sh := writeOwnShellFile(t) + log := filepath.Join(t.TempDir(), "curl.log") + execShell(t, "bash", `source `+sh+` + `+fakeCurl(log, "")+` + export SHELLROUTE_CTRL=1 + /connect us >/dev/null; true`) + if !strings.Contains(readLog(t, log), "/connect?country=US") { + t.Errorf("curl log: %q", readLog(t, log)) + } +} + +func TestConnectName_RejectsOddCharactersWithoutCalling(t *testing.T) { + sh := writeOwnShellFile(t) + for _, shell := range shells() { + t.Run(shell, func(t *testing.T) { + log := filepath.Join(t.TempDir(), "curl.log") + out := execShell(t, shell, `source `+sh+` + `+fakeCurl(log, "")+` + export SHELLROUTE_CTRL=1 + /connect "bad name"; true`) + if !strings.Contains(out, "Unknown upstream") { + t.Errorf("output:\n%s", out) + } + if readLog(t, log) != "" { + t.Error("no control call should be made for an invalid name") + } + }) + } +} + +func TestConnectName_ShowsProbeWarningWithoutEvaluatingIt(t *testing.T) { + sh := writeOwnShellFile(t) + log := filepath.Join(t.TempDir(), "curl.log") + out := execShell(t, "bash", `source `+sh+` + `+fakeCurl(log, `PROBE_WARNING=unexpected reply from the echo service\n`)+` + export SHELLROUTE_CTRL=1 + /connect work + echo "PW=${PROBE_WARNING:-none}"`) + if !strings.Contains(out, "Exit IP unknown: unexpected reply from the echo service") { + t.Errorf("warning not shown:\n%s", out) + } + if !strings.Contains(out, "PW=none") { + t.Errorf("PROBE_WARNING must not be evaluated into the shell:\n%s", out) + } +} + +func TestRotate_OwnRouteDoesNothing(t *testing.T) { + sh := writeOwnShellFile(t) + for _, shell := range shells() { + t.Run(shell, func(t *testing.T) { + log := filepath.Join(t.TempDir(), "curl.log") + out := execShell(t, shell, `source `+sh+` + `+fakeCurl(log, "")+` + export SHELLROUTE_CTRL=1 SHELLROUTE_SESSION_ID=own-work SHELLROUTE_UPSTREAM=work + /rotate`) + if !strings.Contains(out, "Rotate is only for Shellroute routes; your provider controls rotation.") { + t.Errorf("output:\n%s", out) + } + if readLog(t, log) != "" { + t.Error("/rotate must not call the control port on an own route") + } + }) + } +} + +func TestAccountCommands_OwnRouteNotApplicable(t *testing.T) { + sh := writeOwnShellFile(t) + for _, shell := range shells() { + t.Run(shell, func(t *testing.T) { + log := filepath.Join(t.TempDir(), "curl.log") + out := execShell(t, shell, `source `+sh+` + `+fakeCurl(log, "")+` + export SHELLROUTE_CTRL=1 SHELLROUTE_SESSION_ID=own-work SHELLROUTE_UPSTREAM=work + /balance; /countries; /cities US; /iptype datacenter; /sticky on + echo "T=${SHELLROUTE_IPTYPE:-unset} S=${SHELLROUTE_STICKY:-unset}"`) + if strings.Count(out, "Not applicable to your own upstream.") != 5 { + t.Errorf("want five not-applicable lines:\n%s", out) + } + if !strings.Contains(out, "T=unset S=unset") { + t.Errorf("settings must not change on an own route:\n%s", out) + } + if readLog(t, log) != "" { + t.Error("no control calls expected") + } + }) + } +} + +func TestAccountCommands_HostedRouteStillCall(t *testing.T) { + sh := writeOwnShellFile(t) + log := filepath.Join(t.TempDir(), "curl.log") + execShell(t, "bash", `source `+sh+` + `+fakeCurl(log, "")+` + export SHELLROUTE_CTRL=1 SHELLROUTE_SESSION_ID=sess SHELLROUTE_COUNTRY=US + /balance >/dev/null; /countries >/dev/null; true`) + got := readLog(t, log) + if !strings.Contains(got, "/balance") || !strings.Contains(got, "/countries") { + t.Errorf("hosted route should call the control port: %q", got) + } +} + +func TestCleanup_UnsetsUpstream(t *testing.T) { + sh := writeOwnShellFile(t) + for _, shell := range shells() { + t.Run(shell, func(t *testing.T) { + out := execShell(t, shell, `source `+sh+` + export SHELLROUTE_UPSTREAM=work SHELLROUTE_SESSION_ID=own-work + _sr_cleanup_session + echo "${SHELLROUTE_UPSTREAM:-UNSET} ${SHELLROUTE_SESSION_ID:-UNSET}"`) + if out != "UNSET UNSET" { + t.Errorf("got %q", out) + } + }) + } +} diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 0f1e47a..ad68e59 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -21,16 +21,18 @@ var Version = "dev" // Run starts the interactive shell session. func Run(cfg *config.Config) error { - printBanner() + printBanner(len(cfg.Upstreams) > 0) client := api.New(cfg.APIURL, cfg.APIKey) // Show balance warning on startup (threshold from server) - if bal, err := client.GetBalance(); err == nil { - if bal.BalanceUSD == 0 { - display.Warn("Balance $0.00 — top up to use at https://console.shellroute.com") - } else if bal.LowBalance { - display.Warn("Balance low (%s) — top up at https://console.shellroute.com", display.FormatBalance(bal.BalanceUSD)) + if cfg.HasAuth() { + if bal, err := client.GetBalance(); err == nil { + if bal.BalanceUSD == 0 { + display.Warn("Balance $0.00 — top up to use at https://console.shellroute.com") + } else if bal.LowBalance { + display.Warn("Balance low (%s) — top up at https://console.shellroute.com", display.FormatBalance(bal.BalanceUSD)) + } } } @@ -43,18 +45,22 @@ func Run(cfg *config.Config) error { runShell(ctrlPort, "", cfg.DefaultType) - if resp := ctrl.StopAndDisconnect(); resp != nil { - printSessionSummary(resp) + if resp, own := ctrl.StopAndDisconnect(); resp != nil { + printSessionSummary(resp, own) } return nil } -func printSessionSummary(resp *api.SessionEndResponse) { +func printSessionSummary(resp *api.SessionEndResponse, own bool) { + if own { + display.OwnSessionSummary("Disconnected.", resp.DurationSec, resp.BytesTotal) + return + } display.SessionSummary("Disconnected.", resp.DurationSec, resp.BytesTotal, resp.CostUSD, resp.BalanceUSD) } -func printBanner() { +func printBanner(hasUpstreams bool) { bold := lipgloss.NewStyle().Bold(true).Render("shellroute") ver := lipgloss.NewStyle().Foreground(lipgloss.ANSIColor(8)).Render(" " + Version) dim := lipgloss.NewStyle().Foreground(lipgloss.ANSIColor(8)) @@ -63,6 +69,9 @@ func printBanner() { fmt.Println(" " + bold + ver) fmt.Println() fmt.Println(dim.Render(" /connect Connect and route traffic")) + if hasUpstreams { + fmt.Println(dim.Render(" /connect Route through your own proxy")) + } fmt.Println(dim.Render(" /countries List available countries")) fmt.Println(dim.Render(" /ssh SSH through the proxy")) fmt.Println(dim.Render(" /help All commands")) @@ -224,6 +233,12 @@ _sr_prompt() { _sr_msg=" \033[33m✗ Balance depleted. Top up, then run /connect to reconnect.\033[0m" _SR_KILLSWITCH_SHOWN="depleted" fi + elif [ -n "$SHELLROUTE_UPSTREAM" ]; then + # Own proxy: nothing to rotate to. Say so once. + if [ "$_SR_KILLSWITCH_SHOWN" != "lost" ]; then + _sr_msg=" \033[31m✗ Connection lost through ${SHELLROUTE_UPSTREAM}. Run /connect.\033[0m" + _SR_KILLSWITCH_SHOWN="lost" + fi else # Auto-reconnect on connection lost _sr_msg=" \033[33m⟳ Reconnecting...\033[0m" @@ -256,6 +271,11 @@ _sr_prompt() { PS1="\[\033[33m\]●\[\033[0m\]\[\033[1m\] shellroute\[\033[0m\] · \[\033[33m\]balance depleted\[\033[0m\]\n\[\033[90m\]\w\[\033[0m\] \[\033[33m\]❯\[\033[0m\] " elif [ "$_SR_KILLSWITCH_SHOWN" = "lost" ]; then PS1="\[\033[31m\]●\[\033[0m\]\[\033[1m\] shellroute\[\033[0m\] · \[\033[31m\]connection lost\[\033[0m\]\n\[\033[90m\]\w\[\033[0m\] \[\033[31m\]❯\[\033[0m\] " + elif [ -n "$SHELLROUTE_UPSTREAM" ]; then + local tag="$SHELLROUTE_UPSTREAM" + [ -n "$SHELLROUTE_COUNTRY" ] && tag="${tag} (${SHELLROUTE_COUNTRY})" + local ip="${SHELLROUTE_EXIT_IP:-exit IP unknown}" + PS1="\[\033[32m\]●\[\033[0m\]\[\033[1m\] shellroute\[\033[0m\] \[\033[90m\]${tag} · ${ip}\[\033[0m\]\n\[\033[90m\]\w\[\033[0m\] \[\033[32m\]❯\[\033[0m\] " elif [ -n "$SHELLROUTE_COUNTRY" ]; then local cc=$(echo "$SHELLROUTE_COUNTRY" | tr '[:upper:]' '[:lower:]') local ip="$SHELLROUTE_EXIT_IP" @@ -310,6 +330,8 @@ _sr_prompt() { if [[ "$_SR_KILLSWITCH_SHOWN" != "$_reason" ]]; then if [[ "$_reason" = "depleted" ]]; then print $'\e[33m ✗ Balance depleted. Top up, then run /connect to reconnect.\e[0m' + elif [[ -n "$SHELLROUTE_UPSTREAM" ]]; then + print $'\e[31m ✗ Connection lost through '"${SHELLROUTE_UPSTREAM}"$'. Run /connect.\e[0m' else print $'\e[31m ✗ Connection lost. Run /connect.\e[0m' fi @@ -323,6 +345,8 @@ _sr_prompt() { PROMPT=$'%F{yellow}●%f %Bshellroute%b · %F{yellow}balance depleted%f\n%F{242}%~%f %F{yellow}❯%f ' elif [[ "$_SR_KILLSWITCH_SHOWN" = "lost" ]]; then PROMPT=$'%F{red}●%f %Bshellroute%b · %F{red}connection lost%f\n%F{242}%~%f %F{red}❯%f ' + elif [[ -n "$SHELLROUTE_UPSTREAM" ]]; then + PROMPT=$'%F{green}●%f %Bshellroute%b %F{242}${SHELLROUTE_UPSTREAM}${SHELLROUTE_COUNTRY:+ (${SHELLROUTE_COUNTRY})} · ${SHELLROUTE_EXIT_IP:-exit IP unknown}%f\n%F{242}%~%f %F{green}❯%f ' elif [[ -n "$SHELLROUTE_COUNTRY" ]]; then PROMPT=$'%F{green}●%f %Bshellroute%b %F{242}${(L)SHELLROUTE_COUNTRY} · ${SHELLROUTE_EXIT_IP}%f\n%F{242}%~%f %F{green}❯%f ' else @@ -383,6 +407,19 @@ _sr_accept_line() { return fi + # Own proxy: nothing to rotate to. Block the command and say so. + if [[ -n "$SHELLROUTE_UPSTREAM" ]]; then + if [[ "$_SR_KILLSWITCH_SHOWN" != "lost" ]]; then + _SR_KILLSWITCH_SHOWN="lost" + _SR_HEALTH="dead" + zle -M " ✗ Connection lost through ${SHELLROUTE_UPSTREAM}. Run /connect." + zle reset-prompt + else + zle .accept-line + fi + return + fi + # Auto-reconnect on connection lost zle -M " ⟳ Reconnecting..." zle reset-prompt diff --git a/internal/tui/tui_shell.go b/internal/tui/tui_shell.go index bb70448..d7e144b 100644 --- a/internal/tui/tui_shell.go +++ b/internal/tui/tui_shell.go @@ -9,6 +9,7 @@ func writeShellFunctions(f *os.File) { writeCleanupHelper(f) writeSSHProxyHelper(f) writeConnectFunc(f) + writeConnectUpstreamFunc(f) writeDisconnectFunc(f) writeRotateFunc(f) writeSimpleCommands(f) @@ -23,7 +24,7 @@ func writeCleanupHelper(f *os.File) { 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 + unset SHELLROUTE_SESSION_ID SHELLROUTE_COUNTRY SHELLROUTE_EXIT_IP SHELLROUTE_PORT SHELLROUTE_COUNTRY_NAME SHELLROUTE_CITY SHELLROUTE_UPSTREAM if [ "$_SR_OWNS_NODE_PROXY" = "1" ] && [ "$NODE_USE_ENV_PROXY" = "1" ]; then unset NODE_USE_ENV_PROXY fi @@ -78,9 +79,9 @@ func writeConnectFunc(f *os.File) { local country city_arg iptype sticky_flag connect_msg country=$(echo "$1" | tr '[:lower:]' '[:upper:]' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') if [ ${#country} -ne 2 ]; then - echo -e " \033[90mCountry must be a 2-letter code (e.g. US, GB, DE).\033[0m" - echo -e " \033[90mUsage: /connect or /connect \033[0m" - return 1 + # Not a country code: one of your own saved upstreams. + _sr_connect_upstream "$(echo "$1" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" + return $? fi [[ "$country" = "UK" ]] && country="GB" shift; city_arg="$*" @@ -133,6 +134,39 @@ func writeConnectFunc(f *os.File) { `) } +func writeConnectUpstreamFunc(f *os.File) { + f.WriteString(` +_sr_connect_upstream() { + local name="$1" + case "$name" in + *[!a-z0-9_-]*|"") + echo -e " \033[90mUnknown upstream: ${name}\033[0m" + echo -e " \033[90mUsage: /connect or /connect . Save one with: shellroute upstream add \033[0m" + return 1 ;; + esac + unset _SR_KILLSWITCH_SHOWN + echo " Connecting through ${name}..." + local result + result=$(curl -s --max-time 60 "http://127.0.0.1:${SHELLROUTE_CTRL}/connect?upstream=${name}" 2>&1) + local curl_rc=$? + if [ $curl_rc -ne 0 ] || echo "$result" | grep -q "^ERROR "; then + local err=$(echo "$result" | grep '^ERROR ' | sed 's/^ERROR //') + [ -z "$err" ] && err="Connection timed out. Try again." + echo " ✗ ${err}" + return 1 + fi + local warn=$(echo "$result" | grep '^PROBE_WARNING=' | cut -d= -f2-) + result=$(echo "$result" | grep -v '^PROBE_WARNING=') + eval "$result" + local where="" + [ -n "$SHELLROUTE_COUNTRY" ] && where=" (${SHELLROUTE_COUNTRY})" + echo " ✓ Connected through ${SHELLROUTE_UPSTREAM}${where} — exit IP: ${SHELLROUTE_EXIT_IP:-unknown}" + [ -n "$warn" ] && echo -e " \033[33m⚠ Exit IP unknown: ${warn}\033[0m" + return 0 +} +`) +} + func writeDisconnectFunc(f *os.File) { f.WriteString(` /disconnect() { @@ -161,6 +195,10 @@ func writeRotateFunc(f *os.File) { echo " Not connected." return 1 fi + if [ -n "$SHELLROUTE_UPSTREAM" ]; then + echo " Rotate is only for Shellroute routes; your provider controls rotation." + return 0 + fi unset _SR_KILLSWITCH_SHOWN local iptype="${SHELLROUTE_IPTYPE:-residential}" local rotate_msg=" Rotating to ${SHELLROUTE_COUNTRY}" @@ -200,11 +238,21 @@ func writeSimpleCommands(f *os.File) { curl -sf "http://127.0.0.1:${SHELLROUTE_CTRL}/status" } +_sr_own_route() { + if [ -n "$SHELLROUTE_UPSTREAM" ]; then + echo " Not applicable to your own upstream." + return 0 + fi + return 1 +} + /balance() { + _sr_own_route && return 0 curl -sf "http://127.0.0.1:${SHELLROUTE_CTRL}/balance" } /countries() { + _sr_own_route && return 0 curl -sf "http://127.0.0.1:${SHELLROUTE_CTRL}/countries" } `) @@ -216,6 +264,7 @@ func writeHelpFunc(f *os.File) { echo "" echo " Session commands:" echo " /connect [city] Connect and route traffic" + echo " /connect Route through your own proxy (shellroute upstream add)" echo " /disconnect Disconnect current session" echo " /rotate Rotate to a new IP" echo " /status Show session info" @@ -242,6 +291,14 @@ func writeHelpFunc(f *os.File) { echo " shellroute countries List available countries" echo " shellroute cities List cities in a country" echo "" + echo " Your own proxies (no account needed):" + echo " shellroute upstream add Save a proxy (http, https, socks5, socks5h)" + echo " shellroute upstream list List saved proxies, passwords hidden" + echo " shellroute upstream test Check a proxy and show its exit IP" + echo " shellroute upstream remove Delete a saved proxy" + echo " shellroute run -- Run a command through your proxy" + echo " shellroute proxy --upstream Persistent local proxy over your proxy" + echo "" } `) } @@ -261,6 +318,7 @@ func writeSSHFunc(f *os.File) { func writeSettingsCommands(f *os.File) { f.WriteString(` /iptype() { + _sr_own_route && return 0 if [ -z "$1" ]; then echo " IP type: ${SHELLROUTE_IPTYPE:-residential}" echo -e " \033[90mOptions: residential | datacenter | mix\033[0m" @@ -278,6 +336,7 @@ func writeSettingsCommands(f *os.File) { } /sticky() { + _sr_own_route && return 0 if [ -z "$1" ]; then echo " Sticky: ${SHELLROUTE_STICKY:-off}" return @@ -294,6 +353,7 @@ func writeSettingsCommands(f *os.File) { } /cities() { + _sr_own_route && return 0 if [ -z "$1" ]; then echo -e " \033[90mUsage: /cities (e.g. /cities US)\033[0m" return 1 From d94e9365d3cec0f4a8baf4aa3872d8a19f02e102 Mon Sep 17 00:00:00 2001 From: contra Date: Wed, 14 Oct 2026 21:21:54 +0300 Subject: [PATCH 04/13] feat: bring-your-own-proxy docs, status for own sessions, Docker upstream test layer README gets a 'Use your own proxy' section; docs/bring-your-own-proxy.md covers URL formats, how run picks the route, what stays the same, what is different, failure behavior and troubleshooting by error message; docs/compatibility.md says the matrix applies to both route kinds; the changelog lists 0.2.0 as unreleased. shellroute status lists live own-proxy sessions from the session files and no longer needs a login when only those exist. A SOCKS5 'not allowed by ruleset' after a credentials exchange gets the hint '(check the username and password)': 3proxy answers a wrong password that way. scripts/test-upstreams.sh runs the layer-2 checks C-01 to C-10 and C-14 against 3proxy (HTTP with auth, SOCKS5 with and without auth), gost (SOCKS5 with auth; HTTPS with auth on Linux), squid (open and basic auth) and an ssh -D tunnel, all in Docker on one network with a marker container only the proxies can resolve. CI gets an 'upstreams' job for it. Signed-off-by: contra --- .github/workflows/ci.yml | 11 ++ CHANGELOG.md | 15 ++ README.md | 16 ++ docs/bring-your-own-proxy.md | 104 ++++++++++ docs/compatibility.md | 2 + internal/cli/status.go | 75 +++++++- internal/cli/status_own_test.go | 73 +++++++ internal/proxy/upstream_socks5.go | 8 +- internal/proxy/upstream_test.go | 35 ++++ scripts/test-upstreams.sh | 307 ++++++++++++++++++++++++++++++ scripts/upstreams/marker.py | 61 ++++++ 11 files changed, 703 insertions(+), 4 deletions(-) create mode 100644 docs/bring-your-own-proxy.md create mode 100644 internal/cli/status_own_test.go create mode 100755 scripts/test-upstreams.sh create mode 100755 scripts/upstreams/marker.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 998ba27..4587ea1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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: diff --git a/CHANGELOG.md b/CHANGELOG.md index aa73881..085f457 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 [--country CC] [--note TEXT]`, `upstream list`, `upstream remove `, `upstream test ` +- `shellroute run -- `, `shellroute run --proxy -- `, `SHELLROUTE_PROXY=` for CI +- `shellroute proxy --upstream ` for a persistent local proxy over your own +- `/connect ` 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) and names the cause of a failure: cannot resolve host, connection refused, timeout, proxy rejected credentials, proxy returned a status. `--no-check` skips the exit IP 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. diff --git a/README.md b/README.md index b850d1f..9ec45b6 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,8 @@ shellroute run DE -- curl https://ipinfo.io/json | `shellroute run -- ` | Run one command through the proxy | | `shellroute proxy --country ` | Persistent proxy (blocks until Ctrl+C) | | `shellroute proxy stop` | Stop running proxy sessions | +| `shellroute run -- ` | Run one command through your own proxy | +| `shellroute upstream add\|list\|remove\|test` | Manage your own proxies | #### Local proxy mode @@ -79,6 +81,20 @@ shellroute proxy --country US curl -x http://127.0.0.1:41900 https://ipinfo.io/ip ``` +### Use your own proxy + +Already pay a proxy provider, or run one? Save it and use its name wherever a country goes. No account needed. + +```bash +shellroute upstream add work http://user:pass@proxy.example.com:8080 --country DE +shellroute upstream test work # via work · exit IP 91.198.0.12 (DE) · 212 ms +shellroute run work -- curl https://ipinfo.io/json +shellroute proxy --upstream work +shellroute run --proxy socks5://127.0.0.1:1080 -- curl https://ipinfo.io/json +``` + +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. `http`, `https`, `socks5` and `socks5h` proxies work. [Details.](docs/bring-your-own-proxy.md) + ### Info | Command | Description | diff --git a/docs/bring-your-own-proxy.md b/docs/bring-your-own-proxy.md new file mode 100644 index 0000000..6f58b45 --- /dev/null +++ b/docs/bring-your-own-proxy.md @@ -0,0 +1,104 @@ +# 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 + +```bash +# Save it once. The URL carries the credentials. +shellroute upstream add work http://user:pass@proxy.example.com:8080 --country DE + +# Check it: opens one tunnel and reports the exit IP. +shellroute upstream test work +# ✓ via work · exit IP 91.198.0.12 (DE) · 212 ms + +# Use its name wherever a country goes. +shellroute run work -- curl https://ipinfo.io/json +shellroute proxy --upstream work # persistent local proxy on 127.0.0.1:41900 +shellroute # interactive shell, then: /connect work + +shellroute upstream list # passwords shown as *** +shellroute upstream remove work +``` + +Without saving: + +```bash +shellroute run --proxy socks5://127.0.0.1:1080 -- curl https://ipinfo.io/json +SHELLROUTE_PROXY=http://user:pass@proxy.example.com:8080 shellroute run -- curl https://ipinfo.io/json # CI +shellroute upstream test https://user:pass@proxy.example.com: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 work` and `shellroute run DE` never collide. + +## How `shellroute run` picks the route + +In this order: + +1. `--proxy `: 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. + +`--country` on `upstream add` is a label. Shellroute cannot know where a third-party proxy exits, so the label is shown as `work (DE)` in status output and nowhere verified. + +## 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 comes 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. +- 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 | +| `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 work. 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 "work"`: it is not saved. `shellroute upstream list` shows what is; `shellroute upstream add work ` 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 exit IP in the wrong country: the label is yours, the exit is the provider's. Check the username or port format in the provider's documentation. diff --git a/docs/compatibility.md b/docs/compatibility.md index 3fe530c..f0e94b9 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -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. diff --git a/internal/cli/status.go b/internal/cli/status.go index ba3b293..caf0191 100644 --- a/internal/cli/status.go +++ b/internal/cli/status.go @@ -12,6 +12,7 @@ import ( "github.com/shellroute/shellroute-cli/internal/api" "github.com/shellroute/shellroute-cli/internal/config" "github.com/shellroute/shellroute-cli/internal/display" + "github.com/shellroute/shellroute-cli/internal/session" "github.com/spf13/cobra" ) @@ -37,7 +38,21 @@ func runStatus(cmd *cobra.Command, args []string) error { } cfg, _ := loadConfig() + + // Routes through the user's own proxies live only on this machine. + own := localOwnSessions() + if statusFormat == "human" { + printOwnSessions(cmd, own) + } + if cfg == nil || !cfg.HasAuth() { + if len(own) > 0 { + if statusFormat == "json" { + data, _ := json.MarshalIndent(map[string]interface{}{"own_sessions": own}, "", " ") + fmt.Fprintln(cmd.OutOrStdout(), string(data)) + } + return nil + } display.Error("Not authenticated. Run `shellroute login` or use `--api-key`.") return fmt.Errorf("not authenticated") } @@ -61,8 +76,9 @@ func runStatus(cmd *cobra.Command, args []string) error { if statusFormat == "json" { out := map[string]interface{}{ - "sessions": active, - "balance": nil, + "sessions": active, + "own_sessions": own, + "balance": nil, } if bal != nil { out["balance"] = map[string]interface{}{ @@ -71,7 +87,7 @@ func runStatus(cmd *cobra.Command, args []string) error { } } data, _ := json.MarshalIndent(out, "", " ") - fmt.Println(string(data)) + fmt.Fprintln(cmd.OutOrStdout(), string(data)) return nil } @@ -99,6 +115,59 @@ func runStatus(cmd *cobra.Command, args []string) error { return nil } +// ownSessionInfo is one local session through the user's own proxy. +type ownSessionInfo struct { + PID int `json:"pid"` + Upstream string `json:"upstream"` + Via string `json:"via"` + Country string `json:"country,omitempty"` + ExitIP string `json:"exit_ip,omitempty"` + Port int `json:"port"` + Mode string `json:"mode,omitempty"` + Uptime string `json:"uptime"` +} + +// localOwnSessions lists live own-route sessions from the session files. +func localOwnSessions() []ownSessionInfo { + infos, err := session.LoadActiveSessions() + if err != nil { + return nil + } + var out []ownSessionInfo + for _, s := range infos { + if s.Via == "" { + continue + } + name := s.Upstream + if name == "" { + name = "(url)" + } + out = append(out, ownSessionInfo{ + PID: s.PID, Upstream: name, Via: s.Via, Country: s.Country, ExitIP: s.ExitIP, + Port: s.Port, Mode: s.Mode, Uptime: sessionDuration(s.StartTime), + }) + } + return out +} + +func printOwnSessions(cmd *cobra.Command, own []ownSessionInfo) { + if len(own) == 0 { + return + } + fmt.Fprintln(cmd.ErrOrStderr(), display.Bold(fmt.Sprintf("Own proxy sessions (%d):", len(own)))) + for _, s := range own { + label := s.Upstream + if s.Country != "" { + label += " (" + s.Country + ")" + } + ip := s.ExitIP + if ip == "" { + ip = "exit IP unknown" + } + fmt.Fprintf(cmd.ErrOrStderr(), " PID %d %s %s port %d up %s\n", s.PID, label, ip, s.Port, s.Uptime) + } +} + func runStatusInSession(ctrlPort string) error { resp, err := http.Get(fmt.Sprintf("http://127.0.0.1:%s/status", ctrlPort)) if err != nil { diff --git a/internal/cli/status_own_test.go b/internal/cli/status_own_test.go new file mode 100644 index 0000000..f2645a3 --- /dev/null +++ b/internal/cli/status_own_test.go @@ -0,0 +1,73 @@ +package cli + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/shellroute/shellroute-cli/internal/session" +) + +func writeSessionFile(t *testing.T, dir string, info session.Info) { + t.Helper() + data, _ := json.Marshal(info) + if err := os.WriteFile(filepath.Join(dir, "session-"+itoa(info.PID)+".json"), data, 0600); err != nil { + t.Fatal(err) + } +} + +func itoa(n int) string { + s := "" + if n == 0 { + return "0" + } + for n > 0 { + s = string(rune('0'+n%10)) + s + n /= 10 + } + return s +} + +func TestLocalOwnSessions_ListsOnlyOwnRoutesOfLiveProcesses(t *testing.T) { + dir := withOwnConfig(t) + now := time.Now().Format(time.RFC3339) + // This process is alive: an own route and a hosted route. + writeSessionFile(t, dir, session.Info{PID: os.Getpid(), SessionID: "own-work", Country: "DE", Port: 41900, StartTime: now, Mode: "proxy", Upstream: "work", Via: "http://u:***@proxy.example:8080"}) + hosted := session.Info{PID: os.Getpid(), SessionID: "sess", Country: "US", Port: 41901, StartTime: now} + data, _ := json.Marshal(hosted) + os.WriteFile(filepath.Join(dir, "session-hosted.json"), data, 0600) + + own := localOwnSessions() + if len(own) != 1 { + t.Fatalf("own sessions = %+v, want one", own) + } + if own[0].Upstream != "work" || own[0].Country != "DE" || own[0].Via != "http://u:***@proxy.example:8080" || own[0].Mode != "proxy" { + t.Errorf("own[0] = %+v", own[0]) + } +} + +func TestStatus_WithoutLoginShowsOwnSessions(t *testing.T) { + dir := withOwnConfig(t) + writeSessionFile(t, dir, session.Info{PID: os.Getpid(), SessionID: "own-work", Port: 41900, StartTime: time.Now().Format(time.RFC3339), Mode: "proxy", Upstream: "work", Via: "socks5://h:1080"}) + + out, err := execCLI(t, "status", "--format", "json") + if err != nil { + t.Fatalf("status without login but with an own session: %v", err) + } + if !strings.Contains(out, `"upstream": "work"`) { + t.Errorf("json lacks the own session:\n%s", out) + } + if _, err := execCLI(t, "status"); err != nil { + t.Errorf("human status: %v", err) + } +} + +func TestStatus_WithoutLoginAndNoSessionsStillFails(t *testing.T) { + withOwnConfig(t) + if _, err := execCLI(t, "status"); err == nil || err.Error() != "not authenticated" { + t.Errorf("err = %v", err) + } +} diff --git a/internal/proxy/upstream_socks5.go b/internal/proxy/upstream_socks5.go index 07eb740..60d71b8 100644 --- a/internal/proxy/upstream_socks5.go +++ b/internal/proxy/upstream_socks5.go @@ -129,7 +129,13 @@ func (u *SOCKS5Upstream) handshake(conn net.Conn, host string, port int) error { return &DialError{Kind: KindHandshake, Msg: "not a SOCKS5 proxy"} } if head[1] != 0x00 { - return &DialError{Kind: KindProxyStatus, Status: int(head[1]), Msg: socksReplyText(head[1])} + msg := socksReplyText(head[1]) + if head[1] == 0x02 && wantAuth { + // Some servers accept the credentials exchange and refuse at the + // request instead when the password is wrong. + msg += " (check the username and password)" + } + return &DialError{Kind: KindProxyStatus, Status: int(head[1]), Msg: msg} } // Skip the bound address and port. diff --git a/internal/proxy/upstream_test.go b/internal/proxy/upstream_test.go index e9afdca..e7a8cac 100644 --- a/internal/proxy/upstream_test.go +++ b/internal/proxy/upstream_test.go @@ -268,6 +268,41 @@ func TestSOCKS5Upstream_TargetRefusedIsProxyStatus(t *testing.T) { } } +func TestSOCKS5Upstream_RulesetRefusalAfterAuthHintsAtPassword(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer ln.Close() + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + go func(c net.Conn) { + defer c.Close() + buf := make([]byte, 64) + c.Read(buf) // greeting + c.Write([]byte{0x05, 0x02}) // choose user/pass + c.Read(buf) // credentials + c.Write([]byte{0x01, 0x00}) // "accepted" + c.Read(buf) // CONNECT + c.Write([]byte{0x05, 0x02, 0x00, 0x01, 0, 0, 0, 0, 0, 0}) // not allowed by ruleset + }(conn) + } + }() + up := NewSOCKS5Upstream(ln.Addr().String(), "alice", "wrong", "label") + _, err = up.DialTarget(context.Background(), "example.com:443") + de := dialErr(t, err) + if de.Kind != KindProxyStatus || de.Status != 2 { + t.Fatalf("kind %d status %d", de.Kind, de.Status) + } + if !strings.Contains(Cause(err), "check the username and password") { + t.Errorf("Cause = %q", Cause(err)) + } +} + func TestSOCKS5Upstream_NotASOCKSServer(t *testing.T) { // A server that answers every byte stream with HTTP text. ln, err := net.Listen("tcp", "127.0.0.1:0") diff --git a/scripts/test-upstreams.sh b/scripts/test-upstreams.sh new file mode 100755 index 0000000..83483ff --- /dev/null +++ b/scripts/test-upstreams.sh @@ -0,0 +1,307 @@ +#!/usr/bin/env bash +# Layer 2 of the bring-your-own-proxy test plan: real proxy software in Docker. +# +# Starts 3proxy, gost, squid and an sshd container on one Docker network next +# to a marker server, builds the CLI, runs the check list below through each +# proxy, prints one table and exits non-zero on any FAIL. Nothing leaves the +# machine except image pulls. Requires: docker, python3, openssl, curl, go. +# ssh is optional. Works with bash 3.2 (macOS) and bash 5 (Linux CI). +# +# C-01 upstream test reports the exit IP within 15 s +# C-02 HTTPS via CONNECT: body received, exit 0, reached through the proxy +# C-03 plain HTTP: same +# C-04 wrong password: error names credentials, non-zero, < 15 s, no child +# C-05 unreachable proxy: error names the cause, < 15 s +# C-06 NO_PROXY bypass: a target listed in NO_PROXY is reached directly +# C-07 20 MB download: sha256 matches, metered bytes within 2 percent +# C-08 20 parallel curls all succeed +# C-09 one connection held 90 s (UPSTREAMS_LONG=1; skipped otherwise) +# C-10 child exit code 3 becomes shellroute exit code 3 +# C-14 credentials never leak: child env, upstream list, session file +# C-11 (interactive shell) is covered by the Go shell tests, not here. +# +# How "reached through the proxy" is proven: the marker is a container that +# only the proxies can resolve, by the name "marker" on the shared network. +# The host cannot resolve it, so a request that arrives there went through a +# proxy, whatever source address the platform shows. +set -uo pipefail +cd "$(dirname "$0")/.." + +TMP=$(mktemp -d) +HOME_DIR="$TMP/home" +mkdir -p "$HOME_DIR" +export SHELLROUTE_HOME="$HOME_DIR" +unset SHELLROUTE_API_KEY SHELLROUTE_PROXY +export SHELLROUTE_API_URL="http://127.0.0.1:1" # the API must never be needed +NET=sr-upstreams +CONTAINERS=(sr-marker sr-3proxy sr-gost sr-squid-open sr-squid-auth sr-sshd) +PIDS=() +cleanup() { + for p in "${PIDS[@]:-}"; do [ -n "$p" ] && kill "$p" 2>/dev/null; done + docker rm -f "${CONTAINERS[@]}" >/dev/null 2>&1 + docker network rm "$NET" >/dev/null 2>&1 + rm -rf "$TMP" +} +trap cleanup EXIT + +say() { printf '%s\n' "$*" >&2; } +need() { command -v "$1" >/dev/null 2>&1 || { say "missing: $1"; exit 2; }; } +need docker; need python3; need openssl; need curl; need go +docker info >/dev/null 2>&1 || { say "docker daemon not running"; exit 2; } + +say "== build ==" +go build -o "$TMP/shellroute" ./cmd/shellroute || exit 2 +SR="$TMP/shellroute" + +free_port() { python3 -c 'import socket; s=socket.socket(); s.bind(("127.0.0.1",0)); print(s.getsockname()[1]); s.close()'; } +json_field() { python3 -c 'import sys,json +try: print(json.load(sys.stdin).get(sys.argv[1],"")) +except Exception: print("")' "$1"; } +wait_port() { for _ in $(seq 1 80); do (echo > /dev/tcp/127.0.0.1/"$1") 2>/dev/null && return 0; sleep 0.5; done; return 1; } + +# --- network and marker container --- +say "== start marker ==" +docker rm -f "${CONTAINERS[@]}" >/dev/null 2>&1 +docker network rm "$NET" >/dev/null 2>&1 +docker network create "$NET" >/dev/null || exit 2 +MARK_PORT=$(free_port); MARK_TLS_PORT=$(free_port) +openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes -days 2 \ + -subj "/CN=marker" -addext "subjectAltName=DNS:marker,DNS:localhost,IP:127.0.0.1" \ + -keyout "$TMP/marker.key" -out "$TMP/marker.crt" >/dev/null 2>&1 || exit 2 +chmod 644 "$TMP/marker.key" "$TMP/marker.crt" +docker run -d --name sr-marker --network "$NET" --network-alias marker \ + -p "127.0.0.1:$MARK_PORT:8080" -p "127.0.0.1:$MARK_TLS_PORT:8443" \ + -v "$PWD/scripts/upstreams/marker.py:/marker.py:ro" -v "$TMP/marker.crt:/certs/cert.pem:ro" -v "$TMP/marker.key:/certs/key.pem:ro" \ + python:3-alpine sh -c "python3 /marker.py 8080 & exec python3 /marker.py 8443 /certs/cert.pem /certs/key.pem" >/dev/null || exit 2 +# A published port accepts connections before the container's process listens, +# so wait for an actual answer. +BLOB_SHA="" +for _ in $(seq 1 60); do + BLOB_SHA=$(curl -s --noproxy '*' --max-time 2 "http://127.0.0.1:$MARK_PORT/" | json_field blob_sha256) + [ -n "$BLOB_SHA" ] && break + sleep 0.5 +done +[ -n "$BLOB_SHA" ] || { say "marker did not answer"; docker logs sr-marker >&2; exit 2; } +for _ in $(seq 1 60); do + curl -s --noproxy '*' --max-time 2 --cacert "$TMP/marker.crt" -o /dev/null "https://127.0.0.1:$MARK_TLS_PORT/" && break + sleep 0.5 +done +TARGET="marker:8080" +TARGET_TLS="marker:8443" +if curl -s --noproxy '*' --max-time 3 -o /dev/null "http://$TARGET/"; then + say "the host resolves 'marker' itself; this run cannot prove traversal"; exit 2 +fi + +# A second marker runs on the host for the NO_PROXY check: reached directly it +# sees the host's own address; reached through a proxy container it does not. +HOST_MARK_PORT=$(free_port) +python3 scripts/upstreams/marker.py "$HOST_MARK_PORT" & PIDS+=($!) +LAN_IP=$(python3 -c 'import socket +s=socket.socket(socket.AF_INET, socket.SOCK_DGRAM) +try: + s.connect(("10.255.255.255", 1)); print(s.getsockname()[0]) +except Exception: print("")') +case "$LAN_IP" in 127.*|"") LAN_IP="";; esac + +# --- proxies --- +say "== start proxies ==" +P3_HTTP=$(free_port); P3_SOCKS=$(free_port); P3_SOCKS_OPEN=$(free_port) +cat > "$TMP/3proxy.cfg" << CFG +nscache 65536 +timeouts 1 5 30 60 180 1800 15 60 +users alice:CL:s3cret +log +auth strong +allow alice +proxy -p3128 +socks -p1080 +flush +auth none +allow * +socks -p1081 +CFG +docker run -d --name sr-3proxy --network "$NET" -p "127.0.0.1:$P3_HTTP:3128" -p "127.0.0.1:$P3_SOCKS:1080" -p "127.0.0.1:$P3_SOCKS_OPEN:1081" \ + -v "$TMP/3proxy.cfg:/etc/3proxy/3proxy.cfg:ro" 3proxy/3proxy:lts >/dev/null || exit 2 + +GOST_SOCKS=$(free_port); GOST_HTTPS=$(free_port) +docker run -d --name sr-gost --network "$NET" -p "127.0.0.1:$GOST_SOCKS:1082" -p "127.0.0.1:$GOST_HTTPS:8443" \ + -v "$TMP/marker.crt:/certs/cert.pem:ro" -v "$TMP/marker.key:/certs/key.pem:ro" gogost/gost:latest \ + -L "socks5://carol:pw@:1082" -L "https://bob:pw@:8443?certFile=/certs/cert.pem&keyFile=/certs/key.pem" >/dev/null || exit 2 + +SQUID_OPEN=$(free_port); SQUID_AUTH=$(free_port) +# squid drops to user "proxy" and cannot write /dev/stdout: keep logs off. +cat > "$TMP/squid-open.conf" << CFG +http_port 3129 +acl localnet src all +http_access allow all +cache deny all +access_log none +pid_filename /tmp/squid.pid +CFG +printf 'dave:%s\n' "$(openssl passwd -apr1 pw)" > "$TMP/squid.passwd" +chmod 644 "$TMP/squid.passwd" +cat > "$TMP/squid-auth.conf" << CFG +http_port 3130 +auth_param basic program /usr/lib/squid/basic_ncsa_auth /etc/squid/passwd +auth_param basic realm test +acl authed proxy_auth REQUIRED +http_access allow authed +http_access deny all +cache deny all +access_log none +pid_filename /tmp/squid.pid +CFG +docker run -d --name sr-squid-open --network "$NET" -p "127.0.0.1:$SQUID_OPEN:3129" \ + -v "$TMP/squid-open.conf:/etc/squid/squid.conf:ro" ubuntu/squid:6.6-24.04_edge >/dev/null || exit 2 +docker run -d --name sr-squid-auth --network "$NET" -p "127.0.0.1:$SQUID_AUTH:3130" \ + -v "$TMP/squid-auth.conf:/etc/squid/squid.conf:ro" -v "$TMP/squid.passwd:/etc/squid/passwd:ro" ubuntu/squid:6.6-24.04_edge >/dev/null || exit 2 + +SSH_SOCKS="" +if command -v ssh >/dev/null 2>&1 && command -v ssh-keygen >/dev/null 2>&1; then + SSH_PORT=$(free_port); SSH_SOCKS=$(free_port) + ssh-keygen -q -t ed25519 -N "" -f "$TMP/sshkey" >/dev/null + docker run -d --name sr-sshd --network "$NET" -p "127.0.0.1:$SSH_PORT:2222" -e PUID=1000 -e PGID=1000 \ + -e PUBLIC_KEY="$(cat "$TMP/sshkey.pub")" -e USER_NAME=tester linuxserver/openssh-server:latest >/dev/null || SSH_SOCKS="" +fi + +for p in "$P3_HTTP" "$P3_SOCKS" "$P3_SOCKS_OPEN" "$GOST_SOCKS" "$GOST_HTTPS" "$SQUID_OPEN" "$SQUID_AUTH"; do + wait_port "$p" || { say "port $p did not open"; docker logs --tail 20 sr-3proxy sr-gost sr-squid-open sr-squid-auth >&2 2>&1; exit 2; } +done +if [ -n "$SSH_SOCKS" ]; then + if wait_port "$SSH_PORT"; then + sleep 3 + # The image disables TCP forwarding by default; ssh -D needs it. + docker exec sr-sshd sh -c 'f=/config/sshd/sshd_config; grep -q "^AllowTcpForwarding" "$f" && sed -i "s/^AllowTcpForwarding.*/AllowTcpForwarding yes/" "$f" || echo "AllowTcpForwarding yes" >> "$f"' >/dev/null 2>&1 + docker restart sr-sshd >/dev/null 2>&1 + wait_port "$SSH_PORT" && sleep 3 + ssh -i "$TMP/sshkey" -p "$SSH_PORT" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR \ + -N -D "127.0.0.1:$SSH_SOCKS" tester@127.0.0.1 & PIDS+=($!) + wait_port "$SSH_SOCKS" || SSH_SOCKS="" + else + SSH_SOCKS="" + fi +fi + +# --- check helpers (bash 3.2 compatible: results live in a TSV file) --- +RESULTS="$TMP/results.tsv"; : > "$RESULTS" +PROXIES=() +CHECKS=(C-01 C-02 C-03 C-04 C-05 C-06 C-07 C-08 C-09 C-10 C-14) +FAILS=0 +mark() { + printf '%s\t%s\t%s\n' "$1" "$2" "$3" >> "$RESULTS" + if [ "$3" = FAIL ]; then FAILS=$((FAILS+1)); say " FAIL $1 $2: ${4:-}"; fi + return 0 +} +diag() { [ -n "${1:-}" ] && docker logs --tail 8 "$1" 2>&1 | sed 's/^/ | /' >&2; return 0; } +lookup() { awk -F'\t' -v p="$1" -v c="$2" '$1==p && $2==c {r=$3} END {print (r==""?"?":r)}' "$RESULTS"; } +timed() { local start; start=$(date +%s); "$@"; local rc=$?; echo "$(( $(date +%s) - start ))" > "$TMP/elapsed"; return $rc; } +# reached_marker : the request got the marker's answer, which only a proxy can reach +reached_marker() { [ "$1" -eq 0 ] && echo "$2" | grep -q shellroute-upstream-test; } + +run_checks() { + local name="$1" url="$2" container="$3" + PROXIES+=("$name") + rm -f "$HOME_DIR/config.toml" + "$SR" upstream add work "$url" >/dev/null 2>&1 || { for c in "${CHECKS[@]}"; do mark "$name" "$c" FAIL "upstream add failed"; done; return; } + local out rc body src + # C-01 + out=$(timed "$SR" upstream test work 2>&1); rc=$? + if [ $rc -eq 0 ] && echo "$out" | grep -q "exit IP" && [ "$(cat "$TMP/elapsed")" -le 15 ]; then mark "$name" C-01 PASS; else mark "$name" C-01 FAIL "$out"; diag "$container"; fi + # C-02 HTTPS via CONNECT + body=$("$SR" run work --no-stat -- curl -sS --cacert "$TMP/marker.crt" "https://$TARGET_TLS/https" 2>"$TMP/err"); rc=$? + if reached_marker "$rc" "$body"; then mark "$name" C-02 PASS; else mark "$name" C-02 FAIL "rc=$rc $(echo "$body" | head -c 120) $(tail -2 "$TMP/err")"; diag "$container"; fi + # C-03 plain HTTP + body=$("$SR" run work --no-stat -- curl -sS "http://$TARGET/plain" 2>"$TMP/err"); rc=$? + if reached_marker "$rc" "$body"; then mark "$name" C-03 PASS; else mark "$name" C-03 FAIL "rc=$rc $(echo "$body" | head -c 120) $(tail -2 "$TMP/err")"; diag "$container"; fi + # C-04 wrong password (skipped for proxies without auth) + if [[ "$url" == *@* ]]; then + local bad; bad=$(echo "$url" | sed -E 's#://([^:@]+):[^@]*@#://\1:WRONG@#') + rm -f "$TMP/child-ran" + out=$(timed "$SR" run --proxy "$bad" --no-stat -- touch "$TMP/child-ran" 2>&1); rc=$? + if [ $rc -ne 0 ] && echo "$out" | grep -qiE "credentials|password" && [ ! -e "$TMP/child-ran" ] && [ "$(cat "$TMP/elapsed")" -le 15 ]; then mark "$name" C-04 PASS; else mark "$name" C-04 FAIL "rc=$rc $out"; fi + else + mark "$name" C-04 SKIP + fi + # C-05 unreachable proxy host (same scheme, closed port) + local scheme=${url%%://*} + out=$(timed "$SR" run --proxy "$scheme://127.0.0.1:1" --no-stat -- true 2>&1); rc=$? + if [ $rc -ne 0 ] && echo "$out" | grep -qiE "refused|timeout|resolve" && [ "$(cat "$TMP/elapsed")" -le 15 ]; then mark "$name" C-05 PASS; else mark "$name" C-05 FAIL "rc=$rc $out"; fi + # C-06 NO_PROXY bypass: the host marker on the LAN address, listed in NO_PROXY, is reached directly + if [ -n "$LAN_IP" ]; then + body=$(NO_PROXY="$LAN_IP" "$SR" run work --no-stat --no-check -- curl -sS --max-time 10 "http://$LAN_IP:$HOST_MARK_PORT/direct" 2>"$TMP/err"); rc=$? + src=$(echo "$body" | json_field source) + if [ $rc -eq 0 ] && [ "$src" = "$LAN_IP" ]; then mark "$name" C-06 PASS; else mark "$name" C-06 FAIL "rc=$rc src=$src want $LAN_IP"; fi + else + mark "$name" C-06 SKIP + fi + # C-07 20 MB download: sha256 and metered bytes + "$SR" run work --no-check -- curl -sS -o "$TMP/blob" "http://$TARGET/blob" 2>"$TMP/err"; rc=$? + local sha; sha=$(shasum -a 256 "$TMP/blob" 2>/dev/null | cut -d' ' -f1) + local metered; metered=$(grep -oE "Data used:[^0-9]*[0-9.]+ [KMG]?B" "$TMP/err" | tail -1) + local mb; mb=$(echo "$metered" | grep -oE "[0-9.]+ MB" | grep -oE "[0-9.]+") + if [ $rc -eq 0 ] && [ "$sha" = "$BLOB_SHA" ] && [ -n "$mb" ] && python3 -c "import sys; mb=float('$mb'); sys.exit(0 if 19.6 <= mb <= 20.4 else 1)"; then mark "$name" C-07 PASS; else mark "$name" C-07 FAIL "rc=$rc sha_ok=$([ "$sha" = "$BLOB_SHA" ] && echo y || echo n) metered='$metered'"; fi + # C-08 20 parallel curls + out=$("$SR" run work --no-stat --no-check -- bash -c 'for i in $(seq 1 20); do curl -sS -f -o /dev/null "http://'"$TARGET"'/par$i" & done; wait; echo done' 2>&1); rc=$? + if [ $rc -eq 0 ] && echo "$out" | grep -q done; then mark "$name" C-08 PASS; else mark "$name" C-08 FAIL "rc=$rc $out"; fi + # C-09 long-lived connection + if [ "${UPSTREAMS_LONG:-0}" = "1" ]; then + out=$("$SR" run work --no-stat --no-check -- curl -sS -f -o /dev/null --limit-rate 220k --max-time 150 "http://$TARGET/blob" 2>&1); rc=$? + if [ $rc -eq 0 ]; then mark "$name" C-09 PASS; else mark "$name" C-09 FAIL "rc=$rc $out"; fi + else + mark "$name" C-09 SKIP + fi + # C-10 child exit code + "$SR" run work --no-stat --no-check -- sh -c 'exit 3' >/dev/null 2>&1; rc=$? + if [ $rc -eq 3 ]; then mark "$name" C-10 PASS; else mark "$name" C-10 FAIL "rc=$rc"; fi + # C-14 credentials never leak + local pass=""; [[ "$url" == *@* ]] && pass=$(echo "$url" | sed -E 's#.*://[^:@]+:([^@]*)@.*#\1#') + local leak=0 + out=$("$SR" run work --no-stat --no-check -- env 2>&1) + echo "$out" | grep -E '^(HTTP|HTTPS|http|https)_(PROXY|proxy)=' | grep -qv '127.0.0.1' && leak=1 + [ -n "$pass" ] && echo "$out" | grep -q "$pass" && leak=1 + "$SR" upstream list 2>&1 | { [ -n "$pass" ] && grep -q "$pass"; } && leak=1 + "$SR" proxy --upstream work --no-check --format json >"$TMP/proxy.json" 2>/dev/null & local ppid=$! + sleep 2 + cat "$HOME_DIR"/session-*.json 2>/dev/null | { [ -n "$pass" ] && grep -q "$pass"; } && leak=1 + # A background job of a non-interactive shell ignores SIGINT; SIGTERM stops the proxy. + kill -TERM $ppid 2>/dev/null + for _ in $(seq 1 20); do kill -0 $ppid 2>/dev/null || break; sleep 0.5; done + kill -KILL $ppid 2>/dev/null; wait $ppid 2>/dev/null + grep -q "proxy_url" "$TMP/proxy.json" || leak=1 + if [ $leak -eq 0 ]; then mark "$name" C-14 PASS; else mark "$name" C-14 FAIL "credential or env leak"; fi +} + +say "== checks ==" +[ -n "$LAN_IP" ] || say " no LAN address found: C-06 is skipped" +run_checks "3proxy http+auth" "http://alice:s3cret@127.0.0.1:$P3_HTTP" sr-3proxy +run_checks "3proxy socks5+auth" "socks5://alice:s3cret@127.0.0.1:$P3_SOCKS" sr-3proxy +run_checks "3proxy socks5 open" "socks5://127.0.0.1:$P3_SOCKS_OPEN" sr-3proxy +run_checks "gost socks5+auth" "socks5h://carol:pw@127.0.0.1:$GOST_SOCKS" sr-gost +if [ "$(uname -s)" = "Linux" ]; then + # Go honours SSL_CERT_FILE on Linux, so the proxy's self-signed cert can be trusted for the run. + SSL_CERT_FILE="$TMP/marker.crt" run_checks "gost https+auth" "https://bob:pw@127.0.0.1:$GOST_HTTPS" sr-gost +else + PROXIES+=("gost https+auth"); for c in "${CHECKS[@]}"; do mark "gost https+auth" "$c" SKIP; done + say " SKIP gost https+auth: Go on macOS ignores SSL_CERT_FILE, so a self-signed proxy certificate cannot be trusted for the run" +fi +run_checks "squid http open" "http://127.0.0.1:$SQUID_OPEN" sr-squid-open +run_checks "squid http+auth" "http://dave:pw@127.0.0.1:$SQUID_AUTH" sr-squid-auth +if [ -n "$SSH_SOCKS" ]; then + run_checks "ssh -D socks5" "socks5://127.0.0.1:$SSH_SOCKS" sr-sshd +else + PROXIES+=("ssh -D socks5"); for c in "${CHECKS[@]}"; do mark "ssh -D socks5" "$c" SKIP; done +fi + +# --- table --- +echo +printf '| %-18s |' "Proxy"; for c in "${CHECKS[@]}"; do printf ' %s |' "$c"; done; echo +printf '|%s|' "$(printf -- '-%.0s' $(seq 1 20))"; for c in "${CHECKS[@]}"; do printf '%s|' "------"; done; echo +for p in "${PROXIES[@]}"; do + printf '| %-18s |' "$p" + for c in "${CHECKS[@]}"; do printf ' %-4s |' "$(lookup "$p" "$c")"; done + echo +done +echo +echo "$("$SR" --version 2>/dev/null | head -1) · $(uname -s) $(uname -m) · docker $(docker version --format '{{.Server.Version}}' 2>/dev/null) · $(date -u +%Y-%m-%d)" +echo "C-09 runs with UPSTREAMS_LONG=1. C-11 (interactive shell) is covered by the Go shell tests." +[ "$FAILS" -eq 0 ] diff --git a/scripts/upstreams/marker.py b/scripts/upstreams/marker.py new file mode 100755 index 0000000..872142b --- /dev/null +++ b/scripts/upstreams/marker.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +"""Marker server for scripts/test-upstreams.sh. + +Answers every request with JSON naming the client address it saw and a fixed +marker, so a check can tell a direct request (source is the host) from one that +came through a proxy container (source is the container). /blob returns a +20 MB deterministic body for the large-download check. + +Usage: marker.py [certfile keyfile] +""" +import hashlib +import json +import ssl +import sys +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +BLOB = (b"shellroute-upstream-test-blob-" * 1000)[:20 * 1024 * 1024 // 1 ] +BLOB = (BLOB * ((20 * 1024 * 1024) // len(BLOB) + 1))[:20 * 1024 * 1024] +BLOB_SHA = hashlib.sha256(BLOB).hexdigest() + + +class Handler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def log_message(self, *args): + pass + + def do_GET(self): + if self.path.startswith("/blob"): + self.send_response(200) + self.send_header("Content-Type", "application/octet-stream") + self.send_header("Content-Length", str(len(BLOB))) + self.end_headers() + self.wfile.write(BLOB) + return + body = json.dumps({ + "source": self.client_address[0], + "marker": "shellroute-upstream-test", + "path": self.path, + "host": self.headers.get("Host", ""), + "blob_sha256": BLOB_SHA, + }).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + +def main(): + port = int(sys.argv[1]) + srv = ThreadingHTTPServer(("0.0.0.0", port), Handler) + if len(sys.argv) > 3: + ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + ctx.load_cert_chain(sys.argv[2], sys.argv[3]) + srv.socket = ctx.wrap_socket(srv.socket, server_side=True) + srv.serve_forever() + + +if __name__ == "__main__": + main() From 3d14813cc7e76d142e7a46dd80fddaf9e41ac8b0 Mon Sep 17 00:00:00 2001 From: contra Date: Wed, 14 Oct 2026 21:50:10 +0300 Subject: [PATCH 05/13] test: run the Docker proxy layer from scripts/run-tests.sh; parse the colored summary run-tests.sh gets an 'Own proxies in Docker' stage that runs scripts/test-upstreams.sh when a Docker daemon is up and reports SKIP otherwise. The byte check (C-07) now strips terminal color codes before reading the 'Data used' line; on a terminal the reset code after the label contains a digit and broke the pattern, so the check failed although the download and its checksum were fine. Failure lines use the 'FAIL:' shape the gate's summary looks for. Signed-off-by: contra --- scripts/run-tests.sh | 10 ++++++++++ scripts/test-upstreams.sh | 5 +++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh index 9ef3869..d30a813 100755 --- a/scripts/run-tests.sh +++ b/scripts/run-tests.sh @@ -33,6 +33,16 @@ run_stage "Go unit tests" go test -race ./... run_stage "Public audit" bash scripts/audit-public.sh +# Own proxies: real proxy software in Docker (3proxy, gost, squid, ssh -D). +# Needs a running Docker daemon; skipped, not failed, without one. +if docker info >/dev/null 2>&1; then + run_stage "Own proxies in Docker" bash scripts/test-upstreams.sh +else + echo "=== Own proxies in Docker ===" + echo "--- Own proxies in Docker: SKIP (Docker daemon not running) ---" + echo "" +fi + # Cross-compile check — catch platform-specific build failures run_stage "Cross-compile darwin/arm64" bash -c 'GOOS=darwin GOARCH=arm64 go build ./cmd/shellroute' run_stage "Cross-compile darwin/amd64" bash -c 'GOOS=darwin GOARCH=amd64 go build ./cmd/shellroute' diff --git a/scripts/test-upstreams.sh b/scripts/test-upstreams.sh index 83483ff..b271f75 100755 --- a/scripts/test-upstreams.sh +++ b/scripts/test-upstreams.sh @@ -189,7 +189,7 @@ CHECKS=(C-01 C-02 C-03 C-04 C-05 C-06 C-07 C-08 C-09 C-10 C-14) FAILS=0 mark() { printf '%s\t%s\t%s\n' "$1" "$2" "$3" >> "$RESULTS" - if [ "$3" = FAIL ]; then FAILS=$((FAILS+1)); say " FAIL $1 $2: ${4:-}"; fi + if [ "$3" = FAIL ]; then FAILS=$((FAILS+1)); say " FAIL: $1 $2 ${4:-}"; fi return 0 } diag() { [ -n "${1:-}" ] && docker logs --tail 8 "$1" 2>&1 | sed 's/^/ | /' >&2; return 0; } @@ -237,7 +237,8 @@ run_checks() { # C-07 20 MB download: sha256 and metered bytes "$SR" run work --no-check -- curl -sS -o "$TMP/blob" "http://$TARGET/blob" 2>"$TMP/err"; rc=$? local sha; sha=$(shasum -a 256 "$TMP/blob" 2>/dev/null | cut -d' ' -f1) - local metered; metered=$(grep -oE "Data used:[^0-9]*[0-9.]+ [KMG]?B" "$TMP/err" | tail -1) + # The summary is colored on a terminal; strip the escape codes before parsing. + local metered; metered=$(perl -pe 's/\e\[[0-9;]*m//g' "$TMP/err" | grep -oE "Data used: *[0-9.]+ [KMG]?B" | tail -1) local mb; mb=$(echo "$metered" | grep -oE "[0-9.]+ MB" | grep -oE "[0-9.]+") if [ $rc -eq 0 ] && [ "$sha" = "$BLOB_SHA" ] && [ -n "$mb" ] && python3 -c "import sys; mb=float('$mb'); sys.exit(0 if 19.6 <= mb <= 20.4 else 1)"; then mark "$name" C-07 PASS; else mark "$name" C-07 FAIL "rc=$rc sha_ok=$([ "$sha" = "$BLOB_SHA" ] && echo y || echo n) metered='$metered'"; fi # C-08 20 parallel curls From e3ad34dbba12f0c622852c53f454891165bfaba7 Mon Sep 17 00:00:00 2001 From: contra Date: Thu, 15 Oct 2026 10:46:01 +0300 Subject: [PATCH 06/13] feat(upstream): show the observed exit country instead of a typed label upstream add loses --country. A typed country was shown as 'work (DE)' as if Shellroute knew where the proxy exits; it does not. The start-up probe now asks the exit IP service for JSON (ipinfo.io/json, then ipify) and records the country it reports for the observed exit IP. upstream test, run, status, the shell prompt and the session file show it as 'DE (observed)', or nothing when the service does not report one. The session file is rewritten after the probe so status and stop see the observed values. Signed-off-by: contra --- CHANGELOG.md | 4 +- README.md | 4 +- docs/bring-your-own-proxy.md | 12 +++--- internal/cli/connect.go | 3 +- internal/cli/run.go | 2 +- internal/cli/run_upstream.go | 13 +++---- internal/cli/run_upstream_test.go | 19 ++++++---- internal/cli/status.go | 11 +++--- internal/cli/upstream.go | 28 +++++--------- internal/cli/upstream_test.go | 20 +++------- internal/config/upstream.go | 9 +++-- internal/config/upstream_test.go | 10 ++--- internal/proxy/probe.go | 56 +++++++++++++++++++++++----- internal/proxy/upstream_test.go | 39 +++++++++++++++++-- internal/session/control.go | 10 ++--- internal/session/control_own.go | 2 +- internal/session/control_own_test.go | 10 ++--- internal/session/own_test.go | 26 ++++++++----- internal/session/session.go | 15 +++++--- internal/tui/own_shell_test.go | 2 +- internal/tui/tui.go | 4 +- internal/tui/tui_shell.go | 4 +- 22 files changed, 183 insertions(+), 120 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 085f457..5a6e336 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,13 +4,13 @@ Bring your own proxy. Route a command or the interactive shell through a proxy you already have, with no Shellroute account. -- `shellroute upstream add [--country CC] [--note TEXT]`, `upstream list`, `upstream remove `, `upstream test ` +- `shellroute upstream add [--note TEXT]`, `upstream list`, `upstream remove `, `upstream test ` - `shellroute run -- `, `shellroute run --proxy -- `, `SHELLROUTE_PROXY=` for CI - `shellroute proxy --upstream ` for a persistent local proxy over your own - `/connect ` 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) and names the cause of a failure: cannot resolve host, connection refused, timeout, proxy rejected credentials, proxy returned a status. `--no-check` skips the exit IP probe +- 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 diff --git a/README.md b/README.md index 9ec45b6..de97840 100644 --- a/README.md +++ b/README.md @@ -86,8 +86,8 @@ curl -x http://127.0.0.1:41900 https://ipinfo.io/ip Already pay a proxy provider, or run one? Save it and use its name wherever a country goes. No account needed. ```bash -shellroute upstream add work http://user:pass@proxy.example.com:8080 --country DE -shellroute upstream test work # via work · exit IP 91.198.0.12 (DE) · 212 ms +shellroute upstream add work http://user:pass@proxy.example.com:8080 +shellroute upstream test work # via work · exit IP 91.198.0.12 · DE (observed) · 212 ms shellroute run work -- curl https://ipinfo.io/json shellroute proxy --upstream work shellroute run --proxy socks5://127.0.0.1:1080 -- curl https://ipinfo.io/json diff --git a/docs/bring-your-own-proxy.md b/docs/bring-your-own-proxy.md index 6f58b45..88d692a 100644 --- a/docs/bring-your-own-proxy.md +++ b/docs/bring-your-own-proxy.md @@ -6,11 +6,11 @@ Route a command or an interactive shell through a proxy you already have. No She ```bash # Save it once. The URL carries the credentials. -shellroute upstream add work http://user:pass@proxy.example.com:8080 --country DE +shellroute upstream add work http://user:pass@proxy.example.com:8080 -# Check it: opens one tunnel and reports the exit IP. +# Check it: opens one tunnel and reports the exit IP and where it appears to be. shellroute upstream test work -# ✓ via work · exit IP 91.198.0.12 (DE) · 212 ms +# ✓ via work · exit IP 91.198.0.12 · DE (observed) · 212 ms # Use its name wherever a country goes. shellroute run work -- curl https://ipinfo.io/json @@ -55,7 +55,7 @@ In this order: 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. -`--country` on `upstream add` is a label. Shellroute cannot know where a third-party proxy exits, so the label is shown as `work (DE)` in status output and nowhere verified. +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 @@ -66,7 +66,7 @@ Your proxy's password never reaches the child process, its environment, your she ## What is different - No login, no balance, no cost line. The end-of-run summary shows duration and data only. -- The exit IP comes 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. +- 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. @@ -101,4 +101,4 @@ A `4xx` from the proxy for one target is forwarded to the client and does not co - `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 exit IP in the wrong country: the label is yours, the exit is the provider's. Check the username or port format in the provider's documentation. +- `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. diff --git a/internal/cli/connect.go b/internal/cli/connect.go index afa58b3..c5f34f1 100644 --- a/internal/cli/connect.go +++ b/internal/cli/connect.go @@ -138,7 +138,7 @@ func runConnectOwn(cfg *config.Config) error { ctx, cancel := context.WithCancel(context.Background()) sigs := NewSignalHandler(cancel, syscall.SIGINT, syscall.SIGTERM) - sess, err := session.StartOwn(ctx, target.upstream, target.name, target.label, port, + sess, err := session.StartOwn(ctx, target.upstream, target.name, port, session.StartOpts{Mode: "proxy", SkipExitIP: connectNoCheck}) if err != nil { sigs.Stop() @@ -255,6 +255,7 @@ func outputJSON(sess *session.Session) { } data["upstream"] = who data["type"] = "own" + data["country_observed"] = sess.Country != "" } enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") diff --git a/internal/cli/run.go b/internal/cli/run.go index b8be26d..fbc6b7e 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -171,7 +171,7 @@ func runOwn(cfg *config.Config, target runTarget, args []string) error { ctx, cancel := context.WithCancel(context.Background()) sigs := NewSignalHandler(cancel, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP) - sess, err := session.StartOwn(ctx, target.upstream, target.name, target.label, port, + sess, err := session.StartOwn(ctx, target.upstream, target.name, port, session.StartOpts{TrackRelays: true, Mode: "run", SkipExitIP: runNoCheck}) if err != nil { sigs.Stop() diff --git a/internal/cli/run_upstream.go b/internal/cli/run_upstream.go index 7cae0be..afbcb6a 100644 --- a/internal/cli/run_upstream.go +++ b/internal/cli/run_upstream.go @@ -30,7 +30,6 @@ type runTarget struct { country string // hosted route; "" for an own route upstream proxy.Upstream // own route name string // saved upstream name; "" for --proxy or the environment - label string // own route: country label from the saved entry, unverified } func (t runTarget) own() bool { return t.upstream != nil } @@ -105,7 +104,7 @@ func resolveRunTarget(cfg *config.Config, args []string, dashIdx int, proxyFlag, if err != nil { return runTarget{}, nil, fmt.Errorf("saved upstream %q has an invalid URL (%v). Remove it and add it again", first, err) } - return runTarget{upstream: up, name: first, label: entry.Country}, rest, nil + return runTarget{upstream: up, name: first}, rest, nil } if envProxy != "" { @@ -133,7 +132,7 @@ func resolveUpstreamRef(cfg *config.Config, ref string) (runTarget, error) { if err != nil { return runTarget{}, fmt.Errorf("saved upstream %q has an invalid URL (%v). Remove it and add it again", ref, err) } - return runTarget{upstream: up, name: ref, label: entry.Country}, nil + return runTarget{upstream: up, name: ref}, nil } if strings.Contains(ref, "://") { up, err := proxy.ParseUpstreamURL(ref) @@ -166,7 +165,8 @@ func ownRouteSelected(cmd *cobra.Command, args []string) bool { return false } -// viaLine formats the own-route status line: via work · exit IP 1.2.3.4 (DE) · 212 ms. +// viaLine formats the own-route status line: +// via work · exit IP 1.2.3.4 · DE (observed) · 212 ms. func viaLine(sess *session.Session) string { who := sess.UpstreamName if who == "" { @@ -174,11 +174,10 @@ func viaLine(sess *session.Session) string { } parts := []string{"via " + who} if ip := sess.GetExitIP(); ip != "" { - s := "exit IP " + ip + parts = append(parts, "exit IP "+ip) if sess.Country != "" { - s += " (" + sess.Country + ")" + parts = append(parts, sess.Country+" (observed)") } - parts = append(parts, s) } if sess.ConnectMs > 0 { parts = append(parts, fmt.Sprintf("%d ms", sess.ConnectMs)) diff --git a/internal/cli/run_upstream_test.go b/internal/cli/run_upstream_test.go index 1c42c53..9d27715 100644 --- a/internal/cli/run_upstream_test.go +++ b/internal/cli/run_upstream_test.go @@ -13,7 +13,7 @@ import ( func cfgWithUpstream() *config.Config { cfg := config.Default() - cfg.AddUpstream(config.Upstream{Name: "work", URL: "http://u:p@proxy.example:8080", Country: "DE"}) + cfg.AddUpstream(config.Upstream{Name: "work", URL: "http://u:p@proxy.example:8080"}) cfg.AddUpstream(config.Upstream{Name: "broken", URL: "ftp://x:1"}) return cfg } @@ -34,15 +34,14 @@ func TestResolveRunTarget(t *testing.T) { wantCountry string wantOwn bool wantName string - wantLabel string wantRest []string wantErr string }{ {name: "positional country", cfg: cfg, args: []string{"US", "curl", "x"}, dash: 1, wantCountry: "US", wantRest: []string{"curl", "x"}}, {name: "positional country without dash", cfg: cfg, args: []string{"US", "curl"}, dash: -1, wantCountry: "US", wantRest: []string{"curl"}}, {name: "country flag", cfg: cfg, args: []string{"curl"}, dash: 0, country: "DE", wantCountry: "DE", wantRest: []string{"curl"}}, - {name: "saved name", cfg: cfg, args: []string{"work", "curl"}, dash: 1, wantOwn: true, wantName: "work", wantLabel: "DE", wantRest: []string{"curl"}}, - {name: "saved name without dash", cfg: cfg, args: []string{"work", "curl"}, dash: -1, wantOwn: true, wantName: "work", wantLabel: "DE", wantRest: []string{"curl"}}, + {name: "saved name", cfg: cfg, args: []string{"work", "curl"}, dash: 1, wantOwn: true, wantName: "work", wantRest: []string{"curl"}}, + {name: "saved name without dash", cfg: cfg, args: []string{"work", "curl"}, dash: -1, wantOwn: true, wantName: "work", wantRest: []string{"curl"}}, {name: "unknown word without dash is the command", cfg: cfg, args: []string{"curl", "x"}, dash: -1, wantErr: "country required"}, {name: "unknown name before dash", cfg: cfg, args: []string{"nope", "curl"}, dash: 1, wantErr: "unknown upstream \"nope\""}, {name: "saved name with invalid URL", cfg: cfg, args: []string{"broken", "curl"}, dash: 1, wantErr: "invalid URL"}, @@ -69,8 +68,8 @@ func TestResolveRunTarget(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if got.own() != tc.wantOwn || got.country != tc.wantCountry || got.name != tc.wantName || got.label != tc.wantLabel { - t.Errorf("target = own:%v country:%q name:%q label:%q", got.own(), got.country, got.name, got.label) + if got.own() != tc.wantOwn || got.country != tc.wantCountry || got.name != tc.wantName { + t.Errorf("target = own:%v country:%q name:%q", got.own(), got.country, got.name) } if strings.Join(rest, " ") != strings.Join(tc.wantRest, " ") { t.Errorf("rest = %v, want %v", rest, tc.wantRest) @@ -81,7 +80,7 @@ func TestResolveRunTarget(t *testing.T) { func TestResolveUpstreamRef(t *testing.T) { cfg := cfgWithUpstream() - if got, err := resolveUpstreamRef(cfg, "work"); err != nil || got.name != "work" || got.label != "DE" { + if got, err := resolveUpstreamRef(cfg, "work"); err != nil || got.name != "work" { t.Errorf("saved name: %+v %v", got, err) } if got, err := resolveUpstreamRef(cfg, "https://h:443"); err != nil || got.name != "" || !got.own() { @@ -138,9 +137,13 @@ func TestOwnRouteSelected(t *testing.T) { func TestViaLine(t *testing.T) { s := &session.Session{Own: true, UpstreamName: "work", Country: "DE", ConnectMs: 212} s.SetExitIP("91.198.0.1") - if got := viaLine(s); got != "via work · exit IP 91.198.0.1 (DE) · 212 ms" { + if got := viaLine(s); got != "via work · exit IP 91.198.0.1 · DE (observed) · 212 ms" { t.Errorf("viaLine = %q", got) } + s.Country = "" + if got := viaLine(s); got != "via work · exit IP 91.198.0.1 · 212 ms" { + t.Errorf("viaLine without country = %q", got) + } s2 := &session.Session{Own: true, Via: "socks5://h:1080"} if got := viaLine(s2); got != "via socks5://h:1080" { t.Errorf("viaLine without IP = %q", got) diff --git a/internal/cli/status.go b/internal/cli/status.go index caf0191..a05a21e 100644 --- a/internal/cli/status.go +++ b/internal/cli/status.go @@ -120,7 +120,7 @@ type ownSessionInfo struct { PID int `json:"pid"` Upstream string `json:"upstream"` Via string `json:"via"` - Country string `json:"country,omitempty"` + Country string `json:"country_observed,omitempty"` ExitIP string `json:"exit_ip,omitempty"` Port int `json:"port"` Mode string `json:"mode,omitempty"` @@ -156,15 +156,14 @@ func printOwnSessions(cmd *cobra.Command, own []ownSessionInfo) { } fmt.Fprintln(cmd.ErrOrStderr(), display.Bold(fmt.Sprintf("Own proxy sessions (%d):", len(own)))) for _, s := range own { - label := s.Upstream - if s.Country != "" { - label += " (" + s.Country + ")" - } ip := s.ExitIP if ip == "" { ip = "exit IP unknown" } - fmt.Fprintf(cmd.ErrOrStderr(), " PID %d %s %s port %d up %s\n", s.PID, label, ip, s.Port, s.Uptime) + if s.Country != "" { + ip += " " + s.Country + } + fmt.Fprintf(cmd.ErrOrStderr(), " PID %d %s %s port %d up %s\n", s.PID, s.Upstream, ip, s.Port, s.Uptime) } } diff --git a/internal/cli/upstream.go b/internal/cli/upstream.go index 14104a4..b22095b 100644 --- a/internal/cli/upstream.go +++ b/internal/cli/upstream.go @@ -12,10 +12,7 @@ import ( "github.com/spf13/cobra" ) -var ( - upstreamAddCountry string - upstreamAddNote string -) +var upstreamAddNote string var upstreamCmd = &cobra.Command{ Use: "upstream", @@ -23,14 +20,15 @@ var upstreamCmd = &cobra.Command{ Long: `Save proxies you already have and route through them with no Shellroute account. Save one, then use its name wherever a country goes: - shellroute upstream add work http://user:pass@proxy.example.com:8080 --country DE + shellroute upstream add work http://user:pass@proxy.example.com:8080 shellroute run work -- curl https://ipinfo.io/ip shellroute proxy --upstream work shellroute # then /connect work 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.`, +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{ @@ -62,7 +60,6 @@ var upstreamTestCmd = &cobra.Command{ } func init() { - upstreamAddCmd.Flags().StringVar(&upstreamAddCountry, "country", "", "Country label shown in status (not verified)") upstreamAddCmd.Flags().StringVar(&upstreamAddNote, "note", "", "Free-text note") upstreamCmd.AddCommand(upstreamAddCmd, upstreamListCmd, upstreamRemoveCmd, upstreamTestCmd) } @@ -75,18 +72,11 @@ func runUpstreamAdd(cmd *cobra.Command, args []string) error { if _, err := proxy.ParseUpstreamURL(rawURL); err != nil { return err } - country := "" - if upstreamAddCountry != "" { - country = config.NormalizeCountry(upstreamAddCountry) - if err := config.ValidateCountry(country); err != nil { - return err - } - } cfg, err := loadConfig() if err != nil { return err } - replaced := cfg.AddUpstream(config.Upstream{Name: name, URL: strings.TrimSpace(rawURL), Country: country, Note: upstreamAddNote}) + replaced := cfg.AddUpstream(config.Upstream{Name: name, URL: strings.TrimSpace(rawURL), Note: upstreamAddNote}) if err := config.Save(cfg); err != nil { return err } @@ -109,9 +99,9 @@ func runUpstreamList(cmd *cobra.Command, args []string) error { } rows := make([][]string, 0, len(cfg.Upstreams)) for _, u := range cfg.Upstreams { - rows = append(rows, []string{u.Name, proxy.RedactURL(u.URL), u.Country, u.Note}) + rows = append(rows, []string{u.Name, proxy.RedactURL(u.URL), u.Note}) } - headers := []string{"NAME", "URL", "COUNTRY", "NOTE"} + headers := []string{"NAME", "URL", "NOTE"} widths := make([]int, len(headers)) for i, h := range headers { widths[i] = len(h) @@ -166,8 +156,8 @@ func runUpstreamTest(cmd *cobra.Command, args []string) error { switch { case err == nil: where := "" - if t.label != "" { - where = " (" + t.label + ")" + if res.Country != "" { + where = " · " + res.Country + " (observed)" } display.Success("via %s · exit IP %s%s · %d ms", t.describe(), res.ExitIP, where, res.Latency.Milliseconds()) return nil diff --git a/internal/cli/upstream_test.go b/internal/cli/upstream_test.go index ebc5991..9368dc3 100644 --- a/internal/cli/upstream_test.go +++ b/internal/cli/upstream_test.go @@ -41,9 +41,9 @@ func execCLI(t *testing.T, args ...string) (string, error) { func withExitIPEndpoints(t *testing.T, endpoints ...string) { t.Helper() - old := proxy.ExitIPEndpoints - proxy.ExitIPEndpoints = endpoints - t.Cleanup(func() { proxy.ExitIPEndpoints = old }) + old := proxy.ExitInfoEndpoints + proxy.ExitInfoEndpoints = endpoints + t.Cleanup(func() { proxy.ExitInfoEndpoints = old }) } func ipEcho(t *testing.T, body string) *httptest.Server { @@ -102,18 +102,10 @@ func TestUpstreamAdd_RejectsBadURLs(t *testing.T) { } } -func TestUpstreamAdd_RejectsBadCountryLabel(t *testing.T) { - withOwnConfig(t) - _, err := execCLI(t, "upstream", "add", "work", "http://proxy.example:8080", "--country", "XYZ") - if err == nil || !strings.Contains(err.Error(), "2 letters") { - t.Errorf("err = %v", err) - } -} - func TestUpstreamAddListRemove(t *testing.T) { dir := withOwnConfig(t) - if _, err := execCLI(t, "upstream", "add", "work", "http://alice:s3cret@proxy.example:8080", "--country", "de", "--note", "office"); err != nil { + if _, err := execCLI(t, "upstream", "add", "work", "http://alice:s3cret@proxy.example:8080", "--note", "office"); err != nil { t.Fatalf("add: %v", err) } if _, err := execCLI(t, "upstream", "add", "home", "socks5://home.example:1080"); err != nil { @@ -124,7 +116,7 @@ func TestUpstreamAddListRemove(t *testing.T) { if err != nil { t.Fatalf("list: %v", err) } - for _, want := range []string{"NAME", "work", "http://alice:***@proxy.example:8080", "DE", "office", "home", "socks5://home.example:1080"} { + for _, want := range []string{"NAME", "work", "http://alice:***@proxy.example:8080", "office", "home", "socks5://home.example:1080"} { if !strings.Contains(out, want) { t.Errorf("list output lacks %q:\n%s", want, out) } @@ -201,7 +193,7 @@ func TestUpstreamTest_CredentialsError(t *testing.T) { func TestUpstreamTest_URLSuccess(t *testing.T) { withOwnConfig(t) p := proxytest.StartHTTPProxy(t, "", "") - withExitIPEndpoints(t, ipEcho(t, "203.0.113.5").URL) + withExitIPEndpoints(t, ipEcho(t, `{"ip": "203.0.113.5", "country": "LT"}`).URL) if _, err := execCLI(t, "upstream", "test", "http://"+p.Addr); err != nil { t.Errorf("test by URL: %v", err) } diff --git a/internal/config/upstream.go b/internal/config/upstream.go index ab2d492..6cb47e4 100644 --- a/internal/config/upstream.go +++ b/internal/config/upstream.go @@ -7,11 +7,12 @@ import ( // Upstream is a saved proxy of the user's own, stored in config.toml as a // [[upstream]] table. URL carries the credentials; the file is mode 0600. +// There is no country field on purpose: where a third-party proxy exits is +// observed at run time, not declared. type Upstream struct { - Name string `toml:"name"` - URL string `toml:"url"` - Country string `toml:"country,omitempty"` // display label only; not verified - Note string `toml:"note,omitempty"` + Name string `toml:"name"` + URL string `toml:"url"` + Note string `toml:"note,omitempty"` } // Upstream names are 3 to 32 characters of lowercase letters, digits, diff --git a/internal/config/upstream_test.go b/internal/config/upstream_test.go index 501d744..45d95c8 100644 --- a/internal/config/upstream_test.go +++ b/internal/config/upstream_test.go @@ -36,7 +36,7 @@ func TestValidateUpstreamName(t *testing.T) { func TestUpstreamAddFindRemove(t *testing.T) { cfg := Default() - if replaced := cfg.AddUpstream(Upstream{Name: "work", URL: "http://u:p@h:8080", Country: "DE"}); replaced { + if replaced := cfg.AddUpstream(Upstream{Name: "work", URL: "http://u:p@h:8080", Note: "office"}); replaced { t.Error("first add must not report a replacement") } if replaced := cfg.AddUpstream(Upstream{Name: "home", URL: "socks5://h:1080"}); replaced { @@ -49,7 +49,7 @@ func TestUpstreamAddFindRemove(t *testing.T) { t.Fatalf("len = %d, want 2", len(cfg.Upstreams)) } u, ok := cfg.FindUpstream("work") - if !ok || u.URL != "http://u:new@h:8080" || u.Country != "" { + if !ok || u.URL != "http://u:new@h:8080" || u.Note != "" { t.Errorf("FindUpstream(work) = %+v, %v", u, ok) } if _, ok := cfg.FindUpstream("nope"); ok { @@ -69,7 +69,7 @@ func TestUpstreamsRoundTripThroughConfigFile(t *testing.T) { t.Setenv("SHELLROUTE_API_KEY", "") cfg := Default() - cfg.AddUpstream(Upstream{Name: "work", URL: "http://alice:s3cret@proxy.example:8080", Country: "DE", Note: "office"}) + cfg.AddUpstream(Upstream{Name: "work", URL: "http://alice:s3cret@proxy.example:8080", Note: "office"}) cfg.AddUpstream(Upstream{Name: "home", URL: "socks5h://home.example:1080"}) if err := Save(cfg); err != nil { t.Fatal(err) @@ -96,11 +96,11 @@ func TestUpstreamsRoundTripThroughConfigFile(t *testing.T) { t.Fatalf("loaded %d upstreams, want 2:\n%s", len(loaded.Upstreams), raw) } w, ok := loaded.FindUpstream("work") - if !ok || w.URL != "http://alice:s3cret@proxy.example:8080" || w.Country != "DE" || w.Note != "office" { + if !ok || w.URL != "http://alice:s3cret@proxy.example:8080" || w.Note != "office" { t.Errorf("work = %+v", w) } h, _ := loaded.FindUpstream("home") - if h == nil || h.Country != "" || h.Note != "" { + if h == nil || h.Note != "" { t.Errorf("home = %+v", h) } } diff --git a/internal/proxy/probe.go b/internal/proxy/probe.go index 155bf45..5f21995 100644 --- a/internal/proxy/probe.go +++ b/internal/proxy/probe.go @@ -2,6 +2,7 @@ package proxy import ( "context" + "encoding/json" "errors" "fmt" "io" @@ -11,21 +12,57 @@ import ( "time" ) -// ExitIPEndpoints are the services asked for the public IP of a route. +// ExitIPEndpoints are the plain-text services asked for the public IP of a +// hosted route. var ExitIPEndpoints = []string{ "https://ipinfo.io/ip", "https://api.ipify.org", } -// ProbeResult is what Probe learned about an upstream. +// ExitInfoEndpoints answer with JSON naming the caller's public IP ("ip") and, +// when the service knows it, the country ("country"). Used for routes through +// the user's own proxy, where nothing else can tell us where the exit is. +var ExitInfoEndpoints = []string{ + "https://ipinfo.io/json", + "https://api.ipify.org?format=json", +} + +// ProbeResult is what Probe learned about an upstream. Country is what the +// exit IP service reported for the exit IP: a measurement, often right, +// sometimes wrong, and empty when the service does not say. type ProbeResult struct { ExitIP string + Country string Latency time.Duration } -// Probe opens a tunnel through the upstream and asks an IP echo service for -// the exit IP. A *DialError means the tunnel could not be opened; any other -// error means the tunnel opened but no service returned a usable IP. +// exitInfo is the JSON shape shared by the exit info services. +type exitInfo struct { + IP string `json:"ip"` + Country string `json:"country"` +} + +// parseExitInfo accepts a JSON body with "ip" and optional "country", or a +// plain-text IP. +func parseExitInfo(body []byte) (ip, country string) { + var info exitInfo + if err := json.Unmarshal(body, &info); err == nil { + if net.ParseIP(strings.TrimSpace(info.IP)) != nil { + return strings.TrimSpace(info.IP), strings.ToUpper(strings.TrimSpace(info.Country)) + } + return "", "" + } + text := strings.TrimSpace(string(body)) + if net.ParseIP(text) != nil { + return text, "" + } + return "", "" +} + +// Probe opens a tunnel through the upstream and asks an exit info service for +// the exit IP and its country. A *DialError means the tunnel could not be +// opened; any other error means the tunnel opened but no service returned a +// usable IP. func Probe(ctx context.Context, up Upstream, timeout time.Duration) (*ProbeResult, error) { ctx, cancel := context.WithTimeout(ctx, timeout) defer cancel() @@ -42,7 +79,7 @@ func Probe(ctx context.Context, up Upstream, timeout time.Duration) (*ProbeResul start := time.Now() var lastErr error - for _, endpoint := range ExitIPEndpoints { + for _, endpoint := range ExitInfoEndpoints { req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) if err != nil { lastErr = err @@ -57,11 +94,10 @@ func Probe(ctx context.Context, up Upstream, timeout time.Duration) (*ProbeResul lastErr = err continue } - body, _ := io.ReadAll(io.LimitReader(resp.Body, 256)) + body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) resp.Body.Close() - ip := strings.TrimSpace(string(body)) - if net.ParseIP(ip) != nil { - return &ProbeResult{ExitIP: ip, Latency: time.Since(start)}, nil + if ip, country := parseExitInfo(body); ip != "" { + return &ProbeResult{ExitIP: ip, Country: country, Latency: time.Since(start)}, nil } lastErr = fmt.Errorf("unexpected reply from %s", endpoint) } diff --git a/internal/proxy/upstream_test.go b/internal/proxy/upstream_test.go index e7a8cac..3a8247b 100644 --- a/internal/proxy/upstream_test.go +++ b/internal/proxy/upstream_test.go @@ -814,9 +814,42 @@ func (d *directUpstream) Describe() string { return "direct" } func withExitIPEndpoints(t *testing.T, endpoints ...string) { t.Helper() - old := ExitIPEndpoints - ExitIPEndpoints = endpoints - t.Cleanup(func() { ExitIPEndpoints = old }) + old := ExitInfoEndpoints + ExitInfoEndpoints = endpoints + t.Cleanup(func() { ExitInfoEndpoints = old }) +} + +func TestProbe_ReadsCountryFromJSON(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + io.WriteString(w, `{"ip": "203.0.113.7", "city": "Vilnius", "country": "lt"}`) + })) + defer ts.Close() + withExitIPEndpoints(t, ts.URL) + + res, err := Probe(context.Background(), &directUpstream{}, 5*time.Second) + if err != nil { + t.Fatal(err) + } + if res.ExitIP != "203.0.113.7" || res.Country != "LT" { + t.Errorf("res = %+v, want IP 203.0.113.7 and country LT", res) + } +} + +func TestParseExitInfo(t *testing.T) { + cases := []struct{ body, ip, country string }{ + {`{"ip":"1.2.3.4","country":"DE"}`, "1.2.3.4", "DE"}, + {`{"ip":"1.2.3.4"}`, "1.2.3.4", ""}, + {"1.2.3.4\n", "1.2.3.4", ""}, + {`{"ip":"not-an-ip"}`, "", ""}, + {"", "", ""}, + {`{"country":"DE"}`, "", ""}, + } + for _, tc := range cases { + ip, country := parseExitInfo([]byte(tc.body)) + if ip != tc.ip || country != tc.country { + t.Errorf("parseExitInfo(%q) = %q,%q want %q,%q", tc.body, ip, country, tc.ip, tc.country) + } + } } func TestProbe_ReadsExitIPThroughUpstream(t *testing.T) { diff --git a/internal/session/control.go b/internal/session/control.go index fdfb414..bdb44d2 100644 --- a/internal/session/control.go +++ b/internal/session/control.go @@ -760,16 +760,16 @@ func (c *Controller) httpStatus(w http.ResponseWriter, r *http.Request) { uptime := time.Since(c.sess.StartTime).Truncate(time.Second) if c.sess.Own { - label := c.sess.UpstreamName + fmt.Fprintf(w, " Upstream: %s\n", c.sess.UpstreamName) + fmt.Fprintf(w, " Via: %s\n", c.sess.Via) + fmt.Fprintf(w, " Exit IP: %s\n", c.sess.GetExitIP()) if c.sess.Country != "" { - label += " (" + c.sess.Country + ")" + fmt.Fprintf(w, " Country: %s (observed)\n", c.sess.Country) } - fmt.Fprintf(w, " Upstream: %s\n", label) - fmt.Fprintf(w, " Via: %s\n", c.sess.Via) } else { fmt.Fprintf(w, " Country: %s\n", c.sess.Country) + fmt.Fprintf(w, " Exit IP: %s\n", c.sess.GetExitIP()) } - fmt.Fprintf(w, " Exit IP: %s\n", c.sess.GetExitIP()) fmt.Fprintf(w, " Health: %s\n", health) fmt.Fprintf(w, " Uptime: %s\n", uptime) } diff --git a/internal/session/control_own.go b/internal/session/control_own.go index e0b8dc5..470e0b0 100644 --- a/internal/session/control_own.go +++ b/internal/session/control_own.go @@ -29,7 +29,7 @@ func (c *Controller) httpConnectOwn(w http.ResponseWriter, name string) { c.mu.Unlock() // Start on a temp port; the old session keeps running until the new one works. - sess, err := StartOwn(context.Background(), up, name, entry.Country, 0, StartOpts{}) + sess, err := StartOwn(context.Background(), up, name, 0, StartOpts{}) if err != nil { if proxy.TunnelFailed(err) { fmt.Fprintf(w, "ERROR Cannot connect through %s: %s\n", name, proxy.Cause(err)) diff --git a/internal/session/control_own_test.go b/internal/session/control_own_test.go index eb5eacc..6253659 100644 --- a/internal/session/control_own_test.go +++ b/internal/session/control_own_test.go @@ -18,10 +18,10 @@ func ownController(t *testing.T) (*Controller, *proxytest.HTTPProxy) { t.Setenv("SHELLROUTE_HOME", t.TempDir()) p := proxytest.StartHTTPProxy(t, "u", "p") cfg := &config.Config{} - cfg.AddUpstream(config.Upstream{Name: "work", URL: "http://u:p@" + p.Addr, Country: "DE"}) + cfg.AddUpstream(config.Upstream{Name: "work", URL: "http://u:p@" + p.Addr}) cfg.AddUpstream(config.Upstream{Name: "dead", URL: "http://127.0.0.1:1"}) ctrl := NewController(api.New("http://127.0.0.1:1", ""), cfg) - withExitIPEndpoints(t, echoIP(t, "203.0.113.4").URL) + withExitIPEndpoints(t, echoIP(t, `{"ip": "203.0.113.4", "country": "DE"}`).URL) t.Cleanup(func() { ctrl.StopAndDisconnect() }) return ctrl, p } @@ -151,13 +151,13 @@ func TestStatusOwn_ShowsUpstreamNotPassword(t *testing.T) { w := httptest.NewRecorder() ctrl.httpStatus(w, httptest.NewRequest("GET", "/status", nil)) body := w.Body.String() - for _, want := range []string{"Upstream: work (DE)", "Via: http://u:***@", "Exit IP: 203.0.113.4", "Health: healthy"} { + for _, want := range []string{"Upstream: work", "Via: http://u:***@", "Exit IP: 203.0.113.4", "Country: DE (observed)", "Health: healthy"} { if !strings.Contains(body, want) { t.Errorf("status lacks %q:\n%s", want, body) } } - if strings.Contains(body, "u:p@") || strings.Contains(body, "Country:") { - t.Errorf("status wrong:\n%s", body) + if strings.Contains(body, "u:p@") { + t.Errorf("status leaks the password:\n%s", body) } } diff --git a/internal/session/own_test.go b/internal/session/own_test.go index e6e69d7..16f4a2c 100644 --- a/internal/session/own_test.go +++ b/internal/session/own_test.go @@ -35,9 +35,9 @@ func (f *failingUpstream) Describe() string { return "failing" } func withExitIPEndpoints(t *testing.T, endpoints ...string) { t.Helper() - old := proxy.ExitIPEndpoints - proxy.ExitIPEndpoints = endpoints - t.Cleanup(func() { proxy.ExitIPEndpoints = old }) + old := proxy.ExitInfoEndpoints + proxy.ExitInfoEndpoints = endpoints + t.Cleanup(func() { proxy.ExitInfoEndpoints = old }) } func echoIP(t *testing.T, body string) *httptest.Server { @@ -52,15 +52,15 @@ func echoIP(t *testing.T, body string) *httptest.Server { func TestStartOwn_NoAPIAndStop(t *testing.T) { dir := t.TempDir() t.Setenv("SHELLROUTE_HOME", dir) - withExitIPEndpoints(t, echoIP(t, "203.0.113.9\n").URL) + withExitIPEndpoints(t, echoIP(t, `{"ip": "203.0.113.9", "country": "DE"}`).URL) up := &directUpstream{} - sess, err := StartOwn(context.Background(), up, "work", "DE", 0, StartOpts{Mode: "run", TrackRelays: true}) + sess, err := StartOwn(context.Background(), up, "work", 0, StartOpts{Mode: "run", TrackRelays: true}) if err != nil { t.Fatalf("StartOwn: %v", err) } if !sess.Own || sess.UpstreamName != "work" || sess.Country != "DE" { - t.Errorf("session = %+v", sess) + t.Errorf("session = %+v (country must be the observed one)", sess) } if sess.GetExitIP() != "203.0.113.9" { t.Errorf("exit IP = %q", sess.GetExitIP()) @@ -102,7 +102,7 @@ func TestStartOwn_TunnelFailureReturnsDialError(t *testing.T) { t.Setenv("SHELLROUTE_HOME", dir) withExitIPEndpoints(t, "http://127.0.0.1:1/ip") - _, err := StartOwn(context.Background(), &failingUpstream{kind: proxy.KindDial}, "work", "", 0, StartOpts{}) + _, err := StartOwn(context.Background(), &failingUpstream{kind: proxy.KindDial}, "work", 0, StartOpts{}) if !proxy.TunnelFailed(err) { t.Fatalf("err = %v, want tunnel failure", err) } @@ -116,7 +116,7 @@ func TestStartOwn_ProbeFailureStillStartsWithWarning(t *testing.T) { t.Setenv("SHELLROUTE_HOME", t.TempDir()) withExitIPEndpoints(t, echoIP(t, "not an ip").URL) - sess, err := StartOwn(context.Background(), &directUpstream{}, "", "", 0, StartOpts{}) + sess, err := StartOwn(context.Background(), &directUpstream{}, "", 0, StartOpts{}) if err != nil { t.Fatalf("StartOwn should succeed when only the echo service misbehaves: %v", err) } @@ -137,7 +137,7 @@ func TestStartOwn_SkipExitIPMakesNoProbe(t *testing.T) { withExitIPEndpoints(t, "http://127.0.0.1:1/ip") up := &directUpstream{} - sess, err := StartOwn(context.Background(), up, "work", "DE", 0, StartOpts{SkipExitIP: true}) + sess, err := StartOwn(context.Background(), up, "work", 0, StartOpts{SkipExitIP: true}) if err != nil { t.Fatal(err) } @@ -153,14 +153,20 @@ func TestStartOwn_ProxyRoutesThroughUpstream(t *testing.T) { withExitIPEndpoints(t, target.URL) up := &directUpstream{} - sess, err := StartOwn(context.Background(), up, "work", "", 0, StartOpts{}) + sess, err := StartOwn(context.Background(), up, "work", 0, StartOpts{}) if err != nil { t.Fatal(err) } defer sess.Stop() + if sess.Country != "" { + t.Errorf("plain-text echo gives no country, got %q", sess.Country) + } // A request through the local proxy must go out through the upstream. before := up.calls.Load() + old := proxy.ExitIPEndpoints + proxy.ExitIPEndpoints = []string{target.URL} + defer func() { proxy.ExitIPEndpoints = old }() if ip := detectExitIP(context.Background(), sess.Port); ip != "198.51.100.1" { t.Fatalf("exit IP via local proxy = %q", ip) } diff --git a/internal/session/session.go b/internal/session/session.go index 4fa722f..ddffff3 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -35,7 +35,8 @@ type Session struct { LowBalance bool // Own is set when the route is the user's own proxy: no API calls, no - // billing, Country holds the user's label. + // billing. Country then holds the country the exit IP service reported + // for the observed exit IP, or "" when unknown. Own bool UpstreamName string // saved upstream name, "" for an ad-hoc URL Via string // redacted upstream description for status output @@ -208,14 +209,14 @@ func startWithResponse(ctx context.Context, client *api.Client, resp *api.Sessio // StartOwn starts the local proxy over the user's own upstream. No account, // no API call, no billing. name is the saved upstream name ("" for an ad-hoc -// URL); label is the user's country label for display, which Shellroute -// cannot verify. +// URL). // // Unless opts.SkipExitIP is set, the upstream is checked first: one tunnel // attempt and one retry, each bounded by the dial timeout. A tunnel failure // returns the *proxy.DialError; when the tunnel opened but no exit IP could be -// read, the session starts and ProbeWarning says why. -func StartOwn(ctx context.Context, up proxy.Upstream, name, label string, port int, opts StartOpts) (*Session, error) { +// read, the session starts and ProbeWarning says why. The exit IP and the +// country the exit info service reports for it are recorded as observed. +func StartOwn(ctx context.Context, up proxy.Upstream, name string, port int, opts StartOpts) (*Session, error) { port, err := reservePort(port) if err != nil { return nil, err @@ -239,7 +240,6 @@ func StartOwn(ctx context.Context, up proxy.Upstream, name, label string, port i } s := &Session{ ID: "own-" + id, - Country: label, Mode: opts.Mode, Own: true, UpstreamName: name, @@ -269,7 +269,10 @@ func StartOwn(ctx context.Context, up proxy.Upstream, name, label string, port i switch { case err == nil: s.SetExitIP(res.ExitIP) + s.Country = res.Country s.ConnectMs = int(res.Latency.Milliseconds()) + // Record what was observed so status and stop can show it. + s.writeInfoFile() case proxy.TunnelFailed(err): cancel() s.removeInfoFile() diff --git a/internal/tui/own_shell_test.go b/internal/tui/own_shell_test.go index ee13482..0b8fe49 100644 --- a/internal/tui/own_shell_test.go +++ b/internal/tui/own_shell_test.go @@ -49,7 +49,7 @@ func TestConnectName_UsesUpstreamEndpoint(t *testing.T) { export SHELLROUTE_CTRL=1 /connect work echo "U=$SHELLROUTE_UPSTREAM C=$SHELLROUTE_COUNTRY IP=$SHELLROUTE_EXIT_IP"`) - if !strings.Contains(out, "Connected through work (DE) — exit IP: 1.2.3.4") { + if !strings.Contains(out, "Connected through work — exit IP: 1.2.3.4 (DE, observed)") { t.Errorf("output:\n%s", out) } if !strings.Contains(out, "U=work C=DE IP=1.2.3.4") { diff --git a/internal/tui/tui.go b/internal/tui/tui.go index ad68e59..500aa7d 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -273,8 +273,8 @@ _sr_prompt() { PS1="\[\033[31m\]●\[\033[0m\]\[\033[1m\] shellroute\[\033[0m\] · \[\033[31m\]connection lost\[\033[0m\]\n\[\033[90m\]\w\[\033[0m\] \[\033[31m\]❯\[\033[0m\] " elif [ -n "$SHELLROUTE_UPSTREAM" ]; then local tag="$SHELLROUTE_UPSTREAM" - [ -n "$SHELLROUTE_COUNTRY" ] && tag="${tag} (${SHELLROUTE_COUNTRY})" local ip="${SHELLROUTE_EXIT_IP:-exit IP unknown}" + [ -n "$SHELLROUTE_COUNTRY" ] && ip="${ip} · ${SHELLROUTE_COUNTRY}" PS1="\[\033[32m\]●\[\033[0m\]\[\033[1m\] shellroute\[\033[0m\] \[\033[90m\]${tag} · ${ip}\[\033[0m\]\n\[\033[90m\]\w\[\033[0m\] \[\033[32m\]❯\[\033[0m\] " elif [ -n "$SHELLROUTE_COUNTRY" ]; then local cc=$(echo "$SHELLROUTE_COUNTRY" | tr '[:upper:]' '[:lower:]') @@ -346,7 +346,7 @@ _sr_prompt() { elif [[ "$_SR_KILLSWITCH_SHOWN" = "lost" ]]; then PROMPT=$'%F{red}●%f %Bshellroute%b · %F{red}connection lost%f\n%F{242}%~%f %F{red}❯%f ' elif [[ -n "$SHELLROUTE_UPSTREAM" ]]; then - PROMPT=$'%F{green}●%f %Bshellroute%b %F{242}${SHELLROUTE_UPSTREAM}${SHELLROUTE_COUNTRY:+ (${SHELLROUTE_COUNTRY})} · ${SHELLROUTE_EXIT_IP:-exit IP unknown}%f\n%F{242}%~%f %F{green}❯%f ' + PROMPT=$'%F{green}●%f %Bshellroute%b %F{242}${SHELLROUTE_UPSTREAM} · ${SHELLROUTE_EXIT_IP:-exit IP unknown}${SHELLROUTE_COUNTRY:+ · ${SHELLROUTE_COUNTRY}}%f\n%F{242}%~%f %F{green}❯%f ' elif [[ -n "$SHELLROUTE_COUNTRY" ]]; then PROMPT=$'%F{green}●%f %Bshellroute%b %F{242}${(L)SHELLROUTE_COUNTRY} · ${SHELLROUTE_EXIT_IP}%f\n%F{242}%~%f %F{green}❯%f ' else diff --git a/internal/tui/tui_shell.go b/internal/tui/tui_shell.go index d7e144b..4aff317 100644 --- a/internal/tui/tui_shell.go +++ b/internal/tui/tui_shell.go @@ -159,8 +159,8 @@ _sr_connect_upstream() { result=$(echo "$result" | grep -v '^PROBE_WARNING=') eval "$result" local where="" - [ -n "$SHELLROUTE_COUNTRY" ] && where=" (${SHELLROUTE_COUNTRY})" - echo " ✓ Connected through ${SHELLROUTE_UPSTREAM}${where} — exit IP: ${SHELLROUTE_EXIT_IP:-unknown}" + [ -n "$SHELLROUTE_COUNTRY" ] && where=" (${SHELLROUTE_COUNTRY}, observed)" + echo " ✓ Connected through ${SHELLROUTE_UPSTREAM} — exit IP: ${SHELLROUTE_EXIT_IP:-unknown}${where}" [ -n "$warn" ] && echo -e " \033[33m⚠ Exit IP unknown: ${warn}\033[0m" return 0 } From 91daf834d1d4e65824d2f20ce31426bde6fcc017 Mon Sep 17 00:00:00 2001 From: contra Date: Fri, 16 Oct 2026 16:07:05 +0300 Subject: [PATCH 07/13] docs: start the own-proxy provider compatibility table with the Evomi datacenter result Signed-off-by: contra --- docs/upstream-compatibility.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 docs/upstream-compatibility.md diff --git a/docs/upstream-compatibility.md b/docs/upstream-compatibility.md new file mode 100644 index 0000000..8d11d59 --- /dev/null +++ b/docs/upstream-compatibility.md @@ -0,0 +1,25 @@ +# 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 and account details are not recorded. Exit IPs are not recorded either; the pool changes. + +| Date | Provider | Plan | Scheme | Endpoint | Result | Notes | +|---|---|---|---|---|---|---| +| 2026-10-16 | Evomi | datacenter trial | http | `dcp.evomi.com:2000` | pass | Tunnel opened on the first attempt (about 0.4 s). Exits rotate per connection: the probe observed the US, the next request left from the Philippines. `store.google.com` answered 301. Wrong password: "proxy rejected credentials", command not started. Summary shows duration and data only. | + +## 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 work +shellroute upstream test work +shellroute run work -- curl -sS https://ipinfo.io/json +shellroute run work -- curl -sS -I https://store.google.com/ +shellroute run --proxy -- true # must be refused +``` + +Add a row with the date, provider, plan, scheme and endpoint. Record what happened, never the credentials. From 56300672d241c80f2386d6862f211f0c195508b6 Mon Sep 17 00:00:00 2001 From: contra Date: Fri, 16 Oct 2026 16:10:33 +0300 Subject: [PATCH 08/13] docs: add the Webshare free-plan result to the provider compatibility table Signed-off-by: contra --- docs/upstream-compatibility.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/upstream-compatibility.md b/docs/upstream-compatibility.md index 8d11d59..1b31c34 100644 --- a/docs/upstream-compatibility.md +++ b/docs/upstream-compatibility.md @@ -6,6 +6,7 @@ Usernames, passwords and account details are not recorded. Exit IPs are not reco | Date | Provider | Plan | Scheme | Endpoint | Result | Notes | |---|---|---|---|---|---|---| +| 2026-10-16 | Webshare | free plan | http | an individual proxy address from the dashboard list, port as listed | pass | Fixed exit: the probe and every request observed the same IP, GB. `store.google.com` answered 301. Wrong password: "proxy rejected credentials", command not started. The shared gateway `p.webshare.io:80` rejected this free plan's credentials; the dashboard's per-proxy addresses worked. | | 2026-10-16 | Evomi | datacenter trial | http | `dcp.evomi.com:2000` | pass | Tunnel opened on the first attempt (about 0.4 s). Exits rotate per connection: the probe observed the US, the next request left from the Philippines. `store.google.com` answered 301. Wrong password: "proxy rejected credentials", command not started. Summary shows duration and data only. | ## How to read the rotation note From 1c21fb4ee0addc2be961243b06df77a3559cfe1b Mon Sep 17 00:00:00 2001 From: contra Date: Fri, 16 Oct 2026 16:14:40 +0300 Subject: [PATCH 09/13] feat(upstream): password hint on SOCKS5 'general failure' after a credentials exchange; Evomi https and socks5 results Evomi's SOCKS5 gateway answers a wrong password with reply 1 (general failure) at the CONNECT step after accepting the RFC 1929 exchange, as 3proxy does with reply 2. Both now carry the '(check the username and password)' hint when credentials were sent; without credentials the text is unchanged. The compatibility table gains the Evomi https (public certificate, no extra setup) and socks5 rows. Signed-off-by: contra --- internal/proxy/upstream_socks5.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/internal/proxy/upstream_socks5.go b/internal/proxy/upstream_socks5.go index 60d71b8..a65d3fe 100644 --- a/internal/proxy/upstream_socks5.go +++ b/internal/proxy/upstream_socks5.go @@ -130,9 +130,10 @@ func (u *SOCKS5Upstream) handshake(conn net.Conn, host string, port int) error { } if head[1] != 0x00 { msg := socksReplyText(head[1]) - if head[1] == 0x02 && wantAuth { + if (head[1] == 0x01 || head[1] == 0x02) && wantAuth { // Some servers accept the credentials exchange and refuse at the - // request instead when the password is wrong. + // request instead when the password is wrong: 3proxy answers + // "not allowed by ruleset", others "general failure". msg += " (check the username and password)" } return &DialError{Kind: KindProxyStatus, Status: int(head[1]), Msg: msg} From 19c4c0b5c3c05d33b97307ac4058ec820f053ed9 Mon Sep 17 00:00:00 2001 From: contra Date: Fri, 16 Oct 2026 16:15:03 +0300 Subject: [PATCH 10/13] docs: Evomi https and socks5 rows and the SOCKS5 general-failure hint (completes the previous commit's docs) Signed-off-by: contra --- docs/bring-your-own-proxy.md | 1 + docs/upstream-compatibility.md | 2 ++ 2 files changed, 3 insertions(+) diff --git a/docs/bring-your-own-proxy.md b/docs/bring-your-own-proxy.md index 88d692a..e1adef4 100644 --- a/docs/bring-your-own-proxy.md +++ b/docs/bring-your-own-proxy.md @@ -82,6 +82,7 @@ Starting a route makes one connection attempt with a 10 second timeout and one r | `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 | diff --git a/docs/upstream-compatibility.md b/docs/upstream-compatibility.md index 1b31c34..78ec4d7 100644 --- a/docs/upstream-compatibility.md +++ b/docs/upstream-compatibility.md @@ -8,6 +8,8 @@ Usernames, passwords and account details are not recorded. Exit IPs are not reco |---|---|---|---|---|---|---| | 2026-10-16 | Webshare | free plan | http | an individual proxy address from the dashboard list, port as listed | pass | Fixed exit: the probe and every request observed the same IP, GB. `store.google.com` answered 301. Wrong password: "proxy rejected credentials", command not started. The shared gateway `p.webshare.io:80` rejected this free plan's credentials; the dashboard's per-proxy addresses worked. | | 2026-10-16 | Evomi | datacenter trial | http | `dcp.evomi.com:2000` | pass | Tunnel opened on the first attempt (about 0.4 s). Exits rotate per connection: the probe observed the US, the next request left from the Philippines. `store.google.com` answered 301. Wrong password: "proxy rejected credentials", command not started. Summary shows duration and data only. | +| 2026-10-16 | Evomi | datacenter trial | https | `dcp.evomi-proxy.com:2001` | pass | TLS to the proxy with its public certificate, no extra setup. Tunnel in about 1.2 s. Exits rotate per connection (US, then Germany). `store.google.com` answered 302. Wrong password: "proxy rejected credentials". | +| 2026-10-16 | Evomi | datacenter trial | socks5 | `dcp.evomi.com:2002` | pass | Tunnel in about 1.1 s. Exits rotate per connection (Malaysia, then GB). `store.google.com` answered 302. Wrong password: the proxy answers "general failure" at the connect step; the CLI reports "proxy returned general failure (check the username and password)" and does not start the command. | ## How to read the rotation note From 021fe1f957f0c253691f3dc57e3a747759eadb4b Mon Sep 17 00:00:00 2001 From: contra Date: Fri, 16 Oct 2026 18:43:11 +0300 Subject: [PATCH 11/13] docs: own-proxy examples use the your-proxy-name placeholder and say it is the user's alias; README covers every use case Signed-off-by: contra --- README.md | 35 +++++++++++++++++++++++++--------- docs/bring-your-own-proxy.md | 30 +++++++++++++++-------------- docs/upstream-compatibility.md | 8 ++++---- internal/cli/run.go | 2 +- internal/cli/upstream.go | 13 ++++++++----- 5 files changed, 55 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index de97840..0d5a17f 100644 --- a/README.md +++ b/README.md @@ -67,8 +67,9 @@ shellroute run DE -- curl https://ipinfo.io/json | `shellroute run -- ` | Run one command through the proxy | | `shellroute proxy --country ` | Persistent proxy (blocks until Ctrl+C) | | `shellroute proxy stop` | Stop running proxy sessions | -| `shellroute run -- ` | Run one command through your own proxy | -| `shellroute upstream add\|list\|remove\|test` | Manage your own proxies | +| `shellroute run your-proxy-name -- ` | 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 @@ -83,17 +84,33 @@ curl -x http://127.0.0.1:41900 https://ipinfo.io/ip ### Use your own proxy -Already pay a proxy provider, or run one? Save it and use its name wherever a country goes. No account needed. +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 -shellroute upstream add work http://user:pass@proxy.example.com:8080 -shellroute upstream test work # via work · exit IP 91.198.0.12 · DE (observed) · 212 ms -shellroute run work -- curl https://ipinfo.io/json -shellroute proxy --upstream work -shellroute run --proxy socks5://127.0.0.1:1080 -- curl https://ipinfo.io/json +# 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. `http`, `https`, `socks5` and `socks5h` proxies work. [Details.](docs/bring-your-own-proxy.md) +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 diff --git a/docs/bring-your-own-proxy.md b/docs/bring-your-own-proxy.md index e1adef4..8a9e4f5 100644 --- a/docs/bring-your-own-proxy.md +++ b/docs/bring-your-own-proxy.md @@ -4,29 +4,31 @@ Route a command or an interactive shell through a proxy you already have. No She ## 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 work http://user:pass@proxy.example.com:8080 +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 work -# ✓ via work · exit IP 91.198.0.12 · DE (observed) · 212 ms +shellroute upstream test your-proxy-name +# ✓ via your-proxy-name · exit IP 91.198.0.12 · DE (observed) · 212 ms -# Use its name wherever a country goes. -shellroute run work -- curl https://ipinfo.io/json -shellroute proxy --upstream work # persistent local proxy on 127.0.0.1:41900 -shellroute # interactive shell, then: /connect work +# 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 work +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:pass@proxy.example.com:8080 shellroute run -- curl https://ipinfo.io/json # CI -shellroute upstream test https://user:pass@proxy.example.com:443 +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 @@ -43,7 +45,7 @@ shellroute upstream test https://user:pass@proxy.example.com:443 - 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 work` and `shellroute run DE` never collide. +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 @@ -92,13 +94,13 @@ While a route runs, each client request opens its own tunnel through your proxy. - `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 work. Run /connect." and blocks commands until you reconnect. +- 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 "work"`: it is not saved. `shellroute upstream list` shows what is; `shellroute upstream add work ` saves it. +- `unknown upstream "your-proxy-name"`: nothing is saved under that alias. `shellroute upstream list` shows what is; `shellroute upstream add your-proxy-name ` 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). diff --git a/docs/upstream-compatibility.md b/docs/upstream-compatibility.md index 78ec4d7..911300d 100644 --- a/docs/upstream-compatibility.md +++ b/docs/upstream-compatibility.md @@ -18,10 +18,10 @@ With a rotating pool every connection may leave from a different exit, and often ## Retesting ```bash -shellroute upstream add work -shellroute upstream test work -shellroute run work -- curl -sS https://ipinfo.io/json -shellroute run work -- curl -sS -I https://store.google.com/ +shellroute upstream add your-proxy-name # 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 -- true # must be refused ``` diff --git a/internal/cli/run.go b/internal/cli/run.go index fbc6b7e..7378d8c 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -39,7 +39,7 @@ Examples: shellroute run GB -- wget https://example.com Your own proxy (no account needed): - shellroute run work -- curl https://httpbin.org/ip # saved with 'shellroute upstream add' + shellroute run your-proxy-name -- curl https://httpbin.org/ip # the alias saved with 'shellroute upstream add' shellroute run --proxy socks5://127.0.0.1:1080 -- curl https://httpbin.org/ip SHELLROUTE_PROXY=http://user:pass@proxy:8080 shellroute run -- curl https://httpbin.org/ip`, Args: cobra.MinimumNArgs(1), diff --git a/internal/cli/upstream.go b/internal/cli/upstream.go index b22095b..3243b61 100644 --- a/internal/cli/upstream.go +++ b/internal/cli/upstream.go @@ -19,11 +19,14 @@ var upstreamCmd = &cobra.Command{ Short: "Use your own proxies", Long: `Save proxies you already have and route through them with no Shellroute account. -Save one, then use its name wherever a country goes: - shellroute upstream add work http://user:pass@proxy.example.com:8080 - shellroute run work -- curl https://ipinfo.io/ip - shellroute proxy --upstream work - shellroute # then /connect work +Save one under an alias you choose (your-proxy-name below), then use that +alias wherever a country code goes: + shellroute upstream add your-proxy-name http://USER:PASSWORD@HOST:PORT + shellroute run your-proxy-name -- curl https://ipinfo.io/ip + shellroute proxy --upstream your-proxy-name + shellroute # then /connect your-proxy-name + +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 From 02965c9ed66e2e8e6ba24d1aa877e27e6703b1a1 Mon Sep 17 00:00:00 2001 From: contra Date: Wed, 21 Oct 2026 14:36:13 +0300 Subject: [PATCH 12/13] fix(upstream): keep SHELLROUTE_PROXY out of the child environment; plain-language probe failures The command run through an own proxy inherited SHELLROUTE_PROXY, so a password passed that way reached the child process, contrary to the documented promise. buildProxyEnv now drops the variable. A probe that opened the tunnel but got no usable answer from the exit IP service showed the raw Go error (URL, "context deadline exceeded"). Probe now returns a ProbeError whose text is one of three plain sentences, and Cause knows it; the run, test and interactive paths all use that text. A local start failure (port, session file) no longer prints the raw error. StartOwn moves to session/own.go to keep session.go under 500 lines. Signed-off-by: contra --- internal/cli/node_proxy_test.go | 16 ++++++ internal/cli/run.go | 14 +++++ internal/cli/run_exit_test.go | 9 ++++ internal/cli/run_upstream.go | 2 +- internal/cli/run_upstream_test.go | 19 +++++++ internal/cli/upstream.go | 2 +- internal/proxy/probe.go | 35 ++++++++++-- internal/proxy/upstream.go | 4 ++ internal/proxy/upstream_test.go | 30 +++++++++++ internal/session/own.go | 88 +++++++++++++++++++++++++++++++ internal/session/own_test.go | 3 ++ internal/session/session.go | 78 --------------------------- 12 files changed, 215 insertions(+), 85 deletions(-) create mode 100644 internal/session/own.go diff --git a/internal/cli/node_proxy_test.go b/internal/cli/node_proxy_test.go index 2a60ae5..53305d2 100644 --- a/internal/cli/node_proxy_test.go +++ b/internal/cli/node_proxy_test.go @@ -173,6 +173,22 @@ func TestUnionNoProxy_Deduplicates(t *testing.T) { } } +func TestBuildProxyEnv_DropsOwnProxyURL(t *testing.T) { + base := setEnv(os.Environ(), "SHELLROUTE_PROXY", "http://user:topsecret@proxy.example:8080") + env := buildProxyEnv(base, "http://127.0.0.1:41900") + if v := envLookup(env, "SHELLROUTE_PROXY"); v != "" { + t.Errorf("SHELLROUTE_PROXY reached the child: %q", v) + } + for _, e := range env { + if strings.Contains(e, "topsecret") { + t.Errorf("own-proxy password in the child environment: %q", e) + } + } + if envLookup(env, "HTTPS_PROXY") != "http://127.0.0.1:41900" { + t.Error("local proxy URL missing from the child environment") + } +} + // helpers func filterEnv(env []string, keys ...string) []string { diff --git a/internal/cli/run.go b/internal/cli/run.go index 7378d8c..5d7dc62 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -280,6 +280,9 @@ 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 { + // The command must not see the own-proxy URL: it can carry a password, + // and the promise is that credentials never reach the child. + base = dropEnv(base, "SHELLROUTE_PROXY") env := append(base, "HTTP_PROXY="+proxyURL, "HTTPS_PROXY="+proxyURL, @@ -301,6 +304,17 @@ func buildProxyEnv(base []string, proxyURL string) []string { return env } +// dropEnv returns env without the variable key. +func dropEnv(env []string, key string) []string { + out := make([]string, 0, len(env)) + for _, e := range env { + if !strings.HasPrefix(e, key+"=") { + out = append(out, e) + } + } + return out +} + // 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. diff --git a/internal/cli/run_exit_test.go b/internal/cli/run_exit_test.go index 5ad65ed..93b3677 100644 --- a/internal/cli/run_exit_test.go +++ b/internal/cli/run_exit_test.go @@ -57,6 +57,15 @@ func TestHelperProcess(t *testing.T) { os.Exit(98) } os.Exit(0) + case "run-own-env": + // SHELLROUTE_PROXY selects the route; the command must not see it. + rootCmd.SetArgs([]string{"run", "--no-check", "--no-stat", + "--", "sh", "-c", `test -z "$SHELLROUTE_PROXY"`}) + if err := rootCmd.Execute(); err != nil { + fmt.Println(err) + os.Exit(98) + } + os.Exit(0) case "ignored-hup": helperIgnoredHUP() } diff --git a/internal/cli/run_upstream.go b/internal/cli/run_upstream.go index afbcb6a..c034fc7 100644 --- a/internal/cli/run_upstream.go +++ b/internal/cli/run_upstream.go @@ -190,7 +190,7 @@ func reportOwnStartError(t runTarget, err error) error { if proxy.TunnelFailed(err) { display.Error("Cannot connect through %s: %s", t.describe(), proxy.Cause(err)) } else { - display.Error("Cannot start the local proxy: %s", err) + display.Error("Cannot start the local proxy. Try again.") } return errors.New("upstream unreachable") } diff --git a/internal/cli/run_upstream_test.go b/internal/cli/run_upstream_test.go index 9d27715..c14ce18 100644 --- a/internal/cli/run_upstream_test.go +++ b/internal/cli/run_upstream_test.go @@ -150,6 +150,25 @@ func TestViaLine(t *testing.T) { } } +func TestRunOwn_ChildDoesNotSeeProxyURL(t *testing.T) { + res := runHelper(t, false, + "SR_TEST_HELPER=run-own-env", + "SHELLROUTE_HOME="+t.TempDir(), + "SHELLROUTE_API_KEY=", + "SHELLROUTE_API_URL=http://127.0.0.1:1", + "SHELLROUTE_PROXY=http://user:topsecret@127.0.0.1:1", + ) + if res.sig != 0 { + t.Fatalf("killed by %v: %s", res.sig, res.out) + } + if res.code != 0 { + t.Errorf("the command saw SHELLROUTE_PROXY (exit %d)\n%s", res.code, res.out) + } + if strings.Contains(res.out, "topsecret") { + t.Errorf("own-proxy password in the output:\n%s", res.out) + } +} + func TestRunOwn_ExitCodeMatchesChild_NoAccount(t *testing.T) { dir := t.TempDir() env := []string{ diff --git a/internal/cli/upstream.go b/internal/cli/upstream.go index 3243b61..a96bd40 100644 --- a/internal/cli/upstream.go +++ b/internal/cli/upstream.go @@ -168,7 +168,7 @@ func runUpstreamTest(cmd *cobra.Command, args []string) error { display.Error("Cannot connect through %s: %s", t.describe(), proxy.Cause(err)) return errors.New("upstream unreachable") default: - display.Warn("Tunnel open via %s, but the exit IP could not be read: %v", t.describe(), err) + display.Warn("Tunnel open via %s, but the exit IP could not be read: %s", t.describe(), proxy.Cause(err)) return nil } } diff --git a/internal/proxy/probe.go b/internal/proxy/probe.go index 5f21995..1b70452 100644 --- a/internal/proxy/probe.go +++ b/internal/proxy/probe.go @@ -59,9 +59,34 @@ func parseExitInfo(body []byte) (ip, country string) { return "", "" } +// ProbeError means the tunnel opened but no exit IP service answered usably. +// Error is a sentence fit for the user; Err keeps the underlying cause for +// errors.Is and errors.As. +type ProbeError struct { + Msg string + Err error +} + +func (e *ProbeError) Error() string { return e.Msg } +func (e *ProbeError) Unwrap() error { return e.Err } + +const ( + probeTimedOut = "the exit IP service did not answer in time" + probeUnexpected = "the exit IP service gave an unexpected reply" + probeUnreachable = "the exit IP service could not be reached through the proxy" +) + +// probeFailure classifies a transport failure after the tunnel opened. +func probeFailure(err error) *ProbeError { + if netCause(err) == "timeout" { + return &ProbeError{Msg: probeTimedOut, Err: err} + } + return &ProbeError{Msg: probeUnreachable, Err: err} +} + // Probe opens a tunnel through the upstream and asks an exit info service for // the exit IP and its country. A *DialError means the tunnel could not be -// opened; any other error means the tunnel opened but no service returned a +// opened; a *ProbeError means the tunnel opened but no service returned a // usable IP. func Probe(ctx context.Context, up Upstream, timeout time.Duration) (*ProbeResult, error) { ctx, cancel := context.WithTimeout(ctx, timeout) @@ -82,7 +107,7 @@ func Probe(ctx context.Context, up Upstream, timeout time.Duration) (*ProbeResul for _, endpoint := range ExitInfoEndpoints { req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) if err != nil { - lastErr = err + lastErr = &ProbeError{Msg: probeUnreachable, Err: err} continue } resp, err := client.Do(req) @@ -91,7 +116,7 @@ func Probe(ctx context.Context, up Upstream, timeout time.Duration) (*ProbeResul if errors.As(err, &de) { return nil, de } - lastErr = err + lastErr = probeFailure(err) continue } body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) @@ -99,10 +124,10 @@ func Probe(ctx context.Context, up Upstream, timeout time.Duration) (*ProbeResul if ip, country := parseExitInfo(body); ip != "" { return &ProbeResult{ExitIP: ip, Country: country, Latency: time.Since(start)}, nil } - lastErr = fmt.Errorf("unexpected reply from %s", endpoint) + lastErr = &ProbeError{Msg: probeUnexpected, Err: fmt.Errorf("unexpected reply from %s", endpoint)} } if lastErr == nil { - lastErr = errors.New("no exit IP service configured") + lastErr = &ProbeError{Msg: probeUnreachable, Err: errors.New("no exit IP service configured")} } return nil, lastErr } diff --git a/internal/proxy/upstream.go b/internal/proxy/upstream.go index 3cc1fc7..22662b1 100644 --- a/internal/proxy/upstream.go +++ b/internal/proxy/upstream.go @@ -88,6 +88,10 @@ func (e *DialError) Unwrap() error { return e.Err } // Cause returns a short explanation of a dial failure for the user. func Cause(err error) string { + var pe *ProbeError + if errors.As(err, &pe) { + return pe.Msg + } var de *DialError if !errors.As(err, &de) { return netCause(err) diff --git a/internal/proxy/upstream_test.go b/internal/proxy/upstream_test.go index 3a8247b..1b4821c 100644 --- a/internal/proxy/upstream_test.go +++ b/internal/proxy/upstream_test.go @@ -898,6 +898,36 @@ func TestProbe_TunnelOKButNoIPIsNotTunnelFailure(t *testing.T) { } } +func TestProbe_FailureTextIsPlain(t *testing.T) { + bad := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + io.WriteString(w, "busy") + })) + defer bad.Close() + withExitIPEndpoints(t, bad.URL) + _, err := Probe(context.Background(), &directUpstream{}, 5*time.Second) + if err == nil || err.Error() != "the exit IP service gave an unexpected reply" { + t.Fatalf("unexpected reply: err = %v", err) + } + if TunnelFailed(err) || Cause(err) != err.Error() { + t.Errorf("a probe failure is not a tunnel failure; Cause = %q", Cause(err)) + } + + block := make(chan struct{}) + slow := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { <-block })) + defer slow.Close() + defer close(block) + withExitIPEndpoints(t, slow.URL) + _, err = Probe(context.Background(), &directUpstream{}, 200*time.Millisecond) + if err == nil || err.Error() != "the exit IP service did not answer in time" { + t.Fatalf("slow service: err = %v", err) + } + for _, leak := range []string{"http", "context deadline", "Get "} { + if strings.Contains(err.Error(), leak) { + t.Errorf("raw error text %q shown to the user: %q", leak, err) + } + } +} + func TestCheck_RetriesDialFailureOnce(t *testing.T) { withExitIPEndpoints(t, "http://127.0.0.1:1/ip") refused := &DialError{Kind: KindDial, Err: errors.New("refused")} diff --git a/internal/session/own.go b/internal/session/own.go new file mode 100644 index 0000000..af58b21 --- /dev/null +++ b/internal/session/own.go @@ -0,0 +1,88 @@ +package session + +import ( + "context" + "fmt" + "time" + + "github.com/shellroute/shellroute-cli/internal/meter" + "github.com/shellroute/shellroute-cli/internal/proxy" +) + +// StartOwn starts the local proxy over the user's own upstream. No account, +// no API call, no billing. name is the saved upstream name ("" for an ad-hoc +// URL). +// +// Unless opts.SkipExitIP is set, the upstream is checked first: one tunnel +// attempt and one retry, each bounded by the dial timeout. A tunnel failure +// returns the *proxy.DialError; when the tunnel opened but no exit IP could be +// read, the session starts and ProbeWarning says why. The exit IP and the +// country the exit info service reports for it are recorded as observed. +func StartOwn(ctx context.Context, up proxy.Upstream, name string, port int, opts StartOpts) (*Session, error) { + port, err := reservePort(port) + if err != nil { + return nil, err + } + + m := meter.New() + proxyServer := proxy.NewServer(&proxy.Config{ + ListenPort: port, + Upstream: up, + Meter: m, + ByteBudget: -1, + TrackRelays: opts.TrackRelays, + FailureThreshold: proxy.OwnFailureThreshold, + }) + + ctx, cancel := context.WithCancel(ctx) + + id := name + if id == "" { + id = "url" + } + s := &Session{ + ID: "own-" + id, + Mode: opts.Mode, + Own: true, + UpstreamName: name, + Via: up.Describe(), + Port: port, + StartTime: time.Now(), + meter: m, + proxy: proxyServer, + cancel: cancel, + } + s.wireProxy() + + if err := s.writeInfoFile(); err != nil { + cancel() + return nil, fmt.Errorf("write session file: %w", err) + } + + go func() { + if err := proxyServer.Start(ctx); err != nil { + _ = err + s.removeInfoFile() + } + }() + + if !opts.SkipExitIP { + res, err := proxy.Check(ctx, up) + switch { + case err == nil: + s.SetExitIP(res.ExitIP) + s.Country = res.Country + s.ConnectMs = int(res.Latency.Milliseconds()) + // Record what was observed so status and stop can show it. + s.writeInfoFile() + case proxy.TunnelFailed(err): + cancel() + s.removeInfoFile() + return nil, err + default: + s.ProbeWarning = proxy.Cause(err) + } + } + + return s, nil +} diff --git a/internal/session/own_test.go b/internal/session/own_test.go index 16f4a2c..4a13a65 100644 --- a/internal/session/own_test.go +++ b/internal/session/own_test.go @@ -124,6 +124,9 @@ func TestStartOwn_ProbeFailureStillStartsWithWarning(t *testing.T) { if sess.ProbeWarning == "" { t.Error("ProbeWarning should explain the missing exit IP") } + if strings.Contains(sess.ProbeWarning, "http") || strings.Contains(sess.ProbeWarning, "Get ") { + t.Errorf("ProbeWarning shows a raw error: %q", sess.ProbeWarning) + } if sess.GetExitIP() != "" { t.Error("no exit IP should be set") } diff --git a/internal/session/session.go b/internal/session/session.go index ddffff3..a9147da 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -207,84 +207,6 @@ func startWithResponse(ctx context.Context, client *api.Client, resp *api.Sessio return s, nil } -// StartOwn starts the local proxy over the user's own upstream. No account, -// no API call, no billing. name is the saved upstream name ("" for an ad-hoc -// URL). -// -// Unless opts.SkipExitIP is set, the upstream is checked first: one tunnel -// attempt and one retry, each bounded by the dial timeout. A tunnel failure -// returns the *proxy.DialError; when the tunnel opened but no exit IP could be -// read, the session starts and ProbeWarning says why. The exit IP and the -// country the exit info service reports for it are recorded as observed. -func StartOwn(ctx context.Context, up proxy.Upstream, name string, port int, opts StartOpts) (*Session, error) { - port, err := reservePort(port) - if err != nil { - return nil, err - } - - m := meter.New() - proxyServer := proxy.NewServer(&proxy.Config{ - ListenPort: port, - Upstream: up, - Meter: m, - ByteBudget: -1, - TrackRelays: opts.TrackRelays, - FailureThreshold: proxy.OwnFailureThreshold, - }) - - ctx, cancel := context.WithCancel(ctx) - - id := name - if id == "" { - id = "url" - } - s := &Session{ - ID: "own-" + id, - Mode: opts.Mode, - Own: true, - UpstreamName: name, - Via: up.Describe(), - Port: port, - StartTime: time.Now(), - meter: m, - proxy: proxyServer, - cancel: cancel, - } - s.wireProxy() - - if err := s.writeInfoFile(); err != nil { - cancel() - return nil, fmt.Errorf("write session file: %w", err) - } - - go func() { - if err := proxyServer.Start(ctx); err != nil { - _ = err - s.removeInfoFile() - } - }() - - if !opts.SkipExitIP { - res, err := proxy.Check(ctx, up) - switch { - case err == nil: - s.SetExitIP(res.ExitIP) - s.Country = res.Country - s.ConnectMs = int(res.Latency.Milliseconds()) - // Record what was observed so status and stop can show it. - s.writeInfoFile() - case proxy.TunnelFailed(err): - cancel() - s.removeInfoFile() - return nil, err - default: - s.ProbeWarning = err.Error() - } - } - - return s, nil -} - // wireProxy forwards proxy callbacks to the session's own hooks. func (s *Session) wireProxy() { s.proxy.OnUpstreamDead = func(reason string) { From 15bcde0d6cde728cd149e36f3a273822a38fc309 Mon Sep 17 00:00:00 2001 From: contra Date: Wed, 21 Oct 2026 15:20:43 +0300 Subject: [PATCH 13/13] docs: keep the provider compatibility table to provider, scheme, endpoint, result and behaviour Signed-off-by: contra --- docs/upstream-compatibility.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/upstream-compatibility.md b/docs/upstream-compatibility.md index 911300d..b8c65a6 100644 --- a/docs/upstream-compatibility.md +++ b/docs/upstream-compatibility.md @@ -2,14 +2,14 @@ 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 and account details are not recorded. Exit IPs are not recorded either; the pool changes. +Usernames, passwords, plans and account details are not recorded, and neither are exit IPs or dates. -| Date | Provider | Plan | Scheme | Endpoint | Result | Notes | -|---|---|---|---|---|---|---| -| 2026-10-16 | Webshare | free plan | http | an individual proxy address from the dashboard list, port as listed | pass | Fixed exit: the probe and every request observed the same IP, GB. `store.google.com` answered 301. Wrong password: "proxy rejected credentials", command not started. The shared gateway `p.webshare.io:80` rejected this free plan's credentials; the dashboard's per-proxy addresses worked. | -| 2026-10-16 | Evomi | datacenter trial | http | `dcp.evomi.com:2000` | pass | Tunnel opened on the first attempt (about 0.4 s). Exits rotate per connection: the probe observed the US, the next request left from the Philippines. `store.google.com` answered 301. Wrong password: "proxy rejected credentials", command not started. Summary shows duration and data only. | -| 2026-10-16 | Evomi | datacenter trial | https | `dcp.evomi-proxy.com:2001` | pass | TLS to the proxy with its public certificate, no extra setup. Tunnel in about 1.2 s. Exits rotate per connection (US, then Germany). `store.google.com` answered 302. Wrong password: "proxy rejected credentials". | -| 2026-10-16 | Evomi | datacenter trial | socks5 | `dcp.evomi.com:2002` | pass | Tunnel in about 1.1 s. Exits rotate per connection (Malaysia, then GB). `store.google.com` answered 302. Wrong password: the proxy answers "general failure" at the connect step; the CLI reports "proxy returned general failure (check the username and password)" and does not start the command. | +| 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 @@ -25,4 +25,4 @@ shellroute run your-proxy-name -- curl -sS -I https://store.google.com/ shellroute run --proxy -- true # must be refused ``` -Add a row with the date, provider, plan, scheme and endpoint. Record what happened, never the credentials. +Add a row with the provider, scheme and endpoint. Record what happened, never the credentials, the plan or the exit IPs.