36
README.md
@ -1,5 +1,24 @@
|
||||
# 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).
|
||||
|
||||
@ -44,17 +63,4 @@ window (xterm.js) and create a websocket with the server, which relays the data
|
||||
|
||||
```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.
|
||||
|
||||
<!-- ## 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">
|
||||
The program has been tested on Linux, WSL2, Raspberry Pi 3B (Debian), and MacOSX using Google Chrome, Firefox, and Safari.
|
1
assets/delete.svg
Normal 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
@ -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 |
@ -8,25 +8,26 @@
|
||||
<meta name="author" content="">
|
||||
|
||||
<!-- automatically refresh the page every 30 seconds -->
|
||||
<meta http-equiv="refresh" content="20">
|
||||
<!-- <meta http-equiv="refresh" content="30"> -->
|
||||
|
||||
<title>Web Terminal</title>
|
||||
|
||||
<!-- Bootstrap core CSS -->
|
||||
<script src="/assets/external/bootstrap.min.js"></script>
|
||||
<link href="/assets/external/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="/assets/main.css" rel="stylesheet">
|
||||
</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="32"
|
||||
class="d-inline-block align-text-top">
|
||||
<img src="/assets/logo.svg" style="margin-right: 0.5rem;" height="32" 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(){window.location.reload()}, 3000);" target="_blank" role="button">
|
||||
onClick="setTimeout(function(){window.location.reload()}, 2000);" target="_blank" role="button">
|
||||
New Session
|
||||
</a>
|
||||
</div>
|
||||
@ -34,25 +35,81 @@
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<div class="container" style="margin-top:1em;">
|
||||
<div class="card-deck row justify-content-center">
|
||||
<div class="container-fluid" style="margin-top:1em;">
|
||||
<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>
|
||||
{{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>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
function del_btn(path) {
|
||||
fetch("/delete/" + path)
|
||||
setTimeout(function () {
|
||||
window.location.reload()
|
||||
}, 800);
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
@ -1,7 +1,7 @@
|
||||
#terminal {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin: 10px 0 20px;
|
||||
margin: 10px 0 10px;
|
||||
}
|
||||
|
||||
#terminal #terminal_view {
|
||||
@ -36,3 +36,8 @@
|
||||
padding-top: 0px;
|
||||
padding-bottom: 0px;
|
||||
}
|
||||
|
||||
.btn:focus {
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
}
|
1
assets/pause.svg
Normal 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
@ -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
@ -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
@ -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
|
||||
}
|
@ -23,33 +23,50 @@
|
||||
<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">
|
||||
<img src="/assets/logo.svg" style="margin-right: 0.5rem;" height="28" class="d-inline-block align-text-top">
|
||||
{{.title}}
|
||||
</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>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
|
||||
<div style="margin-top: 5em;">
|
||||
<div style="margin-top: 3em;">
|
||||
<div id="terminal">
|
||||
<div id="terminal_view"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
term = createTerminal("{{.path}}");
|
||||
// print something to test output and scroll
|
||||
var str = [
|
||||
' ┌────────────────────────────────────────────────────────────────────────────┐\n',
|
||||
' │ Powered by \u001b[32;1mGo, Gin, websocket, pty, and xterm.js\x1b[0m │\n',
|
||||
' └────────────────────────────────────────────────────────────────────────────┘\n',
|
||||
''
|
||||
].join('');
|
||||
function recordOnOff(on) {
|
||||
var btn = document.getElementById("record_onoff");
|
||||
if (btn.value == "Record") {
|
||||
btn.value = "Stop";
|
||||
btn.innerHTML = btn.value
|
||||
fetch("/record/{{.id}}")
|
||||
} else {
|
||||
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>
|
||||
|
||||
</body>
|
||||
|
69
cmd/replay/replay.go
Normal 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
@ -0,0 +1,4 @@
|
||||
echo "Try to resize shell with shell command"
|
||||
printf '\e[8;36;120t'
|
||||
clear
|
||||
$(dirname $0)/replay $1
|
BIN
extra/main.png
Before Width: | Height: | Size: 631 KiB After Width: | Height: | Size: 749 KiB |
Before Width: | Height: | Size: 955 KiB After Width: | Height: | Size: 1.7 MiB |
Before Width: | Height: | Size: 235 KiB |
BIN
extra/view.png
Normal file
After Width: | Height: | Size: 801 KiB |
1
go.mod
@ -23,5 +23,6 @@ require (
|
||||
github.com/ugorji/go/codec v1.1.7 // indirect
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 // 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
|
||||
)
|
||||
|
2
go.sum
@ -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-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/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.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
|
180
main.go
@ -1,11 +1,16 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/dchest/uniuri"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/syssecfsu/witty/term_conn"
|
||||
)
|
||||
@ -32,25 +37,91 @@ func checkOrigin(r *http.Request) bool {
|
||||
}
|
||||
|
||||
type InteractiveSession struct {
|
||||
Ip string
|
||||
Cmd string
|
||||
Name string
|
||||
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 fillIndex(c *gin.Context) {
|
||||
var players []InteractiveSession
|
||||
var records []RecordedSession
|
||||
|
||||
term_conn.ForEachSession(func(tc *term_conn.TermConn) {
|
||||
players = append(players, InteractiveSession{
|
||||
Name: tc.Name,
|
||||
Ip: tc.Ip,
|
||||
Cmd: cmdToExec[0],
|
||||
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()
|
||||
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{
|
||||
"title": "interactive terminal",
|
||||
"players": players,
|
||||
"records": records,
|
||||
})
|
||||
}
|
||||
|
||||
@ -79,42 +150,77 @@ func main() {
|
||||
rt.SetTrustedProxies(nil)
|
||||
rt.LoadHTMLGlob("./assets/*.html")
|
||||
|
||||
rt.GET("/view/:sname", func(c *gin.Context) {
|
||||
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")
|
||||
|
||||
// Fill in the index page
|
||||
rt.GET("/", func(c *gin.Context) {
|
||||
host = &c.Request.Host
|
||||
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)
|
||||
|
||||
rt.RunTLS(":8080", "./tls/cert.pem", "./tls/private-key.pem")
|
||||
|
3
records/README.md
Normal file
@ -0,0 +1,3 @@
|
||||
## Usage
|
||||
|
||||
This directory contains all the records of sessions.
|
@ -52,7 +52,20 @@ func (d *Registry) sendToPlayer(name string, ws *websocket.Conn) bool {
|
||||
tc, ok := d.players[name]
|
||||
|
||||
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()
|
||||
|
@ -2,15 +2,16 @@
|
||||
package term_conn
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/creack/pty"
|
||||
"github.com/dchest/uniuri"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
@ -30,6 +31,9 @@ const (
|
||||
|
||||
// Send pings to peer with this period. Must be less than pongWait.
|
||||
pingPeriod = (pongWait * 9) / 10
|
||||
|
||||
recordCmd = 1
|
||||
stopCmd = 0
|
||||
)
|
||||
|
||||
var upgrader = websocket.Upgrader{
|
||||
@ -46,12 +50,20 @@ type TermConn struct {
|
||||
Name string
|
||||
Ip string
|
||||
|
||||
ws *websocket.Conn
|
||||
ptmx *os.File // the pty that runs the command
|
||||
cmd *exec.Cmd // represents the process, we need it to terminate the process
|
||||
vchan chan *websocket.Conn // channel to receive viewers
|
||||
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
|
||||
ws *websocket.Conn
|
||||
ptmx *os.File // the pty that runs the command
|
||||
record *os.File // record session
|
||||
lastRecTime time.Time // last time a record is written
|
||||
cmd *exec.Cmd // represents the process, we need it to terminate the process
|
||||
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 {
|
||||
@ -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())
|
||||
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)
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
if err != nil {
|
||||
@ -304,7 +364,7 @@ func handlePlayer(w http.ResponseWriter, r *http.Request, cmdline []string) {
|
||||
|
||||
tc := TermConn{
|
||||
ws: ws,
|
||||
Name: uniuri.New(),
|
||||
Name: name,
|
||||
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.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 {
|
||||
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 {
|
||||
handlePlayer(w, r, cmdline)
|
||||
handlePlayer(w, r, name, cmdline)
|
||||
} else {
|
||||
handleViewer(w, r, path)
|
||||
handleViewer(w, r, name)
|
||||
}
|
||||
}
|
||||
|
||||
@ -365,3 +426,11 @@ func Init(checkOrigin func(r *http.Request) bool) {
|
||||
upgrader.CheckOrigin = checkOrigin
|
||||
registry.init()
|
||||
}
|
||||
|
||||
func StartRecord(id string) {
|
||||
registry.recordSession(id, recordCmd)
|
||||
}
|
||||
|
||||
func StopRecord(id string) {
|
||||
registry.recordSession(id, stopCmd)
|
||||
}
|
||||
|