From c3177c1a3c6221f02d836503159164f95ad703e6 Mon Sep 17 00:00:00 2001 From: Andrey Meshkov Date: Mon, 5 Feb 2024 15:33:07 +0300 Subject: [PATCH] Changed the protocol for messages exchange to WebSocket Changed the protocol for messages exchange, it now uses WebSocket internally. This change allows running `udptlspipe` server behind a CDN if that CDN supports WebSocket. --- CHANGELOG.md | 10 +- README.md | 9 +- go.mod | 5 +- go.sum | 7 + internal/cmd/cmd.go | 3 +- internal/pipe/server.go | 267 +++++++++++++++++-------------- internal/pipe/server_test.go | 5 +- internal/pipe/websocket.go | 86 ++++++++++ internal/tunnel/msgreadwriter.go | 6 +- 9 files changed, 270 insertions(+), 128 deletions(-) create mode 100644 internal/pipe/websocket.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 32207c8..b810e61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,15 @@ adheres to [Semantic Versioning][semver]. ## [Unreleased] -[unreleased]: https://github.com/ameshkov/udptlspipe/compare/v1.1.0...HEAD +[unreleased]: https://github.com/ameshkov/udptlspipe/compare/v1.2.0...HEAD + +## [1.2.0] - 2024-02-05 + +* Changed the protocol for messages exchange, it now uses WebSocket internally. + This change allows running `udptlspipe` server behind a CDN if that CDN + supports WebSocket. + +[1.2.0]: https://github.com/ameshkov/udptlspipe/releases/tag/v1.2.0 ## [1.1.0] - 2024-02-03 diff --git a/README.md b/README.md index c764252..b5126f5 100644 --- a/README.md +++ b/README.md @@ -26,9 +26,13 @@ to keep it that way. * Cross-platform (Windows/macOS/Linux/Android/*BSD). * Simple configuration, no complicated configuration files. -* Mimics Google Chrome's TLS ClientHello. +* Mimics Android's okhttp library. * Active probing protection in server mode. * Suitable to wrap WireGuard, OpenVPN, and other UDP session. +* Uses WebSocket for data transfer so can be behind + [a CDN that supports WS][cdnwebsocket]. + +[cdnwebsocket]: https://www.cdnplanet.com/guides/websockets/ @@ -226,5 +230,6 @@ Help Options: * [X] Docker image. * [X] Certificate configuration. -* [ ] Use WebSocket for transport instead of the custom binary proto. +* [X] Use WebSocket for transport instead of the custom binary proto. +* [ ] Use several upstream connections instead of a single one. * [ ] Automatic TLS certs generation (let's encrypt, lego). \ No newline at end of file diff --git a/go.mod b/go.mod index 2f0b22f..a3bf485 100644 --- a/go.mod +++ b/go.mod @@ -4,21 +4,24 @@ go 1.21.6 require ( github.com/AdguardTeam/golibs v0.20.0 + github.com/gobwas/ws v1.3.2 github.com/jessevdk/go-flags v1.5.0 github.com/refraction-networking/utls v1.6.2 github.com/stretchr/testify v1.8.4 golang.org/x/net v0.20.0 + gopkg.in/yaml.v3 v3.0.1 ) require ( github.com/andybalholm/brotli v1.0.6 // indirect github.com/cloudflare/circl v1.3.7 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/gobwas/httphead v0.1.0 // indirect + github.com/gobwas/pool v0.2.1 // indirect github.com/klauspost/compress v1.17.4 // indirect github.com/kr/pretty v0.3.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/quic-go/quic-go v0.40.1 // indirect golang.org/x/crypto v0.18.0 // indirect golang.org/x/sys v0.16.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 4820f1f..59cd929 100644 --- a/go.sum +++ b/go.sum @@ -11,6 +11,12 @@ github.com/go-logr/logr v1.2.4 h1:g01GSCwiDw2xSZfjJ2/T9M+S6pFdcNtFYsp+Y43HYDQ= github.com/go-logr/logr v1.2.4/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= +github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= +github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= +github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= +github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= +github.com/gobwas/ws v1.3.2 h1:zlnbNHxumkRvfPWgfXu8RBwyNR1x8wh9cf5PTOCqs9Q= +github.com/gobwas/ws v1.3.2/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38 h1:yAJXTCF9TqKcTiHJAE8dj7HMvPfh66eeA2JYW7eFpSE= @@ -43,6 +49,7 @@ golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1m golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo= golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= diff --git a/internal/cmd/cmd.go b/internal/cmd/cmd.go index 9e0e11f..70722c2 100644 --- a/internal/cmd/cmd.go +++ b/internal/cmd/cmd.go @@ -10,12 +10,11 @@ import ( "syscall" "time" - tls "github.com/refraction-networking/utls" - "github.com/AdguardTeam/golibs/log" "github.com/ameshkov/udptlspipe/internal/pipe" "github.com/ameshkov/udptlspipe/internal/version" goFlags "github.com/jessevdk/go-flags" + tls "github.com/refraction-networking/utls" ) // Main is the entry point for the command-line tool. diff --git a/internal/pipe/server.go b/internal/pipe/server.go index 5eca5ac..fc8a8e9 100644 --- a/internal/pipe/server.go +++ b/internal/pipe/server.go @@ -20,6 +20,7 @@ import ( "github.com/AdguardTeam/golibs/log" "github.com/ameshkov/udptlspipe/internal/tunnel" "github.com/ameshkov/udptlspipe/internal/udp" + "github.com/gobwas/ws" tls "github.com/refraction-networking/utls" "golang.org/x/net/proxy" ) @@ -29,8 +30,8 @@ import ( // configured. const defaultSNI = "example.org" -// authTimeout is the read timeout for the first auth packet. -const authTimeout = time.Second * 60 +// upgradeTimeout is the read timeout for the first auth packet. +const upgradeTimeout = time.Second * 60 // Server represents an udptlspipe pipe. Depending on whether it is created in // server- or client- mode, it listens to TLS or UDP connections and pipes the @@ -376,165 +377,193 @@ func (s *Server) closeDstConn(conn net.Conn) { delete(s.dstConns, conn) } -// serveConn processes incoming connections, opens the connection to the -// destination and tunnels data between both connections. -func (s *Server) serveConn(conn net.Conn) { - var dstConn net.Conn +// readWriteCloser is a helper object that's used for replacing +// io.ReadWriteCloser when the server peeked into the connection. +type readWriteCloser struct { + io.Reader + io.Writer + io.Closer +} - defer func() { - s.wg.Done() +// upgradeClientConn +func (s *Server) upgradeClientConn(conn net.Conn) (rwc io.ReadWriteCloser, err error) { + log.Debug("Upgrading connection to %s", conn.RemoteAddr()) - s.closeSrcConn(conn) - s.closeDstConn(dstConn) + // Give up to 60 seconds on the upgrade and authentication. + _ = conn.SetReadDeadline(time.Now().Add(upgradeTimeout)) + defer func() { + // Remove the deadline when it's not required any more. + _ = conn.SetReadDeadline(time.Time{}) }() - dstConn, err := s.dialDst() + u, err := url.Parse(fmt.Sprintf("wss://%s/?password=%s", s.tlsConfig.ServerName, s.password)) if err != nil { - log.Error("failed to connect to %s: %v", s.destinationAddr, err) - - return - } - - func() { - s.dstConnsMu.Lock() - defer s.dstConnsMu.Unlock() - - // Track the connection to allow unblocking reads on shutdown. - s.dstConns[dstConn] = struct{}{} - }() - - var srcRw, dstRw io.ReadWriter - srcRw = conn - dstRw = dstConn - - if s.serverMode { - ok, newRw := s.checkAuth(conn) - if !ok { - // Client connection has not been authorized, closing the connection. - return - } - - // Replace the source reader since some bytes in the original srcRw - // may have been read as a part of authentication process. - srcRw = newRw + return nil, err } - if !s.serverMode { - // Authorize the client if necessary. - s.auth(dstConn) + var br *bufio.Reader + br, _, err = ws.DefaultDialer.Upgrade(conn, u) + if err != nil { + return nil, fmt.Errorf("failed to upgrade: %w", err) } - // When the client communicates with the server it uses encoded messages so - // connection between them needs to be wrapped. In server mode it is the - // source connection, in client mode it is the destination connection. - if s.serverMode { - srcRw = tunnel.NewMsgReadWriter(srcRw) - } else { - dstRw = tunnel.NewMsgReadWriter(dstRw) + if br != nil && br.Buffered() > 0 { + // If Upgrade returned a non-empty reader, then probably the server + // immediately sent some data. This is not the expected behavior so + // raise an error here. + return nil, fmt.Errorf("received initial data len=%d from the server", br.Buffered()) } - tunnel.Tunnel(s.String(), srcRw, dstRw) + return newWsConn( + &readWriteCloser{ + Reader: conn, + Writer: conn, + Closer: conn, + }, + conn.RemoteAddr(), + ws.StateClientSide, + ), nil } -// auth writes the password to the destination connection in the case if it's -// specified. This is only done in client mode. -func (s *Server) auth(dstRw io.ReadWriter) { - if s.password == "" { - return - } +// respondWithDummyPage writes a dummy response to the client (part of active +// probing protection). +func (s *Server) respondWithDummyPage(conn net.Conn, req *http.Request) { + response := fmt.Sprintf("%s 403 Forbidden\r\n", req.Proto) + + "Server: nginx\r\n" + + fmt.Sprintf("Date: %s\r\n", time.Now().Format(http.TimeFormat)) + + "Content-Type: text/html\r\n" + + "Connection: close\r\n" + + "\r\n" + + "\r\n" + + "403 Forbidden\r\n" + + "

