mirror of
https://github.com/jart/cosmopolitan.git
synced 2025-02-07 06:53:33 +00:00
redbean demo with an xterm.js powershell/bash
This commit is contained in:
parent
12cc2de22e
commit
83b40fa651
2 changed files with 354 additions and 1 deletions
|
@ -29,7 +29,9 @@ t = DecodeJson(s)
|
|||
Unlock()
|
||||
|
||||
for k,v in pairs(t) do
|
||||
if type(v) == 'number' then
|
||||
Write('<dt>%s<dd>%d\n' % {EscapeHtml(k), v})
|
||||
end
|
||||
end
|
||||
|
||||
Write('</dl>')
|
||||
|
|
351
tool/net/demo/xterm.lua
Executable file
351
tool/net/demo/xterm.lua
Executable file
|
@ -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…
Reference in a new issue