Created
August 3, 2020 17:40
-
-
Save eslym/d3bd7809681aa9c1eb34913043df9bb6 to your computer and use it in GitHub Desktop.
Xterm.js backend with laravel command, ratchet and reactphp
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* Copyright (c) 2014, 2019 The xterm.js authors. All rights reserved. | |
* @license MIT | |
* | |
* Implements the attach method, that attaches the terminal to a WebSocket stream. | |
* | |
* Modified by 0nepeop1e | |
*/ | |
import {Terminal, IDisposable, ITerminalAddon} from 'xterm'; | |
import {fromByteArray as encode_b64, toByteArray as decode_b64} from "base64-js"; | |
interface IAttachOptions { | |
bidirectional?: boolean; | |
} | |
export enum Action { | |
MESSAGE = 1, | |
RESIZE = 2, | |
CLOSE = 3, | |
AUTH = 4, | |
PING = 5, | |
ATTACH = 6, | |
} | |
export class CustomAttachAddon implements ITerminalAddon { | |
private readonly _socket: WebSocket; | |
private readonly _bidirectional: boolean; | |
private _disposables: IDisposable[] = []; | |
constructor(socket: WebSocket, options?: IAttachOptions) { | |
this._socket = socket; | |
// always set binary type to arraybuffer, we do not handle blobs | |
this._socket.binaryType = 'arraybuffer'; | |
this._bidirectional = (!(options && options.bidirectional === false)); | |
} | |
public activate(terminal: Terminal): void { | |
let resize = ({cols, rows}) => { | |
let buff = new ArrayBuffer(12); | |
let view = new DataView(buff); | |
view.setInt32(0, Action.RESIZE, true); | |
view.setInt32(4, cols, true); | |
view.setInt32(8, rows, true); | |
this._socket.send(encode_b64(new Uint8Array(buff))); | |
}; | |
this._disposables.push( | |
addSocketListener(this._socket, 'message', ev => { | |
let data = decode_b64(ev.data); | |
let view = new DataView(data.buffer); | |
let type = view.getInt32(0, true); | |
switch (type) { | |
case Action.MESSAGE: | |
terminal.write(data.slice(4)); | |
break; | |
case Action.RESIZE: | |
let col = view.getInt32(4); | |
let row = view.getInt32(8); | |
terminal.resize(col, row); | |
break; | |
case Action.ATTACH: | |
resize(terminal); | |
break; | |
} | |
}) | |
); | |
if (this._bidirectional) { | |
this._disposables.push(terminal.onData(data => this._sendString(data))); | |
this._disposables.push(terminal.onBinary(data => this._sendBinary(data))); | |
} | |
this._disposables.push(addSocketListener(this._socket, 'close', () => this.dispose())); | |
this._disposables.push(addSocketListener(this._socket, 'error', () => this.dispose())); | |
let buff = new ArrayBuffer(4); | |
new DataView(buff).setInt32(0, Action.ATTACH, true); | |
this._socket.send(encode_b64(new Uint8Array(buff))); | |
terminal.onResize(resize); | |
resize(terminal); | |
} | |
public dispose(): void { | |
this._disposables.forEach(d => d.dispose()); | |
} | |
private _sendString(data: string): void{ | |
let encoder = new TextEncoder(); | |
this._sendBinary(Array.from(encoder.encode(data)).map(c=>String.fromCodePoint(c)).join('')); | |
} | |
private _sendBinary(data: string): void { | |
if (this._socket.readyState !== 1) { | |
return; | |
} | |
let buff = new ArrayBuffer(4 + data.length); | |
new DataView(buff).setInt32(0, Action.MESSAGE, true); | |
let bytes = new Uint8Array(buff); | |
for (let i = 0; i < data.length; i++) { | |
bytes[i + 4] = data.charCodeAt(i) & 0xFF; | |
} | |
this._socket.send(encode_b64(bytes)); | |
} | |
} | |
function addSocketListener<K extends keyof WebSocketEventMap>(socket: WebSocket, type: K, handler: (this: WebSocket, ev: WebSocketEventMap[K]) => any): IDisposable { | |
socket.addEventListener(type, handler); | |
return { | |
dispose: () => { | |
if (!handler) { | |
// Already disposed | |
return; | |
} | |
socket.removeEventListener(type, handler); | |
} | |
}; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
/** | |
* IMPORTANT: | |
* This is a demo, please do not use it directly, | |
* it doesn't implements any security | |
*/ | |
namespace App\Console\Commands\Pty; | |
use Clue\React\Redis\Client as RedisClient; | |
use Clue\React\Redis\Factory as RedisFactory; | |
use Exception; | |
use Illuminate\Console\Command; | |
use Ratchet\ConnectionInterface; | |
use Ratchet\Http\HttpServer; | |
use Ratchet\RFC6455\Messaging\MessageInterface; | |
use Ratchet\Server\IoServer; | |
use Ratchet\WebSocket\MessageComponentInterface; | |
use Ratchet\WebSocket\WsServer; | |
use React\ChildProcess\Process; | |
use React\EventLoop\Factory; | |
use React\EventLoop\LoopInterface; | |
use React\Socket\Server; | |
use SplObjectStorage; | |
class ServeCommand extends Command implements MessageComponentInterface | |
{ | |
private const ACTION_MESSAGE = 1; | |
private const ACTION_RESIZE = 2; | |
private const ACTION_CLOSE = 3; | |
private const ACTION_AUTH = 4; | |
private const ACTION_PING = 5; | |
private const ACTION_ATTACH = 6; | |
/** | |
* The name and signature of the console command. | |
* | |
* @var string | |
*/ | |
protected $signature = 'pty:serve | |
{--H|host=127.0.0.1 : Host to serve pty server} | |
{--p|port=18899 : Port to serve pty server}'; | |
/** | |
* The console command description. | |
* | |
* @var string | |
*/ | |
protected $description = 'Serve pty server'; | |
/** | |
* @var LoopInterface | |
*/ | |
protected $loop; | |
/** | |
* @var Server | |
*/ | |
protected $socket; | |
/** | |
* @var IoServer | |
*/ | |
protected $server; | |
/** | |
* @var SplObjectStorage | |
*/ | |
protected $connections; | |
/** | |
* @var Process[] | |
*/ | |
protected $processes; | |
/** | |
* Create a new command instance. | |
* | |
* @return void | |
*/ | |
public function __construct() | |
{ | |
parent::__construct(); | |
} | |
/** | |
* Execute the console command. | |
* | |
* @return int | |
*/ | |
public function handle() | |
{ | |
$host = $this->option('host'); | |
$port = $this->option('port'); | |
$this->connections = new SplObjectStorage(); | |
$this->loop = Factory::create(); | |
$this->socket = new Server("$host:$port", $this->loop); | |
$this->server = new IoServer( | |
new HttpServer(new WsServer($this)), | |
$this->socket, $this->loop | |
); | |
$this->loop->futureTick(function () use ($host, $port) { | |
$this->info("Listening on $host:$port"); | |
}); | |
$this->server->run(); | |
return 0; | |
} | |
function onOpen(ConnectionInterface $conn) | |
{ | |
$this->info("Connection opened: " . spl_object_id($conn)); | |
$this->connections->attach($conn); | |
} | |
function onClose(ConnectionInterface $conn) | |
{ | |
$this->info("Connection closed: " . spl_object_id($conn)); | |
if (isset($this->processes[spl_object_id($conn)])) { | |
$proc = $this->processes[spl_object_id($conn)]; | |
if ($proc->isRunning()) { | |
$proc->terminate(); | |
} | |
unset($this->processes[spl_object_id($conn)]); | |
} | |
$this->connections->detach($conn); | |
} | |
function onError(ConnectionInterface $conn, Exception $e) | |
{ | |
$this->error("Connection error: $e"); | |
$conn->close(); | |
} | |
public function onMessage(ConnectionInterface $conn, MessageInterface $msg) | |
{ | |
$raw = base64_decode($msg->getContents()); | |
$action = unpack('i', $raw); | |
$this->info("Action $action[1] from " . spl_object_id($conn) . " base64: " . $msg->getContents() . " raw: " . bin2hex($raw)); | |
switch ($action[1]) { | |
case self::ACTION_ATTACH: | |
if (!isset($this->processes[spl_object_id($conn)])) { | |
$run_as = $_SERVER['USER']; | |
$process = new Process( | |
/* | |
* NOTE: | |
* Here using sudo is to run bash with minimum env | |
* env -i will clear all environments which cause ...problems | |
*/ | |
"sudo -u \"$run_as\" -i setsid bash -l", | |
null, null, | |
[ | |
0 => ['pty', 'r'], | |
1 => ['pty', 'w'], | |
2 => ['pty', 'w'], | |
] | |
); | |
$process->start($this->loop); | |
$conn->send(base64_encode(pack('i', self::ACTION_ATTACH))); | |
$process->on('exit', function ($code) use ($conn) { | |
$msg = pack('ii', self::ACTION_CLOSE, $code); | |
$conn->send(base64_encode($msg)); | |
$conn->close(); | |
}); | |
$handler = function ($data) use ($conn) { | |
$msg = pack('i', self::ACTION_MESSAGE) . $data; | |
$conn->send(base64_encode($msg)); | |
}; | |
$process->stdout->on('data', $handler); | |
$process->stderr->on('data', $handler); | |
$this->processes[spl_object_id($conn)] = $process; | |
} | |
break; | |
case self::ACTION_MESSAGE: | |
if (isset($this->processes[spl_object_id($conn)])) { | |
$process = $this->processes[spl_object_id($conn)]; | |
$process->stdin->write(substr($raw, 4)); | |
} | |
break; | |
case self::ACTION_RESIZE: | |
if (isset($this->processes[spl_object_id($conn)])) { | |
$process = $this->processes[spl_object_id($conn)]; | |
$data = unpack('i3', $raw); | |
$col = $data[2]; | |
$row = $data[3]; | |
// tricks to access private property without reflection | |
$getstream = (function () { | |
/** @noinspection PhpUndefinedFieldInspection */ | |
return $this->stream; | |
}); | |
$stty = new Process("stty cols $col rows $row", null, null, [ | |
$getstream->call($process->stdin), | |
$getstream->call($process->stdout), | |
$getstream->call($process->stderr), | |
]); | |
$stty->start($this->loop); | |
} | |
break; | |
} | |
} | |
} |
@jdbravo i think you should ask it on stackoverflow or ask somebody who are good with reactphp instead
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
I tried to use this code with PHP 8.1. But $process->stdout->on('data', $handler); callback is never executed.
I can send commands over xterm.js, like: touch /tmp/tmpfile and it works. But I'm not getting anything back.
I also added a print in the handler, like this:
$handler = function ($data) use ($conn) {
$this->info("test");
echo "test\n";
$msg = pack('i', self::ACTION_MESSAGE) . $data;
$conn->send(base64_encode($msg));
};
But "test" is never printed. Any idea what can be happening?