diff --git a/README.md b/README.md index 589b8f7..270fc8f 100644 --- a/README.md +++ b/README.md @@ -1,44 +1,52 @@ -# Web Terminal -A (unsafe) technical demo to export a shell to web browser. -It is just a simple demo in case some people are interested in -how to setup xterm.js with websocket. +# Web-based Terminal Emulator +This program allows you to use terminal in the browser. Simply run the program and give it the command to execute when users connect via the browser. ___Interestingly___, it allows others to view your interactive sessions as well. This could be useful to provide remote support and/or help. You can use the program to run any command line programs, such as ```bash```, ```htop```, ```vi```, ```ssh```. This following screenshot shows that six interactive session running by the server. + +To use the program, you need to provide a TLS cert. You can request a free [Let's Encrypt](https://letsencrypt.org/) cert or use a self-signed cert. The program currently does not support user authentication. Therefore, do not run it in untrusted networks or leave it running. A probably safe use of the program is to run ```ssh```. Please ensure that you do not automatically login to the ssh server (e.g., via key authentication). + +___AGAIN, Do NOT run this in an untrusted network. You will expose your +shell to anyone that can access your network and Do NOT leave +the server running.___ This program is written in the go programming language, using the Gin web framework, gorilla/websocket, pty, and xterm.js! The workflow is simple, the client will initiate a terminal -window (xterm.js) and create a websocket with the server. On -the server side, it serves the basic HTML/JS/CSS files and -websockets (by shovling the data between pty and xterm). - -___It is amazing what you can do with 270 lines of go code.___ - -To use the program, download/clone the code, and in the web_terminal -directory, run ```go build .```, this will create the binary called -web_terminal. Then, go to the tls directory and create a self-signed -certificate according to the instructions in README. - -To run it, use ```./web_terminal cmd options_to_cmd```. -If no cmd and options are given, web_terminal will run bash by default. -You can run shells but also single programs, such as htop. For example, -you can export the ssh shell, such as ```./web_terminal ssh 192.168.1.2 -l pi```. +window (xterm.js) and create a websocket with the server, which relays the data between pty and xterm. +## Installation -The program -has been tested on Linux, WSL2, Raspberry Pi 3B (Debian), and MacOSX. +1. Install the [go](https://go.dev/) compiler. +2. Download the release and unzip it, or clone the repo + + ```git clone https://github.com/syssecfsu/web_terminal.git``` -***known bug*** +3. Go to the ```tls``` directory and create a self-signed cert + + \# Generate a private key for a curve -On MacOS X, running zsh with web_terminal will produce an extra % -each time in Google Chrome. Consider it a ___feautre___, will not -fix unless there is a pull request. Safari works fine though. + ```openssl ecparam -name prime256v1 -genkey -noout -out private-key.pem``` + \# Create a self-signed certificate -**NOTE** + ```openssl req -new -x509 -key private-key.pem -out cert.pem -days 360``` -___Do NOT run this in an untrusted network. You will expose your -shell to anyone that can access your network and Do NOT leave -the server running.___ +4. Return to the root directory of the source code and build the program + + ```go build .``` + +5. Start the server and give it the command to run. The server listens on 8080, for example: + + ```./web_terminal htop``` or + + ```./web_terminal ssh -l ``` + +6. Connect to the server, for example + + ```https://your_ip_address:8080``` + +The program has been tested on Linux, WSL2, Raspberry Pi 3B (Debian), and MacOSX using Google Chrome, Firefox, and Safari. + +## An Screencast featuring older version of web_terminal Here is a screencast for sshing into Raspberry Pi running [pi-hole](https://pi-hole.net/) diff --git a/assets/index.html b/assets/index.html index 4a6755a..b67a56a 100644 --- a/assets/index.html +++ b/assets/index.html @@ -21,7 +21,7 @@ Web-based Terminal Emulator - New Session + New Session @@ -31,13 +31,17 @@
+ {{range .players}}
Interactive session
-

From {{.ip}}, running {{.cmd}}

- View +

From {{.Ip}}, running {{.Cmd}}, session ID: {{.Name}} +

