From 17c19c1a8d5c19b13485506cecdf84cf9ca579b8 Mon Sep 17 00:00:00 2001 From: MJ Kim Date: Fri, 12 Sep 2025 15:55:02 +0900 Subject: [PATCH 1/4] Fix upstream HTTP proxy feature Martian's proxy handling logic does not use RoundTripper passed by proxify. So instead set ProxyDialer in fastdialer. Signed-off-by: MJ Kim --- proxy.go | 122 ++++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 99 insertions(+), 23 deletions(-) diff --git a/proxy.go b/proxy.go index 8c9070c..6dd5678 100644 --- a/proxy.go +++ b/proxy.go @@ -4,6 +4,7 @@ import ( "bufio" "context" "crypto/tls" + "encoding/base64" "fmt" "io" "log" @@ -91,6 +92,96 @@ type Proxy struct { listenAddr string } +type httpProxyDialer struct { + proxyURL *url.URL + forward proxy.Dialer +} + +// Dial connects to the address using the HTTP proxy. +func (d *httpProxyDialer) Dial(_, addr string) (net.Conn, error) { + conn, err := d.forward.Dial("tcp", d.proxyURL.Host) + if err != nil { + return nil, err + } + + connectReq := &http.Request{ + Method: "CONNECT", + URL: &url.URL{Opaque: addr}, + Host: addr, + Header: make(http.Header), + } + if d.proxyURL.User != nil { + encodedUserinfo := base64.StdEncoding.EncodeToString([]byte(d.proxyURL.User.String())) + connectReq.Header.Set("Proxy-Authorization", "Basic "+encodedUserinfo) + } + + if err := connectReq.Write(conn); err != nil { + _ = conn.Close() + return nil, err + } + + br := bufio.NewReader(conn) + resp, err := http.ReadResponse(br, connectReq) + if err != nil { + _ = conn.Close() + return nil, err + } + if resp.StatusCode != http.StatusOK { + _ = conn.Close() + return nil, fmt.Errorf("unexpected response from proxy: %s", resp.Status) + } + + return conn, nil +} + +type httpProxyRoundRobinDialer struct { + proxyDialers map[string]httpProxyDialer + transport *rbtransport.RoundTransport +} + +// Dial connects to the address on the named network via one of the HTTP proxies using round-robin scheduling. +func (d *httpProxyRoundRobinDialer) Dial(network, addr string) (net.Conn, error) { + nextProxyURL := d.transport.Next() + dialer, ok := d.proxyDialers[nextProxyURL] + if !ok { + return nil, fmt.Errorf("no matching proxy dialer found") + } + return dialer.Dial(network, addr) +} + +func newHTTPProxyRoundRobinDialer(upstreamProxies []string) (proxy.Dialer, error) { + if len(upstreamProxies) == 0 { + return nil, fmt.Errorf("proxy URLs cannot be empty") + } + + proxyURLs := make([]*url.URL, 0, len(upstreamProxies)) + dialers := make(map[string]httpProxyDialer) + for _, proxyAddr := range upstreamProxies { + proxyURL, err := url.Parse(proxyAddr) + if err != nil { + return nil, err + } + proxyURLs = append(proxyURLs, proxyURL) + dialer := httpProxyDialer{proxyURL: proxyURL, forward: proxy.Direct} + dialers[proxyURL.String()] = dialer + } + + robin, err := rbtransport.NewWithOptions(1, toStringSlice(proxyURLs)...) + if err != nil { + return nil, err + } + + return &httpProxyRoundRobinDialer{proxyDialers: dialers, transport: robin}, nil +} + +func toStringSlice(urls []*url.URL) []string { + s := make([]string, len(urls)) + for i, u := range urls { + s[i] = u.String() + } + return s +} + func NewProxy(options *Options) (*Proxy, error) { switch options.Verbosity { @@ -144,6 +235,14 @@ func NewProxy(options *Options) (*Proxy, error) { } fastdialerOptions.BaseResolvers = []string{"127.0.0.1" + options.ListenDNSAddr} } + + if len(options.UpstreamHTTPProxies) > 0 { + proxyDialer, err := newHTTPProxyRoundRobinDialer(options.UpstreamHTTPProxies) + if err != nil { + return nil, err + } + fastdialerOptions.ProxyDialer = &proxyDialer + } dialer, err := fastdialer.NewDialer(fastdialerOptions) if err != nil { return nil, err @@ -501,29 +600,6 @@ func (p *Proxy) getRoundTripper() (http.RoundTripper, error) { InsecureSkipVerify: true, }, } - - if len(p.options.UpstreamHTTPProxies) > 0 { - roundtrip = &http.Transport{Proxy: func(req *http.Request) (*url.URL, error) { - return url.Parse(p.rbhttp.Next()) - }, TLSClientConfig: &tls.Config{InsecureSkipVerify: true}} - } else if len(p.options.UpstreamSock5Proxies) > 0 { - // for each socks5 proxy create a dialer - socks5Dialers := make(map[string]proxy.Dialer) - for _, socks5proxy := range p.options.UpstreamSock5Proxies { - dialer, err := proxy.SOCKS5("tcp", socks5proxy, nil, proxy.Direct) - if err != nil { - return nil, err - } - socks5Dialers[socks5proxy] = dialer - } - roundtrip = &http.Transport{Dial: func(network, addr string) (net.Conn, error) { - // lookup next dialer - socks5Proxy := p.rbsocks5.Next() - socks5Dialer := socks5Dialers[socks5Proxy] - // use it to perform the request - return socks5Dialer.Dial(network, addr) - }, TLSClientConfig: &tls.Config{InsecureSkipVerify: true}} - } return roundtrip, nil } From 4363d18fa3c051f00cbb15d263575bd4791a7fe5 Mon Sep 17 00:00:00 2001 From: MJ Kim Date: Fri, 12 Sep 2025 22:22:38 +0900 Subject: [PATCH 2/4] Add upstream SOCKS5 proxy implementation Signed-off-by: MJ Kim --- proxy.go | 60 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/proxy.go b/proxy.go index 6dd5678..6884ceb 100644 --- a/proxy.go +++ b/proxy.go @@ -182,6 +182,57 @@ func toStringSlice(urls []*url.URL) []string { return s } +type socks5ProxyRoundRobinDialer struct { + proxyDialers map[string]proxy.Dialer + robin *rbtransport.RoundTransport +} + +// Dial connects to the address on the named network via one of the SOCKS5 proxies using round-robin scheduling. +func (d *socks5ProxyRoundRobinDialer) Dial(network, addr string) (net.Conn, error) { + nextProxyURL := d.robin.Next() + dialer, ok := d.proxyDialers[nextProxyURL] + if !ok { + return nil, fmt.Errorf("no matching proxy dialer found") + } + return dialer.Dial(network, addr) +} + +func newSOCKS5ProxyRoundRobinDialer(upstreamProxies []string) (proxy.Dialer, error) { + if len(upstreamProxies) == 0 { + return nil, fmt.Errorf("proxy addresses cannot be empty") + } + + proxyURLs := make([]*url.URL, 0, len(upstreamProxies)) + dialers := make(map[string]proxy.Dialer) + for _, proxyAddr := range upstreamProxies { + proxyURL, err := url.Parse(proxyAddr) + if err != nil { + return nil, err + } + proxyURLs = append(proxyURLs, proxyURL) + var auth *proxy.Auth + if proxyURL.User != nil { + password, _ := proxyURL.User.Password() + auth = &proxy.Auth{ + User: proxyURL.User.Username(), + Password: password, + } + } + dialer, err := proxy.SOCKS5("tcp", proxyURL.Host, auth, proxy.Direct) + if err != nil { + return nil, err + } + dialers[proxyAddr] = dialer + } + + robin, err := rbtransport.NewWithOptions(1, toStringSlice(proxyURLs)...) + if err != nil { + return nil, err + } + + return &socks5ProxyRoundRobinDialer{proxyDialers: dialers, robin: robin}, nil +} + func NewProxy(options *Options) (*Proxy, error) { switch options.Verbosity { @@ -243,6 +294,15 @@ func NewProxy(options *Options) (*Proxy, error) { } fastdialerOptions.ProxyDialer = &proxyDialer } + + if len(options.UpstreamSock5Proxies) > 0 { + dialer, err := newSOCKS5ProxyRoundRobinDialer(options.UpstreamSock5Proxies) + if err != nil { + return nil, err + } + fastdialerOptions.ProxyDialer = &dialer + } + dialer, err := fastdialer.NewDialer(fastdialerOptions) if err != nil { return nil, err From edef8a2ebc0ddf2480a33c574fa1628111dc77f1 Mon Sep 17 00:00:00 2001 From: MJ Kim Date: Fri, 12 Sep 2025 23:56:53 +0900 Subject: [PATCH 3/4] Create upstream.go Create upstream.go and move all code related to the upstream proxy feature. Signed-off-by: MJ Kim --- proxy.go | 144 ------------------------------------------------ upstream.go | 154 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 154 insertions(+), 144 deletions(-) create mode 100644 upstream.go diff --git a/proxy.go b/proxy.go index 6884ceb..922d8f6 100644 --- a/proxy.go +++ b/proxy.go @@ -4,7 +4,6 @@ import ( "bufio" "context" "crypto/tls" - "encoding/base64" "fmt" "io" "log" @@ -12,7 +11,6 @@ import ( "net/http" "net/http/httptest" "net/http/httputil" - "net/url" "os" "strconv" "strings" @@ -38,7 +36,6 @@ import ( sliceutil "github.com/projectdiscovery/utils/slice" stringsutil "github.com/projectdiscovery/utils/strings" "github.com/things-go/go-socks5" - "golang.org/x/net/proxy" ) type OnRequestFunc func(req *http.Request, ctx *martian.Context) error @@ -92,147 +89,6 @@ type Proxy struct { listenAddr string } -type httpProxyDialer struct { - proxyURL *url.URL - forward proxy.Dialer -} - -// Dial connects to the address using the HTTP proxy. -func (d *httpProxyDialer) Dial(_, addr string) (net.Conn, error) { - conn, err := d.forward.Dial("tcp", d.proxyURL.Host) - if err != nil { - return nil, err - } - - connectReq := &http.Request{ - Method: "CONNECT", - URL: &url.URL{Opaque: addr}, - Host: addr, - Header: make(http.Header), - } - if d.proxyURL.User != nil { - encodedUserinfo := base64.StdEncoding.EncodeToString([]byte(d.proxyURL.User.String())) - connectReq.Header.Set("Proxy-Authorization", "Basic "+encodedUserinfo) - } - - if err := connectReq.Write(conn); err != nil { - _ = conn.Close() - return nil, err - } - - br := bufio.NewReader(conn) - resp, err := http.ReadResponse(br, connectReq) - if err != nil { - _ = conn.Close() - return nil, err - } - if resp.StatusCode != http.StatusOK { - _ = conn.Close() - return nil, fmt.Errorf("unexpected response from proxy: %s", resp.Status) - } - - return conn, nil -} - -type httpProxyRoundRobinDialer struct { - proxyDialers map[string]httpProxyDialer - transport *rbtransport.RoundTransport -} - -// Dial connects to the address on the named network via one of the HTTP proxies using round-robin scheduling. -func (d *httpProxyRoundRobinDialer) Dial(network, addr string) (net.Conn, error) { - nextProxyURL := d.transport.Next() - dialer, ok := d.proxyDialers[nextProxyURL] - if !ok { - return nil, fmt.Errorf("no matching proxy dialer found") - } - return dialer.Dial(network, addr) -} - -func newHTTPProxyRoundRobinDialer(upstreamProxies []string) (proxy.Dialer, error) { - if len(upstreamProxies) == 0 { - return nil, fmt.Errorf("proxy URLs cannot be empty") - } - - proxyURLs := make([]*url.URL, 0, len(upstreamProxies)) - dialers := make(map[string]httpProxyDialer) - for _, proxyAddr := range upstreamProxies { - proxyURL, err := url.Parse(proxyAddr) - if err != nil { - return nil, err - } - proxyURLs = append(proxyURLs, proxyURL) - dialer := httpProxyDialer{proxyURL: proxyURL, forward: proxy.Direct} - dialers[proxyURL.String()] = dialer - } - - robin, err := rbtransport.NewWithOptions(1, toStringSlice(proxyURLs)...) - if err != nil { - return nil, err - } - - return &httpProxyRoundRobinDialer{proxyDialers: dialers, transport: robin}, nil -} - -func toStringSlice(urls []*url.URL) []string { - s := make([]string, len(urls)) - for i, u := range urls { - s[i] = u.String() - } - return s -} - -type socks5ProxyRoundRobinDialer struct { - proxyDialers map[string]proxy.Dialer - robin *rbtransport.RoundTransport -} - -// Dial connects to the address on the named network via one of the SOCKS5 proxies using round-robin scheduling. -func (d *socks5ProxyRoundRobinDialer) Dial(network, addr string) (net.Conn, error) { - nextProxyURL := d.robin.Next() - dialer, ok := d.proxyDialers[nextProxyURL] - if !ok { - return nil, fmt.Errorf("no matching proxy dialer found") - } - return dialer.Dial(network, addr) -} - -func newSOCKS5ProxyRoundRobinDialer(upstreamProxies []string) (proxy.Dialer, error) { - if len(upstreamProxies) == 0 { - return nil, fmt.Errorf("proxy addresses cannot be empty") - } - - proxyURLs := make([]*url.URL, 0, len(upstreamProxies)) - dialers := make(map[string]proxy.Dialer) - for _, proxyAddr := range upstreamProxies { - proxyURL, err := url.Parse(proxyAddr) - if err != nil { - return nil, err - } - proxyURLs = append(proxyURLs, proxyURL) - var auth *proxy.Auth - if proxyURL.User != nil { - password, _ := proxyURL.User.Password() - auth = &proxy.Auth{ - User: proxyURL.User.Username(), - Password: password, - } - } - dialer, err := proxy.SOCKS5("tcp", proxyURL.Host, auth, proxy.Direct) - if err != nil { - return nil, err - } - dialers[proxyAddr] = dialer - } - - robin, err := rbtransport.NewWithOptions(1, toStringSlice(proxyURLs)...) - if err != nil { - return nil, err - } - - return &socks5ProxyRoundRobinDialer{proxyDialers: dialers, robin: robin}, nil -} - func NewProxy(options *Options) (*Proxy, error) { switch options.Verbosity { diff --git a/upstream.go b/upstream.go new file mode 100644 index 0000000..882733f --- /dev/null +++ b/upstream.go @@ -0,0 +1,154 @@ +package proxify + +import ( + "bufio" + "encoding/base64" + "fmt" + "net" + "net/http" + "net/url" + + rbtransport "github.com/projectdiscovery/roundrobin/transport" + "golang.org/x/net/proxy" +) + +type httpProxyDialer struct { + proxyURL *url.URL + forward proxy.Dialer +} + +// Dial connects to the address using the HTTP proxy. +func (d *httpProxyDialer) Dial(_, addr string) (net.Conn, error) { + conn, err := d.forward.Dial("tcp", d.proxyURL.Host) + if err != nil { + return nil, err + } + + connectReq := &http.Request{ + Method: "CONNECT", + URL: &url.URL{Opaque: addr}, + Host: addr, + Header: make(http.Header), + } + if d.proxyURL.User != nil { + encodedUserinfo := base64.StdEncoding.EncodeToString([]byte(d.proxyURL.User.String())) + connectReq.Header.Set("Proxy-Authorization", "Basic "+encodedUserinfo) + } + + if err := connectReq.Write(conn); err != nil { + _ = conn.Close() + return nil, err + } + + br := bufio.NewReader(conn) + resp, err := http.ReadResponse(br, connectReq) + if err != nil { + _ = conn.Close() + return nil, err + } + if resp.StatusCode != http.StatusOK { + _ = conn.Close() + return nil, fmt.Errorf("unexpected response from proxy: %s", resp.Status) + } + + return conn, nil +} + +type httpProxyRoundRobinDialer struct { + proxyDialers map[string]httpProxyDialer + transport *rbtransport.RoundTransport +} + +// Dial connects to the address on the named network via one of the HTTP proxies using round-robin scheduling. +func (d *httpProxyRoundRobinDialer) Dial(network, addr string) (net.Conn, error) { + nextProxyURL := d.transport.Next() + dialer, ok := d.proxyDialers[nextProxyURL] + if !ok { + return nil, fmt.Errorf("no matching proxy dialer found") + } + return dialer.Dial(network, addr) +} + +func newHTTPProxyRoundRobinDialer(upstreamProxies []string) (proxy.Dialer, error) { + if len(upstreamProxies) == 0 { + return nil, fmt.Errorf("proxy URLs cannot be empty") + } + + proxyURLs := make([]*url.URL, 0, len(upstreamProxies)) + dialers := make(map[string]httpProxyDialer) + for _, proxyAddr := range upstreamProxies { + proxyURL, err := url.Parse(proxyAddr) + if err != nil { + return nil, err + } + proxyURLs = append(proxyURLs, proxyURL) + dialer := httpProxyDialer{proxyURL: proxyURL, forward: proxy.Direct} + dialers[proxyURL.String()] = dialer + } + + robin, err := rbtransport.NewWithOptions(1, toStringSlice(proxyURLs)...) + if err != nil { + return nil, err + } + + return &httpProxyRoundRobinDialer{proxyDialers: dialers, transport: robin}, nil +} + +type socks5ProxyRoundRobinDialer struct { + proxyDialers map[string]proxy.Dialer + robin *rbtransport.RoundTransport +} + +// Dial connects to the address on the named network via one of the SOCKS5 proxies using round-robin scheduling. +func (d *socks5ProxyRoundRobinDialer) Dial(network, addr string) (net.Conn, error) { + nextProxyURL := d.robin.Next() + dialer, ok := d.proxyDialers[nextProxyURL] + if !ok { + return nil, fmt.Errorf("no matching proxy dialer found") + } + return dialer.Dial(network, addr) +} + +func newSOCKS5ProxyRoundRobinDialer(upstreamProxies []string) (proxy.Dialer, error) { + if len(upstreamProxies) == 0 { + return nil, fmt.Errorf("proxy URLs cannot be empty") + } + + proxyURLs := make([]*url.URL, 0, len(upstreamProxies)) + dialers := make(map[string]proxy.Dialer) + for _, proxyAddr := range upstreamProxies { + proxyURL, err := url.Parse(proxyAddr) + if err != nil { + return nil, err + } + proxyURLs = append(proxyURLs, proxyURL) + var auth *proxy.Auth + if proxyURL.User != nil { + password, _ := proxyURL.User.Password() + auth = &proxy.Auth{ + User: proxyURL.User.Username(), + Password: password, + } + } + dialer, err := proxy.SOCKS5("tcp", proxyURL.Host, auth, proxy.Direct) + if err != nil { + return nil, err + } + dialers[proxyAddr] = dialer + } + + robin, err := rbtransport.NewWithOptions(1, toStringSlice(proxyURLs)...) + if err != nil { + return nil, err + } + + return &socks5ProxyRoundRobinDialer{proxyDialers: dialers, robin: robin}, nil +} + +func toStringSlice(urls []*url.URL) []string { + s := make([]string, len(urls)) + for i, u := range urls { + s[i] = u.String() + } + return s +} From 3b85c078a408d9fe1f499132a0194fabf01fa0f9 Mon Sep 17 00:00:00 2001 From: MJ Kim Date: Sat, 13 Sep 2025 00:20:08 +0900 Subject: [PATCH 4/4] Add unit tests for upstream.go Signed-off-by: MJ Kim --- upstream_test.go | 128 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 upstream_test.go diff --git a/upstream_test.go b/upstream_test.go new file mode 100644 index 0000000..fa8008c --- /dev/null +++ b/upstream_test.go @@ -0,0 +1,128 @@ +package proxify + +import ( + "net/url" + "reflect" + "testing" +) + +func TestNewHTTPProxyRoundRobinDialer(t *testing.T) { + tests := []struct { + name string + upstreamProxies []string + shouldThrowErr bool + }{ + { + name: "empty", + upstreamProxies: []string{}, + shouldThrowErr: true, + }, + { + name: "one", + upstreamProxies: []string{"http://localhost:7777"}, + shouldThrowErr: false, + }, + { + name: "multiple", + upstreamProxies: []string{"http://localhost:7777", "http://localhost:9999"}, + shouldThrowErr: false, + }, + { + name: "invalid", + upstreamProxies: []string{"http://:invalid"}, + shouldThrowErr: true, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + actual, actualErr := newHTTPProxyRoundRobinDialer(test.upstreamProxies) + if (actualErr != nil) != test.shouldThrowErr { + t.Errorf("newHTTPProxyRoundRobinDialer() actualErr = %v, shouldThrowErr = %v", actualErr, test.shouldThrowErr) + return + } + if !test.shouldThrowErr && actual == nil { + t.Errorf("newHTTPProxyRoundRobinDialer() actual = %v, expected non-nil", actual) + } + }) + } +} + +func TestNewSOCKS5ProxyRoundRobinDialer(t *testing.T) { + tests := []struct { + name string + upstreamProxies []string + shouldThrowErr bool + }{ + { + name: "empty", + upstreamProxies: []string{}, + shouldThrowErr: true, + }, + { + name: "one", + upstreamProxies: []string{"socks5://localhost:10070"}, + shouldThrowErr: false, + }, + { + name: "multiple", + upstreamProxies: []string{"socks5://localhost:10070", "socks5://localhost:10090"}, + shouldThrowErr: false, + }, + { + name: "invalid", + upstreamProxies: []string{"socks5://:invalid"}, + shouldThrowErr: true, + }, + { + name: "auth", + upstreamProxies: []string{"socks5://user:pass@localhost:10070"}, + shouldThrowErr: false, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + actual, actualErr := newSOCKS5ProxyRoundRobinDialer(test.upstreamProxies) + if (actualErr != nil) != test.shouldThrowErr { + t.Errorf("newSOCKS5ProxyRoundRobinDialer() actualErr = %v, shouldThrowErr = %v", actualErr, test.shouldThrowErr) + return + } + if !test.shouldThrowErr && actual == nil { + t.Errorf("newSOCKS5ProxyRoundRobinDialer() actual = %v, expected non-nil", actual) + } + }) + } +} + +func TestToStringSlice(t *testing.T) { + tests := []struct { + name string + urls []*url.URL + expected []string + }{ + { + name: "single", + urls: []*url.URL{{Scheme: "http", Host: "localhost:8080"}}, + expected: []string{"http://localhost:8080"}, + }, + { + name: "multiple", + urls: []*url.URL{ + {Scheme: "http", Host: "localhost:8080"}, + {Scheme: "socks5", User: url.UserPassword("user", "pass"), Host: "localhost:8081"}, + }, + expected: []string{"http://localhost:8080", "socks5://user:pass@localhost:8081"}, + }, + { + name: "empty", + urls: []*url.URL{}, + expected: []string{}, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if actual := toStringSlice(test.urls); !reflect.DeepEqual(actual, test.expected) { + t.Errorf("toStringSlice() actual = %v, expected = %v", actual, test.expected) + } + }) + } +}