Merge pull request #2 from syssecfsu/record_session

Record session
This commit is contained in:
syssecfsu 2022-01-16 21:18:40 -05:00 committed by GitHub
commit 46144eca35
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 684 additions and 98 deletions

View File

@ -1,5 +1,24 @@
# WiTTY: Web-based interactive TTY # WiTTY: Web-based interactive TTY
This program allows you to use terminal in the browser. Simply run the program and give it the command to execute when users connect via the browser. ___Interestingly___, it allows others to view your interactive sessions as well. This could be useful to provide remote support and/or help. You can use the program to run any command line programs, such as ```bash```, ```htop```, ```vi```, ```ssh```. This following screenshot shows that six interactive session running ```zsh``` on macOS Monterey. <img src="https://github.com/syssecfsu/witty/blob/master/extra/main.png?raw=true" width="800px"> This program allows you to use terminal in the browser. Simply run the program and give it the command to execute when users connect via the browser. ___Interestingly___, it allows others to view your interactive sessions as well. This could be useful to provide remote support and/or help. You can use the program 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="https://github.com/syssecfsu/witty/blob/master/extra/main.png?raw=true" width="800px">
With WiTTY, you can also __record and replay your interactive sessions__. The following screenshot shows three recorded sessions. You can replay/download/delete them.
<img src="https://github.com/syssecfsu/witty/blob/master/extra/view.png?raw=true" width="800px">
Here is a session, where we sshed into a Raspberry Pi running
[pi-hole](https://pi-hole.net/)
(```./witty ssh 192.168.1.2 -l pi```,
WiTTY runs in a WSL2 VM on Windows) being replayed. You can play/pause the session.
<img src="https://github.com/syssecfsu/witty/blob/master/extra/screencast.gif?raw=true" width="800px">
<!--
commands to create high quality gif from mkv/mp4 files
ffmpeg -i replay.mkv -vf palettegen palette.png
ffmpeg -i replay.mkv -i palette.png -lavfi paletteuse output.gif
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). 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).
@ -44,17 +63,4 @@ window (xterm.js) and create a websocket with the server, which relays the data
```https://your_ip_address:8080``` ```https://your_ip_address:8080```
The program has been tested on Linux, WSL2, Raspberry Pi 3B (Debian), and MacOSX using Google Chrome, Firefox, and Safari. The program has been tested on Linux, WSL2, Raspberry Pi 3B (Debian), and MacOSX using Google Chrome, Firefox, and Safari.
<!-- ## Known issue
WiTTY might have some display/encoding issues on macOS with Firefox, especially for zsh. Safari works fine though. -->
## An Screencast <sub>featuring an older version of WiTTY</sub>
Here is a screencast for sshing into Raspberry Pi running
[pi-hole](https://pi-hole.net/)
(```./witty ssh 192.168.1.2 -l pi```,
WiTTY runs in a WSL2 VM on Windows):
<img src="https://github.com/syssecfsu/witty/blob/master/extra/screencast.gif?raw=true" width="800px">

1
assets/delete.svg Normal file
View File

@ -0,0 +1 @@
<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="trash-alt" class="svg-inline--fa fa-trash-alt fa-w-14" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path fill="currentColor" d="M32 464a48 48 0 0 0 48 48h288a48 48 0 0 0 48-48V128H32zm272-256a16 16 0 0 1 32 0v224a16 16 0 0 1-32 0zm-96 0a16 16 0 0 1 32 0v224a16 16 0 0 1-32 0zm-96 0a16 16 0 0 1 32 0v224a16 16 0 0 1-32 0zM432 32H312l-9.4-18.7A24 24 0 0 0 281.1 0H166.8a23.72 23.72 0 0 0-21.4 13.3L136 32H16A16 16 0 0 0 0 48v32a16 16 0 0 0 16 16h416a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16z"></path></svg>

After

Width:  |  Height:  |  Size: 597 B

1
assets/download.svg Normal file
View File

@ -0,0 +1 @@
<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="download" class="svg-inline--fa fa-download fa-w-16" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M216 0h80c13.3 0 24 10.7 24 24v168h87.7c17.8 0 26.7 21.5 14.1 34.1L269.7 378.3c-7.5 7.5-19.8 7.5-27.3 0L90.1 226.1c-12.6-12.6-3.7-34.1 14.1-34.1H192V24c0-13.3 10.7-24 24-24zm296 376v112c0 13.3-10.7 24-24 24H24c-13.3 0-24-10.7-24-24V376c0-13.3 10.7-24 24-24h146.7l49 49c20.1 20.1 52.5 20.1 72.6 0l49-49H488c13.3 0 24 10.7 24 24zm-124 88c0-11-9-20-20-20s-20 9-20 20 9 20 20 20 20-9 20-20zm64 0c0-11-9-20-20-20s-20 9-20 20 9 20 20 20 20-9 20-20z"></path></svg>

After

Width:  |  Height:  |  Size: 678 B

View File

@ -8,25 +8,26 @@
<meta name="author" content=""> <meta name="author" content="">
<!-- automatically refresh the page every 30 seconds --> <!-- automatically refresh the page every 30 seconds -->
<meta http-equiv="refresh" content="20"> <!-- <meta http-equiv="refresh" content="30"> -->
<title>Web Terminal</title> <title>Web Terminal</title>
<!-- Bootstrap core CSS --> <!-- Bootstrap core CSS -->
<script src="/assets/external/bootstrap.min.js"></script>
<link href="/assets/external/bootstrap.min.css" rel="stylesheet"> <link href="/assets/external/bootstrap.min.css" rel="stylesheet">
<link href="/assets/main.css" rel="stylesheet"> <link href="/assets/main.css" rel="stylesheet">
</head>
<body> <body>
<header> <header>
<nav class="navbar navbar-dark bg-dark shadow-sm navbar-xs"> <nav class="navbar navbar-dark bg-dark shadow-sm navbar-xs">
<div class="container-fluid"> <div class="container-fluid">
<a class="navbar-brand mx-auto" href="https://github.com/syssecfsu/witty" target="_blank"> <a class="navbar-brand mx-auto" href="https://github.com/syssecfsu/witty" target="_blank">
<img src="/assets/logo.svg" style="margin-right: 0.5rem;" height="32" <img src="/assets/logo.svg" style="margin-right: 0.5rem;" height="32" class="d-inline-block align-text-top">
class="d-inline-block align-text-top">
WiTTY: Web-based interactive TTY WiTTY: Web-based interactive TTY
</a> </a>
<a class="btn btn-primary btn-sm float-end" href="/new" <a class="btn btn-primary btn-sm float-end" href="/new"
onClick="setTimeout(function(){window.location.reload()}, 3000);" target="_blank" role="button"> onClick="setTimeout(function(){window.location.reload()}, 2000);" target="_blank" role="button">
New Session New Session
</a> </a>
</div> </div>
@ -34,25 +35,81 @@
</header> </header>
<main> <main>
<div class="container" style="margin-top:1em;"> <div class="container-fluid" style="margin-top:1em;">
<div class="card-deck row justify-content-center"> <nav>
<div class="nav nav-tabs" id="nav-tab" role="tablist">
<button class="nav-link border bg-light" id="nav-home-tab" data-bs-toggle="tab" data-bs-target="#nav-home"
type="button" role="tab" aria-controls="nav-home" aria-selected="true">Live</button>
<button class="nav-link border active bg-light" id="nav-profile-tab" data-bs-toggle="tab"
data-bs-target="#nav-profile" type="button" role="tab" aria-controls="nav-profile"
aria-selected="false">Saved</button>
</div>
</nav>
<div class="tab-content bg-light" id="nav-tabContent">
<div class="tab-pane fade" id="nav-home" role="tabpanel" aria-labelledby="nav-home-tab">
<div class="card-deck row justify-content-center">
<!-- repeat this for each interactive session -->
{{range .players}}
<div class="card shadow-sm border-danger bg-white mb-3" style="width: 16rem; margin:1em;">
<div class="card-body">
<h5 class="card-title">Interactive session</h5>
<p class="card-text">From <em>{{.Ip}}</em>, running <strong>{{.Cmd}}</strong>, session ID:
<u>{{.Id}}</u>
</p>
<a class="btn btn-outline-success btn-sm float-end" href="/view/{{.Id}}" target="_blank"
role="button">View
Session</a>
</div>
</div>
{{end}}
<!-- repeat this for each interactive session -->
{{range .players}}
<div class="card shadow-sm border-info bg-light mb-3" style="width: 16rem; margin:1em;">
<div class="card-body">
<h5 class="card-title">Interactive session</h5>
<p class="card-text">From <em>{{.Ip}}</em>, running <strong>{{.Cmd}}</strong>, session ID: <u>{{.Name}}</u>
</p>
<a class="btn btn-secondary btn-sm float-end" href="/view/{{.Name}}" target="_blank" role="button">View
Session</a>
</div> </div>
</div> </div>
{{end}}
<div class="tab-pane fade show active" id="nav-profile" role="tabpanel" aria-labelledby="nav-profile-tab">
<div class="card-deck row justify-content-center">
<!-- repeat this for each recorded session -->
{{range .records}}
<div class="card shadow-sm border-info bg-light mb-3" style="width: 16rem; margin:1em;">
<div class="card-body">
<h5 class="card-title">Recorded session</h5>
<p class="card-text">File name: <u>{{.Fname}}</u>, file size: <em>{{.Fsize}}KB</em>,
recorded at <strong>{{.Time}}</strong>, duration: <mark>{{.Duration}}s</mark>,
</p>
<div class="btn-toolbar float-end" role="toolbar" aria-label="records buttons">
<a class="btn btn-outline-success btn-sm m-1" href="/replay/{{.Fname}}" target="_blank" role="button">
<img src="/assets/play.svg" height="18px">
</a>
<a class="btn btn-outline-success btn-sm m-1" href="/records/{{.Fname}}" role="button" download>
<img src="/assets/download.svg" height="18px">
</a>
<button type="button" class="btn btn-outline-success btn-sm m-1" onclick="del_btn({{.Fname}})">
<img src="/assets/delete.svg" height="18px">
</button>
</div>
</div>
</div>
{{end}}
</div>
</div>
</div> </div>
</div> </div>
</main> </main>
<script>
function del_btn(path) {
fetch("/delete/" + path)
setTimeout(function () {
window.location.reload()
}, 800);
}
</script>
</body> </body>
</html> </html>

View File

@ -1,7 +1,7 @@
#terminal { #terminal {
display: flex; display: flex;
justify-content: center; justify-content: center;
margin: 10px 0 20px; margin: 10px 0 10px;
} }
#terminal #terminal_view { #terminal #terminal_view {
@ -36,3 +36,8 @@
padding-top: 0px; padding-top: 0px;
padding-bottom: 0px; padding-bottom: 0px;
} }
.btn:focus {
outline: none;
box-shadow: none;
}

