refactor files, add authentication

This commit is contained in:
Zhi Wang 2022-01-21 09:36:31 -05:00
parent 0acf1a95af
commit 481e5473aa
11 changed files with 414 additions and 240 deletions

1
assets/img/sign-in.svg Normal file
View File

@ -0,0 +1 @@
<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="sign-in-alt" class="svg-inline--fa fa-sign-in-alt fa-w-16" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M416 448h-84c-6.6 0-12-5.4-12-12v-40c0-6.6 5.4-12 12-12h84c17.7 0 32-14.3 32-32V160c0-17.7-14.3-32-32-32h-84c-6.6 0-12-5.4-12-12V76c0-6.6 5.4-12 12-12h84c53 0 96 43 96 96v192c0 53-43 96-96 96zm-47-201L201 79c-15-15-41-4.5-41 17v96H24c-13.3 0-24 10.7-24 24v96c0 13.3 10.7 24 24 24h136v96c0 21.5 26 32 41 17l168-168c9.3-9.4 9.3-24.6 0-34z"></path></svg>

After

Width:  |  Height:  |  Size: 578 B

1
assets/img/sign-out.svg Normal file
View File

@ -0,0 +1 @@
<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="sign-out-alt" class="svg-inline--fa fa-sign-out-alt fa-w-16" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M497 273L329 441c-15 15-41 4.5-41-17v-96H152c-13.3 0-24-10.7-24-24v-96c0-13.3 10.7-24 24-24h136V88c0-21.4 25.9-32 41-17l168 168c9.3 9.4 9.3 24.6 0 34zM192 436v-40c0-6.6-5.4-12-12-12H96c-17.7 0-32-14.3-32-32V160c0-17.7 14.3-32 32-32h84c6.6 0 12-5.4 12-12V76c0-6.6-5.4-12-12-12H96c-53 0-96 43-96 96v192c0 53 43 96 96 96h84c6.6 0 12-5.4 12-12z"></path></svg>

After

Width:  |  Height:  |  Size: 584 B

View File

@ -27,10 +27,15 @@
class="d-inline-block align-text-top">
WiTTY: Web-based interactive TTY
</a>
<a class="btn btn-primary btn-sm float-end" href="/new" onClick="setTimeout(function(){refresh(true)}, 1000)"
target="_blank" role="button">
New Session
</a>
<div class="btn-toolbar float-end" role="toolbar" aria-label="top buttons">
<a class="btn btn-primary btn-sm m-1" href="/new" onClick="setTimeout(function(){refresh(true)}, 1000)"
target="_blank" role="button">
New Session
</a>
<a class="btn btn-primary btn-sm m-1" href="/logout" role="button">
Logout
</a>
</div>
</div>
</nav>
</header>

View File

@ -15,8 +15,8 @@
<body class="text-center">
<main class="form-signin">
<form action="/sign-in" method="post">
<img class="mb-4" src="/assets/img/logo.svg" alt="" width="64">
<form action="/login" method="post">
<img class="mb-4" src="/assets/img/keyboard.svg" alt="" width="64">
<div class="form-floating">
<input type="text" class="form-control" id="username" name="username" placeholder="User Name">
@ -27,10 +27,7 @@
<label for="passwd">Password</label>
</div>
<div class="checkbox mb-3">
<label><input type="checkbox" name="remember" value="true"> Remember me</label>
</div>
<button class="w-100 btn btn-lg btn-primary" type="submit">Sign in</button>
<button class="w-100 btn btn-lg btn-primary mt-5" type="submit">Sign in</button>
<p class="mt-5 mb-3 text-muted">WiTTY: Web-based Interactive TTY</p>
</form>
</main>

9
go.mod
View File

@ -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

13
go.sum
View File

@ -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=

233
main.go
View File

@ -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)
}

83
web/auth.go Normal file
View File

@ -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{})
}

97
web/interactive.go Normal file
View File

@ -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")
}

108
web/record.go Normal file
View File

@ -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)
}
}

87
web/routing.go Normal file
View File

@ -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")
}