mirror of
https://github.com/syssecfsu/witty.git
synced 2025-02-09 19:04:08 +01:00
commit
082d70c898
24
README.md
24
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. <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
1
assets/img/sign-in.svg
Normal 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
1
assets/img/sign-out.svg
Normal 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
39
assets/signin.css
Normal 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;
|
||||
}
|
@ -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>
|
||||
|
60
assets/template/login.html
Normal file
60
assets/template/login.html
Normal 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
9
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
|
||||
|
13
go.sum
13
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=
|
||||
|
266
main.go
266
main.go
@ -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")
|
||||
}
|
||||
|
@ -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
92
web/auth.go
Normal 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
102
web/interactive.go
Normal 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
108
web/record.go
Normal 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
93
web/routing.go
Normal 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
152
web/user.go
Normal 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
|
||||
}
|
Loading…
Reference in New Issue
Block a user