1
assets/pause.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!-- Font Awesome Free 5.15.4 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) --><path d="M144 479H48c-26.5 0-48-21.5-48-48V79c0-26.5 21.5-48 48-48h96c26.5 0 48 21.5 48 48v352c0 26.5-21.5 48-48 48zm304-48V79c0-26.5-21.5-48-48-48h-96c-26.5 0-48 21.5-48 48v352c0 26.5 21.5 48 48 48h96c26.5 0 48-21.5 48-48z"/></svg>

After

Width:  |  Height:  |  Size: 474 B

1
assets/play.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!-- Font Awesome Free 5.15.4 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) --><path d="M424.4 214.7L72.4 6.6C43.8-10.3 0 6.1 0 47.9V464c0 37.5 40.7 60.1 72.4 41.3l352-208c31.4-18.5 31.5-64.1 0-82.6z"/></svg>

After

Width:  |  Height:  |  Size: 371 B

82
assets/replay.html Normal file
View File

@ -0,0 +1,82 @@
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<script src="/assets/external/xterm.js"></script>
<script src="/assets/external/xterm-addon-attach.js"></script>
<script src="/assets/external/xterm-addon-fit.js"></script>
<script src="/assets/external/xterm-addon-web-links.js"></script>
<script src="/assets/replay.js"></script>
<link rel="stylesheet" href="/assets/external/xterm.css" />
<link href="/assets/external/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="/assets/main.css" />
<title>Replay</title>
</head>
<body>
<header>
<nav class="navbar navbar-dark bg-dark shadow-sm navbar-xs">
<div class="container-fluid">
<a class="navbar-brand mx-auto" href="https://github.com/syssecfsu/witty" target="_blank">
<img src="/assets/logo.svg" style="margin-right: 0.5rem;" height="28"
class="d-inline-block align-text-top">
replay terminal
</a>
</div>
</nav>
</header>
<div class="d-flex flex-column align-items-center" style="margin-top: 2rem;">
<div id="terminal">
<div id="terminal_view"></div>
</div>
<div class="d-flex align-items-center">
<button type="button" class="btn btn-primary btn-sm" style="margin-right: 3px;" onclick="playbtn()">
<img src="/assets/play.svg" id="play-btn" height="18px">
</button>
<input type="range" class="form-range" min="0" max="100" id="replay-control"
onchange="console.log(this.value)">
</div>
</div>
<script>
term = Init()
var rc = document.querySelector("#replay-control")
rc.value = 0
var icon = document.getElementById("play-btn")
var path = "/records/{{.fname}}"
var pause = false
function playbtn() {
if (icon.src.includes("play")) {
icon.src = "/assets/pause.svg"
pause = false
replay_session(term, path, rc.value,
function () {
return pause
},
function (percent) {
rc.value = percent
},
function () {
icon.src = "/assets/play.svg"
rc.value = 0
})
} else {
icon.src = "/assets/play.svg"
pause = true
}
}
</script>
</body>
</html>

