纯 PHP 实现网页 Linux 终端实践

编辑于 2016-11-24

* 移动设备下, 可左滑手指以查看较宽代码

前言

囿于知识的积累,一直没有实现这个想法。这个功能还是挺有用的,接下来我将详细展开其说明和实现。

基于:

Workerman 网络框架

term.js 网页端Linux终端协议

于 2016-11-24 修改: 改正了不正确的描述

运行效果:

demo

需求分析

依照我的设想,主要包括2个功能:

  • 多用户控制 Linux

    即一个或多个用户同时在网页上与Linux交互,正如 ssh 那般

  • 教学模式(主从模式)

    一个用户操作,其他用户可观看

实现

前端页面:

window.addEventListener('load', function() {
  var socket = new WebSocket("ws://"+document.domain+":7778");
  socket.onopen = function() {
    var term = new Terminal({
        cols: 130,
        rows: 30,
        useStyle: false,
        screenKeys: false,
        cursorBlink: false
    });

    term.open(document.body);

    term.on('data', function(data) {
        socket.send(data);
    });

    socket.onmessage = function(data) {
      console.log(data.data);
      term.write(data.data);
    };

    socket.onclose = function() {
      term.destroy();
    };
  };
}, false);

首先引入 term.js 插件,它提供了 linux socket 管道协议,我们不必理会协议的具体细节。

首先我们new了一个 websocket 对象,这是html5的新标准,在 IE9 以及以下浏览器下不被支持。

接着,调用一些接口,与Linux的管道( pipe )交互,不必深究其具体的协议层实现。


后端:

先定义了一些常量:

// 要运行的程序
define('CMD', 'bash');

//允许的主用户,用于主从(教学模式)
define('ALLOW_IP', '127.0.0.1');

//0:多用户控制模式
//1:教学模式 允许浏览器访问 ALLOW_IP:7779 来控制,其他作为“学生”,只能看
define('MODE',1);

// 允许用户输入?
define('ALLOW_CLIENT_INPUT', true);

// 运行上述程序的用户
define('USER', 'tony');

然后是框架的初始化,对于此框架不做过多介绍,具体请参阅

http://doc3.workerman.net/

require_once __DIR__ . '/Workerman/Autoloader.php';
$worker = new Worker("Websocket://0.0.0.0:7778");
$worker->name = 'pty';
$worker->user = USER;
//教学模式下,只能打开一个进程
//这个变量记录了进程是否已经打开
$opened = FALSE;

接下来是核心部分,我注册了一个回调方法,此方法在客户端连接的时候触发,进行一些资源初始化操作。

$worker->onConnect = function($connection) use($opened)
{
    static $proc;

    unset($_SERVER['argv']);

    if(! $opened)
    {
        $proc['process'] = 
        proc_open (
                            CMD, 
                            [
                                0=>['pty'],
                                1=>['pty'],
                                2=>['pty']
                                ],
                            $pipes,
                            file_exists('/home/'.USER)?'/home/'.USER:NULL,
                            array_merge(['COLUMNS'=>130, 'LINES'=> 30], $_SERVER)
                        );
        $proc['pipes'] = $pipes;
        stream_set_blocking($pipes[0], 0);
        $proc['process_stdout'] = new TcpConnection($pipes[1]);
        $proc['process_stdin'] = new TcpConnection($pipes[2]);
        
        //教学模式下,只允许打开一个进程
        //多用户模式下,每连入一个用户就打开一个进程
        if(MODE == 1)
            $opened = TRUE;
    }

    //将进程信息传给 $connection 对象,这个对象会在所有的客户端中传递
    $connection->process = $proc['process'];
    $connection->pipes = $proc['pipes'];
    $connection->process_stdout = $proc['process_stdout'];
    $connection->process_stdin = $proc['process_stdin'];
    ...
}

关键在于 proc_open (),先看看官网怎么说的?

