Skip to content

Instantly share code, notes, and snippets.

@eslym
Created August 3, 2020 17:40
Show Gist options
  • Save eslym/d3bd7809681aa9c1eb34913043df9bb6 to your computer and use it in GitHub Desktop.
Save eslym/d3bd7809681aa9c1eb34913043df9bb6 to your computer and use it in GitHub Desktop.
Xterm.js backend with laravel command, ratchet and reactphp
/**
* 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);
}
};
}
<?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
Copy link

jdbravo commented Apr 6, 2023

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?

@eslym
Copy link
Author

eslym commented Apr 7, 2023

@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