Repository: libp2p/go-libp2p
Pull request #3137 (2025-01-14 22:51:35)

feat(transport/websocket): support SOCKS proxy with wss
closes https://github.com/libp2p/go-libp2p/issues/286 (a 7 year old issue??)

This PR allows users to use a SOCKS5 proxy when dialing `/wss` addresses by using the `HTTPS_PROXY` environment variable. 

Here's an example using vole to dial through a socks proxy:

```
HTTPS_PROXY="socks5://127.0.0.1:9081" vole libp2p ping /dns4/am6.bootstrap.libp2p.io/tcp/443/wss/p2p/QmbLHAnMoJPWSCR5Zhtx6BHJX9KiKNN6tpvbUcqanj75Nb
```

parkan: lgtm :+1: 

MarcoPolo: I didn't really debug, but SetEnv might be surprising if other tests are run in parallel.
parkan: > I didn't really debug, but SetEnv might be surprising if other tests are run in parallel.

huh I was fairly certain that SetEnv only affects the current process and children, though I guess the tests may run in the same process as separate goroutines?
parkan: I am going to make a test build of singularity against this for a functional test and report tmw.
parkan: hmm actually can't verify correct behavior w/vole, hold please
parkan: OK I can't say that this :100: works just yet because we're getting proxy errors, but the fact that proxy use is attempted is a big win:

```
104.202.252.41:24001: i/o timeout\n  |   * [/ip4/104.202.252.41/tcp/24002/ws] proxy: failed to read greeting from SOCKS5 proxy at http-proxy.us.archive.org:8080: read tcp 172.17.0.9:59326->207.241.238.251:8080: i/o timeout\n      └─ Wraps: (6) failed to dial /ip4/104.202.252.41/tcp/24002/ws\n        └─ Wraps: (7) proxy: failed to read greeting from SOCKS5 proxy at http-proxy.us.archive.org:8080: read tcp 172.17.0.9:59326->207.241.238.251:8080: i/o timeout\n      └─ Wraps: (8) failed to dial /ip4/104.202.252.41/tcp/24001\n        └─ Wraps: (9) dial tcp4 104.202.252.41:24001\n          └─ Wraps: (10) i/o timeout\n      └─ Wraps: (11) all dials failed\nError types: (1) *withstack.withStack (2) *withstack.withStack (3) *errutil.withPrefix (4) *fmt.wrapError (5) *swarm.DialError (6) 
```

draft PR for singularity here https://github.com/data-preservation-programs/singularity/pull/450
MarcoPolo: I can make this work with HTTP_PROXY and `ws`. Can you give me a ws endpoint to check against?

Just curious, what is the reason you are using `/ws` without tls? Why can’t you open a tcp stream? Is the client on a browser?
parkan: @MarcoPolo client is inside a secure network where all connections to the outside must go through SOCKS and only to allowlisted hosts -- this is actually not entirely uncommon in "enterprise" environments though it is a huge pain

