From 0acf1a95afc50fc03d3ebfa18e565aa8c76b0fcf Mon Sep 17 00:00:00 2001 From: Zhi Wang Date: Thu, 20 Jan 2022 16:40:32 -0500 Subject: [PATCH 1/4] WIP --- assets/signin.css | 39 +++++++++++++++++++++++++++++++++++++ assets/template/signin.html | 39 +++++++++++++++++++++++++++++++++++++ main.go | 12 +++++------- 3 files changed, 83 insertions(+), 7 deletions(-) create mode 100644 assets/signin.css create mode 100644 assets/template/signin.html diff --git a/assets/signin.css b/assets/signin.css new file mode 100644 index 0000000..4be8852 --- /dev/null +++ b/assets/signin.css @@ -0,0 +1,39 @@ +html, +body { + height: 100%; +} + +body { + display: flex; + align-items: center; + padding-top: 40px; + padding-bottom: 40px; + background-color: #f5f5f5; +} + +.form-signin { + width: 100%; + max-width: 330px; + padding: 15px; + margin: auto; +} + +.form-signin .checkbox { + font-weight: 400; +} + +.form-signin .form-floating:focus-within { + z-index: 2; +} + +.form-signin input[type="text"] { + margin-bottom: -1px; + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; +} + +.form-signin input[type="password"] { + margin-bottom: 10px; + border-top-left-radius: 0; + border-top-right-radius: 0; +} \ No newline at end of file diff --git a/assets/template/signin.html b/assets/template/signin.html new file mode 100644 index 0000000..14b5e84 --- /dev/null +++ b/assets/template/signin.html @@ -0,0 +1,39 @@ + + + + + + + + + + WiTTY Login + + + + + + +
+
+ + +
+ + +
+
+ + +
+ +
+ +
+ +

WiTTY: Web-based Interactive TTY

+
+
+ + + \ No newline at end of file diff --git a/main.go b/main.go index f14a829..98f544d 100644 --- a/main.go +++ b/main.go @@ -147,13 +147,12 @@ func main() { // Fill in the index page rt.GET("/", func(c *gin.Context) { host = &c.Request.Host - players, records := collectTabData(c) - c.HTML(http.StatusOK, "index.html", gin.H{ - "title": "interactive terminal", - "players": players, - "records": records, - }) + c.HTML(http.StatusOK, "index.html", gin.H{}) + }) + + rt.GET("/sign-in", func(c *gin.Context) { + c.HTML(http.StatusOK, "signin.html", gin.H{}) }) rt.GET("/favicon.ico", func(c *gin.Context) { @@ -175,7 +174,6 @@ func main() { active1 = "active" } - host = &c.Request.Host players, records := collectTabData(c) c.HTML(http.StatusOK, "tab.html", gin.H{ From 481e5473aacf4975ebf50e0f5eb5669224af95d1 Mon Sep 17 00:00:00 2001 From: Zhi Wang Date: Fri, 21 Jan 2022 09:36:31 -0500 Subject: [PATCH 2/4] refactor files, add authentication --- assets/img/sign-in.svg | 1 + assets/img/sign-out.svg | 1 + assets/template/index.html | 13 +- assets/template/{signin.html => login.html} | 9 +- go.mod | 9 + go.sum | 13 ++ main.go | 233 +------------------- web/auth.go | 83 +++++++ web/interactive.go | 97 ++++++++ web/record.go | 108 +++++++++ web/routing.go | 87 ++++++++ 11 files changed, 414 insertions(+), 240 deletions(-) create mode 100644 assets/img/sign-in.svg create mode 100644 assets/img/sign-out.svg rename assets/template/{signin.html => login.html} (74%) create mode 100644 web/auth.go create mode 100644 web/interactive.go create mode 100644 web/record.go create mode 100644 web/routing.go diff --git a/assets/img/sign-in.svg b/assets/img/sign-in.svg new file mode 100644 index 0000000..d68a1b3 --- /dev/null +++ b/assets/img/sign-in.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/img/sign-out.svg b/assets/img/sign-out.svg new file mode 100644 index 0000000..5c9daff --- /dev/null +++ b/assets/img/sign-out.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/template/index.html b/assets/template/index.html index 5cd549a..fa999a2 100644 --- a/assets/template/index.html +++ b/assets/template/index.html @@ -27,10 +27,15 @@ class="d-inline-block align-text-top"> WiTTY: Web-based interactive TTY - - New Session - + diff --git a/assets/template/signin.html b/assets/template/login.html similarity index 74% rename from assets/template/signin.html rename to assets/template/login.html index 14b5e84..c836d16 100644 --- a/assets/template/signin.html +++ b/assets/template/login.html @@ -15,8 +15,8 @@
-
- + +
@@ -27,10 +27,7 @@
-
- -
- +

WiTTY: Web-based Interactive TTY

diff --git a/go.mod b/go.mod index 33c1f37..7091d1c 100644 --- a/go.mod +++ b/go.mod @@ -9,8 +9,17 @@ require ( github.com/gorilla/websocket v1.4.2 ) +require ( + github.com/boj/redistore v0.0.0-20180917114910-cd5dcc76aeff // indirect + github.com/gomodule/redigo v2.0.0+incompatible // indirect + github.com/gorilla/context v1.1.1 // indirect + github.com/gorilla/securecookie v1.1.1 // indirect + github.com/gorilla/sessions v1.2.1 // indirect +) + require ( github.com/gin-contrib/sse v0.1.0 // indirect + github.com/gin-gonic/contrib v0.0.0-20201101042839-6a891bf89f19 github.com/go-playground/locales v0.13.0 // indirect github.com/go-playground/universal-translator v0.17.0 // indirect github.com/go-playground/validator/v10 v10.4.1 // indirect diff --git a/go.sum b/go.sum index f251df0..53e6079 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/boj/redistore v0.0.0-20180917114910-cd5dcc76aeff h1:RmdPFa+slIr4SCBg4st/l/vZWVe9QJKMXGO60Bxbe04= +github.com/boj/redistore v0.0.0-20180917114910-cd5dcc76aeff/go.mod h1:+RTT1BOk5P97fT2CiHkbFQwkK3mjsFAP6zCYV2aXtjw= 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= @@ -7,6 +9,8 @@ github.com/dchest/uniuri v0.0.0-20200228104902-7aecb25e1fe5 h1:RAV05c0xOkJ3dZGS0 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/contrib v0.0.0-20201101042839-6a891bf89f19 h1:J2LPEOcQmWaooBnBtUDV9KHFEnP5LYTZY03GiQ0oQBw= +github.com/gin-gonic/contrib v0.0.0-20201101042839-6a891bf89f19/go.mod h1:iqneQ2Df3omzIVTkIfn7c1acsVnMGiSLn4XF5Blh3Yg= github.com/gin-gonic/gin v1.7.7 h1:3DoBmSbJbZAWqXJC3SLjAPfutPJJRN1U5pALB7EeTTs= github.com/gin-gonic/gin v1.7.7/go.mod h1:axIBovoeJpVj8S3BwE0uPMTeReE4+AfFtqpqaZ1qq1U= github.com/go-playground/assert/v2 v2.0.1 h1:MsBgLAaY856+nPRTKrp3/OZK38U/wa0CcBYNjji3q3A= @@ -19,7 +23,16 @@ github.com/go-playground/validator/v10 v10.4.1 h1:pH2c5ADXtd66mxoE0Zm9SUhxE20r7a github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4= github.com/golang/protobuf v1.3.3 h1:gyjaxf+svBWX08ZjK86iN9geUJF0H6gp2IRKX6Nf6/I= github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/gomodule/redigo v2.0.0+incompatible h1:K/R+8tc58AaqLkqG2Ol3Qk+DR/TlNuhuh457pBFPtt0= +github.com/gomodule/redigo v2.0.0+incompatible/go.mod h1:B4C85qUVwatsJoIUNIfCRsp7qO0iAmpGFZ4EELWSbC4= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8= +github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= +github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ= +github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= +github.com/gorilla/sessions v1.1.1/go.mod h1:8KCfur6+4Mqcc6S0FEfKuN15Vl5MgXW92AE8ovaJD0w= +github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI= +github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= 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/json-iterator/go v1.1.9 h1:9yzud/Ht36ygwatGx56VwCZtlI/2AD15T1X2sjSuGns= diff --git a/main.go b/main.go index 98f544d..f196052 100644 --- a/main.go +++ b/main.go @@ -1,137 +1,25 @@ package main import ( - "encoding/json" - "io/ioutil" "log" - "net/http" - "net/url" "os" - "strconv" - "strings" - "github.com/dchest/uniuri" - "github.com/gin-gonic/gin" - "github.com/syssecfsu/witty/term_conn" + "github.com/syssecfsu/witty/web" ) -// command line options -var cmdToExec = []string{"bash"} - -var host *string = nil - -// simple function to check origin -func checkOrigin(r *http.Request) bool { - org := r.Header.Get("Origin") - h, err := url.Parse(org) - - if err != nil { - return false - } - - if (host == nil) || (*host != h.Host) { - log.Println("Failed origin check of ", org) - } - - return (host != nil) && (*host == h.Host) -} - -type InteractiveSession struct { - Ip string - Cmd string - Id string -} - -type RecordedSession struct { - Fname string - Fsize string - Duration string - Time string -} - -// how many seconds of the session -func getDuration(fname string) int64 { - fp, err := os.Open("./records/" + fname) - - if err != nil { - log.Println("Failed to open record file", err) - return 0 - } - - decoder := json.NewDecoder(fp) - - if decoder == nil { - log.Println("Failed to create JSON decoder") - return 0 - } - - // To work with javascript decoder, we organize the file as - // an array of writeRecord. golang decode instead decode - // as individual record. Call decoder.Token to skip opening [ - decoder.Token() - - var dur int64 = 0 - - for decoder.More() { - var record term_conn.WriteRecord - - if err := decoder.Decode(&record); err != nil { - log.Println("Failed to decode record", err) - continue - } - - dur += record.Dur.Milliseconds() - } - - return dur/1000 + 1 -} - -func collectTabData(c *gin.Context) (players []InteractiveSession, records []RecordedSession) { - term_conn.ForEachSession(func(tc *term_conn.TermConn) { - players = append(players, InteractiveSession{ - Id: tc.Name, - Ip: tc.Ip, - Cmd: cmdToExec[0], - }) - }) - - files, err := ioutil.ReadDir("./records/") - - if err == nil { - for _, finfo := range files { - fname := finfo.Name() - if !strings.HasSuffix(fname, ".rec") { - continue - } - fsize := finfo.Size() / 1024 - duration := getDuration(fname) - - records = append(records, - RecordedSession{ - Fname: fname, - Fsize: strconv.FormatInt(fsize, 10), - Duration: strconv.FormatInt(duration, 10), - Time: finfo.ModTime().Format("Jan/2/2006, 15:04:05"), - }) - } - } - - return -} - func main() { fp, err := os.OpenFile("witty.log", os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644) if err == nil { defer fp.Close() log.SetOutput(fp) - gin.DefaultWriter = fp } // parse the arguments. User can pass the command to execute // by default, we use bash, but macos users might want to use zsh // you can also run single program, such as pstree, htop... // but program might misbehave (htop seems to be fine) + var cmdToExec = []string{"bash"} args := os.Args if len(args) > 1 { @@ -139,120 +27,5 @@ func main() { log.Println(cmdToExec) } - rt := gin.Default() - - rt.SetTrustedProxies(nil) - rt.LoadHTMLGlob("./assets/template/*") - - // Fill in the index page - rt.GET("/", func(c *gin.Context) { - host = &c.Request.Host - - c.HTML(http.StatusOK, "index.html", gin.H{}) - }) - - rt.GET("/sign-in", func(c *gin.Context) { - c.HTML(http.StatusOK, "signin.html", gin.H{}) - }) - - rt.GET("/favicon.ico", func(c *gin.Context) { - c.File("./assets/img/favicon.ico") - }) - - // to update the tabs of current interactive and saved sessions - rt.GET("/update/:active", func(c *gin.Context) { - var active0, active1 string - - // setup which tab is active, it is hard to do in javascript at - // client side due to timing issues. - which := c.Param("active") - if which == "0" { - active0 = "active" - active1 = "" - } else { - active0 = "" - active1 = "active" - } - - players, records := collectTabData(c) - - c.HTML(http.StatusOK, "tab.html", gin.H{ - "players": players, - "records": records, - "active0": active0, - "active1": active1, - }) - }) - - // create a new interactive session - rt.GET("/new", func(c *gin.Context) { - if host == nil { - host = &c.Request.Host - } - - id := uniuri.New() - - c.HTML(http.StatusOK, "term.html", gin.H{ - "title": "interactive terminal", - "path": "/ws_new/" + id, - "id": id, - "logo": "keyboard", - }) - }) - - rt.GET("/ws_new/:id", func(c *gin.Context) { - id := c.Param("id") - term_conn.ConnectTerm(c.Writer, c.Request, false, id, cmdToExec) - }) - - // create a viewer of an interactive session - rt.GET("/view/:id", func(c *gin.Context) { - id := c.Param("id") - c.HTML(http.StatusOK, "term.html", gin.H{ - "title": "viewer terminal", - "path": "/ws_view/" + id, - "id": id, - "logo": "view", - }) - }) - - rt.GET("/ws_view/:id", func(c *gin.Context) { - id := c.Param("id") - term_conn.ConnectTerm(c.Writer, c.Request, true, id, nil) - }) - - // start/stop recording the session - rt.GET("/record/:id", func(c *gin.Context) { - id := c.Param("id") - term_conn.StartRecord(id) - }) - - rt.GET("/stop/:id", func(c *gin.Context) { - id := c.Param("id") - term_conn.StopRecord(id) - }) - - // create a viewer of an interactive session - rt.GET("/replay/:id", func(c *gin.Context) { - id := c.Param("id") - log.Println("replay/ called with", id) - c.HTML(http.StatusOK, "replay.html", gin.H{ - "fname": id, - }) - }) - - rt.GET("/delete/:fname", func(c *gin.Context) { - fname := c.Param("fname") - if err := os.Remove("./records/" + fname); err != nil { - log.Println("Failed to delete file,", err) - } - }) - - // handle static files - rt.Static("/assets", "./assets") - rt.Static("/records", "./records") - - term_conn.Init(checkOrigin) - - rt.RunTLS(":8080", "./tls/cert.pem", "./tls/private-key.pem") + web.StartWeb(fp, cmdToExec) } diff --git a/web/auth.go b/web/auth.go new file mode 100644 index 0000000..50ae9c6 --- /dev/null +++ b/web/auth.go @@ -0,0 +1,83 @@ +package web + +import ( + "net/http" + "strings" + + "github.com/gin-gonic/contrib/sessions" + "github.com/gin-gonic/gin" +) + +const ( + userkey = "user" +) + +func login(c *gin.Context) { + session := sessions.Default(c) + + username := c.PostForm("username") + passwd := c.PostForm("passwd") + + // Validate form input + if strings.Trim(username, " ") == "" || strings.Trim(passwd, " ") == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Username/password can't be empty"}) + return + } + + // Check for username and password match, usually from a database + if username != "hello" || passwd != "world" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication failed"}) + return + } + + // Save the username in the session + session.Set(userkey, username) // In real world usage you'd set this to the users ID + + if err := session.Save(); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save session"}) + return + } + + host = &c.Request.Host + + c.Redirect(http.StatusSeeOther, "/") +} + +func logout(c *gin.Context) { + session := sessions.Default(c) + user := session.Get(userkey) + if user == nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid session token"}) + return + } + session.Delete(userkey) + if err := session.Save(); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save session"}) + return + } + c.Redirect(http.StatusFound, "/login") +} + +// AuthRequired is a simple middleware to check the session +func AuthRequired(c *gin.Context) { + if (c.Request.URL.String() == "/login") || + strings.HasPrefix(c.Request.URL.String(), "/assets") { + c.Next() + return + } + + session := sessions.Default(c) + user := session.Get(userkey) + if user == nil { + // Abort the request with the appropriate error code + c.Redirect(http.StatusTemporaryRedirect, "/login") + c.Abort() + return + } + // Continue down the chain to handler etc + c.Next() +} + +func loginPage(c *gin.Context) { + c.HTML(http.StatusOK, "login.html", gin.H{}) +} diff --git a/web/interactive.go b/web/interactive.go new file mode 100644 index 0000000..2b8962b --- /dev/null +++ b/web/interactive.go @@ -0,0 +1,97 @@ +package web + +import ( + "net/http" + + "github.com/dchest/uniuri" + "github.com/gin-gonic/gin" + "github.com/syssecfsu/witty/term_conn" +) + +type InteractiveSession struct { + Ip string + Cmd string + Id string +} + +func collectSessions(c *gin.Context, cmd string) (players []InteractiveSession) { + term_conn.ForEachSession(func(tc *term_conn.TermConn) { + players = append(players, InteractiveSession{ + Id: tc.Name, + Ip: tc.Ip, + Cmd: cmd, + }) + }) + + return +} + +func indexPage(c *gin.Context) { + host = &c.Request.Host + + c.HTML(http.StatusOK, "index.html", gin.H{}) +} + +func updateIndex(c *gin.Context) { + var active0, active1 string + + // setup which tab is active, it is hard to do in javascript at + // client side due to timing issues. + which := c.Param("active") + if which == "0" { + active0 = "active" + active1 = "" + } else { + active0 = "" + active1 = "active" + } + + players := collectSessions(c, cmdToExec[0]) + records := collectRecords(c, cmdToExec[0]) + + c.HTML(http.StatusOK, "tab.html", gin.H{ + "players": players, + "records": records, + "active0": active0, + "active1": active1, + }) +} + +func newInteractive(c *gin.Context) { + if host == nil { + host = &c.Request.Host + } + + id := uniuri.New() + + c.HTML(http.StatusOK, "term.html", gin.H{ + "title": "interactive terminal", + "path": "/ws_new/" + id, + "id": id, + "logo": "keyboard", + }) +} + +func newTermConn(c *gin.Context) { + id := c.Param("id") + term_conn.ConnectTerm(c.Writer, c.Request, false, id, cmdToExec) +} + +func viewPage(c *gin.Context) { + id := c.Param("id") + c.HTML(http.StatusOK, "term.html", gin.H{ + "title": "viewer terminal", + "path": "/ws_view/" + id, + "id": id, + "logo": "view", + }) +} + +func newViewWS(c *gin.Context) { + id := c.Param("id") + term_conn.ConnectTerm(c.Writer, c.Request, true, id, nil) +} + +func favIcon(c *gin.Context) { + c.File("./assets/img/favicon.ico") +} diff --git a/web/record.go b/web/record.go new file mode 100644 index 0000000..7dac4fa --- /dev/null +++ b/web/record.go @@ -0,0 +1,108 @@ +package web + +import ( + "encoding/json" + "io/ioutil" + "log" + "net/http" + "os" + "strconv" + "strings" + + "github.com/gin-gonic/gin" + "github.com/syssecfsu/witty/term_conn" +) + +type RecordedSession struct { + Fname string + Fsize string + Duration string + Time string +} + +// how many seconds of the session +func getDuration(fname string) int64 { + fp, err := os.Open("./records/" + fname) + + if err != nil { + log.Println("Failed to open record file", err) + return 0 + } + + decoder := json.NewDecoder(fp) + + if decoder == nil { + log.Println("Failed to create JSON decoder") + return 0 + } + + // To work with javascript decoder, we organize the file as + // an array of writeRecord. golang decode instead decode + // as individual record. Call decoder.Token to skip opening [ + decoder.Token() + + var dur int64 = 0 + + for decoder.More() { + var record term_conn.WriteRecord + + if err := decoder.Decode(&record); err != nil { + log.Println("Failed to decode record", err) + continue + } + + dur += record.Dur.Milliseconds() + } + + return dur/1000 + 1 +} + +func collectRecords(c *gin.Context, cmd string) (records []RecordedSession) { + files, err := ioutil.ReadDir("./records/") + + if err == nil { + for _, finfo := range files { + fname := finfo.Name() + if !strings.HasSuffix(fname, ".rec") { + continue + } + fsize := finfo.Size() / 1024 + duration := getDuration(fname) + + records = append(records, + RecordedSession{ + Fname: fname, + Fsize: strconv.FormatInt(fsize, 10), + Duration: strconv.FormatInt(duration, 10), + Time: finfo.ModTime().Format("Jan/2/2006, 15:04:05"), + }) + } + } + + return +} + +func startRecord(c *gin.Context) { + id := c.Param("id") + term_conn.StartRecord(id) +} + +func stopRecord(c *gin.Context) { + id := c.Param("id") + term_conn.StopRecord(id) +} + +func replayPage(c *gin.Context) { + id := c.Param("id") + log.Println("replay/ called with", id) + c.HTML(http.StatusOK, "replay.html", gin.H{ + "fname": id, + }) +} + +func delRec(c *gin.Context) { + fname := c.Param("fname") + if err := os.Remove("./records/" + fname); err != nil { + log.Println("Failed to delete file,", err) + } +} diff --git a/web/routing.go b/web/routing.go new file mode 100644 index 0000000..c418715 --- /dev/null +++ b/web/routing.go @@ -0,0 +1,87 @@ +package web + +import ( + "log" + "net/http" + "net/url" + "os" + + "github.com/dchest/uniuri" + "github.com/gin-gonic/contrib/sessions" + "github.com/gin-gonic/gin" + "github.com/syssecfsu/witty/term_conn" +) + +var host *string = nil +var cmdToExec []string + +// simple function to check origin +func checkOrigin(r *http.Request) bool { + org := r.Header.Get("Origin") + h, err := url.Parse(org) + + if err != nil { + return false + } + + if (host == nil) || (*host != h.Host) { + log.Println("Failed origin check of ", org) + } + + return (host != nil) && (*host == h.Host) +} + +func StartWeb(fp *os.File, cmd []string) { + cmdToExec = cmd + + if fp != nil { + gin.DefaultWriter = fp + } + + rt := gin.Default() + + // We randomly generate a key for now, should use a fixed key + // so login can survive server reboot + store := sessions.NewCookieStore([]byte(uniuri.NewLen(32))) + rt.Use(sessions.Sessions("witty-session", store)) + rt.Use(AuthRequired) + + rt.SetTrustedProxies(nil) + rt.LoadHTMLGlob("./assets/template/*") + + // Fill in the index page + rt.GET("/", indexPage) + rt.GET("/login", loginPage) + + rt.POST("/login", login) + rt.GET("/logout", logout) + + // to update the tabs of current interactive and saved sessions + rt.GET("/update/:active", updateIndex) + + // create a new interactive session + rt.GET("/new", newInteractive) + rt.GET("/ws_new/:id", newTermConn) + + // create a viewer of an interactive session + rt.GET("/view/:id", viewPage) + rt.GET("/ws_view/:id", newViewWS) + + // start/stop recording the session + rt.GET("/record/:id", startRecord) + rt.GET("/stop/:id", stopRecord) + + // create a viewer of an interactive session + rt.GET("/replay/:id", replayPage) + + // delete a recording + rt.GET("/delete/:fname", delRec) + + // handle static files + rt.Static("/assets", "./assets") + rt.Static("/records", "./records") + rt.GET("/favicon.ico", favIcon) + + term_conn.Init(checkOrigin) + rt.RunTLS(":8080", "./tls/cert.pem", "./tls/private-key.pem") +} From 033d9b3f145bc7a4382d8210161d223afa943f66 Mon Sep 17 00:00:00 2001 From: Zhi Wang Date: Fri, 21 Jan 2022 17:53:23 -0500 Subject: [PATCH 3/4] add toast messages --- assets/template/login.html | 26 ++++++++++++++++- term_conn/relay.go | 18 +++++------- web/auth.go | 55 +++++++++++++++++++--------------- web/record.go | 2 +- web/routing.go | 60 +++++++++++++++++++------------------- 5 files changed, 96 insertions(+), 65 deletions(-) diff --git a/assets/template/login.html b/assets/template/login.html index c836d16..7a71760 100644 --- a/assets/template/login.html +++ b/assets/template/login.html @@ -14,8 +14,19 @@ + +
-
+
@@ -31,6 +42,19 @@

WiTTY: Web-based Interactive TTY

+ + + \ No newline at end of file diff --git a/term_conn/relay.go b/term_conn/relay.go index 882bc61..41e6ef1 100644 --- a/term_conn/relay.go +++ b/term_conn/relay.go @@ -248,8 +248,8 @@ out: if err != nil { log.Println("Failed to marshal record", err) } else { - tc.record.Write(jbuf) tc.record.Write([]byte(",")) // write a deliminator + tc.record.Write(jbuf) } tc.lastRecTime = time.Now() @@ -259,7 +259,7 @@ out: var err error if cmd == recordCmd { // use the session ID and current as file name - fname := "./records/" + tc.Name + "_" + strconv.FormatInt(time.Now().Unix(), 16) + ".rec" + fname := "./records/" + tc.Name + "_" + strconv.FormatInt(time.Now().Unix(), 16) + ".scr" tc.record, err = os.OpenFile(fname, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644) if err != nil { @@ -268,16 +268,14 @@ out: } tc.record.Write([]byte("[")) // write a [ for an array of json objs + + // write a dummy record to clear the screen. tc.lastRecTime = time.Now() + jbuf, _ := json.Marshal(WriteRecord{Dur: time.Since(tc.lastRecTime), Data: []byte("\033[2J\033[H")}) + tc.record.Write(jbuf) + } else { - fsinfo, err := tc.record.Stat() - - if err == nil { - tc.record.Truncate(fsinfo.Size() - 1) - tc.record.Seek(0, 2) // truncate does not change read/write location - tc.record.Write([]byte("]")) - } - + tc.record.Write([]byte("]")) tc.record.Close() tc.record = nil } diff --git a/web/auth.go b/web/auth.go index 50ae9c6..d3fad53 100644 --- a/web/auth.go +++ b/web/auth.go @@ -9,9 +9,16 @@ import ( ) const ( - userkey = "user" + userkey = "authorized_user" + loginKey = "login_msg" ) +func leftLoginMsg(c *gin.Context, msg string) { + session := sessions.Default(c) + session.Set(loginKey, msg) + session.Save() +} + func login(c *gin.Context) { session := sessions.Default(c) @@ -20,64 +27,66 @@ func login(c *gin.Context) { // Validate form input if strings.Trim(username, " ") == "" || strings.Trim(passwd, " ") == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "Username/password can't be empty"}) + leftLoginMsg(c, "User name or password cannot be empty") + c.Redirect(http.StatusSeeOther, "/login") return } // Check for username and password match, usually from a database if username != "hello" || passwd != "world" { - c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication failed"}) + leftLoginMsg(c, "Username/password does not match") + c.Redirect(http.StatusSeeOther, "/login") return } // Save the username in the session - session.Set(userkey, username) // In real world usage you'd set this to the users ID + session.Set(userkey, username) if err := session.Save(); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save session"}) + leftLoginMsg(c, "Failed to save session data") + c.Redirect(http.StatusSeeOther, "/login") return } host = &c.Request.Host - c.Redirect(http.StatusSeeOther, "/") } func logout(c *gin.Context) { session := sessions.Default(c) + user := session.Get(userkey) - if user == nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid session token"}) - return - } - session.Delete(userkey) - if err := session.Save(); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save session"}) - return + if user != nil { + session.Delete(userkey) + session.Save() } + + leftLoginMsg(c, "Welcome to WiTTY") c.Redirect(http.StatusFound, "/login") } // AuthRequired is a simple middleware to check the session func AuthRequired(c *gin.Context) { - if (c.Request.URL.String() == "/login") || - strings.HasPrefix(c.Request.URL.String(), "/assets") { - c.Next() - return - } - session := sessions.Default(c) user := session.Get(userkey) + if user == nil { - // Abort the request with the appropriate error code + leftLoginMsg(c, "Not authorized, login first") c.Redirect(http.StatusTemporaryRedirect, "/login") c.Abort() return } - // Continue down the chain to handler etc + c.Next() } func loginPage(c *gin.Context) { - c.HTML(http.StatusOK, "login.html", gin.H{}) + session := sessions.Default(c) + msg := session.Get(loginKey) + + if msg == nil { + msg = "Login first" + } + + c.HTML(http.StatusOK, "login.html", gin.H{"msg": msg}) } diff --git a/web/record.go b/web/record.go index 7dac4fa..c58cb0f 100644 --- a/web/record.go +++ b/web/record.go @@ -63,7 +63,7 @@ func collectRecords(c *gin.Context, cmd string) (records []RecordedSession) { if err == nil { for _, finfo := range files { fname := finfo.Name() - if !strings.HasSuffix(fname, ".rec") { + if !strings.HasSuffix(fname, ".scr") { continue } fsize := finfo.Size() / 1024 diff --git a/web/routing.go b/web/routing.go index c418715..831f044 100644 --- a/web/routing.go +++ b/web/routing.go @@ -44,44 +44,44 @@ func StartWeb(fp *os.File, cmd []string) { // so login can survive server reboot store := sessions.NewCookieStore([]byte(uniuri.NewLen(32))) rt.Use(sessions.Sessions("witty-session", store)) - rt.Use(AuthRequired) rt.SetTrustedProxies(nil) rt.LoadHTMLGlob("./assets/template/*") - - // Fill in the index page - rt.GET("/", indexPage) - rt.GET("/login", loginPage) - - rt.POST("/login", login) - rt.GET("/logout", logout) - - // to update the tabs of current interactive and saved sessions - rt.GET("/update/:active", updateIndex) - - // create a new interactive session - rt.GET("/new", newInteractive) - rt.GET("/ws_new/:id", newTermConn) - - // create a viewer of an interactive session - rt.GET("/view/:id", viewPage) - rt.GET("/ws_view/:id", newViewWS) - - // start/stop recording the session - rt.GET("/record/:id", startRecord) - rt.GET("/stop/:id", stopRecord) - - // create a viewer of an interactive session - rt.GET("/replay/:id", replayPage) - - // delete a recording - rt.GET("/delete/:fname", delRec) - // handle static files rt.Static("/assets", "./assets") rt.Static("/records", "./records") rt.GET("/favicon.ico", favIcon) + rt.GET("/login", loginPage) + rt.POST("/login", login) + + g1 := rt.Group("/", AuthRequired) + + // Fill in the index page + g1.GET("/", indexPage) + g1.GET("/logout", logout) + + // to update the tabs of current interactive and saved sessions + g1.GET("/update/:active", updateIndex) + + // create a new interactive session + g1.GET("/new", newInteractive) + g1.GET("/ws_new/:id", newTermConn) + + // create a viewer of an interactive session + g1.GET("/view/:id", viewPage) + g1.GET("/ws_view/:id", newViewWS) + + // start/stop recording the session + g1.GET("/record/:id", startRecord) + g1.GET("/stop/:id", stopRecord) + + // create a viewer of an interactive session + g1.GET("/replay/:id", replayPage) + + // delete a recording + g1.GET("/delete/:fname", delRec) + term_conn.Init(checkOrigin) rt.RunTLS(":8080", "./tls/cert.pem", "./tls/private-key.pem") } From 8dfac2d026cfa34297493ccbee34fee230fba2e5 Mon Sep 17 00:00:00 2001 From: Zhi Wang Date: Fri, 21 Jan 2022 21:42:22 -0500 Subject: [PATCH 4/4] add user authentication --- README.md | 24 +++--- assets/template/index.html | 2 +- main.go | 55 +++++++++++--- web/auth.go | 2 +- web/interactive.go | 7 +- web/routing.go | 10 ++- web/user.go | 152 +++++++++++++++++++++++++++++++++++++ 7 files changed, 226 insertions(+), 26 deletions(-) create mode 100644 web/user.go diff --git a/README.md b/README.md index b996369..4948f8e 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ This program allows you to use terminal in the browser. Simply run the program a This repository contains a recorded session in the ```assets/extra``` directory ([M1NXZvHdvA8vSCKp_61e5d60f.rec](extra/M1NXZvHdvA8vSCKp_61e5d60f.rec)) that shows me upgrading pihole. Just put the file under the ```records``` directory, run the server, you should find the recording in the ```Recorded Session``` tab. -3. More features are planned, including user authentication. Suggestions are welcome. +3. More features are planned, suggestions are welcome. You can use WiTTY to run any command line programs, such as ```bash```, ```htop```, ```vi```, ```ssh```. This following screenshot shows that three interactive session running ```zsh``` on macOS Monterey. @@ -32,11 +32,7 @@ gifsicle -O3 .\output.gif -o replay.gif --> -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.__ +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. By default, WiTTY authenticate users with username and password. You can add a new user using ```witty adduser ```, and delete an existing user with ```witty deluser ```. It is also possible to disable user authentication with ```-n/-naked``` to the run command. For example, ```witty run -n zsh``` will run zsh without user authentication. This program is written in the [go programming language](https://go.dev/), using the [Gin web framework](https://github.com/gin-gonic/gin), [gorilla/websocket](https://github.com/gorilla/websocket), [pty](https://github.com/creack/pty), and the wonderful [xterm.js](https://xtermjs.org/)! @@ -66,13 +62,21 @@ Most icons were provided by [fontawesome](https://fontawesome.com/) under this [ ```go build .``` -5. Start the server and give it the command to run. The server listens on 8080, for example: +5. Add a user to the user accounts, follow the instructions on screen to provide the password - ```./witty htop``` or + ```./witty adduser ``` - ```./witty ssh -l ``` +6. Start the server and give it the command to run. The server listens on 8080, for example: + + ```./witty run htop``` or -6. Connect to the server, for example + ```./witty run ssh -l ``` + + If so desired, you can disable user authenticate with ```-n/-naked```, (not recommended) for example: + + ```./witty run -naked htop``` + +7. Connect to the server, for example ```https://:8080``` diff --git a/assets/template/index.html b/assets/template/index.html index fa999a2..da753a6 100644 --- a/assets/template/index.html +++ b/assets/template/index.html @@ -32,7 +32,7 @@ target="_blank" role="button"> New Session - + Logout diff --git a/main.go b/main.go index f196052..18b5c41 100644 --- a/main.go +++ b/main.go @@ -1,6 +1,8 @@ package main import ( + "flag" + "fmt" "log" "os" @@ -15,17 +17,48 @@ func main() { log.SetOutput(fp) } - // parse the arguments. User can pass the command to execute - // by default, we use bash, but macos users might want to use zsh - // you can also run single program, such as pstree, htop... - // but program might misbehave (htop seems to be fine) - var cmdToExec = []string{"bash"} - args := os.Args - - if len(args) > 1 { - cmdToExec = args[1:] - log.Println(cmdToExec) + if len(os.Args) < 2 { + fmt.Println("witty (adduser|deluser|run)") + return + } + + var naked bool + runCmd := flag.NewFlagSet("run", flag.ExitOnError) + runCmd.BoolVar(&naked, "n", false, "Run WiTTY without user authentication") + runCmd.BoolVar(&naked, "naked", false, "Run WiTTY without user authentication") + + switch os.Args[1] { + case "adduser": + if len(os.Args) != 3 { + fmt.Println("witty adduser ") + return + } + web.AddUser(os.Args[2]) + + case "deluser": + if len(os.Args) != 3 { + fmt.Println("witty deluser ") + return + } + web.DelUser(os.Args[2]) + + case "run": + runCmd.Parse(os.Args[2:]) + + var cmdToExec []string + + args := runCmd.Args() + if len(args) > 0 { + cmdToExec = args + } else { + cmdToExec = []string{"bash"} + } + + web.StartWeb(fp, cmdToExec, naked) + + default: + fmt.Println("witty (adduser|deluser|run)") + return } - web.StartWeb(fp, cmdToExec) } diff --git a/web/auth.go b/web/auth.go index d3fad53..1affae8 100644 --- a/web/auth.go +++ b/web/auth.go @@ -33,7 +33,7 @@ func login(c *gin.Context) { } // Check for username and password match, usually from a database - if username != "hello" || passwd != "world" { + if !ValidateUser([]byte(username), []byte(passwd)) { leftLoginMsg(c, "Username/password does not match") c.Redirect(http.StatusSeeOther, "/login") return diff --git a/web/interactive.go b/web/interactive.go index 2b8962b..f8c23e4 100644 --- a/web/interactive.go +++ b/web/interactive.go @@ -28,8 +28,13 @@ func collectSessions(c *gin.Context, cmd string) (players []InteractiveSession) func indexPage(c *gin.Context) { host = &c.Request.Host + var disabled = "" - c.HTML(http.StatusOK, "index.html", gin.H{}) + if noAuth { + disabled = "disabled" + } + + c.HTML(http.StatusOK, "index.html", gin.H{"disabled": disabled}) } func updateIndex(c *gin.Context) { diff --git a/web/routing.go b/web/routing.go index 831f044..ae9874b 100644 --- a/web/routing.go +++ b/web/routing.go @@ -14,6 +14,7 @@ import ( var host *string = nil var cmdToExec []string +var noAuth bool // simple function to check origin func checkOrigin(r *http.Request) bool { @@ -31,8 +32,9 @@ func checkOrigin(r *http.Request) bool { return (host != nil) && (*host == h.Host) } -func StartWeb(fp *os.File, cmd []string) { +func StartWeb(fp *os.File, cmd []string, naked bool) { cmdToExec = cmd + noAuth = naked if fp != nil { gin.DefaultWriter = fp @@ -55,7 +57,11 @@ func StartWeb(fp *os.File, cmd []string) { rt.GET("/login", loginPage) rt.POST("/login", login) - g1 := rt.Group("/", AuthRequired) + g1 := rt.Group("/") + + if !naked { + g1.Use(AuthRequired) + } // Fill in the index page g1.GET("/", indexPage) diff --git a/web/user.go b/web/user.go new file mode 100644 index 0000000..8a1ab41 --- /dev/null +++ b/web/user.go @@ -0,0 +1,152 @@ +package web + +import ( + "bytes" + "crypto/sha256" + "encoding/json" + "fmt" + "os" + + "github.com/dchest/uniuri" + "golang.org/x/term" +) + +const ( + userFileName = "user.db" +) + +type UserRecord struct { + User []byte `json:"Username"` + Seed []byte `json:"Seed"` + Passwd [32]byte `json:"Password"` +} + +func hashPassword(seed []byte, passwd []byte) [32]byte { + input := append(seed, passwd...) + return sha256.Sum256(input) +} + +func addUser(username []byte, passwd []byte) { + var users []UserRecord + var err error + + seed := []byte(uniuri.NewLen(64)) + hashed := hashPassword(seed, passwd) + + exist := false + file, err := os.ReadFile(userFileName) + + if (err == nil) && (json.Unmarshal(file, users) == nil) { + // update the existing user if it exists + for _, u := range users { + if bytes.Equal(u.User, username) { + u.Seed = seed + u.Passwd = hashed + exist = true + break + } + } + } + + if !exist { + users = append(users, UserRecord{username, seed, hashed}) + } + + output, err := json.Marshal(users) + if err != nil { + fmt.Println("Failed to marshal passwords", err) + return + } + + os.WriteFile(userFileName, output, 0660) +} + +func AddUser(username string) { + fmt.Println("Please type your password (it will not be echoed back):") + passwd, err := term.ReadPassword(int(os.Stdin.Fd())) + + if err != nil { + fmt.Println("Failed to read password", err) + return + } + + fmt.Println("Please type your password again:") + passwd2, err := term.ReadPassword(int(os.Stdin.Fd())) + + if err != nil { + fmt.Println("Failed to read password", err) + return + } + + if !bytes.Equal(passwd, passwd2) { + fmt.Println("Password mismatch, try again") + return + } + + addUser([]byte(username), passwd) +} + +func DelUser(username string) { + var users []UserRecord + var err error + + exist := false + file, err := os.ReadFile(userFileName) + if err != nil { + fmt.Println("Failed to read users file", err) + return + } + + err = json.Unmarshal(file, &users) + + if err != nil { + fmt.Println("Failed to parse json format", err) + return + } + // update the existing user if it exists + for i, u := range users { + if bytes.Equal(u.User, []byte(username)) { + users = append(users[:i], users[i+1:]...) + exist = true + break + } + } + + if exist { + output, err := json.Marshal(users) + if err != nil { + fmt.Println("Failed to marshal passwords", err) + return + } + + os.WriteFile(userFileName, output, 0660) + } +} + +func ValidateUser(username []byte, passwd []byte) bool { + var users []UserRecord + var err error + + file, err := os.ReadFile(userFileName) + if err != nil { + fmt.Println("Failed to read users file", err) + return false + } + + err = json.Unmarshal(file, &users) + + if err != nil { + fmt.Println("Failed to parse json format", err) + return false + } + + // update the existing user if it exists + for _, u := range users { + if bytes.Equal(u.User, []byte(username)) { + hashed := hashPassword(u.Seed, passwd) + return bytes.Equal(hashed[:], u.Passwd[:]) + } + } + + return false +}