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.
pull/66/head
Vasile Popescu 1 year ago committed by Elis Popescu
parent f67f97c41d
commit ef39dfc5a1

@ -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

@ -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)
}

@ -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 <file name>] [--listen <[ip]:port>]
[--frontend-path <path>] [--tty-proxy <host:port>]
[--readonly] [--public] [no-tls] [--verbose] [--version]
tty-share [--verbose] [--logfile <file name>]
tty-share [--verbose] [--logfile <file name>] [-L <local_port>:<remote_host>:<remote_port>]
[--detach-keys] <session URL> # 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)
}

@ -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()
}

@ -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

@ -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()
}
Loading…
Cancel
Save