403 Forbidden

\r\n" + + "
nginx
\r\n" + + "\r\n" + + "\r\n" - _, _ = dstRw.Write([]byte(s.password + "\r\n")) + _, _ = conn.Write([]byte(response)) } -// checkAuth checks the first bytes sent by the client and looks for the -// password there. It also implements the active probing protection by detecting -// HTTP requests and returning a default stub HTTP response if detected. -// The function returns an io.ReadWriter that should be used further to work -// with this connection. -func (s *Server) checkAuth(srcConn net.Conn) (ok bool, rw io.ReadWriter) { - if s.password == "" { - // No authentication and probing checks. - return true, srcConn - } +// upgradeServerConn attempts to upgrade the server connection and returns a +// rwc that wraps the original connection and can be used for tunneling data. +func (s *Server) upgradeServerConn(conn net.Conn) (rwc io.ReadWriteCloser, err error) { + log.Debug("Upgrading connection from %s", conn.RemoteAddr()) - // Give up to 60 seconds on the authentication. - _ = srcConn.SetReadDeadline(time.Now().Add(authTimeout)) + // Give up to 60 seconds on the upgrade and authentication. + _ = conn.SetReadDeadline(time.Now().Add(upgradeTimeout)) defer func() { // Remove the deadline when it's not required any more. - _ = srcConn.SetReadDeadline(time.Time{}) + _ = conn.SetReadDeadline(time.Time{}) }() // bufio.Reader may read more than requested, so it's crucial to use // TeeReader so that we could restore the bytes that has been read. var buf bytes.Buffer - r := bufio.NewReader(io.TeeReader(srcConn, &buf)) + r := bufio.NewReader(io.TeeReader(conn, &buf)) - lineBytes, err := r.ReadBytes('\n') + req, err := http.ReadRequest(r) if err != nil { - log.Debug("Could not read password from the first bytes: %v", err) - - return false, srcConn + return nil, fmt.Errorf("cannot read HTTP request: %w", err) } - line := strings.TrimSpace(string(lineBytes)) - if s.password == line { - log.Debug("Authentication successful") + if !strings.EqualFold(req.Header.Get("Upgrade"), "websocket") { + s.respondWithDummyPage(conn, req) - // Skip the line that contains the password, we don't need it anymore. - _, _ = buf.ReadBytes('\n') + return nil, fmt.Errorf("not a websocket") + } - // Now that authentication has been successful, return a new - // io.ReadWriter that restores the first bytes save for the password - // bytes. - rw = &multiReadWriter{ - Reader: io.MultiReader(bytes.NewReader(buf.Bytes()), srcConn), - Writer: srcConn, - } + clientPassword := req.URL.Query().Get("password") + if s.password != "" && clientPassword != s.password { + s.respondWithDummyPage(conn, req) - return true, rw + return nil, fmt.Errorf("wrong password: %s", clientPassword) } - log.Debug("Authentication unsuccessful, check if probing detection is required") + // Now that authentication check has been done restore the peeked up data + // and use ws.Upgrade to do the actual WebSocket upgrade. + multiRwc := &readWriteCloser{ + Reader: io.MultiReader(bytes.NewReader(buf.Bytes()), conn), + Writer: conn, + Closer: conn, + } - method, rest, ok1 := strings.Cut(line, " ") - requestURI, proto, ok2 := strings.Cut(rest, " ") - if !ok1 || !ok2 || !strings.HasPrefix(proto, "HTTP/1") { - // Not HTTP protocol for sure, existing right away. - return false, srcConn + _, err = ws.Upgrade(multiRwc) + if err != nil { + return nil, fmt.Errorf("failed to upgrade WebSocket: %w", err) } - log.Debug("Detected HTTP: %s %s %s", method, requestURI, proto) + return newWsConn(multiRwc, conn.RemoteAddr(), ws.StateServerSide), nil +} - // Mimic nginx default 403 Forbidden response. - response := fmt.Sprintf("%s 403 Forbidden\r\n", proto) + - "Server: nginx\r\n" + - fmt.Sprintf("Date: %s\r\n", time.Now().Format(http.TimeFormat)) + - "Content-Type: text/html\r\n" + - "Connection: close\r\n" + - "\r\n" + - "\r\n" + - "403 Forbidden\r\n" + - "
nginx
\r\n" + - "\r\n" + - "\r\n" +// serveConn processes incoming connection, authenticates it and proxies the +// data from it to the destination address. +func (s *Server) serveConn(conn net.Conn) { + defer func() { + s.wg.Done() - log.Debug("Returned a stub HTTP response") + s.closeSrcConn(conn) + }() - // Writing the stub response. - _, _ = srcConn.Write([]byte(response)) + var rwc io.ReadWriteCloser = conn - return false, srcConn -} + if s.serverMode { + var err error + rwc, err = s.upgradeServerConn(conn) + if err != nil { + log.Error("failed to accept server conn: %v", err) -// multiReadWriter is a helper object that's used for replacing io.ReadWriter -// when the server peeked into the connection. -type multiReadWriter struct { - io.Reader - io.Writer + return + } + } + + s.processConn(rwc) } -// type check -var _ io.ReadWriter = (*multiReadWriter)(nil) +// processConn processes the prepared server connection that is passed as rwc. +func (s *Server) processConn(rwc io.ReadWriteCloser) { + var dstConn net.Conn + + defer s.closeDstConn(dstConn) + + dstConn, err := s.dialDst() + if err != nil { + log.Error("failed to connect to %s: %v", s.destinationAddr, err) + + return + } + + func() { + s.dstConnsMu.Lock() + defer s.dstConnsMu.Unlock() + + // Track the connection to allow unblocking reads on shutdown. + s.dstConns[dstConn] = struct{}{} + }() + + var dstRwc io.ReadWriteCloser = dstConn + if !s.serverMode { + dstRwc, err = s.upgradeClientConn(dstConn) + if err != nil { + log.Error("failed to upgrade: %v", err) + } + } + + // Prepare ReadWriter objects for tunneling. + var srcRw, dstRw io.ReadWriter + srcRw = rwc + dstRw = dstRwc + + // When the client communicates with the server it uses encoded messages so + // connection between them needs to be wrapped. In server mode it is the + // source connection, in client mode it is the destination connection. + if s.serverMode { + srcRw = tunnel.NewMsgReadWriter(srcRw) + } else { + dstRw = tunnel.NewMsgReadWriter(dstRw) + } + + tunnel.Tunnel(s.String(), srcRw, dstRw) +} // dialDst creates a connection to the destination. Depending on the mode the // server operates in, it is either a TLS connection or a UDP connection. @@ -548,7 +577,7 @@ func (s *Server) dialDst() (conn net.Conn, err error) { return nil, fmt.Errorf("failed to open connection to %s: %w", s.destinationAddr, err) } - tlsConn := tls.UClient(tcpConn, s.tlsConfig, tls.HelloChrome_Auto) + tlsConn := tls.UClient(tcpConn, s.tlsConfig, tls.HelloAndroid_11_OkHttp) err = tlsConn.Handshake() if err != nil { diff --git a/internal/pipe/server_test.go b/internal/pipe/server_test.go index aec3548..3125e4c 100644 --- a/internal/pipe/server_test.go +++ b/internal/pipe/server_test.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "net" + "strings" "testing" "github.com/AdguardTeam/golibs/log" @@ -55,8 +56,8 @@ func TestServer_Start_integration(t *testing.T) { pipeConn, err := net.Dial("udp", pipeClient.Addr().String()) require.NoError(t, err) - for i := 0; i < 10; i++ { - strMsg := fmt.Sprintf("test message %d", i) + for i := 0; i < 1000; i++ { + strMsg := fmt.Sprintf("test message %d: %s", i, strings.Repeat("a", i)) msg := []byte(strMsg) // Write a message to the pipe. diff --git a/internal/pipe/websocket.go b/internal/pipe/websocket.go new file mode 100644 index 0000000..5425c82 --- /dev/null +++ b/internal/pipe/websocket.go @@ -0,0 +1,86 @@ +package pipe + +import ( + "io" + "net" + + "github.com/AdguardTeam/golibs/log" + "github.com/gobwas/ws" + "github.com/gobwas/ws/wsutil" +) + +// wsConn represents a WebSocket connection that's been already initialized. +type wsConn struct { + rwc io.ReadWriteCloser + remoteAddr net.Addr + r *wsutil.Reader + w *wsutil.Writer +} + +// newWsConn creates a wrapper over the existing network connection that is +// able to send/read messages using WebSocket protocol. +func newWsConn(rwc io.ReadWriteCloser, remoteAddr net.Addr, state ws.State) (c *wsConn) { + r := wsutil.NewReader(rwc, state) + w := wsutil.NewWriter(rwc, state, ws.OpBinary) + + return &wsConn{ + rwc: rwc, + remoteAddr: remoteAddr, + r: r, + w: w, + } +} + +// type check +var _ io.ReadWriteCloser = (*wsConn)(nil) + +// Read implements the io.ReadWriteCloser interface for *wsConn. +func (w *wsConn) Read(b []byte) (n int, err error) { + n, err = w.r.Read(b) + if err == wsutil.ErrNoFrameAdvance { + log.Debug("Reading the next WebSocket frame from %v", w.remoteAddr) + + hdr, fErr := w.r.NextFrame() + if fErr != nil { + return 0, io.EOF + } + + log.Debug( + "Received WebSocket frame with opcode=%d len=%d fin=%v from %v", + hdr.OpCode, + hdr.Length, + hdr.Fin, + w.remoteAddr, + ) + + // Reading again after the frame has been read. + n, err = w.r.Read(b) + + // EOF in the case of wsutil.Reader does not mean that the connection is + // closed, it only means that the current frame is finished. + if err == io.EOF { + err = nil + } + } + + return n, err +} + +// Write implements the io.ReadWriteCloser interface for *wsConn. +func (w *wsConn) Write(b []byte) (n int, err error) { + log.Debug("Writing data len=%d to the WebSocket %v", len(b), w.remoteAddr) + + n, err = w.w.Write(b) + if err != nil { + return 0, err + } + + err = w.w.Flush() + + return n, err +} + +// Close implements the io.ReadWriteCloser interface for *wsConn. +func (w *wsConn) Close() (err error) { + return w.rwc.Close() +} diff --git a/internal/tunnel/msgreadwriter.go b/internal/tunnel/msgreadwriter.go index c3b8a1f..e377970 100644 --- a/internal/tunnel/msgreadwriter.go +++ b/internal/tunnel/msgreadwriter.go @@ -17,6 +17,10 @@ const MaxMessageLength = 1280 // will be padded with random bytes. const MinMessageLength = 100 +// MaxPaddingLength is the maximum size of a random padding that's added to +// every message. +const MaxPaddingLength = 256 + // MsgReadWriter is a wrapper over io.ReadWriter that encodes messages written // to and read from the base writer. type MsgReadWriter struct { @@ -71,7 +75,7 @@ func (rw *MsgReadWriter) Write(b []byte) (n int, err error) { if minLength <= 0 { minLength = 1 } - maxLength := 256 + maxLength := MaxPaddingLength if maxLength <= minLength { maxLength = minLength + 1 }