diff --git a/.gitignore b/.gitignore index b512c09..94fc4f2 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ -node_modules \ No newline at end of file +node_modules +.parcel-cache \ No newline at end of file diff --git a/cmd/gterm/main.go b/cmd/gterm/main.go index a26fa46..b04b814 100644 --- a/cmd/gterm/main.go +++ b/cmd/gterm/main.go @@ -1,29 +1,14 @@ package main import ( - _ "embed" "flag" - "net/http" - "os" - "os/exec" - "strings" - "github.com/creack/pty" - "github.com/gorilla/websocket" - "kumoly.io/lib/klog" "kumoly.io/lib/ksrv" - "kumoly.io/lib/ksrv/engine" - "kumoly.io/tools/goshell/public" + "kumoly.io/tools/gterm" ) -//go:embed index.html -var index string - -var tmpl *engine.Engine -var servePublic = http.FileServer(http.FS(public.FS)) - var ( - flagAppName string + flagAppName string = "gterm" flagAddr string flagShell string ) @@ -37,59 +22,7 @@ func main() { flag.Parse() server := ksrv.New() + g := gterm.New() - mux := http.NewServeMux() - - tmpl = engine.Must(engine.New("").Parse(index)) - mux.HandleFunc("/", func(rw http.ResponseWriter, r *http.Request) { - if r.URL.String() == "/" { - tmpl.Execute(rw, App{flagAppName}) - return - } - file, err := public.FS.Open(strings.TrimPrefix(r.URL.String(), "/")) - if err != nil { - klog.Debug(err) - tmpl.Execute(rw, App{flagAppName}) - return - } - stat, err := file.Stat() - if err != nil || stat.IsDir() { - klog.Debug(err) - tmpl.Execute(rw, App{flagAppName}) - return - } - servePublic.ServeHTTP(rw, r) - }) - - server.Handle(mux).Listen(flagAddr).Serve() -} - -type App struct { - AppName string -} - -type windowSize struct { - Rows uint16 `json:"rows"` - Cols uint16 `json:"cols"` - X uint16 - Y uint16 -} - -var upgrader = websocket.Upgrader{ - ReadBufferSize: 1024, - WriteBufferSize: 1024, -} - -func handleWebsocket(w http.ResponseWriter, r *http.Request) { - l := klog.Sub(ksrv.GetIP(r)) - conn, err := upgrader.Upgrade(w, r, nil) - if err != nil { - l.Error("Unable to upgrade connection, err: ", err) - return - } - - cmd := exec.Command("/bin/bash", "-l") - cmd.Env = append(os.Environ(), "TERM=xterm") - pty.Winsize - + server.Handle(g).Listen(flagAddr).Serve() } diff --git a/go.mod b/go.mod index aa58cd2..ec12ecc 100644 --- a/go.mod +++ b/go.mod @@ -1,10 +1,11 @@ -module kumoly.io/tools/goshell +module kumoly.io/tools/gterm go 1.17 require ( github.com/creack/pty v1.1.17 github.com/gorilla/websocket v1.4.2 + github.com/rs/xid v1.3.0 kumoly.io/lib/klog v0.0.8 kumoly.io/lib/ksrv v0.0.2-0.20211112060911-0d61b343a298 ) diff --git a/go.sum b/go.sum index 78f97c1..cbad9e5 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,8 @@ github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0U github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/rs/xid v1.3.0 h1:6NjYksEUlhurdVehpc7S7dk6DAmcKv8V9gG0FsVN2U4= +github.com/rs/xid v1.3.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211103235746-7861aae1554b h1:1VkfZQv42XQlA/jchYumAnv1UPo6RgF9rJFkTgZIxO4= golang.org/x/sys v0.0.0-20211103235746-7861aae1554b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/gterm.go b/gterm.go index 87128da..b8ea01d 100644 --- a/gterm.go +++ b/gterm.go @@ -1,33 +1,287 @@ package gterm import ( + "bytes" + _ "embed" + "encoding/json" + "fmt" "net/http" + "os" + "os/exec" + "strings" + "sync" + "time" + "github.com/creack/pty" + "github.com/gorilla/websocket" + "github.com/rs/xid" "kumoly.io/lib/klog" "kumoly.io/lib/ksrv" + "kumoly.io/lib/ksrv/engine" + "kumoly.io/tools/gterm/public" ) -var log = klog.Sub("gterm") +//go:embed index.html +var index string -type Config struct { - AllowIP string +var tmpl *engine.Engine +var servePublic = http.FileServer(http.FS(public.FS)) + +var pool map[string]chan struct{} + +func init() { + tmpl = engine.Must(engine.New("").Parse(index)) + pool = map[string]chan struct{}{} } type GTerm struct { -} - -func init() { + AppName string + // Arguments is a list of strings to pass as arguments to the specified Command + Args []string + // Command is the path to the binary we should create a TTY for + Cmd string + // Envs env pairs to pass to the command + Envs []string + // ErrorLimit defines the number of consecutive errors that can happen + // before a connection is considered unusable + ErrorLimit int + // Timeout defines the maximum duration between which a ping and pong + // cycle should be tolerated, beyond this the connection should be deemed dead + Timeout time.Duration + BufferSize int } func New() *GTerm { - g := >erm{} - ksrv.New() - return g + // if g.Cmd == "" { + // g.Cmd = "bash" + // } + // if len(g.Envs) == 0 { + // g.Envs = os.Environ() + // } + // if g.Timeout < time.Second { + // g.Timeout = 20 * time.Second + // } + // if g.ErrorLimit == 0 { + // g.ErrorLimit = 10 + // } + // if g.BufferSize == 0 { + // g.BufferSize = 512 + // } + return >erm{ + AppName: "GTERM", + Cmd: "bash", + Envs: os.Environ(), + Timeout: 20 * time.Second, + ErrorLimit: 10, + BufferSize: 512, + } } func (g *GTerm) ServeHTTP(w http.ResponseWriter, r *http.Request) { - + if strings.HasPrefix(r.URL.String(), "/ws") { + g.WS(w, r) + } else { + g.App(w, r) + } } -func (g *GTerm) BaseEndpoint(w http.ResponseWriter, r *http.Request) +type App struct { + AppName string +} + +func (g *GTerm) App(w http.ResponseWriter, r *http.Request) { + if r.URL.String() == "/" { + tmpl.Execute(w, App{g.AppName}) + return + } + file, err := public.FS.Open(strings.TrimPrefix(r.URL.String(), "/")) + if err != nil { + // tmpl.Execute(w, App{g.AppName}) + http.NotFound(w, r) + return + } + stat, err := file.Stat() + if err != nil || stat.IsDir() { + // tmpl.Execute(w, App{g.AppName}) + http.NotFound(w, r) + return + } + servePublic.ServeHTTP(w, r) +} + +func (g *GTerm) WS(w http.ResponseWriter, r *http.Request) { + id := ksrv.GetIP(r) + " : " + xid.New().String() + pool[id] = make(chan struct{}, 1) + defer func() { + close(pool[id]) + delete(pool, id) + }() + l := klog.Sub(id) + l.Info("connection established.") + upgrader := websocket.Upgrader{ + HandshakeTimeout: 0, + ReadBufferSize: g.BufferSize, + WriteBufferSize: g.BufferSize, + } + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + l.Error(err) + return + } + + cmd := exec.Command(g.Cmd, g.Args...) + cmd.Env = g.Envs + tty, err := pty.Start(cmd) + if err != nil { + l.Error(err) + conn.WriteMessage(websocket.TextMessage, []byte(err.Error())) + } + defer func() { + if err := cmd.Process.Kill(); err != nil { + l.Error(err) + } + if _, err := cmd.Process.Wait(); err != nil { + l.Error(err) + } + if err := tty.Close(); err != nil { + l.Error(err) + } + if err := conn.Close(); err != nil { + l.Error(err) + } + }() + + var connectionClosed bool + var waiter sync.WaitGroup + waiter.Add(1) + + // this is a keep-alive loop that ensures connection does not hang-up itself + lastPongTime := time.Now() + conn.SetPongHandler(func(msg string) error { + lastPongTime = time.Now() + return nil + }) + go func() { + for { + if err := conn.WriteMessage(websocket.PingMessage, []byte("keepalive")); err != nil { + l.Warn("failed to write ping message") + return + } + time.Sleep(g.Timeout / 2) + if time.Since(lastPongTime) > g.Timeout { + l.Warn("failed to get response from ping, triggering disconnect now...") + waiter.Done() + return + } + l.Debug("received response from ping successfully") + } + }() + + // tty >> xterm.js + go func() { + errorCounter := 0 + for { + // consider the connection closed/errored out so that the socket handler + // can be terminated - this frees up memory so the service doesn't get + // overloaded + if errorCounter > g.ErrorLimit { + waiter.Done() + break + } + buffer := make([]byte, g.BufferSize) + readLength, err := tty.Read(buffer) + if err != nil { + l.Warn("failed to read from tty: ", err) + if err := conn.WriteMessage(websocket.TextMessage, []byte("bye!")); err != nil { + l.Warn("failed to send termination message from tty to xterm.js: ", err) + } + waiter.Done() + return + } + if err := conn.WriteMessage(websocket.BinaryMessage, buffer[:readLength]); err != nil { + l.Warn("failed to send ", readLength, " bytes from tty to xterm.js") + errorCounter++ + continue + } + l.Debug("sent message of size ", readLength, " bytes from tty to xterm.js") + errorCounter = 0 + } + }() + + // tty << xterm.js + go func() { + for { + // data processing + messageType, data, err := conn.ReadMessage() + if err != nil { + if !connectionClosed { + l.Warn("failed to get next reader: ", err) + } + return + } + dataLength := len(data) + dataBuffer := bytes.Trim(data, "\x00") + dataType, ok := WebsocketMessageType[messageType] + if !ok { + dataType = "uunknown" + } + l.Info(fmt.Sprintf("received %s (type: %v) message of size %v byte(s) from xterm.js with key sequence: %v", dataType, messageType, dataLength, dataBuffer)) + + // process + if dataLength == -1 { // invalid + l.Warn("failed to get the correct number of bytes read, ignoring message") + continue + } + + // handle resizing + if messageType == websocket.BinaryMessage { + if dataBuffer[0] == 1 { + ttySize := &TTYSize{} + resizeMessage := bytes.Trim(dataBuffer[1:], " \n\r\t\x00\x01") + if err := json.Unmarshal(resizeMessage, ttySize); err != nil { + l.Warn(fmt.Sprintf("failed to unmarshal received resize message '%s': %s", string(resizeMessage), err)) + continue + } + l.InfoF(klog.H{"rows": ttySize.Rows, "columns": ttySize.Cols}, "resizing tty...") + if err := pty.Setsize(tty, &pty.Winsize{ + Rows: ttySize.Rows, + Cols: ttySize.Cols, + }); err != nil { + l.Warn("failed to resize tty, error: ", err) + } + continue + } + } + + // write to tty + bytesWritten, err := tty.Write(dataBuffer) + if err != nil { + l.Warn(fmt.Sprintf("failed to write %v bytes to tty: %s", len(dataBuffer), err)) + continue + } + l.Debug(bytesWritten, " bytes written to tty...") + } + }() + + waiter.Wait() + l.Info("closing connection...") + connectionClosed = true +} + +var WebsocketMessageType = map[int]string{ + websocket.BinaryMessage: "binary", + websocket.TextMessage: "text", + websocket.CloseMessage: "close", + websocket.PingMessage: "ping", + websocket.PongMessage: "pong", +} + +// TTYSize represents a JSON structure to be sent by the frontend +// xterm.js implementation to the xterm.js websocket handler +type TTYSize struct { + Cols uint16 `json:"cols"` + Rows uint16 `json:"rows"` + X uint16 `json:"x"` + Y uint16 `json:"y"` +} diff --git a/index.html b/index.html index d548c2c..768a919 100644 --- a/index.html +++ b/index.html @@ -1 +1,47 @@ -
f?q(e,c,i,!0,!1,p):T(t,n,o,c,i,s,u,a,p)},V=(e,t,n,o,c,i,s,u,a)=>{let l=0;const f=t.length;let p=e.length-1,d=f-1;while(l<=p&&l<=d){const r=e[l],o=t[l]=a?nr(t[l]):tr(t[l]);if(!Gn(r,o))break;g(r,o,n,null,c,i,s,u,a),l++}while(l<=p&&l<=d){const r=e[p],o=t[d]=a?nr(t[d]):tr(t[d]);if(!Gn(r,o))break;g(r,o,n,null,c,i,s,u,a),p--,d--}if(l>p){if(l<=d){const e=d+1,r=e