Пишем простенький веб-эмулятор терминала на PHP

в 9:48, , рубрики: jslinux, php, метки: ,

Я думаю, очень много людей думало над тем, чтобы сделать свой эмулятор терминала на PHP, и обычно останавливались на решениях вроде следующего:

<?php echo '<form><input name="cmd" /></form>'; if(isset($_GET['cmd'])) system($_GET['cmd']);

Конечно же, такое решение вызывает целый набор проблем, самая незначительная из которых — это то, что ошибки на экран не попадают. Есть и намного более значительные вещи, например запуск vi просто «подвесит» выполнение команды и придется открывать новую консоль и писать killall vi. И что уж точно не получится сделать, так это выполнить команды ssh или sudo, которые требуют чтения пароля прямо с терминала. Я постараюсь показать способ, с помощью которого можно устранить большую часть описанных выше проблем.

Пишем простецкий эмулятор терминала на PHP

Для нашего эмулятора терминала понадобятся:

  • PHP 5.2+ (с напильником — PHP 4.3+)
  • Работающие функции system() и proc_open()
  • Работающая функция flush() (с напильником и long polling сойдет)
  • term.js и utils.js из проекта JSLinux
  • Linux, Mac OS X на стороне сервера (на *BSD тоже должно работать)

Возможно, увидев ссылку на JSLinux, вы уже начали догадываться, что мы будем делать ;).

Основная идея реализации

В документации к PHP сказано, что proc_open() предназначен для двухсторонней коммуникации с процессами, поэтому мы откроем bash в интерактивном режиме с помощью этой функции и будем дальше с ним работать. К сожалению, поддержки псевдотерминалов в PHP из коробки нет, поэтому реализацию нужной нам прослойки мы напишем на Си.

Какой-либо защиты, а также проверок на ошибки и корректного завершения терминала в этом примере не предполагается(!), об этом вам придется думать самим ;).

Файл shell.php

Получение ввода пользователя

Мы должны каким-то образом получать ввод от пользователя, например через FIFO-файл:

<?php
$temp_fifo_file = '/tmp/dolphin-pipe-'.uniqid('dolph');
if (!posix_mkfifo($temp_fifo_file, 0600)) {
    echo "Fatal error: Cannot create fifo file: something wrong with the system.n";
    exit(1);
}

function deleteTempFifo() { unlink($GLOBALS['temp_fifo']); }
register_shutdown_function('deleteTempFifo');

$cmdfp = fopen($temp_fifo_file, 'r+');
stream_set_blocking($cmdfp, 0);

Установка переменных окружения для терминала

JSLinux работает в режиме эмуляции vt100, мы тоже будем делать также :)

putenv('TERM=vt100');
$cols = 80; // можно задать любые числа, в общем-то
$rows = 24;

Команда для запуска bash

В принципе, мы можем просто запустить "bash -i", и это будет работать (даже "sh -i" будет работать), но ещё лучше, если мы сможем работать через псевдотерминал, программы будут вести себя более «естественно» в таком случае. Заодно мы можем использовать свой bashrc, в котором настроим цветное приглашение :).

chdir(dirname(__FILE__));
$cmd = "bash --rcfile ./bashrc -i 2>&1";
// попробуем скомпилировать нашу утилиту для эмуляции терминала (pt.c, будет приведена далее)
if (!file_exists('pt')) {
    system('cc -D__'.strtoupper(trim(`uname`)).'__ -o pt pt.c -lutil 2>&1', $retval);
    if ($retval) echo('<b>Warning:</b> Cannot compile pseudotty helper');
}
clearstatcache();
if (file_exists('pt')) $cmd = "./pt $rows $cols $cmd";

$pp = proc_open($cmd, array(array('pipe','r'), array('pipe', 'w')), $pipes);
stream_set_blocking($pipes[0], 0);
stream_set_blocking($pipes[1], 0);
?>

Посылка команд из javascript

Мы будем делать по одному HTTP-запросу на символ (или больше символов, если сервер «не успевает»). Да, это может быть неоправданной тратой ресурсов в данном случае и можно всё сделать через вебсокеты, но в плане реализации моя схема намного проще :).

<html><head><title>Terminal</title></head><body>
<script>
    var pipeName = <?=json_encode($temp_fifo_file)?>, pending_str = '', processing = false;
    var sendCmdInterv = setInterval(function() {
        if (processing) return;
        if (pending_str.length) {
            processing = true;
            var previous_str = pending_str;
            pending_str = '';
            var http = new XMLHttpRequest();
            http.open("GET", "send-cmd.php?pipe=" + pipeName + "&cmd=" + encodeURIComponent(previous_str), true);
            http.onreadystatechange = function() {
                if (http.readyState == 4 && http.status == 200) {
                    processing = false;
                    pending_str = '';
                } else {
                    pending_str = previous_str + pending_str;
                }
            };
            http.send(null);
        }
    }, 16);

    function send_cmd(val) {
        pending_str += val;
    }
</script>
Эмулятор терминала на javascript

В JSLinux есть много разных полезных частей, в данном случае это эмулятор терминала. Автор запрещает распространение и модификацию файла term.js без его ведома, поэтому в этом примере мы будем просто ссылаться на его библиотеку :) и использовать её, как есть.