148
assets/replay.js Normal file
View File

@ -0,0 +1,148 @@
// create a xterm for replay
function createReplayTerminal() {
// vscode-snazzy https://github.com/Tyriar/vscode-snazzy
// copied from xterm.js website
var baseTheme = {
foreground: '#eff0eb',
background: '#282a36',
selection: '#97979b33',
black: '#282a36',
brightBlack: '#686868',
red: '#ff5c57',
brightRed: '#ff5c57',
green: '#5af78e',
brightGreen: '#5af78e',
yellow: '#f3f99d',
brightYellow: '#f3f99d',
blue: '#57c7ff',
brightBlue: '#57c7ff',
magenta: '#ff6ac1',
brightMagenta: '#ff6ac1',
cyan: '#9aedfe',
brightCyan: '#9aedfe',
white: '#f1f1f0',
brightWhite: '#eff0eb'
};
const term = new Terminal({
fontFamily: `'Fira Code', ui-monospace,SFMono-Regular,'SF Mono',Menlo,Consolas,'Liberation Mono',monospace`,
fontSize: 12,
theme: baseTheme,
convertEol: true,
cursorBlink: true,
});
term.open(document.getElementById('terminal_view'));
term.resize(124, 37);
const weblinksAddon = new WebLinksAddon.WebLinksAddon();
term.loadAddon(weblinksAddon);
// fit the xterm viewpoint to parent element
const fitAddon = new FitAddon.FitAddon();
term.loadAddon(fitAddon);
fitAddon.fit();
return term;
}
// sleep for ms seconds
function _sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
// we could sleep for a long time
// periodically check if we need to end replay.
// This is pretty ugly but the callback mess otherwise
async function sleep(ms, paused) {
var loop_cnt = parseInt(ms / 20) + 1
for (i = 0; i < loop_cnt; i++) {
if (paused()) {
return paused()
}
await _sleep(20)
}
return paused()
}
// convert data to uint8array, we cannot convert it to string as
// it will mess up special characters
function base64ToUint8array(base64) {
var raw = window.atob(base64);
var rawLength = raw.length;
var array = new Uint8Array(new ArrayBuffer(rawLength));
for (i = 0; i < rawLength; i++) {
array[i] = raw.charCodeAt(i);
}
return array;
}
// replay session
// term: xterm, path: session file to replay,
// start: start position to replay in percentile, range 0-100
// callback to update the progress bar
async function replay_session(term, path, start, paused, prog, end) {
var session
// read file from server
await fetch(path)
.then(res => res.json())
.then(out => {
session = out
})
var total_dur = 0
var cur = 0
//calculate the total duration
for (const item of session) {
item.Duration = parseInt(item.Duration / 1000000)
total_dur += item.Duration
}
start = parseInt(total_dur * start / 100)
console.log("Total duration:", total_dur, "start replay on", start)
term.reset()
for (const item of session) {
cur += item.Duration
// we will blast through the beginning of the session
if (cur >= start) {
// we are cheating a little bit here, we do not want to wait for too long
if (await sleep(Math.min(item.Duration, 1000), paused) == true) {
return
}
}
if (item.Duration >= total_dur / 100) {
prog(parseInt(cur * 100 / total_dur))
}
term.write(base64ToUint8array(item.Data))
}
end()
}
function Init() {
let term = createReplayTerminal();
var str = [
' ┌────────────────────────────────────────────────────────────────────────────┐\n',
' │ \u001b[32;1mhttps://github.com/syssecfsu/witty\x1b[0m <- click it! │\n',
' └────────────────────────────────────────────────────────────────────────────┘\n',
''
].join('');
term.writeln(str);
// adjust the progress bar size to that of terminal
var view = document.querySelector("#terminal")
var pbar = document.querySelector("#replay-control")
pbar.setAttribute("style", "width:" + (view.offsetWidth - 32) + "px");
return term
}