/**
 * Execute a command and open file pointers for input/output
 * @param string $cmd 
 * The command to execute
 *
 * @param array $descriptorspec
 * An indexed array where the key represents the descriptor number and the
 * value represents how PHP will pass that descriptor to the child
 * process. 0 is stdin, 1 is stdout, while 2 is stderr.
 *
 * Each element can be:
 * An array describing the pipe to pass to the process. The first
 * element is the descriptor type and the second element is an option for
 * the given type. Valid types are pipe (the second
 * element is either r to pass the read end of the pipe
 * to the process, or w to pass the write end) and
 * file (the second element is a filename).
 * A stream resource representing a real file descriptor (e.g. opened file,
 * a socket, STDIN).
 * 
 * The file descriptor numbers are not limited to 0, 1 and 2 - you may
 * specify any valid file descriptor number and it will be passed to the
 * child process. This allows your script to interoperate with other
 * scripts that run as "co-processes". In particular, this is useful for
 * passing passphrases to programs like PGP, GPG and openssl in a more
 * secure manner. It is also useful for reading status information
 * provided by those programs on auxiliary file descriptors.
 * 
 * @param array $pipes 
 * Will be set to an indexed array of file pointers that correspond to
 * PHP's end of any pipes that are created.
 * 
 * @param string $cwd [optional] 
 * The initial working dir for the command.
 * absolute directory path, or NULL (currect path).
 * 
 * @param array $env [optional] 
 * An array with the environment variables for the command that will be
 * run, or NULL to use the same environment as the current PHP process
 * 
 * @return resource a resource representing the process, which should be freed using
 * proc_close when you are finished with it. On failure
 * returns FALSE
 */
function proc_open (
        $cmd, 
        array $descriptorspec,
        array &$pipes,  
        $cwd = null, 
        array $env = null,   
        array $other_options = null
    ) {}

简单的说,就是打开一个程序,并取得管道。重点在于第二个参数descriptorspec

一个索引数组。 数组的键表示描述符,数组元素值表示 PHP 如何将这些描述符传送至子进程。
0 表示标准输入(stdin)
1 表示标准输出(stdout)
2 表示标准错误(stderr)

包含了要传送至进程的管道的描述信息。 第一个元素为描述符类型, 第二个元素是针对该描述符的选项。 有效的类型有:
pipe (第二个元素可以是: r 向进程传送该管道的读取端,w 向进程传送该管道的写入端)
file(第二个元素为文件名)。表达一个真实文件描述符的流资源类型 (例如:已打开的文件,一个 socket 端口,STDIN)

文件描述符的值不限于 0,1 和 2,你可以使用任何有效的文件描述符并将其传送至子进程。 这使得你的脚本可以和其他脚本交互操作。 例如,可以通过指定文件描述符将密码以更加安全的方式 传送至诸如 PGP,GPG 和 openssl 程序, 同时也可以很方便的获取这些程序的状态信息。

在官网文档中还提到,以伪终端 (pty)的形式进行交互,即用代码描述就是:

$descriptorspec = array(0 => array('pty'),
                        1 => array('pty'),
                        2 => array('pty'));

Linux 默认为每个进程打开了3个「文件描述符」, 即: 0输入 2输出 3错误, 我们只需要把这些描述符通过管道重定向, 就能实现网页对程序的控制。

解决了这个问题后,只需要将管道命令转发就行了。由于使用了 TCP,网络的暂时性阻塞不会影响消息的完整性。

$worker->onConnect = function($connection) use($opened)
{

    //(这一段是上文的 proc_open () )
    //stdout 和 stdin 在客户端连接并创建进程后监听,此消息来自系统而不是客户端
    $connection->process_stdout->onMessage = 
    function($process_connection, $data)use($connection)
    {
        if(MODE == 0)
            $connection->send($data);
        if(MODE == 1)
        {
            //Only outputed by master.
            if($connection->getRemoteIp()==ALLOW_IP) {
                foreach($connection->worker->connections as $con)
                    $con->send($data);
            }
        }
    };
    
    $connection->process_stdin->onMessage = 
    function($process_connection, $data)use($connection)
    {
        $connection->send($data);
    };
};

//收到客户端消息,直接向管道写入
$worker->onMessage = function($connection, $data)
{
        if(MODE == 0)
            if(ALLOW_CLIENT_INPUT)
                fwrite($connection->pipes[0], $data);
        if(MODE == 1)
            if($connection->getRemoteIp()==ALLOW_IP)
                fwrite($connection->pipes[0], $data);
};

//断开函数
$worker->onClose = function($connection) 
{
    //这里是一些终止管道进程的代码,不再赘述
}

最后,启动 web 服务,提供网页支持。

$webserver = new WebServer('http://0.0.0.0:7779');
$webserver->addRoot('localhost', __DIR__ . '/Web');
//浏览器中输入 http://0.0.0.0:7779 即可

整体的给出目录结构:

tony@tony-pc:~/download/webpty$ tree -L 2
.
|-- start.php
|-- Web/
|   |-- imgs
|   |-- index.html
|   `-- term.js
`-- Workerman
    |-- Autoloader.php
    |-- Connection/
    |-- Events/
    |-- Lib/
    |-- Protocols/
    |-- WebServer.php
    `-- Worker.php

输入 php start.php start 即可运行。

总结

主要是利用了Linux 管道进程和 PTY (即伪终端)来转送请求。
这里是运行了 bash 这个 shell ,即标准命令行界面,其实也可以运行其他的程序,实现网页监控等功能。