From 481e5473aacf4975ebf50e0f5eb5669224af95d1 Mon Sep 17 00:00:00 2001 From: Zhi Wang Date: Fri, 21 Jan 2022 09:36:31 -0500 Subject: [PATCH] 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") +}