View File

@ -23,33 +23,50 @@
<nav class="navbar navbar-dark bg-dark shadow-sm navbar-xs"> <nav class="navbar navbar-dark bg-dark shadow-sm navbar-xs">
<div class="container-fluid"> <div class="container-fluid">
<a class="navbar-brand mx-auto" href="https://github.com/syssecfsu/witty" target="_blank"> <a class="navbar-brand mx-auto" href="https://github.com/syssecfsu/witty" target="_blank">
<img src="/assets/logo.svg" style="margin-right: 0.5rem;" height="28" <img src="/assets/logo.svg" style="margin-right: 0.5rem;" height="28" class="d-inline-block align-text-top">
class="d-inline-block align-text-top">
{{.title}} {{.title}}
</a> </a>
<!-- <a class="btn btn-primary btn-sm float-end" href="/new" role="button">New Session</a> --> <button type="button" id="record_onoff" class="btn btn-primary btn-sm float-end" value="Record"
onclick="recordOnOff()">Record</button>
</div> </div>
</nav> </nav>
</header> </header>
<div style="margin-top: 5em;"> <div style="margin-top: 3em;">
<div id="terminal"> <div id="terminal">
<div id="terminal_view"></div> <div id="terminal_view"></div>
</div> </div>
</div> </div>
<script> <script>
term = createTerminal("{{.path}}"); function recordOnOff(on) {
// print something to test output and scroll var btn = document.getElementById("record_onoff");
var str = [ if (btn.value == "Record") {
' ┌────────────────────────────────────────────────────────────────────────────┐\n', btn.value = "Stop";
' │ Powered by \u001b[32;1mGo, Gin, websocket, pty, and xterm.js\x1b[0m │\n', btn.innerHTML = btn.value
' └────────────────────────────────────────────────────────────────────────────┘\n', fetch("/record/{{.id}}")
'' } else {
].join(''); btn.value = "Record";
btn.innerHTML = btn.value
fetch("/stop/{{.id}}")
}
}
term.writeln(str); function Init() {
term = createTerminal("{{.path}}");
// print something to test output and scroll
var str = [
' ┌────────────────────────────────────────────────────────────────────────────┐\n',
' │ \u001b[32;1mhttps://github.com/syssecfsu/witty\x1b[0m <- click it! \n',
' └────────────────────────────────────────────────────────────────────────────┘\n',
''
].join('');
term.writeln (str)
}
Init()
</script> </script>
</body> </body>

