Best way to open a PTY with PHP

Viewed 653

Problem description

I would like to open a Linux pseudoterminal with PHP but it seems there is no simple way of doing that. I have experimented with different solutions but none seem to be good enough. The goal of the PTY is to emulate a terminal with the capability of flawlessly interacting with programs such as zsh and sudo. Other programming languages including Python and C have functions or libraries for that. Python has the PTY library which can simply do pty.spawn("/bin/zsh"), and C has the openpty() function.

My ideal end goal is to have a PHP function that allows me to read and write from/into the PTY terminal and does not require installing external libraries. (A lot of shared hosting providers do not allow that.)

What I have tried so far

Using proc_open()

My initial idea was just to use the proc_open() PHP function to create a Bash terminal with stdin, stdout and stderr pipes (Based on Example #1 in the PHP documentation) This, however, did soon prove to be problematic because it is actually not a real PTY. Running stty -a errored with stty: stdin isn't a terminal. Here are the instructions for reproducing this.

  1. Run this with php pty_test.php
  2. Read the output of the shell with cat /tmp/stdout.
  3. Input commands with > /tmp/stdin.

Here is the PHP code which I used for this:

<?php
/* pty_test.php */


ini_set('display_errors', 1);
ini_set('display_startup_ūūerrors', 1);
error_reporting(E_ALL);

define("STD_IN", 0);
define("STD_OUT", 1);
define("STD_ERR", 2);

set_time_limit(0);
umask(0);

$chunk_size = 1400;
$write_a = null;
$error_a = null;
$shell = "/bin/sh -i ";
$stdin_fifo = "/tmp/stdin";
$stdout_fifo = "/tmp/stdout";

posix_mkfifo($stdin_fifo, 0644);
posix_mkfifo($stdout_fifo, 0644);
$resource_stdin = fopen($stdin_fifo, "rb+");
$resource_stdout = fopen($stdout_fifo, "wb+");

$descriptorspec = array(
   STD_IN => array("pipe", "rb"),
   STD_OUT => array("pipe", "wb"),
   STD_ERR => array("pipe", "wb")
);

$process = proc_open($shell, $descriptorspec, $pipes, null, $env = null);

stream_set_blocking($pipes[STD_IN], 0);
stream_set_blocking($pipes[STD_OUT], 0);
stream_set_blocking($pipes[STD_ERR], 0);
stream_set_blocking($resource_stdin, 0);
stream_set_blocking($resource_stdout, 0);

while (1) {

    $read_a = array($resource_stdin, $pipes[STD_OUT], $pipes[STD_ERR]);
    $num_changed_streams = stream_select($read_a, $write_a, $error_a, null);
    
    if (in_array($resource_stdin, $read_a)) {
        $input = fread($resource_stdin, $chunk_size);
        fwrite($pipes[STD_IN], $input);
    }
    if (in_array($pipes[STD_OUT], $read_a)) {
        $input = fread($pipes[STD_OUT], $chunk_size);
        fwrite($resource_stdout, $input);
    }
    if (in_array($pipes[STD_ERR], $read_a)) {
        $input = fread($pipes[STD_ERR], $chunk_size);
        fwrite($resource_stdout, $input);
    }
}

fclose($resource_stdin);
fclose($resource_stdout);
fclose($pipes[STD_IN]);
fclose($pipes[STD_OUT]);
fclose($pipes[STD_ERR]);
proc_close($process);
unlink($stdin_fifo);
unlink($stdout_fifo);

?> 

Python PTY

I noticed that running python3 -c "import pty;pty.spawn('/bin/bash');" in the non-pty shell (which I described above) will result in a fully interactive PTY shell as I desired. This resulted in a half-good solution: setting the $shell variable to be python3 -c "import pty;pty.spawn('/bin/bash')" will spawn the interactive shell using Python3. But relying on external software is not ideal since having Python3 is not always guaranteed. (And this solution also feels way too hacky...)

/dev/ptmx

I was reading the source code of the proc_open() function also found the source for openpty(). Unfortunately, PHP can't directly call this function but perhaps it is possible to replicate the behavior of it. I could fopen("/dev/ptmx","r+") to create a new slave but openpty() also uses grantpt() and unlockpt(), which are not available in PHP.

Foreign Function Interface FFI allows access to external libraries. Maybe it would be possible to import pty.h and to run openpty(). Unfortunately, FFI is very experimental and may not always be available.

TL;DR

What is the safest and most reliable way to spawn a PTY using PHP?

1 Answers

You do not have to use FFI to write PHP shared library.

I just tried to write an open source PHP library for this purpose. I named it TeaOpenPTY. I think this can be a good example how to write a simple PHP library in C.



How to use the TeaOpenPTY library?

File test.php

<?php

use TeaOpenPTY\TeaOpenPTY;

$app  = "/usr/bin/bash";
$argv = [$app, "-i"];

$teaOpenPTY = new TeaOpenPTY($app);

echo "Starting TeaOpenPTY...\n";

$ret = $teaOpenPTY->exec(...$argv);

if ($ret === -1) {
  echo "Error: ", $teaOpenPTY->error(), "\n";
}

echo "TeaOpenPTY terminated!\n";

Run

ammarfaizi2@integral:/tmp$ wget https://github.com/ammarfaizi2/TeaOpenPTY/raw/master/compiled/tea_openpty.so
[...output abbreviated...]
2020-12-28 14:39:20 (612 KB/s) - ‘tea_openpty.so’ saved [19048/19048]

ammarfaizi2@integral:/tmp$ echo $$ # Show the current bash PID
19068
ammarfaizi2@integral:/tmp$ php -d extension=$(pwd)/tea_openpty.so test.php
Starting TeaOpenPTY...
ammarfaizi2@integral:/tmp$ echo $$ # Now we are in the terminal spawned by tea_openpty
329423
ammarfaizi2@integral:/tmp$ stty -a
speed 38400 baud; rows 46; columns 192; line = 0;
intr = ^C; quit = ^\; erase = ^?; kill = ^U; eof = ^D; eol = <undef>;
eol2 = <undef>; swtch = <undef>; start = ^Q; stop = ^S; susp = ^Z; rprnt = ^R;
werase = ^W; lnext = ^V; discard = ^O; min = 1; time = 0;
-parenb -parodd -cmspar cs8 -hupcl -cstopb cread -clocal -crtscts
-ignbrk -brkint -ignpar -parmrk -inpck -istrip -inlcr -igncr icrnl ixon -ixoff
-iuclc -ixany -imaxbel iutf8
opost -olcuc -ocrnl onlcr -onocr -onlret -ofill -ofdel nl0 cr0 tab0 bs0 vt0 ff0
isig icanon iexten echo echoe echok -echonl -noflsh -xcase -tostop -echoprt
echoctl echoke -flusho -extproc
ammarfaizi2@integral:/tmp$ exit # Terminate the terminal
exit
TeaOpenPTY terminated!
ammarfaizi2@integral:/tmp$ echo $$
19068
ammarfaizi2@integral:/tmp$ 

Related