<style>
    .term {
        font-family: monaco,courier,fixed,monospace,swiss,sans-serif;
        font-size: 13px;
        line-height: 16px;
        color: #f0f0f0;
        background: #000000;
    }

    tr {
        height: 16px;
    }

    .termReverse {
        color: #000000;
        background: #00ff00;
    }
</style>

<script src="http://bellard.org/jslinux/utils.js"></script>
<script src="http://bellard.org/jslinux/term.js"></script>
<script>var term = new Term(<?=$cols?>, <?=$rows?>, send_cmd); term.open();</script>

Чтение пользовательского ввода и исполнение команд

Читаем пользовательский ввод из нашего FIFO-файла, вывод нашей команды из соответствующего пайпа, всё в неблокирующем режиме, для простоты. Также, в качестве небольшого костыля, заменяем "n" на "rn", так как на самом деле, term.js обрабатывает вывод драйвера последовательного порта, а не «сырой» вывод программы ;).

<?php
echo "<!-- ".str_repeat('-', 4096)." -->n";
flush();

while (!feof($pipes[1])) {
	$ln = fgets($pipes[1], 4096);
	if ($ln !== false) {
        $ln = str_replace("n", "rn", $ln);
		echo '<script>term.write('.json_encode($ln).');</script>';
        flush();
		continue;
	}
	
    $inp_ln = fgets($cmdfp, 4096);
    if ($inp_ln !== false) {
        // ensure that command is fully written by setting blocking to 1
        stream_set_blocking($pipes[0], 1);
        fwrite($pipes[0], $inp_ln);
        stream_set_blocking($pipes[0], 0);
    }
	
	usleep(20000);
}

proc_close($pp);
?>

Очищаем прием команд после выхода

После того, как процесс завершился (например человек нажал Ctrl+D или ввел «exit»), нужно перестать отправлять пользовательский ввод на сервер, ибо принимать его всё равно больше некому :).

<script>clearInterval(sendCmdInterv);</script>
</body>
</html>

Файл send-cmd.php

Файл, на который мы отправляем команды, называется send-cmd.php и состоит вот из чего (да-да, никаких проверок входных параметров :)):

<?php
$fp = fopen($_GET['pipe'], 'r+');
fwrite($fp, $_GET['cmd']);
fclose($fp);

Файл bashrc

Вот какой bashrc я предлагаю использовать:

export PS1='[33[01;33m]u[33[00m]:[33[01;34m]w[33[00m]$ '
export PS2='> '
export PS4='+ '
export LANG=en_US.UTF-8

echo Welcome to simple terminal emulator'!'
echo Scroll up and down using Ctrl-Up, Ctrl-Down, Ctrl-PageUp and Ctrl-PageDown.
echo Output handling is based on JSLinux term.js library. Enjoy'!'

Файл pt.c

Утилиты работы с псевдотерминалом уже существуют, например есть хорошая библиотека expect, которая делает почти то, что нам нужно. Тем не менее, мне показалось интересным написать свою утилиту, которая будет просто устанавливать нужные размеры терминала и выводить всё на stdout и принимать ввод на stdin:

#include <unistd.h>
#include <sys/select.h>
#include <stdio.h>
#ifdef __LINUX__
#include <pty.h>
#else
#include <util.h>
#endif

static void set_fds(fd_set *reads, int pttyno) {
	FD_ZERO(reads);
	FD_SET(0, reads);
	FD_SET(pttyno, reads);
}

int main(int argc, char *argv[]) {
	char buf[1024];
	int pttyno, n = 0;
	int pid;
	struct winsize winsz;
	
	if (argc < 3) {
		fprintf(stderr, "Usage: %s <rows> <cols> <cmd> [args]n", argv[0]);
		return 1;
	}
	
	winsz.ws_row = atoi(argv[1]);
	winsz.ws_col = atoi(argv[2]);
	winsz.ws_xpixel = winsz.ws_col * 14;
	winsz.ws_ypixel = winsz.ws_row * 14;
	
	pid = forkpty(&pttyno, NULL, NULL, &winsz);
	if (pid < 0) {
		perror("Cannot forkpty");
		return 1;
	} else if (pid == 0) {
		execvp(argv[3], argv + 3);
		perror("Cannot exec bash");
	}
	
	fd_set reads;
	set_fds(&reads, pttyno);
	
	while (select(pttyno + 1, &reads, NULL, NULL, NULL)) {
		if (FD_ISSET(0, &reads)) {
			n = read(0, buf, sizeof buf);
			if (n == 0) {
				return 0;
			} else if (n < 0) {
				perror("Could not read from stdin");
				return 1;
			}
			write(pttyno, buf, n);
		}
		
		if (FD_ISSET(pttyno, &reads)) {
			n = read(pttyno, buf, sizeof buf);
			if (n == 0) {
				return 0;
			} else if (n < 0) {
				perror("Cannot read from ptty");
				return 1;
			}
			write(1, buf, n);
		}
		
		set_fds(&reads, pttyno);
	}
	
	int statloc;
	wait(&statloc);
	
	return 0;
}

Демонстрация работы

К сожалению, ссылку на работающее демо я предоставить вам не могу, поскольку у меня нет сервера, который мне не жалко ;). Тем не менее, я записал короткое видео, которое показывает, как это работает:

Всё вместе

По идее, все исходные коды должны быть в данной статье. Если вам лениво, я выложил архив с соответствующими файлами на народ.диск

Автор: youROCK

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js