diff --git a/README.md b/README.md index d84d6ba..d1faf08 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ Dumbiest HTTP proxy ever. * Supports client authentication with client TLS certificates * Supports HTTP/2 * Resilient to DPI (including active probing, see `hidden_domain` option for authentication providers) +* Connecting via upstream HTTP(S)/SOCKS5 proxies (proxy chaining) ## Installation @@ -167,6 +168,7 @@ Authentication parameters are passed as URI via `-auth` parameter. Scheme of URI ``` $ ~/go/bin/dumbproxy -h +Usage of /home/user/go/bin/dumbproxy: -auth string auth parameters (default "none://") -autocert @@ -199,6 +201,8 @@ $ ~/go/bin/dumbproxy -h update given htpasswd file and add/set password for username. Username and password can be passed as positional arguments or requested interactively -passwd-cost int bcrypt password cost (for -passwd mode) (default 4) + -proxy value + upstream proxy URL. Can be repeated multiple times to chain proxies. Examples: socks5h://127.0.0.1:9050; https://user:password@example.com:443 -timeout duration timeout for network operations (default 10s) -verbosity int diff --git a/go.mod b/go.mod index 3be7fed..731e29f 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/Snawoot/dumbproxy go 1.13 require ( - github.com/tg123/go-htpasswd v1.2.0 // indirect + github.com/tg123/go-htpasswd v1.2.0 golang.org/x/crypto v0.0.0-20220829220503-c86fa9a7ed90 - golang.org/x/net v0.0.0-20220826154423-83b083e8dc8b // indirect + golang.org/x/net v0.5.0 ) diff --git a/go.sum b/go.sum index e9164bd..bc6caf2 100644 --- a/go.sum +++ b/go.sum @@ -1,29 +1,52 @@ github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962 h1:KeNholpO2xKjgaaSyd+DyQRrsQjhbSeS7qe4nEw8aQw= github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962/go.mod h1:kC29dT1vFpj7py2OvG1khBdQpo3kInWP+6QipLbdngo= +github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/tg123/go-htpasswd v1.2.0 h1:UKp34m9H467/xklxUxU15wKRru7fwXoTojtxg25ITF0= github.com/tg123/go-htpasswd v1.2.0/go.mod h1:h7IzlfpvIWnVJhNZ0nQ9HaFxHb7pn5uFJYLlEUJa2sM= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= golang.org/x/crypto v0.0.0-20190228161510-8dd112bcdc25/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20220829220503-c86fa9a7ed90 h1:Y/gsMcFOcR+6S6f3YeMKl5g+dZMEWqcz5Czj/GWYbkM= golang.org/x/crypto v0.0.0-20220829220503-c86fa9a7ed90/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20220826154423-83b083e8dc8b h1:ZmngSVLe/wycRns9MKikG9OWIEjGcGAkacif7oYQaUY= -golang.org/x/net v0.0.0-20220826154423-83b083e8dc8b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.5.0 h1:GyT4nK/YDHSqa1c4753ouYCDajOYKTja9Xb/OHtgvSw= +golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10 h1:WIoqL4EROvwiPdUtaip4VcDdpZ4kha7wBWZrbVKCIZg= -golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.4.0 h1:Zr2JFtRQNX3BCZ8YtxRE9hNJYC8J6I1MVbMg6owUp18= +golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.4.0 h1:O7UWfv5+A2qiuulQk30kVinPoMtoIPeVaKLEgLpVkvg= +golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.6.0 h1:3XmdazWV+ubf7QgHSTWeykHOci5oeekaGJBLkrkaw4k= +golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/handler.go b/handler.go index 62ab0c5..936026b 100644 --- a/handler.go +++ b/handler.go @@ -10,21 +10,29 @@ import ( "time" ) +type HandlerDialer interface { + DialContext(ctx context.Context, net, address string) (net.Conn, error) +} + type ProxyHandler struct { timeout time.Duration auth Auth logger *CondLogger + dialer HandlerDialer httptransport http.RoundTripper outbound map[string]string outboundMux sync.RWMutex } -func NewProxyHandler(timeout time.Duration, auth Auth, logger *CondLogger) *ProxyHandler { - httptransport := &http.Transport{} +func NewProxyHandler(timeout time.Duration, auth Auth, dialer HandlerDialer, logger *CondLogger) *ProxyHandler { + httptransport := &http.Transport{ + DialContext: dialer.DialContext, + } return &ProxyHandler{ timeout: timeout, auth: auth, logger: logger, + dialer: dialer, httptransport: httptransport, outbound: make(map[string]string), } @@ -32,8 +40,7 @@ func NewProxyHandler(timeout time.Duration, auth Auth, logger *CondLogger) *Prox func (s *ProxyHandler) HandleTunnel(wr http.ResponseWriter, req *http.Request) { ctx, _ := context.WithTimeout(req.Context(), s.timeout) - dialer := net.Dialer{} - conn, err := dialer.DialContext(ctx, "tcp", req.RequestURI) + conn, err := s.dialer.DialContext(ctx, "tcp", req.RequestURI) if err != nil { s.logger.Error("Can't satisfy CONNECT request: %v", err) http.Error(wr, "Can't satisfy CONNECT request", http.StatusBadGateway) diff --git a/main.go b/main.go index acd0484..502f624 100644 --- a/main.go +++ b/main.go @@ -5,6 +5,7 @@ import ( "flag" "fmt" "log" + "net" "net/http" "os" "path/filepath" @@ -69,6 +70,7 @@ type CLIArgs struct { passwd string passwdCost int positionalArgs []string + proxy []string } func parse_args() CLIArgs { @@ -94,6 +96,10 @@ func parse_args() CLIArgs { flag.StringVar(&args.passwd, "passwd", "", "update given htpasswd file and add/set password for username. "+ "Username and password can be passed as positional arguments or requested interactively") flag.IntVar(&args.passwdCost, "passwd-cost", bcrypt.MinCost, "bcrypt password cost (for -passwd mode)") + flag.Func("proxy", "upstream proxy URL. Can be repeated multiple times to chain proxies. Examples: socks5h://127.0.0.1:9050; https://user:password@example.com:443", func(p string) error { + args.proxy = append(args.proxy, p) + return nil + }) flag.Parse() args.positionalArgs = flag.Args() return args @@ -139,9 +145,19 @@ func run() int { } defer auth.Stop() + var dialer Dialer = new(net.Dialer) + for _, proxyURL := range args.proxy { + newDialer, err := proxyDialerFromURL(proxyURL, dialer) + if err != nil { + mainLogger.Critical("Failed to create dialer for proxy %q: %v", proxyURL, err) + return 3 + } + dialer = newDialer + } + server := http.Server{ Addr: args.bind_address, - Handler: NewProxyHandler(args.timeout, auth, proxyLogger), + Handler: NewProxyHandler(args.timeout, auth, maybeWrapWithContextDialer(dialer), proxyLogger), ErrorLog: log.New(logWriter, "HTTPSRV : ", log.LstdFlags|log.Lshortfile), ReadTimeout: 0, ReadHeaderTimeout: 0, @@ -186,7 +202,9 @@ func run() int { } server.TLSConfig = cfg err = server.ListenAndServeTLS("", "") + mainLogger.Info("Proxy server started.") } else { + mainLogger.Info("Proxy server started.") err = server.ListenAndServe() } mainLogger.Critical("Server terminated with a reason: %v", err) diff --git a/upstream.go b/upstream.go new file mode 100644 index 0000000..d223ab5 --- /dev/null +++ b/upstream.go @@ -0,0 +1,166 @@ +package main + +import ( + "bufio" + "bytes" + "context" + "crypto/tls" + "encoding/base64" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "strings" + "sync" + + xproxy "golang.org/x/net/proxy" +) + +type HTTPProxyDialer struct { + address string + tls bool + userinfo *url.Userinfo + next ContextDialer +} + +func NewHTTPProxyDialer(address string, tls bool, userinfo *url.Userinfo, next Dialer) *HTTPProxyDialer { + return &HTTPProxyDialer{ + address: address, + tls: tls, + next: maybeWrapWithContextDialer(next), + userinfo: userinfo, + } +} + +func HTTPProxyDialerFromURL(u *url.URL, next xproxy.Dialer) (xproxy.Dialer, error) { + host := u.Hostname() + port := u.Port() + tls := false + + switch strings.ToLower(u.Scheme) { + case "http": + if port == "" { + port = "80" + } + case "https": + tls = true + if port == "" { + port = "443" + } + default: + return nil, errors.New("unsupported proxy type") + } + + address := net.JoinHostPort(host, port) + + return NewHTTPProxyDialer(address, tls, u.User, next), nil +} + +func (d *HTTPProxyDialer) Dial(network, address string) (net.Conn, error) { + return d.DialContext(context.Background(), network, address) +} + +func (d *HTTPProxyDialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) { + switch network { + case "tcp", "tcp4", "tcp6": + default: + return nil, errors.New("only \"tcp\" network is supported") + } + conn, err := d.next.DialContext(ctx, "tcp", d.address) + if err != nil { + return nil, fmt.Errorf("proxy dialer is unable to make connection: %w", err) + } + if d.tls { + hostname, _, err := net.SplitHostPort(d.address) + if err != nil { + hostname = address + } + conn = tls.Client(conn, &tls.Config{ + ServerName: hostname, + }) + } + + stopGuardEvent := make(chan struct{}) + guardErr := make(chan error, 1) + go func() { + select { + case <-stopGuardEvent: + close(guardErr) + case <-ctx.Done(): + conn.Close() + guardErr <- ctx.Err() + } + }() + var stopGuardOnce sync.Once + stopGuard := func() { + stopGuardOnce.Do(func() { + close(stopGuardEvent) + }) + } + defer stopGuard() + + var reqBuf bytes.Buffer + fmt.Fprintf(&reqBuf, "CONNECT %s HTTP/1.1\r\nHost: %s\r\n", address, address) + if d.userinfo != nil { + fmt.Fprintf(&reqBuf, "Proxy-Authorization: %s\r\n", basicAuthHeader(d.userinfo)) + } + fmt.Fprintf(&reqBuf, "User-Agent: dumbproxy/%s\r\n\r\n", version) + _, err = io.Copy(conn, &reqBuf) + if err != nil { + conn.Close() + return nil, fmt.Errorf("unable to write proxy request for remote connection: %w", err) + } + + resp, err := readResponse(conn) + if err != nil { + conn.Close() + return nil, fmt.Errorf("reading proxy response failed: %w", err) + } + + if resp.StatusCode != http.StatusOK { + conn.Close() + return nil, fmt.Errorf("bad status code from proxy: %d", resp.StatusCode) + } + + stopGuard() + if err := <-guardErr; err != nil { + return nil, fmt.Errorf("context error: %w", err) + } + return conn, nil +} + +var ( + responseTerminator = []byte("\r\n\r\n") +) + +func readResponse(r io.Reader) (*http.Response, error) { + var respBuf bytes.Buffer + b := make([]byte, 1) + for !bytes.HasSuffix(respBuf.Bytes(), responseTerminator) { + n, err := r.Read(b) + if err != nil { + return nil, fmt.Errorf("unable to read HTTP response: %w", err) + } + if n == 0 { + continue + } + _, err = respBuf.Write(b) + if err != nil { + return nil, fmt.Errorf("unable to store byte into buffer: %w", err) + } + } + resp, err := http.ReadResponse(bufio.NewReader(&respBuf), nil) + if err != nil { + return nil, fmt.Errorf("unable to decode proxy response: %w", err) + } + return resp, nil +} + +func basicAuthHeader(userinfo *url.Userinfo) string { + username := userinfo.Username() + password, _ := userinfo.Password() + return "Basic " + base64.StdEncoding.EncodeToString( + []byte(username+":"+password)) +} diff --git a/utils.go b/utils.go index 3308372..4e65559 100644 --- a/utils.go +++ b/utils.go @@ -12,6 +12,7 @@ import ( "log" "net" "net/http" + "net/url" "os" "strings" "sync" @@ -19,6 +20,7 @@ import ( "golang.org/x/crypto/bcrypt" "golang.org/x/crypto/ssh/terminal" + xproxy "golang.org/x/net/proxy" ) const COPY_BUF = 128 * 1024 @@ -300,3 +302,60 @@ func prompt(prompt string, secure bool) (string, error) { } return input, nil } + +type Dialer xproxy.Dialer +type ContextDialer xproxy.ContextDialer + +var registerDialerTypesOnce sync.Once + +func proxyDialerFromURL(proxyURL string, forward Dialer) (Dialer, error) { + registerDialerTypesOnce.Do(func() { + xproxy.RegisterDialerType("http", HTTPProxyDialerFromURL) + xproxy.RegisterDialerType("https", HTTPProxyDialerFromURL) + }) + parsedURL, err := url.Parse(proxyURL) + if err != nil { + return nil, fmt.Errorf("unable to parse proxy URL: %w", err) + } + d, err := xproxy.FromURL(parsedURL, forward) + if err != nil { + return nil, fmt.Errorf("unable to construct proxy dialer from URL %q: %w", proxyURL, err) + } + return d, nil +} + +type wrappedDialer struct { + d Dialer +} + +func (wd wrappedDialer) Dial(net, address string) (net.Conn, error) { + return wd.d.Dial(net, address) +} + +func (wd wrappedDialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) { + var ( + conn net.Conn + done = make(chan struct{}, 1) + err error + ) + go func() { + conn, err = wd.d.Dial(network, address) + close(done) + if conn != nil && ctx.Err() != nil { + conn.Close() + } + }() + select { + case <-ctx.Done(): + err = ctx.Err() + case <-done: + } + return conn, err +} + +func maybeWrapWithContextDialer(d Dialer) ContextDialer { + if xd, ok := d.(ContextDialer); ok { + return xd + } + return wrappedDialer{d} +}