69
cmd/replay/replay.go Normal file
View File

@ -0,0 +1,69 @@
package main
import (
"encoding/json"
"io"
"log"
"os"
"time"
"golang.org/x/term"
)
type writeRecord struct {
Dur time.Duration `json:"Duration"`
Data []byte `json:"Data"`
}
func main() {
if len(os.Args) != 2 {
log.Fatalln("Usage: replay <recordfile>")
}
fp, err := os.Open(os.Args[1])
if err != nil {
log.Fatalln("Failed to open record file", err)
}
screen := struct {
io.Reader
io.Writer
}{os.Stdin, os.Stdout}
t := term.NewTerminal(screen, ">")
if t == nil {
log.Fatalln("Failed to create terminal")
}
w, h, _ := term.GetSize(int(os.Stdout.Fd()))
if (w != 120) || (h != 36) {
log.Println("Set terminal window to 120x36 before continue")
}
decoder := json.NewDecoder(fp)
if decoder == nil {
log.Fatalln("Failed to create JSON decoder")
}
// 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()
for decoder.More() {
var record writeRecord
if err := decoder.Decode(&record); err != nil {
log.Println("Failed to decode record", err)
continue
}
time.Sleep(record.Dur)
t.Write(record.Data)
}
t.Write([]byte("\n\n---end of replay---\n\n"))
}

