Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

redbean demo with an xterm.js powershell/bash #1304

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion tool/net/demo/hitcounter.lua
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@ t = DecodeJson(s)
Unlock()

for k,v in pairs(t) do
Write('<dt>%s<dd>%d\n' % {EscapeHtml(k), v})
if type(v) == 'number' then
Write('<dt>%s<dd>%d\n' % {EscapeHtml(k), v})
end
end

Write('</dl>')
351 changes: 351 additions & 0 deletions tool/net/demo/xterm.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,351 @@
--[[
XTERM Proof of concept
This shows how redbean can be used to launch a powershell (windows)
or bash (other OS) xterm session

The demo loads an HTML page that loads https://xtermjs.org/ and
defines a unique xtermid. An SSE EventSource is then defined which
starts a SSEServer that takes care of starting the shell command
As SSE is mostly a Server=>Client channel, the keyboard events/commands
are sent via side-channel POST requests

SSE Events :
- noname : stdout from the shell, encodeURIComponent encoded
- prompt : a way for the server to write a prompt on the client
(different shells can have different interactive behavior)
- exit : a way to ask the client to stop its EventSource.
this will trigger a POST.exit to close the SSEServer

POST Events :
- command : a command that was typed in the xterm.js
- exit : a way to ask the SSE server to shut down
- ping : a way to tell the server that the xterm.js session is still
alive. A vanished browser session will lead to the closing of
the SSE server

In order to share and reconcile the POSTed Events between the different
processes (POST processes and SSE server), a json shm is used where
the xtermid is used as a key to store the queue of commands.

Notes:
- security: Use at your own risk as it opens a shell on the server and
makes it available on the listening ip/ports
- limitations: the demo does not use a real pty so there are certainly
a lot of limitations and it is not made to be keyboard spammed !
- special chars are currently not handled. Things like CTRL-C to stop
a command is not implemented
- CTRL-V is not implemented
- history is not implemented
- tab completion is not implemented
- some mechanisms have been put in place to try to garbage collect the
shm (tab closing, sending the 'exit' command, .. but there are probably
a lot of other corner cases that need to be tested and handled
- the session state management and supervision could certainly be improved

]]--

ipcat = CategorizeIp(GetServerAddr())
if ipcat ~= 'PRIVATE' and ipcat ~= 'LOOPBACK' then
Write('As a first level of security, the xterm/shell demo is only available on IPs categorized as private or loopback')
Write('The current category is ' .. ipcat)
return
end




function HTMLPage()

Write('<link rel="stylesheet" href="https://xtermjs.org/css/xterm.css" />\n')
Write('<script src="https://xtermjs.org/js/xterm.js"></script>\n')
Write('<script src="https://xtermjs.org/js/addon-webgl.js"></script>\n')
Write('<h1>Redbean xterm.js/shell demo</h1>\n')
Write('<p>you should be able to interact with powershell on windows or bash on other OSs</p>')
Write('<span>Try usual commands like:</span>')
Write('<ul>')
Write('<li>ls</li>')
Write('<li>whoami</li>')
Write('<li>ps</li>')
Write('<li>or any command you dare try ;-)</li>')

Write('</ul>')
Write('<div class="demo"><div class="inner"></div></div>\n')
Write('<script>var xtermid = "'.. UuidV4() ..'"</script>\n')

script = [[
var baseTheme = {
foreground: '#F8F8F8',
background: '#2D2E2C',
selection: '#5DA5D533',
black: '#1E1E1D',
brightBlack: '#262625',
red: '#CE5C5C',
brightRed: '#FF7272',
green: '#5BCC5B',
brightGreen: '#72FF72',
yellow: '#CCCC5B',
brightYellow: '#FFFF72',
blue: '#5D5DD3',
brightBlue: '#7279FF',
magenta: '#BC5ED1',
brightMagenta: '#E572FF',
cyan: '#5DA5D5',
brightCyan: '#72F0FF',
white: '#F8F8F8',
brightWhite: '#FFFFFF'
};
var isBaseTheme = true;
var term = new window.Terminal({
fontFamily: '"Cascadia Code", Menlo, monospace',
theme: baseTheme,
cursorBlink: true,
allowProposedApi: true,
cols: 150,
convertEol: true
});
term.open(document.querySelector('.demo .inner'));
var isWebglEnabled = false;
try {
const webgl = new window.WebglAddon.WebglAddon();
term.loadAddon(webgl);
isWebglEnabled = true;
} catch (e) {
console.warn('WebGL addon threw an exception during load', e);
}


prompt_str = 'redbean shell \ud83e\udd9e > '
function prompt(term, str) {
command = '';
prompt_str = str || prompt_str;
term.write(prompt_str);
}

var command = '';
var running = true;

function sendCommand(type, data) {
return fetch('xterm.lua?xtermid='+xtermid, {
method: "POST",
body: JSON.stringify({ type: type, data: data })
});
}

function runCommand(term, text) {
const command = text.trim();
if (command.length > 0) {
term.writeln('');
sendCommand("command", command);
} else {
term.write("\r\n");
prompt(term);
}
}

term.onData(e => {
if (!running) return;
switch (e) {
case '\u0003': // Ctrl+C
term.write('^C');
prompt(term);
break;
case '\r': // Enter
runCommand(term, command);
command = '';
break;
case '\u007F': // Backspace (DEL)
// Do not delete the prompt
if (term._core.buffer.x > 2) {
term.write('\b \b');
if (command.length > 0) {
command = command.substr(0, command.length - 1);
}
}
break;
default: // Print all other characters for demo
if (e >= String.fromCharCode(0x20) && e <= String.fromCharCode(0x7E) || e >= '\u00a0') {
command += e;
term.write(e);
}
}
});



var evtSource = new EventSource('xterm.lua?xtermid='+xtermid+'&sse');
evtSource.onmessage = (event) => {
term.write(decodeURIComponent(event.data))
};
evtSource.addEventListener("prompt", (event) => {
prompt(term, decodeURIComponent(event.data))
});
evtSource.addEventListener("exit", (event) => {
console.log("exit", event.data);
evtSource.close();
running = false;
term.write('..TERMINATED..');
sendCommand('exit', null);
});


function ping() {
if (running) {
sendCommand('ping', null);
}
}

// we ping the SSE server to avoid a dangling a SSE process on the server
window.addEventListener('load', function () {
var fetchInterval = 2000;
setInterval(ping, fetchInterval);
});

]]

Write('<script>'..script..'</script><body>')

end

-- SSE Events needs yielding to be sent as event-stream
local function streamWrap(func)
return function(...) return coroutine.yield(func(...)) or true end
end
local function writeEvent(event, data)
if event then Write("event: " .. event .. "\n") end
if data then Write("data: " .. EscapeParam(data) .. "\n") end
Write("\n")
end
local streamEvent = streamWrap(writeEvent)

function SSEServer()
xtermid = GetParam('xtermid')
if GetHostOs() == 'WINDOWS' then
cmd = { 'pwsh.exe', "-Interactive", "-Command", "-" }
eol = '\r\n'
else
cmd = { '/bin/bash' }
eol = '\n'
end
cmd[1] = assert(unix.commandv(cmd[1]))

readerIn, writerIn = assert(unix.pipe(unix.O_CLOEXEC))
readerOut, writerOut = assert(unix.pipe(unix.O_CLOEXEC))
child = assert(unix.fork())

if child == 0 then
unix.close(0)
unix.dup(readerIn)
unix.close(1)
unix.dup(writerOut)
unix.close(2)
unix.dup(writerOut)
unix.close(writerIn)
unix.close(readerOut)
unix.execve(cmd[1], cmd)
unix.exit(127)
else
SetHeader('Content-Type', 'text/event-stream')
SetHeader('Connection','Close')
unix.close(readerIn)
unix.close(writerOut)

pollfds = {}
pollfds[readerOut] = unix.POLLIN
polling = true
promptblock = GetTime()
needprompt = true
lastping = GetTime()

while polling do
evs, errno = unix.poll(pollfds, 100)
if not evs then
break
end

hasStdout = false
for fd,revents in pairs(evs) do
if fd == readerOut then
hasStdout =true
data, errno = unix.read(fd)
if data ~= '' then
streamEvent(nil, data)
promptblock = GetTime() + 0.1
end
end
end
command = nextCommand(xtermid)
if command then lastping = GetTime() end
if command and command.type == "command" then
if command.data == 'exit' then
-- ask browser to close its SSE EventSource
streamEvent('exit', '')
end
unix.write(writerIn, command.data .. eol)
promptblock = GetTime() + 1
needprompt = true
end
if (command and command.type == "exit") or lastping < GetTime() - 5 then
polling = false
end
if needprompt and not hasStdout and GetTime() > promptblock then
streamEvent('prompt', 'redbean shell '..utf8.char(129438)..' > ')
needprompt = false
end
end
unix.close(readerOut)
unix.close(writerIn)
deleteSession(xtermid)
unix.wait(-1)
Log(kLogWarn, 'xterm SSE server '..xtermid.. 'has been closed')
return
end
end

function registerCommand(xtermid)
posted = GetPayload()
local s, t, k
Lock()
s = shm:read(SHM_JSON)
if s == '' then s = '{}' end
t = DecodeJson(s)
k = xtermid
if not t[k] then t[k] = {} end
table.insert(t[k], 1, DecodeJson(posted))
shm:write(SHM_JSON, EncodeJson(t))
Unlock()
end
function nextCommand(xtermid)
local s, t, k
Lock()
s = shm:read(SHM_JSON)
if s == '' then s = '{}' end
t = DecodeJson(s)
k = xtermid
if not t[k] then t[k] = {} end
command = table.remove(t[k])
shm:write(SHM_JSON, EncodeJson(t))
Unlock()
return command
end
function deleteSession(xtermid)
local s, t, k
Lock()
s = shm:read(SHM_JSON)
if s == '' then s = '{}' end
t = DecodeJson(s)
k = xtermid
t[k] = nil
shm:write(SHM_JSON, EncodeJson(t))
Unlock()
end


if HasParam('sse') then
SSEServer()
elseif GetMethod() == "POST" then
registerCommand(GetParam('xtermid'))
else
HTMLPage()
end


Loading