for a `ws` endpoint you can check  /ip4/104.202.252.41/tcp/24002/ws/p2p/12D3KooWQnScgybqBcTv6f3yvgbF8pH7sLnrkrbYVB3FRyZY2NsR, I've confirmed it's healthy just now
MarcoPolo: Testing this locally with [vole](https://github.com/ipfs-shipyard/vole) using this branch of go-libp2p I was able to connect to your endpoint using `HTTP_PROXY`.

```
HTTP_PROXY="socks5://127.0.0.1:9081" vole libp2p ping /ip4/104.202.252.41/tcp/24002/ws/p2p/12D3KooWQnScgybqBcTv6f3yvgbF8pH7sLnrkrbYVB3FRyZY2NsR             
```

`ws` uses `HTTP_PROXY` and `wss` uses `HTTPS_PROXY`.

Patch:
diff --git a/p2p/transport/websocket/websocket.go b/p2p/transport/websocket/websocket.go
index e24cb88c..97402b9a 100644
--- a/p2p/transport/websocket/websocket.go
+++ b/p2p/transport/websocket/websocket.go
@@ -160,6 +160,8 @@ func (t *WebsocketTransport) Resolve(_ context.Context, maddr ma.Multiaddr) ([]m
 	return []ma.Multiaddr{parsed.toMultiaddr()}, nil
 }
 
+// Dial will dial the given multiaddr and expect the given peer. If an
+// HTTPS_PROXY env is set, it will use that for the dial out.
 func (t *WebsocketTransport) Dial(ctx context.Context, raddr ma.Multiaddr, p peer.ID) (transport.CapableConn, error) {
 	connScope, err := t.rcmgr.OpenConnection(network.DirOutbound, true, raddr)
 	if err != nil {
@@ -191,7 +193,11 @@ func (t *WebsocketTransport) maDial(ctx context.Context, raddr ma.Multiaddr) (ma
 		return nil, err
 	}
 	isWss := wsurl.Scheme == "wss"
-	dialer := ws.Dialer{HandshakeTimeout: 30 * time.Second}
+	dialer := ws.Dialer{
+		HandshakeTimeout: 30 * time.Second,
+		// Inherit the default proxy behavior
+		Proxy: ws.DefaultDialer.Proxy,
+	}
 	if isWss {
 		sni := ""
 		sni, err = raddr.ValueForProtocol(ma.P_SNI)
@@ -203,17 +209,23 @@ func (t *WebsocketTransport) maDial(ctx context.Context, raddr ma.Multiaddr) (ma
 			copytlsClientConf := t.tlsClientConf.Clone()
 			copytlsClientConf.ServerName = sni
 			dialer.TLSClientConfig = copytlsClientConf
-			ipAddr := wsurl.Host
-			// Setting the NetDial because we already have the resolved IP address, so we don't want to do another resolution.
+			ipPortAddr := wsurl.Host
 			// We set the `.Host` to the sni field so that the host header gets properly set.
+			wsurl.Host = sni + ":" + wsurl.Port()
+			// Setting the NetDial because we already have the resolved IP address, so we can avoid another resolution.
 			dialer.NetDial = func(network, address string) (net.Conn, error) {
-				tcpAddr, err := net.ResolveTCPAddr(network, ipAddr)
+				var tcpAddr *net.TCPAddr
+				var err error
+				if address == wsurl.Host {
+					tcpAddr, err = net.ResolveTCPAddr(network, ipPortAddr) // Use our already resolved IP address
+				} else {
+					tcpAddr, err = net.ResolveTCPAddr(network, address)
+				}
 				if err != nil {
 					return nil, err
 				}
 				return net.DialTCP("tcp", nil, tcpAddr)
 			}
-			wsurl.Host = sni + ":" + wsurl.Port()
 		} else {
 			dialer.TLSClientConfig = t.tlsClientConf
 		}


Test patch:
diff --git a/p2p/transport/websocket/websocket_test.go b/p2p/transport/websocket/websocket_test.go
index 9ca03775..b112ebae 100644
--- a/p2p/transport/websocket/websocket_test.go
+++ b/p2p/transport/websocket/websocket_test.go
@@ -1,6 +1,7 @@
 package websocket
 
 import (
+	"bytes"
 	"context"
 	"crypto/ecdsa"
 	"crypto/elliptic"
@@ -15,10 +16,12 @@ import (
 	"math/big"
 	"net"
 	"net/http"
+	"net/url"
 	"strings"
 	"testing"
 	"time"
 
+	gws "github.com/gorilla/websocket"
 	"github.com/libp2p/go-libp2p/core/crypto"
 	"github.com/libp2p/go-libp2p/core/network"
 	"github.com/libp2p/go-libp2p/core/peer"
@@ -548,3 +551,75 @@ func TestResolveMultiaddr(t *testing.T) {
 		})
 	}
 }
+
+func TestSocksProxy(t *testing.T) {
+	testCases := []string{
+		"/ip4/1.2.3.4/tcp/1/ws",                     // No TLS
+		"/ip4/1.2.3.4/tcp/1/tls/ws",                 // TLS no SNI
+		"/ip4/1.2.3.4/tcp/1/tls/sni/example.com/ws", // TLS with an SNI
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc, func(t *testing.T) {
+			proxyServer, err := net.Listen("tcp", "127.0.0.1:0")
+			require.NoError(t, err)
+			proxyServerErr := make(chan error, 1)
+
+			go func() {
+				defer proxyServer.Close()
+				c, err := proxyServer.Accept()
+				if err != nil {
+					proxyServerErr <- err
+					return
+				}
+				defer c.Close()
+
+				req := [32]byte{}
+				_, err = io.ReadFull(c, req[:3])
+				if err != nil {
+					proxyServerErr <- err
+					return
+				}
+
+				// Handshake a SOCKS5 client: https://www.rfc-editor.org/rfc/rfc1928.html#section-3
+				if !bytes.Equal([]byte{0x05, 0x01, 0x00}, req[:3]) {
+					t.Log("expected SOCKS5 connect request")
+					proxyServerErr <- err
+					return
+				}
+				_, err = c.Write([]byte{0x05, 0x00})
+				if err != nil {
+					proxyServerErr <- err
+					return
+				}
+
+				proxyServerErr <- nil
+			}()
+
+			orig := gws.DefaultDialer.Proxy
+			defer func() { gws.DefaultDialer.Proxy = orig }()
+
+			proxyUrl, err := url.Parse("socks5://" + proxyServer.Addr().String())
+			require.NoError(t, err)
+			gws.DefaultDialer.Proxy = http.ProxyURL(proxyUrl)
+
+			tlsConfig := &tls.Config{InsecureSkipVerify: true} // Our test server doesn't have a cert signed by a CA
+			_, u := newSecureUpgrader(t)
+			tpt, err := New(u, &network.NullResourceManager{}, nil, WithTLSClientConfig(tlsConfig))
+			require.NoError(t, err)
+
+			// This can be any wss address. We aren't actually going to dial it.
+			maToDial := ma.StringCast(tc)
+			_, err = tpt.Dial(context.Background(), maToDial, "")
+			require.ErrorContains(t, err, "failed to read connect reply from SOCKS5 proxy", "This should error as we don't have a real socks server")
+
+			select {
+			case <-time.After(1 * time.Second):
+			case err := <-proxyServerErr:
+				if err != nil {
+					t.Fatal(err)
+				}
+			}
+		})
+	}
+}