4
cmd/replay/run.sh Executable file
View File

@ -0,0 +1,4 @@
echo "Try to resize shell with shell command"
printf '\e[8;36;120t'
clear
$(dirname $0)/replay $1

Binary file not shown.

Before

Width:  |  Height:  |  Size: 631 KiB

After

Width:  |  Height:  |  Size: 749 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 955 KiB

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 235 KiB

BIN
extra/view.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 801 KiB

1
go.mod
View File

@ -23,5 +23,6 @@ require (
github.com/ugorji/go/codec v1.1.7 // indirect github.com/ugorji/go/codec v1.1.7 // indirect
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 // indirect golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 // indirect
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1 // indirect golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1 // indirect
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211
gopkg.in/yaml.v2 v2.2.8 // indirect gopkg.in/yaml.v2 v2.2.8 // indirect
) )

2
go.sum
View File

@ -51,6 +51,8 @@ golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1 h1:SrN+KX8Art/Sf4HNj6Zcz06G7VEz+7w9tdXTPOZ7+l4= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1 h1:SrN+KX8Art/Sf4HNj6Zcz06G7VEz+7w9tdXTPOZ7+l4=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=

180
main.go
View File

@ -1,11 +1,16 @@
package main package main
import ( import (
"encoding/json"
"io/ioutil"
"log" "log"
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
"strconv"
"strings"
"github.com/dchest/uniuri"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/syssecfsu/witty/term_conn" "github.com/syssecfsu/witty/term_conn"
) )
@ -32,25 +37,91 @@ func checkOrigin(r *http.Request) bool {
} }
type InteractiveSession struct { type InteractiveSession struct {
Ip string Ip string
Cmd string Cmd string
Name 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 fillIndex(c *gin.Context) { func fillIndex(c *gin.Context) {
var players []InteractiveSession var players []InteractiveSession
var records []RecordedSession
term_conn.ForEachSession(func(tc *term_conn.TermConn) { term_conn.ForEachSession(func(tc *term_conn.TermConn) {
players = append(players, InteractiveSession{ players = append(players, InteractiveSession{
Name: tc.Name, Id: tc.Name,
Ip: tc.Ip, Ip: tc.Ip,
Cmd: cmdToExec[0], 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()
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"),
})
}
}
c.HTML(http.StatusOK, "index.html", gin.H{ c.HTML(http.StatusOK, "index.html", gin.H{
"title": "interactive terminal", "title": "interactive terminal",
"players": players, "players": players,
"records": records,
}) })
} }
@ -79,42 +150,77 @@ func main() {
rt.SetTrustedProxies(nil) rt.SetTrustedProxies(nil)
rt.LoadHTMLGlob("./assets/*.html") rt.LoadHTMLGlob("./assets/*.html")
rt.GET("/view/:sname", func(c *gin.Context) { // Fill in the index page
sname := c.Param("sname")
c.HTML(http.StatusOK, "term.html", gin.H{
"title": "viewer terminal",
"path": "/ws_view/" + sname,
})
})
rt.GET("/new", func(c *gin.Context) {
if host == nil {
host = &c.Request.Host
}
c.HTML(http.StatusOK, "term.html", gin.H{
"title": "interactive terminal",
"path": "/ws_new",
})
})
rt.GET("/ws_new", func(c *gin.Context) {
term_conn.ConnectTerm(c.Writer, c.Request, false, "", cmdToExec)
})
rt.GET("/ws_view/:sname", func(c *gin.Context) {
path := c.Param("sname")
term_conn.ConnectTerm(c.Writer, c.Request, true, path, nil)
})
// handle static files
rt.Static("/assets", "./assets")
rt.GET("/", func(c *gin.Context) { rt.GET("/", func(c *gin.Context) {
host = &c.Request.Host host = &c.Request.Host
fillIndex(c) fillIndex(c)
}) })
// 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,
})
})
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,
})
})
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) term_conn.Init(checkOrigin)
rt.RunTLS(":8080", "./tls/cert.pem", "./tls/private-key.pem") rt.RunTLS(":8080", "./tls/cert.pem", "./tls/private-key.pem")