+ View + Session
+ {{end}}
diff --git a/extra/main.png b/extra/main.png new file mode 100644 index 0000000..2fa7c8d Binary files /dev/null and b/extra/main.png differ diff --git a/extra/screencast.gif b/extra/screencast.gif old mode 100755 new mode 100644 diff --git a/go.mod b/go.mod index cb43687..d7c193c 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.17 require ( github.com/creack/pty v1.1.17 // indirect + github.com/dchest/uniuri v0.0.0-20200228104902-7aecb25e1fe5 // indirect github.com/gin-contrib/sse v0.1.0 // indirect github.com/gin-gonic/gin v1.7.7 // indirect github.com/go-playground/locales v0.13.0 // indirect @@ -11,6 +12,7 @@ require ( github.com/go-playground/validator/v10 v10.4.1 // indirect github.com/golang/protobuf v1.3.3 // indirect github.com/gorilla/websocket v1.4.2 // indirect + github.com/hashicorp/go-uuid v1.0.2 // indirect github.com/json-iterator/go v1.1.9 // indirect github.com/leodido/go-urn v1.2.0 // indirect github.com/mattn/go-isatty v0.0.12 // indirect diff --git a/go.sum b/go.sum index eeab74b..48472fd 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ github.com/creack/pty v1.1.17 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI= github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dchest/uniuri v0.0.0-20200228104902-7aecb25e1fe5 h1:RAV05c0xOkJ3dZGS0JFybxFKZ2WMLabgx3uXnd7rpGs= +github.com/dchest/uniuri v0.0.0-20200228104902-7aecb25e1fe5/go.mod h1:GgB8SF9nRG+GqaDtLcwJZsQFhcogVCJ79j4EdT0c2V4= github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= github.com/gin-gonic/gin v1.7.7 h1:3DoBmSbJbZAWqXJC3SLjAPfutPJJRN1U5pALB7EeTTs= @@ -18,6 +20,8 @@ github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaW github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/hashicorp/go-uuid v1.0.2 h1:cfejS+Tpcp13yd5nYHWDI6qVCny6wyX2Mt5SGur2IGE= +github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/json-iterator/go v1.1.9 h1:9yzud/Ht36ygwatGx56VwCZtlI/2AD15T1X2sjSuGns= github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/leodido/go-urn v1.2.0 h1:hpXL4XnriNwQ/ABnpepYM/1vCLWNDfUNts8dX3xTG6Y= diff --git a/main.go b/main.go index fa29c1e..133f141 100644 --- a/main.go +++ b/main.go @@ -31,6 +31,30 @@ func checkOrigin(r *http.Request) bool { return (host != nil) && (*host == h.Host) } +type InteractiveSession struct { + Ip string + Cmd string + Name string +} + +func fillIndex(c *gin.Context) { + var players []InteractiveSession + + term_conn.ForEachSession(func(tc *term_conn.TermConn) { + players = append(players, InteractiveSession{ + Name: tc.Name, + Ip: tc.Ip, + Cmd: cmdToExec[0], + }) + }) + + c.HTML(http.StatusOK, "index.html", gin.H{ + "title": "Interactive terminal", + "path": "/ws_do", + "players": players, + }) +} + func main() { fp, err := os.OpenFile("web_term.log", os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644) @@ -56,10 +80,11 @@ func main() { rt.SetTrustedProxies(nil) rt.LoadHTMLGlob("./assets/*.html") - rt.GET("/view/*sname", func(c *gin.Context) { + rt.GET("/view/:sname", func(c *gin.Context) { + sname := c.Param("sname") c.HTML(http.StatusOK, "term.html", gin.H{ "title": "Viewer terminal", - "path": "/ws_view", + "path": "/ws_view/" + sname, }) }) @@ -75,11 +100,12 @@ func main() { }) rt.GET("/ws_do", func(c *gin.Context) { - term_conn.ConnectTerm(c.Writer, c.Request, false, cmdToExec) + term_conn.ConnectTerm(c.Writer, c.Request, false, "", cmdToExec) }) - rt.GET("/ws_view", func(c *gin.Context) { - term_conn.ConnectTerm(c.Writer, c.Request, true, nil) + rt.GET("/ws_view/:sname", func(c *gin.Context) { + path := c.Param("sname") + term_conn.ConnectTerm(c.Writer, c.Request, true, path, nil) }) // handle static files @@ -87,11 +113,7 @@ func main() { rt.GET("/", func(c *gin.Context) { host = &c.Request.Host - - c.HTML(http.StatusOK, "index.html", gin.H{ - "title": "Interactive terminal", - "path": "/ws_do", - }) + fillIndex(c) }) term_conn.Init(checkOrigin) diff --git a/term_conn/reg.go b/term_conn/reg.go index 15cee7c..b313b3b 100644 --- a/term_conn/reg.go +++ b/term_conn/reg.go @@ -21,12 +21,13 @@ func (reg *Registry) init() { reg.players = make(map[string]*TermConn) } -func (d *Registry) addPlayer(name string, tc *TermConn) { +func (d *Registry) addPlayer(tc *TermConn) { d.mtx.Lock() - if _, ok := d.players[name]; ok { - log.Println(name, "already exist in the dispatcher, skip registration") + if _, ok := d.players[tc.Name]; ok { + log.Println(tc.Name, "Already exist in the dispatcher, skip registration") } else { - d.players[name] = tc + d.players[tc.Name] = tc + log.Println("Add interactive session to registry", tc.Name) } d.mtx.Unlock() } @@ -38,6 +39,7 @@ func (d *Registry) removePlayer(name string) error { if _, ok := d.players[name]; ok { delete(d.players, name) err = nil + log.Println("Removed interactive session to registry", name) } d.mtx.Unlock() @@ -56,3 +58,11 @@ func (d *Registry) sendToPlayer(name string, ws *websocket.Conn) bool { d.mtx.Unlock() return ok } + +func ForEachSession(fp func(tc *TermConn)) { + registry.mtx.Lock() + for _, v := range registry.players { + fp(v) + } + registry.mtx.Unlock() +} diff --git a/term_conn/relay.go b/term_conn/relay.go index 470cd19..69cb380 100644 --- a/term_conn/relay.go +++ b/term_conn/relay.go @@ -10,6 +10,7 @@ import ( "time" "github.com/creack/pty" + "github.com/dchest/uniuri" "github.com/gorilla/websocket" ) @@ -29,9 +30,6 @@ const ( // Send pings to peer with this period. Must be less than pongWait. pingPeriod = (pongWait * 9) / 10 - - // Time to wait before force close on connection. - closeGracePeriod = 10 * time.Second ) var upgrader = websocket.Upgrader{ @@ -45,10 +43,11 @@ var upgrader = websocket.Upgrader{ // TermConn represents the connected websocket and pty. // if isViewer is true type TermConn struct { - ws *websocket.Conn - name string + Name string + Ip string // only valid for doers + ws *websocket.Conn ptmx *os.File // the pty that runs the command cmd *exec.Cmd // represents the process, we need it to terminate the process vchan chan *websocket.Conn // channel to receive viewers @@ -259,9 +258,9 @@ out: // this function should be executed by the main goroutine for the connection func (tc *TermConn) release() { - log.Println("Releasing terminal connection", tc.name) + log.Println("Releasing terminal connection", tc.Name) - registry.removePlayer(tc.name) + registry.removePlayer(tc.Name) if tc.ptmx != nil { // cleanup the pty and its related process @@ -306,7 +305,8 @@ func handlePlayer(w http.ResponseWriter, r *http.Request, cmdline []string) { tc := TermConn{ ws: ws, - name: "main", + Name: uniuri.New(), + Ip: ws.RemoteAddr().String(), } defer tc.release() @@ -321,7 +321,7 @@ func handlePlayer(w http.ResponseWriter, r *http.Request, cmdline []string) { return } - registry.addPlayer("main", &tc) + registry.addPlayer(&tc) // main event loop to shovel data between ws and pty // do not call ptyStdoutToWs in this goroutine, otherwise @@ -339,7 +339,7 @@ func handlePlayer(w http.ResponseWriter, r *http.Request, cmdline []string) { } // handle websockets -func handleViewer(w http.ResponseWriter, r *http.Request) { +func handleViewer(w http.ResponseWriter, r *http.Request, path string) { ws, err := upgrader.Upgrade(w, r, nil) if err != nil { @@ -348,17 +348,17 @@ func handleViewer(w http.ResponseWriter, r *http.Request) { } log.Println("\n\nCreated the websocket") - if !registry.sendToPlayer("main", ws) { + if !registry.sendToPlayer(path, ws) { log.Println("Failed to send websocket to player, close it") ws.Close() } } -func ConnectTerm(w http.ResponseWriter, r *http.Request, isViewer bool, cmdline []string) { +func ConnectTerm(w http.ResponseWriter, r *http.Request, isViewer bool, path string, cmdline []string) { if !isViewer { handlePlayer(w, r, cmdline) } else { - handleViewer(w, r) + handleViewer(w, r, path) } }