Merge pull request #3 from syssecfsu/Auth

Auth
This commit is contained in:
syssecfsu 2022-01-21 21:43:39 -05:00 committed by GitHub
commit 082d70c898
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 736 additions and 255 deletions

View File

@ -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. <img src="extra/main.png" width="800px">
@ -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 <username>```, and delete an existing user with ```witty deluser <username>```. 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 <username>```
```./witty ssh <ssh_server_ip> -l <user_name>```
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 <ssh_server_ip> -l <user_name>```
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://<witty_server_ip>:8080```

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

39
assets/signin.css Normal file
View File

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

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 {{.disabled}}" href="/logout" role="button">
Logout
</a>
</div>
</div>
</nav>
</header>

View File

@ -0,0 +1,60 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="description" content="">
<meta name="author" content="">
<title>WiTTY Login</title>
<script src="/assets/external/bootstrap.min.js"></script>
<link href="/assets/external/bootstrap.min.css" rel="stylesheet">
<link href="/assets/signin.css" rel="stylesheet">
</head>
<body class="text-center">
<div class="toast bg-primary text-white border-0" role="alert" aria-live="assertive" aria-atomic="true" id="authMsg"
style="position: absolute;top: 0px; right: 10px; z-index:1;">
<div class="d-flex">
<div class="toast-body">
{{.msg}}
</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"
aria-label="Close"></button>
</div>
</div>
<main class="form-signin">
<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">
<label for="username">User Name</label>
</div>
<div class="form-floating">
<input type="password" class="form-control" id="passwd" name="passwd" placeholder="Password">
<label for="passwd">Password</label>
</div>
<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>
<script>
document.addEventListener("DOMContentLoaded", function () {
var element = document.getElementById("authMsg");
var toast = new bootstrap.Toast(element);
toast.show()
setTimeout(() => {
toast.hide()
}, 1500)
});
</script>
</body>
</html>

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=

266
main.go
View File

@ -1,260 +1,64 @@
package main
import (
"encoding/json"
"io/ioutil"
"flag"
"fmt"
"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)
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
}
rt := gin.Default()
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")
rt.SetTrustedProxies(nil)
rt.LoadHTMLGlob("./assets/template/*")
switch os.Args[1] {
case "adduser":
if len(os.Args) != 3 {
fmt.Println("witty adduser <username>")
return
}
web.AddUser(os.Args[2])
// Fill in the index page
rt.GET("/", func(c *gin.Context) {
host = &c.Request.Host
players, records := collectTabData(c)
case "deluser":
if len(os.Args) != 3 {
fmt.Println("witty deluser <username>")
return
}
web.DelUser(os.Args[2])
c.HTML(http.StatusOK, "index.html", gin.H{
"title": "interactive terminal",
"players": players,
"records": records,
})
})
case "run":
runCmd.Parse(os.Args[2:])
rt.GET("/favicon.ico", func(c *gin.Context) {
c.File("./assets/img/favicon.ico")
})
var cmdToExec []string
// 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 = ""
args := runCmd.Args()
if len(args) > 0 {
cmdToExec = args
} else {
active0 = ""
active1 = "active"
cmdToExec = []string{"bash"}
}
host = &c.Request.Host
players, records := collectTabData(c)
web.StartWeb(fp, cmdToExec, naked)
c.HTML(http.StatusOK, "tab.html", gin.H{
"players": players,
"records": records,
"active0": active0,
"active1": active1,
})
})
default:
fmt.Println("witty (adduser|deluser|run)")
return
}
// 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")
}

View File

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

92
web/auth.go Normal file
View File

@ -0,0 +1,92 @@
package web
import (
"net/http"
"strings"
"github.com/gin-gonic/contrib/sessions"
"github.com/gin-gonic/gin"
)
const (
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)
username := c.PostForm("username")
passwd := c.PostForm("passwd")
// Validate form input
if strings.Trim(username, " ") == "" || strings.Trim(passwd, " ") == "" {
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 !ValidateUser([]byte(username), []byte(passwd)) {
leftLoginMsg(c, "Username/password does not match")
c.Redirect(http.StatusSeeOther, "/login")
return
}
// Save the username in the session
session.Set(userkey, username)
if err := session.Save(); err != nil {
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 {
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) {
session := sessions.Default(c)
user := session.Get(userkey)
if user == nil {
leftLoginMsg(c, "Not authorized, login first")
c.Redirect(http.StatusTemporaryRedirect, "/login")
c.Abort()
return
}
c.Next()
}
func loginPage(c *gin.Context) {
session := sessions.Default(c)
msg := session.Get(loginKey)
if msg == nil {
msg = "Login first"
}
c.HTML(http.StatusOK, "login.html", gin.H{"msg": msg})
}

102
web/interactive.go Normal file
View File

@ -0,0 +1,102 @@
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
var disabled = ""
if noAuth {
disabled = "disabled"
}
c.HTML(http.StatusOK, "index.html", gin.H{"disabled": disabled})
}
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, ".scr") {
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)
}
}

93
web/routing.go Normal file
View File

@ -0,0 +1,93 @@
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
var noAuth bool
// 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, naked bool) {
cmdToExec = cmd
noAuth = naked
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.SetTrustedProxies(nil)
rt.LoadHTMLGlob("./assets/template/*")
// 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("/")
if !naked {
g1.Use(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")
}

152
web/user.go Normal file
View File

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