From a66158f301235cc6d8d5dd7a48cad4bdcb407698 Mon Sep 17 00:00:00 2001 From: WofWca Date: Fri, 7 Feb 2025 17:52:34 +0400 Subject: [PATCH 1/9] refactor: abstract away Gorilla WebSocket lib Only use it in the `common/websocketconn.go` file. This allows us to more easily plug another WebSocket library in place of Gorilla WebSocket. --- common/websocketconn/websocketconn.go | 19 +++++++++++++++++++ proxy/lib/snowflake.go | 5 ++--- server/lib/http.go | 8 ++------ 3 files changed, 23 insertions(+), 9 deletions(-) diff --git a/common/websocketconn/websocketconn.go b/common/websocketconn/websocketconn.go index e5256df5..4eddc9f7 100644 --- a/common/websocketconn/websocketconn.go +++ b/common/websocketconn/websocketconn.go @@ -2,6 +2,7 @@ package websocketconn import ( "io" + "net/http" "time" "github.com/gorilla/websocket" @@ -84,6 +85,24 @@ func closeErrorToEOF(err error) error { return err } +func Dial(url string) (*websocket.Conn, *http.Response, error) { + return websocket.DefaultDialer.Dial(url, nil) +} + +func NewServerUpgrader() (upgrader *websocket.Upgrader) { + return &websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { return true }, + } +} + +func Upgrade( + upgrader *websocket.Upgrader, + w http.ResponseWriter, + r *http.Request, +) (*websocket.Conn, error) { + return upgrader.Upgrade(w, r, nil) +} + // Create a new Conn. func New(ws *websocket.Conn) *Conn { // Set up synchronous pipes to serialize reads and writes to the diff --git a/proxy/lib/snowflake.go b/proxy/lib/snowflake.go index 18a321aa..791652b3 100644 --- a/proxy/lib/snowflake.go +++ b/proxy/lib/snowflake.go @@ -42,7 +42,6 @@ import ( "github.com/pion/ice/v4" - "github.com/gorilla/websocket" "github.com/pion/transport/v3/stdnet" "github.com/pion/webrtc/v4" @@ -361,7 +360,7 @@ func (sf *SnowflakeProxy) datachannelHandler(conn *webRTCConn, remoteIP net.IP, log.Printf("datachannelHandler ends") } -func connectToRelay(relayURL string, remoteIP net.IP) (*websocketconn.Conn, error) { +func connectToRelay(relayURL string, remoteIP net.IP) (net.Conn, error) { u, err := url.Parse(relayURL) if err != nil { return nil, fmt.Errorf("invalid relay url: %s", err) @@ -376,7 +375,7 @@ func connectToRelay(relayURL string, remoteIP net.IP) (*websocketconn.Conn, erro log.Printf("no remote address given in websocket") } - ws, _, err := websocket.DefaultDialer.Dial(u.String(), nil) + ws, _, err := websocketconn.Dial(u.String()) if err != nil { return nil, fmt.Errorf("error dialing relay: %s = %s", u.String(), err) } diff --git a/server/lib/http.go b/server/lib/http.go index 403aeb17..28b3b558 100644 --- a/server/lib/http.go +++ b/server/lib/http.go @@ -15,8 +15,6 @@ import ( "sync" "time" - "github.com/gorilla/websocket" - "gitlab.torproject.org/tpo/anti-censorship/pluggable-transports/snowflake/v2/common/encapsulation" "gitlab.torproject.org/tpo/anti-censorship/pluggable-transports/snowflake/v2/common/turbotunnel" "gitlab.torproject.org/tpo/anti-censorship/pluggable-transports/snowflake/v2/common/websocketconn" @@ -40,9 +38,7 @@ const clientIDAddrMapCapacity = 98304 // before deciding that it's not going to return. const listenAndServeErrorTimeout = 100 * time.Millisecond -var upgrader = websocket.Upgrader{ - CheckOrigin: func(r *http.Request) bool { return true }, -} +var upgrader = websocketconn.NewServerUpgrader() // clientIDAddrMap stores short-term mappings from ClientIDs to IP addresses. // When we call pt.DialOr, tor wants us to provide a USERADDR string that @@ -96,7 +92,7 @@ func (handler *httpHandler) lookupPacketConn(clientID turbotunnel.ClientID) *tur } func (handler *httpHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - ws, err := upgrader.Upgrade(w, r, nil) + ws, err := websocketconn.Upgrade(upgrader, w, r) if err != nil { log.Println(err) return -- GitLab From f0f10f8233ad040db66b35fb8098761bb54684f1 Mon Sep 17 00:00:00 2001 From: WofWca Date: Tue, 11 Feb 2025 00:00:19 +0400 Subject: [PATCH 2/9] refactor: add option to use coder/websocket lib ...instead of gorilla/websocket. The main purpose of this change is to target proxy with it, to allow for `GOOS=js GOARCH=wasm go build -o ./proxy` later. The Gorilla library does not work for js,wasm target because it does not utilize the built-in JavaScript WebSocket constructor, and tries to use e.g. raw DNS. To use the coder/websocket version, provide `-tags coder_websocket_lib` to the Go command, e.g. `go run -tags coder_websocket_lib ./proxy`. The normal build is not affected. The proxy and the server work pretty much normally with this, and they are interoperable with different versions. The only thing missing is not logging `CloseError` on the server and report it as `EOF` instead, see `closeErrorToEOF`. --- common/websocketconn/websocketconn.go | 2 + common/websocketconn/websocketconn_coder.go | 74 +++++++++++++++++++++ common/websocketconn/websocketconn_test.go | 4 ++ go.mod | 1 + go.sum | 2 + 5 files changed, 83 insertions(+) create mode 100644 common/websocketconn/websocketconn_coder.go diff --git a/common/websocketconn/websocketconn.go b/common/websocketconn/websocketconn.go index 4eddc9f7..17e940cf 100644 --- a/common/websocketconn/websocketconn.go +++ b/common/websocketconn/websocketconn.go @@ -1,3 +1,5 @@ +//go:build !(js || coder_websocket_lib) + package websocketconn import ( diff --git a/common/websocketconn/websocketconn_coder.go b/common/websocketconn/websocketconn_coder.go new file mode 100644 index 00000000..f6574ac0 --- /dev/null +++ b/common/websocketconn/websocketconn_coder.go @@ -0,0 +1,74 @@ +//go:build js || coder_websocket_lib + +package websocketconn + +import ( + "context" + "net" + "net/http" + + websocket "github.com/coder/websocket" +) + +type wsConnWrapper struct { + net.Conn + cancelContext *context.CancelFunc +} + +func (connWrapper *wsConnWrapper) Close() error { + err := connWrapper.Conn.Close() + (*connWrapper.cancelContext)() + + return err +} + +func Dial(url string) (*wsConnWrapper, *http.Response, error) { + ctx, cancel := context.WithCancel(context.Background()) + + c, httpResponse, err := websocket.Dial(ctx, url, nil) + if err != nil { + cancel() + return nil, httpResponse, err + } + + netConn := websocket.NetConn(ctx, c, websocket.MessageBinary) + return &wsConnWrapper{ + Conn: netConn, + cancelContext: &cancel, + }, httpResponse, nil +} + +// With the `coder_websocket_lib` build tag, this is a no-op +// (it simply returns `nil`) +func NewServerUpgrader() any { + return nil +} + +func Upgrade( + upgrader any, + w http.ResponseWriter, + r *http.Request, +) (*wsConnWrapper, error) { + c, err := websocket.Accept(w, r, nil) + if err != nil { + return nil, err + } + + ctx, cancel := context.WithCancel(r.Context()) + + netConn := websocket.NetConn(ctx, c, websocket.MessageBinary) + return &wsConnWrapper{ + Conn: netConn, + cancelContext: &cancel, + }, nil +} + +// Create a new Conn. +// With the `coder_websocket_lib` build tag, this is a no-op +// (it simply returns the `ws` argument unchanged). +func New(ws *wsConnWrapper) net.Conn { + // TODO ensure all the erros (EOF) stuff behave as we want them to, + // see `closeErrorToEOF` in Gorilla version. + // This appears to only affect server logging though. + return ws +} diff --git a/common/websocketconn/websocketconn_test.go b/common/websocketconn/websocketconn_test.go index e3191f34..58b759e2 100644 --- a/common/websocketconn/websocketconn_test.go +++ b/common/websocketconn/websocketconn_test.go @@ -1,3 +1,7 @@ +//go:build !(js || coder_websocket_lib) + +// TODO add tests for (js || coder_websocket_lib). + package websocketconn import ( diff --git a/go.mod b/go.mod index 780a46df..dfe63c94 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/aws/aws-sdk-go-v2/config v1.29.6 github.com/aws/aws-sdk-go-v2/credentials v1.17.59 github.com/aws/aws-sdk-go-v2/service/sqs v1.37.14 + github.com/coder/websocket v1.8.12 github.com/golang/mock v1.6.0 github.com/gorilla/websocket v1.5.3 github.com/miekg/dns v1.1.63 diff --git a/go.sum b/go.sum index 3ada4629..bf6535c8 100644 --- a/go.sum +++ b/go.sum @@ -39,6 +39,8 @@ github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDk github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU= github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo= +github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= -- GitLab From db3760836f1e6f68905087354cef421f86fc7c07 Mon Sep 17 00:00:00 2001 From: WofWca Date: Wed, 12 Feb 2025 15:50:44 +0400 Subject: [PATCH 3/9] refactor(proxy): fix some WASM build errors Remove JS-unsupported Pion WebRTC methods from the JS build. The other build targets are unaffected by this commit. --- common/jsnotjs/js_specific.go | 35 ++++++++++++++++ common/jsnotjs/non_js_specific.go | 36 +++++++++++++++++ proxy/lib/snowflake.go | 66 ++++++++++++++++++++++++++----- 3 files changed, 127 insertions(+), 10 deletions(-) create mode 100644 common/jsnotjs/js_specific.go create mode 100644 common/jsnotjs/non_js_specific.go diff --git a/common/jsnotjs/js_specific.go b/common/jsnotjs/js_specific.go new file mode 100644 index 00000000..144cc9da --- /dev/null +++ b/common/jsnotjs/js_specific.go @@ -0,0 +1,35 @@ +//go:build js + +// Provides functions that work in non-JavaScript builds, +// but panic in JavaScript builds. +// +// This is to work around the fact that the js,wasm version of the Pion library +// does not implement these methods. +// This would cause the JS build of Snowflake to fail +// if we were to use those methods unconditionally. +package jsnotjs + +import "log" + +const notSupportedErrorMsg = "This is not supported in JS" + +func SetNetIfNotJs(args ...any) { + log.Fatal(notSupportedErrorMsg) +} + +func SetEphemeralUDPPortRangeIfNotJs(args ...any) any { + log.Fatal(notSupportedErrorMsg) + return nil +} + +func SetNAT1To1IPsIfNotJs(args ...any) { + log.Fatal(notSupportedErrorMsg) +} + +func SetICEMulticastDNSModeIfNotJs(args ...any) { + log.Fatal(notSupportedErrorMsg) +} + +func SetDTLSInsecureSkipHelloVerifyIfNotJs(args ...any) { + log.Fatal(notSupportedErrorMsg) +} diff --git a/common/jsnotjs/non_js_specific.go b/common/jsnotjs/non_js_specific.go new file mode 100644 index 00000000..73c3b7f8 --- /dev/null +++ b/common/jsnotjs/non_js_specific.go @@ -0,0 +1,36 @@ +//go:build !js + +// Provides functions that work in non-JavaScript builds, +// but panic in JavaScript builds. +// +// This is to work around the fact that the js,wasm version of the Pion library +// does not implement these methods. +// This would cause the JS build of Snowflake to fail +// if we were to use those methods unconditionally. +package jsnotjs + +import ( + "github.com/pion/ice/v4" + "github.com/pion/transport/v3" + "github.com/pion/webrtc/v4" +) + +func SetNetIfNotJs(s *webrtc.SettingEngine, net transport.Net) { + s.SetNet(net) +} + +func SetEphemeralUDPPortRangeIfNotJs(s *webrtc.SettingEngine, portMin uint16, portMax uint16) error { + return s.SetEphemeralUDPPortRange(portMin, portMax) +} + +func SetNAT1To1IPsIfNotJs(s *webrtc.SettingEngine, ips []string, candidateType webrtc.ICECandidateType) { + s.SetNAT1To1IPs(ips, candidateType) +} + +func SetICEMulticastDNSModeIfNotJs(s *webrtc.SettingEngine, multicastDNSMode ice.MulticastDNSMode) { + s.SetICEMulticastDNSMode(multicastDNSMode) +} + +func SetDTLSInsecureSkipHelloVerifyIfNotJs(s *webrtc.SettingEngine, skip bool) { + s.SetDTLSInsecureSkipHelloVerify(true) +} diff --git a/proxy/lib/snowflake.go b/proxy/lib/snowflake.go index 791652b3..9e4498c7 100644 --- a/proxy/lib/snowflake.go +++ b/proxy/lib/snowflake.go @@ -36,6 +36,7 @@ import ( "net/http" "net/url" "reflect" + "runtime" "strings" "sync" "time" @@ -47,6 +48,7 @@ import ( "gitlab.torproject.org/tpo/anti-censorship/pluggable-transports/snowflake/v2/common/constants" "gitlab.torproject.org/tpo/anti-censorship/pluggable-transports/snowflake/v2/common/event" + "gitlab.torproject.org/tpo/anti-censorship/pluggable-transports/snowflake/v2/common/jsnotjs" "gitlab.torproject.org/tpo/anti-censorship/pluggable-transports/snowflake/v2/common/messages" "gitlab.torproject.org/tpo/anti-censorship/pluggable-transports/snowflake/v2/common/namematcher" "gitlab.torproject.org/tpo/anti-censorship/pluggable-transports/snowflake/v2/common/task" @@ -138,12 +140,16 @@ type SnowflakeProxy struct { // in case the broker itself did not specify the said URL RelayURL string // OutboundAddress specify an IP address to use as SDP host candidate + // + // This is not supported in the js,wasm build. OutboundAddress string // EphemeralMinPort and EphemeralMaxPort limit the range of ports that // ICE UDP connections may allocate from. // When specifying the range, make sure it's at least 2x as wide // as the amount of clients that you are hoping to serve concurrently // (see the `Capacity` property). + // + // This is not supported in the js,wasm build. EphemeralMinPort uint16 EphemeralMaxPort uint16 // RelayDomainNamePattern is the pattern specify allowed domain name for relay @@ -408,15 +414,23 @@ func (sf *SnowflakeProxy) makeWebRTCAPI() *webrtc.API { } settingsEngine.SetIncludeLoopbackCandidate(sf.KeepLocalAddresses) - // Use the SetNet setting https://pkg.go.dev/github.com/pion/webrtc/v3#SettingEngine.SetNet - // to get snowflake working in shadow (where the AF_NETLINK family is not implemented). - // These two lines of code functionally revert a new change in pion by silently ignoring - // when net.Interfaces() fails, rather than throwing an error - vnet, _ := stdnet.NewNet() - settingsEngine.SetNet(vnet) + if runtime.GOOS != "js" { + // Use the SetNet setting https://pkg.go.dev/github.com/pion/webrtc/v3#SettingEngine.SetNet + // to get snowflake working in shadow (where the AF_NETLINK family is not implemented). + // These two lines of code functionally revert a new change in pion by silently ignoring + // when net.Interfaces() fails, rather than throwing an error + // + // This is not relevant in browsers. + vnet, _ := stdnet.NewNet() + jsnotjs.SetNetIfNotJs(&settingsEngine, vnet) + } if sf.EphemeralMinPort != 0 && sf.EphemeralMaxPort != 0 { - err := settingsEngine.SetEphemeralUDPPortRange(sf.EphemeralMinPort, sf.EphemeralMaxPort) + err := jsnotjs.SetEphemeralUDPPortRangeIfNotJs( + &settingsEngine, + sf.EphemeralMinPort, + sf.EphemeralMaxPort, + ) if err != nil { log.Fatal("Invalid port range: min > max") } @@ -425,12 +439,31 @@ func (sf *SnowflakeProxy) makeWebRTCAPI() *webrtc.API { if sf.OutboundAddress != "" { // replace SDP host candidates with the given IP without validation // still have server reflexive candidates to fall back on - settingsEngine.SetNAT1To1IPs([]string{sf.OutboundAddress}, webrtc.ICECandidateTypeHost) + jsnotjs.SetNAT1To1IPsIfNotJs( + &settingsEngine, + []string{sf.OutboundAddress}, + webrtc.ICECandidateTypeHost, + ) } - settingsEngine.SetICEMulticastDNSMode(ice.MulticastDNSModeDisabled) + // Web apps have no direct control over this, except with + // `RTCIceTransportPolicy === "relay"`, but that won't work for us. + if runtime.GOOS != "js" { + jsnotjs.SetICEMulticastDNSModeIfNotJs( + &settingsEngine, + ice.MulticastDNSModeDisabled, + ) + } - settingsEngine.SetDTLSInsecureSkipHelloVerify(true) + // According to https://github.com/pion/dtls/pull/513, + // browsers already skip "Hello Verify". + // Either way, we can't control this in JS. + if runtime.GOOS != "js" { + jsnotjs.SetDTLSInsecureSkipHelloVerifyIfNotJs( + &settingsEngine, + true, + ) + } return webrtc.NewAPI(webrtc.WithSettingEngine(settingsEngine)) } @@ -777,6 +810,13 @@ func (sf *SnowflakeProxy) Start() error { } if sf.EphemeralMaxPort != 0 { + if runtime.GOOS == "js" { + return fmt.Errorf( + "ephemeral port range (EphemeralMaxPort) is not supported " + + "in the JavaScript version", + ) + } + rangeWidth := sf.EphemeralMaxPort - sf.EphemeralMinPort expectedNumConcurrentClients := sf.Capacity if sf.Capacity == 0 { @@ -800,6 +840,12 @@ func (sf *SnowflakeProxy) Start() error { } } + if runtime.GOOS == "js" && sf.OutboundAddress != "" { + return fmt.Errorf( + "OutboundAddress is not supported in the JavaScript version", + ) + } + config = webrtc.Configuration{ ICEServers: []webrtc.ICEServer{ { -- GitLab From b526c3daf05f634006842c8ff781c9f543c3268d Mon Sep 17 00:00:00 2001 From: WofWca Date: Wed, 12 Feb 2025 18:27:14 +0400 Subject: [PATCH 4/9] refactor(proxy): use StripLocalAddresses for WASM This practically reverts https://gitlab.torproject.org/tpo/anti-censorship/pluggable-transports/snowflake/-/merge_requests/442 (ae5bd528211f07ca4be8582571b5a33f11fcf853) just for the JS / WASM build. `SetIPFilter()` and `SetIncludeLoopbackCandidate()` are not supported by Pion for the js,wasm target, so let's go the old route, the same we go in the pure JS version of Snowflake. The non-JS build is unaffected. This concludes making the proxy WASM build work in browsers, as confirmed by me manually packaging the build in a test extension. The build command is: ``` GOOS=js GOARCH=wasm go build -o ./proxy/main.wasm ./proxy ``` A couple of minor things are left to do to make it production-ready, such as adjusting the PollInterval and replacing ProxyType. --- common/jsnotjs/js_specific.go | 8 ++++ common/jsnotjs/non_js_specific.go | 10 +++++ common/util/util.go | 5 --- proxy/lib/proxy-go_test.go | 2 +- proxy/lib/snowflake.go | 72 +++++++++++++++++++++++++------ 5 files changed, 79 insertions(+), 18 deletions(-) diff --git a/common/jsnotjs/js_specific.go b/common/jsnotjs/js_specific.go index 144cc9da..468939e6 100644 --- a/common/jsnotjs/js_specific.go +++ b/common/jsnotjs/js_specific.go @@ -33,3 +33,11 @@ func SetICEMulticastDNSModeIfNotJs(args ...any) { func SetDTLSInsecureSkipHelloVerifyIfNotJs(args ...any) { log.Fatal(notSupportedErrorMsg) } + +func SetIPFilterIfNotJs(args ...any) { + log.Fatal(notSupportedErrorMsg) +} + +func SetIncludeLoopbackCandidateIfNotJs(arg ...any) { + log.Fatal(notSupportedErrorMsg) +} diff --git a/common/jsnotjs/non_js_specific.go b/common/jsnotjs/non_js_specific.go index 73c3b7f8..181ed842 100644 --- a/common/jsnotjs/non_js_specific.go +++ b/common/jsnotjs/non_js_specific.go @@ -10,6 +10,8 @@ package jsnotjs import ( + "net" + "github.com/pion/ice/v4" "github.com/pion/transport/v3" "github.com/pion/webrtc/v4" @@ -34,3 +36,11 @@ func SetICEMulticastDNSModeIfNotJs(s *webrtc.SettingEngine, multicastDNSMode ice func SetDTLSInsecureSkipHelloVerifyIfNotJs(s *webrtc.SettingEngine, skip bool) { s.SetDTLSInsecureSkipHelloVerify(true) } + +func SetIPFilterIfNotJs(s *webrtc.SettingEngine, filter func(net.IP) (keep bool)) { + s.SetIPFilter(filter) +} + +func SetIncludeLoopbackCandidateIfNotJs(s *webrtc.SettingEngine, include bool) { + s.SetIncludeLoopbackCandidate(include) +} diff --git a/common/util/util.go b/common/util/util.go index f66c69fe..a1806924 100644 --- a/common/util/util.go +++ b/common/util/util.go @@ -74,11 +74,6 @@ func IsLocal(ip net.IP) bool { } // Removes local LAN address ICE candidates -// -// This is unused after https://gitlab.torproject.org/tpo/anti-censorship/pluggable-transports/snowflake/-/merge_requests/442, -// but come in handy later for https://gitlab.torproject.org/tpo/anti-censorship/pluggable-transports/snowflake/-/issues/40322 -// Also this is exported, so let's not remove it at least until -// the next major release. func StripLocalAddresses(str string) string { var desc sdp.SessionDescription err := desc.Unmarshal([]byte(str)) diff --git a/proxy/lib/proxy-go_test.go b/proxy/lib/proxy-go_test.go index e8e50db3..11096f84 100644 --- a/proxy/lib/proxy-go_test.go +++ b/proxy/lib/proxy-go_test.go @@ -336,7 +336,7 @@ func TestBrokerInteractions(t *testing.T) { Convey("Proxy connections to broker", t, func() { var err error - broker, err = newSignalingServer("localhost") + broker, err = newSignalingServer("localhost", false) So(err, ShouldBeNil) tokens = newTokens(0) diff --git a/proxy/lib/snowflake.go b/proxy/lib/snowflake.go index 9e4498c7..da3d4df6 100644 --- a/proxy/lib/snowflake.go +++ b/proxy/lib/snowflake.go @@ -211,11 +211,18 @@ func limitedRead(r io.Reader, limit int64) ([]byte, error) { type SignalingServer struct { url *url.URL transport http.RoundTripper + // Whether to keep (or strip) private addresses from the answer SDP + // prior to sending the answer to the broker. + keepLocalAddresses bool } -func newSignalingServer(rawURL string) (*SignalingServer, error) { +func newSignalingServer( + rawURL string, + keepLocalAddresses bool, +) (*SignalingServer, error) { var err error s := new(SignalingServer) + s.keepLocalAddresses = keepLocalAddresses s.url, err = url.Parse(rawURL) if err != nil { return nil, fmt.Errorf("invalid broker url: %s", err) @@ -285,6 +292,13 @@ func (s *SignalingServer) pollOffer(sid string, proxyType string, acceptedRelayP // and wait for its response func (s *SignalingServer) sendAnswer(sid string, pc *webrtc.PeerConnection) error { ld := pc.LocalDescription() + if !s.keepLocalAddresses { + ld = &webrtc.SessionDescription{ + Type: ld.Type, + SDP: util.StripLocalAddresses(ld.SDP), + } + } + answer, err := util.SerializeSessionDescription(ld) if err != nil { return err @@ -403,16 +417,22 @@ func (d dataChannelHandlerWithRelayURL) datachannelHandler(conn *webRTCConn, rem func (sf *SnowflakeProxy) makeWebRTCAPI() *webrtc.API { settingsEngine := webrtc.SettingEngine{} - if !sf.KeepLocalAddresses { - settingsEngine.SetIPFilter(func(ip net.IP) (keep bool) { - // `IsLoopback()` and `IsUnspecified` are likely not neded here, - // but let's keep them just in case. - // FYI there is similar code in other files in this project. - keep = !util.IsLocal(ip) && !ip.IsLoopback() && !ip.IsUnspecified() - return - }) + // `SetIPFilter()` and `SetIncludeLoopbackCandidate()` are not supported + // in the JS build. We will need to resort to just + // stripping private addresses from the answer SDP. + // See comments around `stripLocalAddresses :=`. + if runtime.GOOS != "js" { + if !sf.KeepLocalAddresses { + jsnotjs.SetIPFilterIfNotJs(&settingsEngine, (func(ip net.IP) (keep bool) { + // `IsLoopback()` and `IsUnspecified` are likely not neded here, + // but let's keep them just in case. + // FYI there is similar code in other files in this project. + keep = !util.IsLocal(ip) && !ip.IsLoopback() && !ip.IsUnspecified() + return + })) + } + jsnotjs.SetIncludeLoopbackCandidateIfNotJs(&settingsEngine, sf.KeepLocalAddresses) } - settingsEngine.SetIncludeLoopbackCandidate(sf.KeepLocalAddresses) if runtime.GOOS != "js" { // Use the SetNet setting https://pkg.go.dev/github.com/pion/webrtc/v3#SettingEngine.SetNet @@ -791,7 +811,21 @@ func (sf *SnowflakeProxy) Start() error { sf.periodicProxyStats = newPeriodicProxyStats(sf.SummaryInterval, sf.EventDispatcher, sf.bytesLogger) sf.EventDispatcher.AddSnowflakeEventListener(sf.periodicProxyStats) - broker, err = newSignalingServer(sf.BrokerURL) + // In the non-JS build of Snowflake proxy, `stripLocalAddresses == true` + // practically has no effect because we also utilize + // `webrtc.SettingEngine.SetIPFilter()`, + // which should filter out private addresses before they even get + // to `pc.LocalDescription()`. + // + // In the JS version, however, `SetIPFilter()` is not available, + // so we need to resort to stripping the private addresses + // at the signaling stage (here, that is). + // + // FYI this comment also applies to the other occurrence of + // `newSignalingServer`. + stripLocalAddresses := runtime.GOOS == "js" && !sf.KeepLocalAddresses + + broker, err = newSignalingServer(sf.BrokerURL, !stripLocalAddresses) if err != nil { return fmt.Errorf("error configuring broker: %s", err) } @@ -906,7 +940,21 @@ func (sf *SnowflakeProxy) Stop() { func (sf *SnowflakeProxy) checkNATType(config webrtc.Configuration, probeURL string) error { log.Printf("Checking our NAT type, contacting NAT check probe server at \"%v\"...", probeURL) - probe, err := newSignalingServer(probeURL) + // In the non-JS build of Snowflake proxy, `stripLocalAddresses == true` + // practically has no effect because we also utilize + // `webrtc.SettingEngine.SetIPFilter()`, + // which should filter out private addresses before they even get + // to `pc.LocalDescription()`. + // + // In the JS version, however, `SetIPFilter()` is not available, + // so we need to resort to stripping the private addresses + // at the signaling stage (here, that is). + // + // FYI this comment also applies to the other occurrence of + // `newSignalingServer`. + stripLocalAddresses := runtime.GOOS == "js" + + probe, err := newSignalingServer(probeURL, !stripLocalAddresses) if err != nil { return fmt.Errorf("Error parsing url: %w", err) } -- GitLab From c225ca798ae1437501a9b5724ba0c75ac9e9b53a Mon Sep 17 00:00:00 2001 From: WofWca Date: Wed, 12 Feb 2025 23:12:18 +0400 Subject: [PATCH 5/9] test: add proxy WASM build to CI --- .gitlab-ci.yml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 61a4d635..42576d9d 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -142,6 +142,21 @@ android: - go get golang.org/x/mobile/bind - gomobile bind -v -target=android $REPRODUCIBLE_FLAGS . +# TODO test: add tests, with wasmbrowsertest. See +# https://go.dev/wiki/WebAssembly#running-tests-in-the-browser +wasm-proxy-build: + image: containers.torproject.org/tpo/anti-censorship/duplicatedcontainerimages:golang-1.23-$DEBIAN_STABLE + <<: *golang-docker-debian-template + artifacts: + paths: + - proxy/proxy.wasm + expire_in: 1 week + when: on_success + script: + - cd $CI_PROJECT_DIR/proxy/ + - go get + - GOOS=js GOARCH=wasm go build $REPRODUCIBLE_FLAGS -o proxy.wasm + go-1.21: image: containers.torproject.org/tpo/anti-censorship/duplicatedcontainerimages:golang-1.21-$DEBIAN_STABLE <<: *golang-docker-debian-template -- GitLab From 6213551f870cf17a9a9dd9331c84b385c58ea5f9 Mon Sep 17 00:00:00 2001 From: WofWca Date: Wed, 19 Feb 2025 01:02:37 +0400 Subject: [PATCH 6/9] feat: add example WASM proxy browser extension Co-authored-by: DavidSM100 --- proxy/README.md | 33 ++++++++++++++++++++++ proxy/wasm-browser-extension/.gitignore | 2 ++ proxy/wasm-browser-extension/index.html | 10 +++++++ proxy/wasm-browser-extension/main.js | 21 ++++++++++++++ proxy/wasm-browser-extension/manifest.json | 12 ++++++++ proxy/wasm-browser-extension/sw.js | 5 ++++ 6 files changed, 83 insertions(+) create mode 100644 proxy/wasm-browser-extension/.gitignore create mode 100644 proxy/wasm-browser-extension/index.html create mode 100644 proxy/wasm-browser-extension/main.js create mode 100644 proxy/wasm-browser-extension/manifest.json create mode 100644 proxy/wasm-browser-extension/sw.js diff --git a/proxy/README.md b/proxy/README.md index 7299695a..e7077125 100644 --- a/proxy/README.md +++ b/proxy/README.md @@ -88,3 +88,36 @@ Usage of ./proxy: ``` For more information on how to run a Snowflake proxy in deployment, see our [community documentation](https://community.torproject.org/relay/setup/snowflake/standalone/). + +## WebAssembly / JavaScript + +The Go version of Snowflake proxy can run in browsers! +To run it in yours: + +1. Build: + + ```bash + cd proxy; \ + GOOS=js GOARCH=wasm go build -o wasm-browser-extension/proxy.wasm && \ + # See https://go.dev/wiki/WebAssembly + ( + cp "$(go env GOROOT)/lib/wasm/wasm_exec.js" ./wasm-browser-extension/ || \ + # Fall back to the Go 1.23 and earlier way + cp "$(go env GOROOT)/misc/wasm/wasm_exec.js" ./wasm-browser-extension/ + ) && \ + echo 'Success!' + ``` + +2. Load the unpacked extension located in `./wasm-browser-extension` +in your Chromium browser. See +["Load an unpacked extension" instructions](https://developer.chrome.com/docs/extensions/get-started/tutorial/hello-world#load-unpacked). + +To see the log output, on page, +find the extension, click "details", +then click "index.html" in "Inspect views". + +To be clear, +[the Snowflake extension](https://gitlab.torproject.org/tpo/anti-censorship/pluggable-transports/snowflake-webext/) +does _not_ use this Go codebase: that's a pure JavaScript implementation. + +For more info about Go and WASM, see . diff --git a/proxy/wasm-browser-extension/.gitignore b/proxy/wasm-browser-extension/.gitignore new file mode 100644 index 00000000..709e5f4f --- /dev/null +++ b/proxy/wasm-browser-extension/.gitignore @@ -0,0 +1,2 @@ +/proxy.wasm +/wasm_exec.js diff --git a/proxy/wasm-browser-extension/index.html b/proxy/wasm-browser-extension/index.html new file mode 100644 index 00000000..104aa1f3 --- /dev/null +++ b/proxy/wasm-browser-extension/index.html @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/proxy/wasm-browser-extension/main.js b/proxy/wasm-browser-extension/main.js new file mode 100644 index 00000000..4ae4840e --- /dev/null +++ b/proxy/wasm-browser-extension/main.js @@ -0,0 +1,21 @@ +const go = new Go(); +go.argv = [ + "", + "-verbose", + // Same as the in the extension version. + // However, the intervali in the extension version is not fixed. + // https://gitlab.torproject.org/tpo/anti-censorship/pluggable-transports/snowflake-webext/-/blob/c920540d7b99f9c848c3229ec274d1079620a26f/config.js#L23-26 + "-poll-interval", + "60s", + // Same as the in the extension version. + // However, the extension version sets capacity to 2 + // if the NAT is unrestricted. + // https://gitlab.torproject.org/tpo/anti-censorship/pluggable-transports/snowflake-webext/-/blob/68d41fdd70b59520db07dbdc9dd9b6d4eaf99e79/config.js#L43 + "-capacity", + "1", +]; +WebAssembly.instantiateStreaming(fetch("./proxy.wasm"), go.importObject).then( + (result) => { + go.run(result.instance); + } +); diff --git a/proxy/wasm-browser-extension/manifest.json b/proxy/wasm-browser-extension/manifest.json new file mode 100644 index 00000000..428228c7 --- /dev/null +++ b/proxy/wasm-browser-extension/manifest.json @@ -0,0 +1,12 @@ +{ + "manifest_version": 3, + "name": "Snowkflake WASM Proxy", + "version": "1.0.0", + "background": { + "service_worker": "sw.js" + }, + "content_security_policy": { + "extension_pages": "default-src 'none'; script-src 'self' 'wasm-unsafe-eval'; connect-src wss://*.torproject.net https://*.torproject.net:*" + }, + "permissions": ["offscreen"] +} diff --git a/proxy/wasm-browser-extension/sw.js b/proxy/wasm-browser-extension/sw.js new file mode 100644 index 00000000..c938e898 --- /dev/null +++ b/proxy/wasm-browser-extension/sw.js @@ -0,0 +1,5 @@ +chrome.offscreen.createDocument({ + url: 'index.html', + reasons: [chrome.offscreen.Reason.WEB_RTC], + justification: 'Use WebRTC.', +}); -- GitLab From ca6b9922005b51d4fb8200475cc5a5c6a389a18a Mon Sep 17 00:00:00 2001 From: WofWca Date: Tue, 18 Feb 2025 19:21:26 +0400 Subject: [PATCH 7/9] improvement(proxy): default WASM ProxyType="wasm" --- proxy/lib/proxy-go_test.go | 4 ++-- proxy/lib/snowflake.go | 20 +++++++++++++++----- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/proxy/lib/proxy-go_test.go b/proxy/lib/proxy-go_test.go index 11096f84..5d051f18 100644 --- a/proxy/lib/proxy-go_test.go +++ b/proxy/lib/proxy-go_test.go @@ -364,7 +364,7 @@ func TestBrokerInteractions(t *testing.T) { b, } - sdp, _ := broker.pollOffer(sampleOffer, DefaultProxyType, "") + sdp, _ := broker.pollOffer(sampleOffer, DefaultProxyTypeStandalone, "") expectedSDP, _ := strconv.Unquote(sampleSDP) So(sdp.SDP, ShouldResemble, expectedSDP) }) @@ -378,7 +378,7 @@ func TestBrokerInteractions(t *testing.T) { b, } - sdp, _ := broker.pollOffer(sampleOffer, DefaultProxyType, "") + sdp, _ := broker.pollOffer(sampleOffer, DefaultProxyTypeStandalone, "") So(sdp, ShouldBeNil) }) Convey("sends answer to broker", func() { diff --git a/proxy/lib/snowflake.go b/proxy/lib/snowflake.go index da3d4df6..aeabc399 100644 --- a/proxy/lib/snowflake.go +++ b/proxy/lib/snowflake.go @@ -61,9 +61,14 @@ const ( DefaultBrokerURL = "https://snowflake-broker.torproject.net/" DefaultNATProbeURL = "https://snowflake-broker.torproject.net:8443/probe" // This is rather a "DefaultDefaultRelayURL" - DefaultRelayURL = "wss://snowflake.torproject.net/" - DefaultSTUNURL = "stun:stun.l.google.com:19302,stun:stun.voip.blackberry.com:3478" - DefaultProxyType = "standalone" + DefaultRelayURL = "wss://snowflake.torproject.net/" + DefaultSTUNURL = "stun:stun.l.google.com:19302,stun:stun.voip.blackberry.com:3478" + + DefaultProxyTypeStandalone = "standalone" + DefaultProxyTypeWasm = "wasm" + // Deprecated: use DefaultProxyTypeStandalone + // or DefaultProxyTypeWasm instead. + DefaultProxyType = DefaultProxyTypeStandalone ) const ( @@ -168,7 +173,9 @@ type SnowflakeProxy struct { NATProbeURL string // NATTypeMeasurementInterval is time before NAT type is retested NATTypeMeasurementInterval time.Duration - // ProxyType is the type reported to the broker, if not provided it "standalone" will be used + // ProxyType is the type reported to the broker. + // If not provided, defaults to "standalone", + // or "wasm" if target is "js,wasm" ProxyType string EventDispatcher event.SnowflakeEventDispatcher shutdown chan struct{} @@ -801,7 +808,10 @@ func (sf *SnowflakeProxy) Start() error { sf.NATProbeURL = DefaultNATProbeURL } if sf.ProxyType == "" { - sf.ProxyType = DefaultProxyType + sf.ProxyType = DefaultProxyTypeStandalone + if runtime.GOOS == "js" { + sf.ProxyType = DefaultProxyTypeWasm + } } if sf.EventDispatcher == nil { sf.EventDispatcher = event.NewSnowflakeEventDispatcher() -- GitLab From 763e64ab3268b2bae3f063bc3438f111f8696e98 Mon Sep 17 00:00:00 2001 From: WofWca Date: Tue, 18 Feb 2025 19:32:48 +0400 Subject: [PATCH 8/9] improvement(broker): recognize "wasm" ProxyType --- common/messages/proxy.go | 1 + doc/broker-spec.txt | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/common/messages/proxy.go b/common/messages/proxy.go index 6fe02be8..56cef77f 100644 --- a/common/messages/proxy.go +++ b/common/messages/proxy.go @@ -19,6 +19,7 @@ const ( var KnownProxyTypes = map[string]bool{ "standalone": true, + "wasm": true, "webext": true, "badge": true, "iptproxy": true, diff --git a/doc/broker-spec.txt b/doc/broker-spec.txt index 3f9ce7ac..f3a48214 100644 --- a/doc/broker-spec.txt +++ b/doc/broker-spec.txt @@ -241,7 +241,7 @@ POST /proxy HTTP { Sid: [generated session id of proxy], Version: 1.3, - Type: ["badge"|"webext"|"standalone"|"mobile"], + Type: ["badge"|"webext"|"standalone"|"wasm"|"mobile"], NAT: ["unknown"|"restricted"|"unrestricted"], Clients: [number of current clients, rounded down to multiples of 8], AcceptedRelayPattern: [a pattern representing accepted set of relay domains] -- GitLab From 98ba2e3502a0ba920d3d43c3aa2189843474df32 Mon Sep 17 00:00:00 2001 From: WofWca Date: Thu, 13 Feb 2025 00:37:38 +0400 Subject: [PATCH 9/9] perf(proxy): reduce WASM build size ...by removing CLI flags unsupported in browsers. From 18MB to 16MB. The biggest one is "metrics". --- proxy/main.go | 56 ++++++++++++++++++++++++++++++++++----------------- 1 file changed, 37 insertions(+), 19 deletions(-) diff --git a/proxy/main.go b/proxy/main.go index 360703e5..fdf44097 100644 --- a/proxy/main.go +++ b/proxy/main.go @@ -7,6 +7,7 @@ import ( "log" "net" "os" + "runtime" "strconv" "strings" "time" @@ -25,7 +26,10 @@ func main() { fmt.Sprint("how often to ask the broker for a new client. Keep in mind that asking for a client will not always result in getting one. Minumum value is ", minPollInterval, ". Valid time units are \"ms\", \"s\", \"m\", \"h\".")) capacity := flag.Uint("capacity", 0, "maximum concurrent clients (default is to accept an unlimited number of clients)") stunURL := flag.String("stun", sf.DefaultSTUNURL, "Comma-separated STUN server `URL`s that this proxy will use will use to, among some other things, determine its public IP address") - logFilename := flag.String("log", "", "log `filename`. If not specified, logs will be output to stderr (console).") + var logFilename string + if runtime.GOOS != "js" { + flag.StringVar(&logFilename, "log", "", "log `filename`. If not specified, logs will be output to stderr (console).") + } rawBrokerURL := flag.String("broker", sf.DefaultBrokerURL, "The `URL` of the broker server that the proxy will be using to find clients") unsafeLogging := flag.Bool("unsafe-logging", false, "keep IP addresses and other sensitive info in the logs") logLocalTime := flag.Bool("log-local-time", false, "Use local time for logging (default: UTC)") @@ -41,13 +45,23 @@ func main() { summaryInterval := flag.Duration("summary-interval", time.Hour, "the time interval between summary log outputs, 0s disables summaries. Valid time units are \"s\", \"m\", \"h\".") disableStatsLogger := flag.Bool("disable-stats-logger", false, "disable the exposing mechanism for stats using logs") - enableMetrics := flag.Bool("metrics", false, "enable the exposing mechanism for stats using metrics") - metricsAddress := flag.String("metrics-address", "localhost", "set listen `address` for metrics service") - metricsPort := flag.Int("metrics-port", 9999, "set port for the metrics service") + var enableMetrics bool + var metricsAddress string + var metricsPort int + if runtime.GOOS != "js" { + flag.BoolVar(&enableMetrics, "metrics", false, "enable the exposing mechanism for stats using metrics") + flag.StringVar(&metricsAddress, "metrics-address", "localhost", "set listen `address` for metrics service") + flag.IntVar(&metricsPort, "metrics-port", 9999, "set port for the metrics service") + } verboseLogging := flag.Bool("verbose", false, "increase log verbosity") - ephemeralPortsRangeFlag := flag.String("ephemeral-ports-range", "", "Set the `range` of ports used for client connections (format:\":\").\nUseful in conjunction with port forwarding, in order to make the proxy NAT type \"unrestricted\".\nIf omitted, the ports will be chosen automatically from a wide range.\nWhen specifying the range, make sure it's at least 2x as wide as the amount of clients that you are hoping to serve concurrently (see the \"capacity\" flag).") - geoipDatabase := flag.String("geoipdb", "/usr/share/tor/geoip", "path to correctly formatted geoip database mapping IPv4 address ranges to country codes") - geoip6Database := flag.String("geoip6db", "/usr/share/tor/geoip6", "path to correctly formatted geoip database mapping IPv6 address ranges to country codes") + var ephemeralPortsRangeFlag string + var geoipDatabase string + var geoip6Database string + if runtime.GOOS != "js" { + flag.StringVar(&ephemeralPortsRangeFlag, "ephemeral-ports-range", "", "Set the `range` of ports used for client connections (format:\":\").\nUseful in conjunction with port forwarding, in order to make the proxy NAT type \"unrestricted\".\nIf omitted, the ports will be chosen automatically from a wide range.\nWhen specifying the range, make sure it's at least 2x as wide as the amount of clients that you are hoping to serve concurrently (see the \"capacity\" flag).") + flag.StringVar(&geoipDatabase, "geoipdb", "/usr/share/tor/geoip", "path to correctly formatted geoip database mapping IPv4 address ranges to country codes") + flag.StringVar(&geoip6Database, "geoip6db", "/usr/share/tor/geoip6", "path to correctly formatted geoip database mapping IPv6 address ranges to country codes") + } versionFlag := flag.Bool("version", false, "display version info to stderr and quit") var ephemeralPortsRange []uint16 = []uint16{0, 0} @@ -69,8 +83,8 @@ func main() { eventLogger := event.NewSnowflakeEventDispatcher() - if *ephemeralPortsRangeFlag != "" { - ephemeralPortsRangeParts := strings.Split(*ephemeralPortsRangeFlag, ":") + if ephemeralPortsRangeFlag != "" { + ephemeralPortsRangeParts := strings.Split(ephemeralPortsRangeFlag, ":") if len(ephemeralPortsRangeParts) == 2 { ephemeralMinPort, err := strconv.ParseUint(ephemeralPortsRangeParts[0], 10, 16) if err != nil { @@ -91,14 +105,18 @@ func main() { ephemeralPortsRange = []uint16{uint16(ephemeralMinPort), uint16(ephemeralMaxPort)} } else { - log.Fatalf("Bad range port format: %v", *ephemeralPortsRangeFlag) + log.Fatalf("Bad range port format: %v", ephemeralPortsRangeFlag) } } - gip, err := geoip.New(*geoipDatabase, *geoip6Database) - if *enableMetrics && err != nil { - // The geoip DB is only used for metrics, let's only report the error if enabled - log.Println("Error loading geoip db for country based metrics:", err) + var gip *geoip.Geoip = nil + if geoipDatabase != "" || geoip6Database != "" { + var err error + gip, err = geoip.New(geoipDatabase, geoip6Database) + if enableMetrics && err != nil { + // The geoip DB is only used for metrics, let's only report the error if enabled + log.Println("Error loading geoip db for country based metrics:", err) + } } proxy := sf.SnowflakeProxy{ @@ -139,8 +157,8 @@ func main() { logOutput = os.Stderr } - if *logFilename != "" { - f, err := os.OpenFile(*logFilename, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600) + if logFilename != "" { + f, err := os.OpenFile(logFilename, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600) if err != nil { log.Fatal(err) } @@ -160,10 +178,10 @@ func main() { proxyEventLogger := sf.NewProxyEventLogger(eventlogOutput, *disableStatsLogger) eventLogger.AddSnowflakeEventListener(proxyEventLogger) - if *enableMetrics { + if enableMetrics { metrics := sf.NewMetrics() - err := metrics.Start(net.JoinHostPort(*metricsAddress, strconv.Itoa(*metricsPort))) + err := metrics.Start(net.JoinHostPort(metricsAddress, strconv.Itoa(metricsPort))) if err != nil { log.Fatalf("could not enable metrics: %v", err) } @@ -173,7 +191,7 @@ func main() { log.Printf("snowflake-proxy %s\n", version.GetVersion()) - err = proxy.Start() + err := proxy.Start() if err != nil { log.Fatal(err) } -- GitLab