From ef39dfc5a101c615add1d98d750fc8b4fda20a2e Mon Sep 17 00:00:00 2001 From: Vasile Popescu Date: Tue, 22 Nov 2022 21:09:40 +0100 Subject: [PATCH] Add support for TCP tunneling Tunnel a TCP connection from the local client side to the remote server side. This, of course, works only for the command line client, and not the web browser client. --- README.md | 10 +++ client.go | 140 ++++++++++++++++++++++++++++++++------ main.go | 41 ++++++----- server/server.go | 129 +++++++++++++++++++++++++++++++---- server/tty_protocol_rw.go | 7 +- server/ws_rw_wrapper.go | 64 +++++++++++++++++ 6 files changed, 337 insertions(+), 54 deletions(-) create mode 100644 server/ws_rw_wrapper.go diff --git a/README.md b/README.md index 633f103..11d97e8 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,16 @@ You can join a session by opening the session URLs in the browser, or with anoth ~ $ tty-share https://on.tty-share.com/s/L8d2ECvHLhU8CXEBaEF5WKV8O3jsZkS5sXwG1__--2_jnFSlGonzXBe0qxd7tZeRvQM/ ``` +**Join a session with TCP port forwarding** + +You can use the `-L` option to create a TCP tunnel, similarly to how you would do it with `ssh`: +``` +tty-share -L 1234:example.com:4567 https://on.tty-share.com/s/L8d2ECvHLhU8CXEBaEF5WKV8O3jsZkS5sXwG1__--2_jnFSlGonzXBe0qxd7tZeRvQM/ +``` +This will make `tty-share` listen locally on port `1234` and forward all connections to `example.com:4567` from the remote side. +The server needs to allow this, by using the `-A` flag. + + ## Building Simply run diff --git a/client.go b/client.go index e7c86a6..61f278d 100644 --- a/client.go +++ b/client.go @@ -1,44 +1,53 @@ package main import ( + "encoding/json" "fmt" "io" + "net" "net/http" "net/url" "os" "os/signal" + "strconv" + "strings" "sync" "sync/atomic" "syscall" "github.com/elisescu/tty-share/server" "github.com/gorilla/websocket" + "github.com/hashicorp/yamux" "github.com/moby/term" log "github.com/sirupsen/logrus" ) type ttyShareClient struct { - url string - wsConn *websocket.Conn - detachKeys string - wcChan chan os.Signal - ioFlagAtomic uint32 // used with atomic - winSizes struct { + url string + ttyWsConn *websocket.Conn + tunnelWsConn *websocket.Conn + tunnelAddresses *string + detachKeys string + wcChan chan os.Signal + ioFlagAtomic uint32 // used with atomic + winSizes struct { thisW uint16 thisH uint16 remoteW uint16 remoteH uint16 } - winSizesMutex sync.Mutex + winSizesMutex sync.Mutex + tunnelMuxSession *yamux.Session } -func newTtyShareClient(url string, detachKeys string) *ttyShareClient { +func newTtyShareClient(url string, detachKeys string, tunnelConfig *string) *ttyShareClient { return &ttyShareClient{ - url: url, - wsConn: nil, - detachKeys: detachKeys, - wcChan: make(chan os.Signal, 1), - ioFlagAtomic: 1, + url: url, + ttyWsConn: nil, + detachKeys: detachKeys, + wcChan: make(chan os.Signal, 1), + ioFlagAtomic: 1, + tunnelAddresses: tunnelConfig, } } @@ -103,7 +112,10 @@ func (c *ttyShareClient) Run() (err error) { } // Get the path of the websockts route from the header - wsPath := resp.Header.Get("TTYSHARE-WSPATH") + ttyWsPath := resp.Header.Get("TTYSHARE-TTY-WSPATH") + ttyWSProtocol := resp.Header.Get("TTYSHARE-VERSION") + + ttyTunnelPath := resp.Header.Get("TTYSHARE-TUNNEL-WSPATH") // Build the WS URL from the host part of the given http URL and the wsPath httpURL, err := url.Parse(c.url) @@ -114,14 +126,98 @@ func (c *ttyShareClient) Run() (err error) { if httpURL.Scheme == "https" { wsScheme = "wss" } - wsURL := wsScheme + "://" + httpURL.Host + wsPath + ttyWsURL := wsScheme + "://" + httpURL.Host + ttyWsPath + ttyTunnelURL := wsScheme + "://" + httpURL.Host + ttyTunnelPath - log.Debugf("Built the WS URL from the headers: %s", wsURL) + log.Debugf("Built the WS URL from the headers: %s", ttyWsURL) - c.wsConn, _, err = websocket.DefaultDialer.Dial(wsURL, nil) + c.ttyWsConn, _, err = websocket.DefaultDialer.Dial(ttyWsURL, nil) if err != nil { return } + defer c.ttyWsConn.Close() + + tunnelFunc := func() { + if *c.tunnelAddresses == "" { + // Don't build a tunnel + return + } + + if ver, err := strconv.Atoi(ttyWSProtocol); err != nil || ver < 2 { + log.Fatalf("Cannot create a tunnel. Server too old (protocol %d, required min. 2)", ver) + } + + c.tunnelWsConn, _, err = websocket.DefaultDialer.Dial(ttyTunnelURL, nil) + if err != nil { + log.Errorf("Cannot create a tunnel connection with the server. Server needs to allow that") + return + } + defer c.tunnelWsConn.Close() + + a := strings.Split(*c.tunnelAddresses, ":") + tunnelRemoteAddress := fmt.Sprintf("%s:%s", a[1], a[2]) + tunnelLocalAddress := fmt.Sprintf(":%s", a[0]) + + initMsg := server.TunInitMsg{ + Address: tunnelRemoteAddress, + } + + data, err := json.Marshal(initMsg) + if err != nil { + log.Errorf("Could not marshal the tunnel init message: %s", err.Error()) + return + } + + err = c.tunnelWsConn.WriteMessage(websocket.TextMessage, data) + + if err != nil { + log.Errorf("Could not initiate the tunnel: %s", err.Error()) + return + } + + wsWRC := server.WSConnReadWriteCloser{ + WsConn: c.tunnelWsConn, + } + + localListener, err := net.Listen("tcp", tunnelLocalAddress) + if err != nil { + log.Errorf("Could not listen locally for the tunnel: %s", err.Error()) + + } + + c.tunnelMuxSession, err = yamux.Server(&wsWRC, nil) + if err != nil { + log.Errorf("Could not create mux server: %s", err.Error()) + } + + for { + localTunconn, err := localListener.Accept() + + if err != nil { + log.Warnf("Cannot accept local tunnel connections: ", err.Error()) + return + } + + muxClient, err := c.tunnelMuxSession.Open() + if err != nil { + log.Warnf("Cannot create a muxer to the remote, over ws: ", err.Error()) + return + } + + go func() { + io.Copy(muxClient, localTunconn) + defer localTunconn.Close() + defer muxClient.Close() + }() + + go func() { + io.Copy(localTunconn, muxClient) + defer localTunconn.Close() + defer muxClient.Close() + }() + + } + } detachBytes, err := term.ToBytes(c.detachKeys) if err != nil { @@ -133,7 +229,7 @@ func (c *ttyShareClient) Run() (err error) { defer term.RestoreTerminal(os.Stdin.Fd(), state) clearScreen() - protoWS := server.NewTTYProtocolWSLocked(c.wsConn) + protoWS := server.NewTTYProtocolWSLocked(c.ttyWsConn) monitorWinChanges := func() { // start monitoring the size of the terminal @@ -197,6 +293,7 @@ func (c *ttyShareClient) Run() (err error) { go monitorWinChanges() go writeLoop() + go tunnelFunc() readLoop() clearScreen() @@ -204,6 +301,11 @@ func (c *ttyShareClient) Run() (err error) { } func (c *ttyShareClient) Stop() { - c.wsConn.Close() + // if we had a tunnel, close it + if c.tunnelMuxSession != nil { + c.tunnelMuxSession.Close() + c.tunnelWsConn.Close() + } + c.ttyWsConn.Close() signal.Stop(c.wcChan) } diff --git a/main.go b/main.go index bbaf4f3..a2584fd 100644 --- a/main.go +++ b/main.go @@ -16,12 +16,13 @@ import ( var version string = "0.0.0" -func createServer(frontListenAddress string, frontendPath string, pty server.PTYHandler, sessionID string) *server.TTYServer { +func createServer(frontListenAddress string, frontendPath string, pty server.PTYHandler, sessionID string, allowTunneling bool) *server.TTYServer { config := ttyServer.TTYServerConfig{ FrontListenAddress: frontListenAddress, FrontendPath: frontendPath, PTY: pty, SessionID: sessionID, + AllowTunneling: allowTunneling, } server := ttyServer.NewTTYServer(config) @@ -47,7 +48,7 @@ Usage: [--logfile ] [--listen <[ip]:port>] [--frontend-path ] [--tty-proxy ] [--readonly] [--public] [no-tls] [--verbose] [--version] - tty-share [--verbose] [--logfile ] + tty-share [--verbose] [--logfile ] [-L ::] [--detach-keys] # connect to an existing session, as a client Examples: @@ -60,25 +61,30 @@ Examples: tty-share http://localhost:8000/s/local/ Flags: +[c] - flags that are used only by the client +[s] - flags that are used only by the server ` - commandName := flag.String("command", os.Getenv("SHELL"), "The command to run") + commandName := flag.String("command", os.Getenv("SHELL"), "[s] The command to run") if *commandName == "" { *commandName = "bash" } - commandArgs := flag.String("args", "", "The command arguments") + commandArgs := flag.String("args", "", "[s] The command arguments") logFileName := flag.String("logfile", "-", "The name of the file to log") - listenAddress := flag.String("listen", "localhost:8000", "tty-server address") + listenAddress := flag.String("listen", "localhost:8000", "[s] tty-server address") versionFlag := flag.Bool("version", false, "Print the tty-share version") - frontendPath := flag.String("frontend-path", "", "The path to the frontend resources. By default, these resources are included in the server binary, so you only need this path if you don't want to use the bundled ones.") - proxyServerAddress := flag.String("tty-proxy", "on.tty-share.com:4567", "Address of the proxy for public facing connections") - readOnly := flag.Bool("readonly", false, "Start a read only session") - publicSession := flag.Bool("public", false, "Create a public session") - noTLS := flag.Bool("no-tls", false, "Don't use TLS to connect to the tty-proxy server. Useful for local debugging") - noWaitEnter := flag.Bool("no-wait", false, "Don't wait for the Enter press before starting the session") - headless := flag.Bool("headless", false, "Don't expect an interactive terminal at stdin") - headlessCols := flag.Int("headless-cols", 80, "Number of cols for the allocated pty when running headless") - headlessRows := flag.Int("headless-rows", 25, "Number of rows for the allocated pty when running headless") - detachKeys := flag.String("detach-keys", "ctrl-o,ctrl-c", "Sequence of keys to press for closing the connection. Supported: https://godoc.org/github.com/moby/term#pkg-variables.") + frontendPath := flag.String("frontend-path", "", "[s] The path to the frontend resources. By default, these resources are included in the server binary, so you only need this path if you don't want to use the bundled ones.") + proxyServerAddress := flag.String("tty-proxy", "on.tty-share.com:4567", "[s] Address of the proxy for public facing connections") + readOnly := flag.Bool("readonly", false, "[s] Start a read only session") + publicSession := flag.Bool("public", false, "[s] Create a public session") + noTLS := flag.Bool("no-tls", false, "[s] Don't use TLS to connect to the tty-proxy server. Useful for local debugging") + noWaitEnter := flag.Bool("no-wait", false, "[s] Don't wait for the Enter press before starting the session") + headless := flag.Bool("headless", false, "[s] Don't expect an interactive terminal at stdin") + headlessCols := flag.Int("headless-cols", 80, "[s] Number of cols for the allocated pty when running headless") + headlessRows := flag.Int("headless-rows", 25, "[s] Number of rows for the allocated pty when running headless") + detachKeys := flag.String("detach-keys", "ctrl-o,ctrl-c", "[c] Sequence of keys to press for closing the connection. Supported: https://godoc.org/github.com/moby/term#pkg-variables.") + allowTunneling := flag.Bool("A", false, "[s] Allow clients to create a TCP tunnel") + tunnelConfig := flag.String("L", "", "[c] TCP tunneling addresses: local_port:remote_host:remote_port. The client will listen on local_port for TCP connections, and will forward those to the from the server side to remote_host:remote_port") + verbose := flag.Bool("verbose", false, "Verbose logging") flag.Usage = func() { fmt.Fprintf(flag.CommandLine.Output(), "%s", usageString) @@ -113,7 +119,8 @@ Flags: args := flag.Args() if len(args) == 1 { connectURL := args[0] - client := newTtyShareClient(connectURL, *detachKeys) + + client := newTtyShareClient(connectURL, *detachKeys, tunnelConfig) err := client.Run() if err != nil { @@ -188,7 +195,7 @@ Flags: pty = &nilPTY{} } - server := createServer(*listenAddress, *frontendPath, pty, sessionID) + server := createServer(*listenAddress, *frontendPath, pty, sessionID, *allowTunneling) if cols, rows, e := ptyMaster.GetWinSize(); e == nil { server.WindowSize(cols, rows) } diff --git a/server/server.go b/server/server.go index d85e83d..ffbb129 100644 --- a/server/server.go +++ b/server/server.go @@ -1,15 +1,19 @@ package server import ( + "encoding/json" "fmt" "html/template" + "io" "mime" + "net" "net/http" "os" "path/filepath" "github.com/gorilla/mux" "github.com/gorilla/websocket" + "github.com/hashicorp/yamux" log "github.com/sirupsen/logrus" ) @@ -36,13 +40,15 @@ type TTYServerConfig struct { FrontendPath string PTY PTYHandler SessionID string + AllowTunneling bool } // TTYServer represents the instance of a tty server type TTYServer struct { - httpServer *http.Server - config TTYServerConfig - session *ttyShareSession + httpServer *http.Server + config TTYServerConfig + session *ttyShareSession + muxTunnelSession *yamux.Session } func (server *TTYServer) serveContent(w http.ResponseWriter, r *http.Request, name string) { @@ -90,30 +96,44 @@ func NewTTYServer(config TTYServerConfig) (server *TTYServer) { installHandlers := func(session string) { // This function installs handlers for paths that contain the "session" passed as a // parameter. The paths are for the static files, websockets, and other. - path := fmt.Sprintf("/s/%s/static/", session) - routesHandler.PathPrefix(path).Handler(http.StripPrefix(path, + staticPath := "/s/" + session + "/static/" + ttyWsPath := "/s/" + session + "/ws" + tunnelWsPath := "/s/" + session + "/tws" + pathPrefix := "/s/" + session + + routesHandler.PathPrefix(staticPath).Handler(http.StripPrefix(staticPath, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server.serveContent(w, r, r.URL.Path) }))) - routesHandler.HandleFunc(fmt.Sprintf("/s/%s/", session), func(w http.ResponseWriter, r *http.Request) { - wsPath := "/s/" + session + "/ws" - pathPrefix := "/s/" + session + routesHandler.HandleFunc(pathPrefix+"/", func(w http.ResponseWriter, r *http.Request) { // Check the frontend/templates/tty-share.in.html file to see where the template applies templateModel := struct { PathPrefix string WSPath string - }{pathPrefix, wsPath} + }{pathPrefix, ttyWsPath} // TODO Extract these in constants - w.Header().Add("TTYSHARE-VERSION", "1") - w.Header().Add("TTYSHARE-WSPATH", wsPath) + w.Header().Add("TTYSHARE-VERSION", "2") + + // Deprecated HEADER (from prev version) + // TODO: Find a proper way to stop handling backward versions + w.Header().Add("TTYSHARE-WSPATH", ttyWsPath) + + w.Header().Add("TTYSHARE-TTY-WSPATH", ttyWsPath) + w.Header().Add("TTYSHARE-TUNNEL-WSPATH", tunnelWsPath) server.handleWithTemplateHtml(w, r, "tty-share.in.html", templateModel) }) - routesHandler.HandleFunc(fmt.Sprintf("/s/%s/ws", session), func(w http.ResponseWriter, r *http.Request) { - server.handleWebsocket(w, r) + routesHandler.HandleFunc(ttyWsPath, func(w http.ResponseWriter, r *http.Request) { + server.handleTTYWebsocket(w, r) }) + if server.config.AllowTunneling { + // tunnel websockets connection + routesHandler.HandleFunc(tunnelWsPath, func(w http.ResponseWriter, r *http.Request) { + server.handleTunnelWebsocket(w, r) + }) + } routesHandler.NotFoundHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { templateModel := struct{ PathPrefix string }{fmt.Sprintf("/s/%s", session)} server.handleWithTemplateHtml(w, r, "404.in.html", templateModel) @@ -131,7 +151,7 @@ func NewTTYServer(config TTYServerConfig) (server *TTYServer) { return server } -func (server *TTYServer) handleWebsocket(w http.ResponseWriter, r *http.Request) { +func (server *TTYServer) handleTTYWebsocket(w http.ResponseWriter, r *http.Request) { if r.Method != "GET" { w.WriteHeader(http.StatusForbidden) return @@ -153,6 +173,84 @@ func (server *TTYServer) handleWebsocket(w http.ResponseWriter, r *http.Request) server.session.HandleWSConnection(conn) } +func (server *TTYServer) handleTunnelWebsocket(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + w.WriteHeader(http.StatusForbidden) + return + } + + upgrader := websocket.Upgrader{ + ReadBufferSize: 1024, + WriteBufferSize: 1024, + } + wsConn, err := upgrader.Upgrade(w, r, nil) + + if err != nil { + log.Error("Cannot upgrade to WS for tunnel route connection: ", err.Error()) + return + } + defer wsConn.Close() + + // Read the first message on this ws route, and expect it to be a json containing the address + // to tunnel to. After that first message, will follow the raw connection data + _, wsReader, err := wsConn.NextReader() + if err != nil { + log.Error("Cannot read from the tunnel WS connection ", err.Error()) + return + } + + var tunInitMsg TunInitMsg + err = json.NewDecoder(wsReader).Decode(&tunInitMsg) + + if err != nil { + log.Error("Cannot decode the tunnel init message ", err.Error()) + return + } + + wsRW := &WSConnReadWriteCloser{ + WsConn: wsConn, + } + + server.muxTunnelSession, err = yamux.Server(wsRW, nil) + + if err != nil { + log.Errorf("Could not open a mux server: ", err.Error()) + return + } + + for { + muxStream, err := server.muxTunnelSession.Accept() + + if err != nil { + if err != io.EOF { + log.Warnf("Mux cannot accept new connections: %s", err.Error()) + } + return + } + + localConn, err := net.Dial("tcp", tunInitMsg.Address) + if err != nil { + log.Error("Cannot create local connection ", err.Error()) + return + } + + go func() { + io.Copy(muxStream, localConn) + // Not sure yet which of the two io.Copy finishes first, so just close everything in both cases + defer localConn.Close() + defer muxStream.Close() + }() + + go func() { + io.Copy(localConn, muxStream) + // Not sure yet which of the two io.Copy finishes first, so just close everything in both cases + defer muxStream.Close() + defer localConn.Close() + }() + } + +} + func panicIfErr(err error) { if err != nil { panic(err.Error()) @@ -193,5 +291,8 @@ func (server *TTYServer) WindowSize(cols, rows int) (err error) { func (server *TTYServer) Stop() error { log.Debug("Stopping the server") + if server.muxTunnelSession != nil { + server.muxTunnelSession.Close() + } return server.httpServer.Close() } diff --git a/server/tty_protocol_rw.go b/server/tty_protocol_rw.go index 0033c56..3160d67 100644 --- a/server/tty_protocol_rw.go +++ b/server/tty_protocol_rw.go @@ -33,13 +33,13 @@ type OnMsgWrite func(data []byte) type OnMsgWinSize func(cols, rows int) type TTYProtocolWSLocked struct { - ws *websocket.Conn - lock sync.Mutex + ws *websocket.Conn + lock sync.Mutex } func NewTTYProtocolWSLocked(ws *websocket.Conn) *TTYProtocolWSLocked { return &TTYProtocolWSLocked{ - ws: ws, + ws: ws, } } @@ -68,7 +68,6 @@ func marshalMsg(aMessage interface{}) (_ []byte, err error) { return nil, nil } - func (handler *TTYProtocolWSLocked) ReadAndHandle(onWrite OnMsgWrite, onWinSize OnMsgWinSize) (err error) { var msg MsgWrapper diff --git a/server/ws_rw_wrapper.go b/server/ws_rw_wrapper.go new file mode 100644 index 0000000..0068970 --- /dev/null +++ b/server/ws_rw_wrapper.go @@ -0,0 +1,64 @@ +package server + +import ( + "io" + + "github.com/gorilla/websocket" +) + +type TunInitMsg struct { + Address string +} + +type WSConnReadWriteCloser struct { + WsConn *websocket.Conn + reader io.Reader +} + +func (conn *WSConnReadWriteCloser) Read(p []byte) (n int, err error) { + // Weird method here, as we need to do a few things: + // - re-use the WS reader between different calls of this function. If the existing reader + // has no more data, then get another reader (NextReader()) + // - if we get a CloseAbnormalClosure, or CloseGoingAway error message from WS, we need to + // transform that into a io.EOF, otherwise yamux will complain. We use yamux on top of this + // reader interface, in order to multiplex multiple streams + // More here: + // https://github.com/hashicorp/yamux/blob/574fd304fd659b0dfdd79e221f4e34f6b7cd9ed2/session.go#L554 + // https://github.com/gorilla/websocket/blob/b65e62901fc1c0d968042419e74789f6af455eb9/examples/chat/client.go#L67 + // https://stackoverflow.com/questions/61108552/go-websocket-error-close-1006-abnormal-closure-unexpected-eof + + filterErr := func() { + + if err != nil && !websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) { + // if we have an error != nil, and it's one of the two, then return EOF + err = io.EOF + } + } + + defer filterErr() + + if conn.reader != nil { + n, err = conn.reader.Read(p) + + if err == io.EOF { + // if this reader has no more data, get the next reader + _, conn.reader, err = conn.WsConn.NextReader() + + if err == nil { + // and read in this same call as well + return conn.reader.Read(p) + } + } + } else { + _, conn.reader, err = conn.WsConn.NextReader() + } + return +} + +func (conn *WSConnReadWriteCloser) Write(p []byte) (n int, err error) { + return len(p), conn.WsConn.WriteMessage(websocket.BinaryMessage, p) +} + +func (conn *WSConnReadWriteCloser) Close() error { + return conn.WsConn.Close() +}