Now with viewers

This commit is contained in:
Zhi Wang 2022-01-11 21:18:19 -05:00
parent 2720c1ba3f
commit 9541171129
9 changed files with 109 additions and 59 deletions

View File

@ -1,44 +1,52 @@
# Web Terminal # Web-based Terminal Emulator
A (unsafe) technical demo to export a shell to web browser. 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. <img src="https://github.com/syssecfsu/web_terminal/blob/master/extra/main.png?raw=true" width="800px">
It is just a simple demo in case some people are interested in
how to setup xterm.js with websocket. 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 This program is written in the go programming language, using the
Gin web framework, gorilla/websocket, pty, and xterm.js! Gin web framework, gorilla/websocket, pty, and xterm.js!
The workflow is simple, the client will initiate a terminal The workflow is simple, the client will initiate a terminal
window (xterm.js) and create a websocket with the server. On window (xterm.js) and create a websocket with the server, which relays the data between pty and xterm.
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```.
## Installation
The program 1. Install the [go](https://go.dev/) compiler.
has been tested on Linux, WSL2, Raspberry Pi 3B (Debian), and MacOSX. 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 % ```openssl ecparam -name prime256v1 -genkey -noout -out private-key.pem```
each time in Google Chrome. Consider it a ___feautre___, will not
fix unless there is a pull request. Safari works fine though.
\# 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 4. Return to the root directory of the source code and build the program
shell to anyone that can access your network and Do NOT leave
the server running.___ ```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 <your_server_ip> -l <user_name>```
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 Here is a screencast for sshing into Raspberry Pi running
[pi-hole](https://pi-hole.net/) [pi-hole](https://pi-hole.net/)

View File

@ -21,7 +21,7 @@
<img src="/assets/logo.svg" style="margin-left: 2em;margin-right: 1em;" height="24" <img src="/assets/logo.svg" style="margin-left: 2em;margin-right: 1em;" height="24"
class="d-inline-block align-text-top"> class="d-inline-block align-text-top">
Web-based Terminal Emulator Web-based Terminal Emulator
<a class="btn btn-primary btn-sm float-end" href="/new" role="button">New Session</a> <a class="btn btn-primary btn-sm float-end" href="/new" target="_blank" role="button">New Session</a>
</div> </div>
</nav> </nav>
</header> </header>
@ -31,13 +31,17 @@
<div class="card-deck row justify-content-center"> <div class="card-deck row justify-content-center">
<!-- repeat this for each interactive session --> <!-- repeat this for each interactive session -->
{{range .players}}
<div class="card shadow-sm border-info bg-light mb-3" style="width: 16rem; margin:1em;"> <div class="card shadow-sm border-info bg-light mb-3" style="width: 16rem; margin:1em;">
<div class="card-body"> <div class="card-body">
<h5 class="card-title">Interactive session</h5> <h5 class="card-title">Interactive session</h5>
<p class="card-text">From {{.ip}}, running {{.cmd}}</p> <p class="card-text">From <em>{{.Ip}}</em>, running <strong>{{.Cmd}}</strong>, session ID: <u>{{.Name}}</u>
<a class="btn btn-secondary btn-sm float-end" href="{{.url}}" role="button">View</a> </p>
<a class="btn btn-secondary btn-sm float-end" href="/view/{{.Name}}" target="_blank" role="button">View
Session</a>
</div> </div>
</div> </div>
{{end}}
</div> </div>
</div> </div>

BIN
extra/main.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 622 KiB

0
extra/screencast.gif Executable file → Normal file
View File

Before

Width:  |  Height:  |  Size: 955 KiB

After

Width:  |  Height:  |  Size: 955 KiB

2
go.mod
View File

@ -4,6 +4,7 @@ go 1.17
require ( require (
github.com/creack/pty v1.1.17 // indirect 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-contrib/sse v0.1.0 // indirect
github.com/gin-gonic/gin v1.7.7 // indirect github.com/gin-gonic/gin v1.7.7 // indirect
github.com/go-playground/locales v0.13.0 // 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/go-playground/validator/v10 v10.4.1 // indirect
github.com/golang/protobuf v1.3.3 // indirect github.com/golang/protobuf v1.3.3 // indirect
github.com/gorilla/websocket v1.4.2 // 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/json-iterator/go v1.1.9 // indirect
github.com/leodido/go-urn v1.2.0 // indirect github.com/leodido/go-urn v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.12 // indirect github.com/mattn/go-isatty v0.0.12 // indirect

4
go.sum
View File

@ -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/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.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/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 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= 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= 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/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 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 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 h1:9yzud/Ht36ygwatGx56VwCZtlI/2AD15T1X2sjSuGns=
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= 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= github.com/leodido/go-urn v1.2.0 h1:hpXL4XnriNwQ/ABnpepYM/1vCLWNDfUNts8dX3xTG6Y=

42
main.go
View File

@ -31,6 +31,30 @@ func checkOrigin(r *http.Request) bool {
return (host != nil) && (*host == h.Host) 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() { func main() {
fp, err := os.OpenFile("web_term.log", os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644) 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.SetTrustedProxies(nil)
rt.LoadHTMLGlob("./assets/*.html") 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{ c.HTML(http.StatusOK, "term.html", gin.H{
"title": "Viewer terminal", "title": "Viewer terminal",
"path": "/ws_view", "path": "/ws_view/" + sname,
}) })
}) })
@ -75,11 +100,12 @@ func main() {
}) })
rt.GET("/ws_do", func(c *gin.Context) { 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) { rt.GET("/ws_view/:sname", func(c *gin.Context) {
term_conn.ConnectTerm(c.Writer, c.Request, true, nil) path := c.Param("sname")
term_conn.ConnectTerm(c.Writer, c.Request, true, path, nil)
}) })
// handle static files // handle static files
@ -87,11 +113,7 @@ func main() {
rt.GET("/", func(c *gin.Context) { rt.GET("/", func(c *gin.Context) {
host = &c.Request.Host host = &c.Request.Host
fillIndex(c)
c.HTML(http.StatusOK, "index.html", gin.H{
"title": "Interactive terminal",
"path": "/ws_do",
})
}) })
term_conn.Init(checkOrigin) term_conn.Init(checkOrigin)

View File

@ -21,12 +21,13 @@ func (reg *Registry) init() {
reg.players = make(map[string]*TermConn) reg.players = make(map[string]*TermConn)
} }
func (d *Registry) addPlayer(name string, tc *TermConn) { func (d *Registry) addPlayer(tc *TermConn) {
d.mtx.Lock() d.mtx.Lock()
if _, ok := d.players[name]; ok { if _, ok := d.players[tc.Name]; ok {
log.Println(name, "already exist in the dispatcher, skip registration") log.Println(tc.Name, "Already exist in the dispatcher, skip registration")
} else { } else {
d.players[name] = tc d.players[tc.Name] = tc
log.Println("Add interactive session to registry", tc.Name)
} }
d.mtx.Unlock() d.mtx.Unlock()
} }
@ -38,6 +39,7 @@ func (d *Registry) removePlayer(name string) error {
if _, ok := d.players[name]; ok { if _, ok := d.players[name]; ok {
delete(d.players, name) delete(d.players, name)
err = nil err = nil
log.Println("Removed interactive session to registry", name)
} }
d.mtx.Unlock() d.mtx.Unlock()
@ -56,3 +58,11 @@ func (d *Registry) sendToPlayer(name string, ws *websocket.Conn) bool {
d.mtx.Unlock() d.mtx.Unlock()
return ok return ok
} }
func ForEachSession(fp func(tc *TermConn)) {
registry.mtx.Lock()
for _, v := range registry.players {
fp(v)
}
registry.mtx.Unlock()
}

View File

@ -10,6 +10,7 @@ import (
"time" "time"
"github.com/creack/pty" "github.com/creack/pty"
"github.com/dchest/uniuri"
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
) )
@ -29,9 +30,6 @@ const (
// Send pings to peer with this period. Must be less than pongWait. // Send pings to peer with this period. Must be less than pongWait.
pingPeriod = (pongWait * 9) / 10 pingPeriod = (pongWait * 9) / 10
// Time to wait before force close on connection.
closeGracePeriod = 10 * time.Second
) )
var upgrader = websocket.Upgrader{ var upgrader = websocket.Upgrader{
@ -45,10 +43,11 @@ var upgrader = websocket.Upgrader{
// TermConn represents the connected websocket and pty. // TermConn represents the connected websocket and pty.
// if isViewer is true // if isViewer is true
type TermConn struct { type TermConn struct {
ws *websocket.Conn Name string
name string Ip string
// only valid for doers // only valid for doers
ws *websocket.Conn
ptmx *os.File // the pty that runs the command ptmx *os.File // the pty that runs the command
cmd *exec.Cmd // represents the process, we need it to terminate the process cmd *exec.Cmd // represents the process, we need it to terminate the process
vchan chan *websocket.Conn // channel to receive viewers 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 // this function should be executed by the main goroutine for the connection
func (tc *TermConn) release() { 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 { if tc.ptmx != nil {
// cleanup the pty and its related process // cleanup the pty and its related process
@ -306,7 +305,8 @@ func handlePlayer(w http.ResponseWriter, r *http.Request, cmdline []string) {
tc := TermConn{ tc := TermConn{
ws: ws, ws: ws,
name: "main", Name: uniuri.New(),
Ip: ws.RemoteAddr().String(),
} }
defer tc.release() defer tc.release()
@ -321,7 +321,7 @@ func handlePlayer(w http.ResponseWriter, r *http.Request, cmdline []string) {
return return
} }
registry.addPlayer("main", &tc) registry.addPlayer(&tc)
// main event loop to shovel data between ws and pty // main event loop to shovel data between ws and pty
// do not call ptyStdoutToWs in this goroutine, otherwise // do not call ptyStdoutToWs in this goroutine, otherwise
@ -339,7 +339,7 @@ func handlePlayer(w http.ResponseWriter, r *http.Request, cmdline []string) {
} }
// handle websockets // 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) ws, err := upgrader.Upgrade(w, r, nil)
if err != nil { if err != nil {
@ -348,17 +348,17 @@ func handleViewer(w http.ResponseWriter, r *http.Request) {
} }
log.Println("\n\nCreated the websocket") 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") log.Println("Failed to send websocket to player, close it")
ws.Close() 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 { if !isViewer {
handlePlayer(w, r, cmdline) handlePlayer(w, r, cmdline)
} else { } else {
handleViewer(w, r) handleViewer(w, r, path)
} }
} }