3
records/README.md Normal file
View File

@ -0,0 +1,3 @@
## Usage
This directory contains all the records of sessions.

View File

@ -52,7 +52,20 @@ func (d *Registry) sendToPlayer(name string, ws *websocket.Conn) bool {
tc, ok := d.players[name] tc, ok := d.players[name]
if ok { if ok {
tc.vchan <- ws tc.viewChan <- ws
}
d.mtx.Unlock()
return ok
}
// Send a command to the session to start, stop recording
func (d *Registry) recordSession(id string, cmd int) bool {
d.mtx.Lock()
tc, ok := d.players[id]
if ok {
tc.recordChan <- cmd
} }
d.mtx.Unlock() d.mtx.Unlock()

View File

@ -2,15 +2,16 @@
package term_conn package term_conn
import ( import (
"encoding/json"
"log" "log"
"net/http" "net/http"
"os" "os"
"os/exec" "os/exec"
"strconv"
"sync" "sync"
"time" "time"
"github.com/creack/pty" "github.com/creack/pty"
"github.com/dchest/uniuri"
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
) )
@ -30,6 +31,9 @@ const (
// Send pings to peer with this period. Must be less than pongWait. // Send pings to peer with this period. Must be less than pongWait.
pingPeriod = (pongWait * 9) / 10 pingPeriod = (pongWait * 9) / 10
recordCmd = 1
stopCmd = 0
) )
var upgrader = websocket.Upgrader{ var upgrader = websocket.Upgrader{
@ -46,12 +50,20 @@ type TermConn struct {
Name string Name string
Ip string Ip string
ws *websocket.Conn ws *websocket.Conn
ptmx *os.File // the pty that runs the command ptmx *os.File // the pty that runs the command
cmd *exec.Cmd // represents the process, we need it to terminate the process record *os.File // record session
vchan chan *websocket.Conn // channel to receive viewers lastRecTime time.Time // last time a record is written
ws_done chan struct{} // ws is closed, only close this chan in ws reader cmd *exec.Cmd // represents the process, we need it to terminate the process
pty_done chan struct{} // pty is closed, close this chan in pty reader viewChan chan *websocket.Conn // channel to receive viewers
recordChan chan int // channel to start/stop recording
ws_done chan struct{} // ws is closed, only close this chan in ws reader
pty_done chan struct{} // pty is closed, close this chan in pty reader
}
type WriteRecord struct {
Dur time.Duration `json:"Duration"`
Data []byte `json:"Data"`
} }
func (tc *TermConn) createPty(cmdline []string) error { func (tc *TermConn) createPty(cmdline []string) error {
@ -230,7 +242,47 @@ out:
} }
} }
case viewer := <-tc.vchan: // Do we need to record the session?
if tc.record != nil {
jbuf, err := json.Marshal(WriteRecord{Dur: time.Since(tc.lastRecTime), Data: buf})
if err != nil {
log.Println("Failed to marshal record", err)
} else {
tc.record.Write(jbuf)
tc.record.Write([]byte(",")) // write a deliminator
}
tc.lastRecTime = time.Now()
}
case cmd := <-tc.recordChan:
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"
tc.record, err = os.OpenFile(fname, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644)
if err != nil {
log.Println("Failed to create record file", fname, err)
tc.record = nil
}
tc.record.Write([]byte("[")) // write a [ for an array of json objs
tc.lastRecTime = time.Now()
} 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.Close()
tc.record = nil
}
case viewer := <-tc.viewChan:
log.Println("Received viewer", viewer.RemoteAddr().String()) log.Println("Received viewer", viewer.RemoteAddr().String())
viewers = append(viewers, viewer) viewers = append(viewers, viewer)
@ -287,14 +339,22 @@ func (tc *TermConn) release() {
log.Printf("Failed to wait for shell process(%v): %v", proc.Pid, err) log.Printf("Failed to wait for shell process(%v): %v", proc.Pid, err)
} }
close(tc.vchan) close(tc.viewChan)
close(tc.recordChan)
if tc.record != nil {
// write a ] and close the file
tc.record.Write([]byte("]"))
tc.record.Close()
tc.record = nil
}
} }
tc.ws.Close() tc.ws.Close()
} }
// handle websockets // handle websockets
func handlePlayer(w http.ResponseWriter, r *http.Request, cmdline []string) { func handlePlayer(w http.ResponseWriter, r *http.Request, name string, cmdline []string) {
ws, err := upgrader.Upgrade(w, r, nil) ws, err := upgrader.Upgrade(w, r, nil)
if err != nil { if err != nil {
@ -304,7 +364,7 @@ func handlePlayer(w http.ResponseWriter, r *http.Request, cmdline []string) {
tc := TermConn{ tc := TermConn{
ws: ws, ws: ws,
Name: uniuri.New(), Name: name,
Ip: ws.RemoteAddr().String(), Ip: ws.RemoteAddr().String(),
} }
@ -313,7 +373,8 @@ func handlePlayer(w http.ResponseWriter, r *http.Request, cmdline []string) {
tc.ws_done = make(chan struct{}) tc.ws_done = make(chan struct{})
tc.pty_done = make(chan struct{}) tc.pty_done = make(chan struct{})
tc.vchan = make(chan *websocket.Conn) tc.viewChan = make(chan *websocket.Conn)
tc.recordChan = make(chan int)
if err := tc.createPty(cmdline); err != nil { if err := tc.createPty(cmdline); err != nil {
log.Println("Failed to create PTY: ", err) log.Println("Failed to create PTY: ", err)
@ -353,11 +414,11 @@ func handleViewer(w http.ResponseWriter, r *http.Request, path string) {
} }
} }
func ConnectTerm(w http.ResponseWriter, r *http.Request, isViewer bool, path string, cmdline []string) { func ConnectTerm(w http.ResponseWriter, r *http.Request, isViewer bool, name string, cmdline []string) {
if !isViewer { if !isViewer {
handlePlayer(w, r, cmdline) handlePlayer(w, r, name, cmdline)
} else { } else {
handleViewer(w, r, path) handleViewer(w, r, name)
} }
} }
@ -365,3 +426,11 @@ func Init(checkOrigin func(r *http.Request) bool) {
upgrader.CheckOrigin = checkOrigin upgrader.CheckOrigin = checkOrigin
registry.init() registry.init()
} }
func StartRecord(id string) {
registry.recordSession(id, recordCmd)
}
func StopRecord(id string) {
registry.recordSession(id, stopCmd)
}