记事本

网页SSH客户端的实现

我觉得很牛 5718 次浏览

功能要求

使用 web 网页充当ssh 客户端,达到在网页端输入 linux 命令可以正常返回 ssh 服务端结果的效果。

技术选择

传输协议的选择

根据以上功能描述,如果用 http 协议则只能期待浏览器发送请求才能得到服务端的结果响应。但是有些 linux 命令的结果是持续输出,再考虑到网络开销,所以需要一个客户端与服务端能持续交互的工具,而 websocket 协议的特性正符合要求。

前端界面模拟与交互

解决了传输协议问题, 考虑到在网页需要呈现一个与 ssh 终端相同的样式,又要能捕捉键盘输入事件, 方便与 websocket 配合把数据发送给服务端。了解到 xtem.js 早已集成完成了这个使命。

Xterm.js 官网
GitHub - xtermjs/xterm.js 仓库

服务端语言的选择

服务端接收 websocket 传输来的数据,与 ssh 交互。这里使用一个 php 轮子,用于 websocket 数据的接收与发送。

Ratchet 源码仓库:

github.com/ratchetphp/Ratchet

Ratchet 官网介绍:
socketo.me/docs/websocket

如何实现

服务端实现

socketo.me/docs/install
composer require cboden/ratchet

第二步:编写监听 websocket 服务启动入口
server.php (可考虑使用 Linux 脚本等方式,让服务常驻后台进程)

<?php
use Ratchet\Server\IoServer;
use Ratchet\Http\HttpServer;
use Ratchet\WebSocket\WsServer;
use MyApp\Servidorsocket;

    require dirname(__DIR__) . '/vendor/autoload.php';
    $server = IoServer::factory(
        new HttpServer(
            new WsServer(
                new Servidorsocket() //实际用于处理websocket数据的类
            )
        ),
        8090 //监听websocket协议传输数据的端口
    );

    $server->run(); //启动服务

?>

第三步:处理 websocket 传来的数据,以及实现与 ssh 的交互

<?php
namespace MyApp;

use Ratchet\MessageComponentInterface;
use Ratchet\ConnectionInterface;

class Servidorsocket implements MessageComponentInterface
{
    protected $clients;
    protected $connection = array();
    protected $shell = array();
    protected $conectado = array();
    //ssh终端实际展示数据的宽度和高度
    const COLS = 80;
    const ROWS = 24;

    public function __construct()
    {
        $this->clients = new \SplObjectStorage;
    }

    public function onOpen(ConnectionInterface $conn)
    {
        // Store the new connection to send messages to later
        $this->clients->attach($conn);
        $this->connection[$conn->resourceId] = null;
        $this->shell[$conn->resourceId] = null;
        $this->conectado[$conn->resourceId] = null;
    }

    public function onMessage(ConnectionInterface $from, $msg)
    {
        $data = json_decode($msg, true);
        switch (key($data)) {
            case 'data': //前端发送的单个字符
                fwrite($this->shell[$from->resourceId], $data['data']['data']);
                usleep(800);
                //这个循环事必要的,用于持续输出后端的数据
                while ($line = fgets($this->shell[$from->resourceId])) {
                    $from->send(mb_convert_encoding($line, "UTF-8"));
                }
                break;
            case 'auth':  //连接ssh服务器,需要php安装ssh2.so扩展
                if ($this->connectSSH($data['auth']['server'], $data['auth']['port'], $data['auth']['user'], $data['auth']['password'], $from)) {
                    $from->send(mb_convert_encoding("Connected....", "UTF-8"));
                    while ($line = fgets($this->shell[$from->resourceId])) {
                        $from->send(mb_convert_encoding($line, "UTF-8"));
                    }
                } else {
                    $from->send(mb_convert_encoding("Error, can not connect to the server. Check the credentials", "UTF-8"));
                    $from->close();
                }
                break;
            default:
               //例如:如果嵌套在管理端,可以用于定时检测用户登录态(具体细节待完善)
                if ($this->conectado[$from->resourceId]) {
                    while ($line = fgets($this->shell[$from->resourceId])) {
                        $from->send(mb_convert_encoding($line, "UTF-8"));
                    }
                }
                break;
        }
    }

    public function connectSSH($server, $port, $user, $password, $from)
    {
        $this->connection[$from->resourceId] = ssh2_connect($server, $port);
        if (ssh2_auth_password($this->connection[$from->resourceId], $user, $password)) {
            //$conn->send("Authentication Successful!\n");
            $this->shell[$from->resourceId] = ssh2_shell($this->connection[$from->resourceId], 'xterm', null, self::COLS, self::ROWS, SSH2_TERM_UNIT_CHARS);
            sleep(1); //这个时长相对合适
            $this->conectado[$from->resourceId] = true;
            return true;
        } else {
            return false;
        }
    }

    public function onClose(ConnectionInterface $conn)
    {
        // The connection is closed, remove it, as we can no longer send it messages
        $this->conectado[$conn->resourceId] = false;
        $this->clients->detach($conn);

        // Gracefully closes terminal, if it exists
        if (isset($this->shell[$conn->resourceId]) && is_resource($this->shell[$conn->resourceId])) {
            fclose($this->shell[$conn->resourceId]);
            $this->shell[$conn->resourceId] = null;
        }
    }

    public function onError(ConnectionInterface $conn, \Exception $e)
    {
        $conn->close();
    }
}

第四步:nginx 代理转发(不使用代理转发请求,直接访问 websocket 监听的端口也行,这里这样做是 wss 协议时,可以使用服务器上的 SSL 证书)

http{
    map $http_upgrade $connection_upgrade {
        default upgrade;
        '' close;
    }

    upstream webconsole {
        server 127.0.0.1:websocket服务监听端口;
    }

    server {
        listen        网页端口;
        location /webconsole {
            proxy_pass http://webconsole; //核心语句
            proxy_set_header       Host $host;
            proxy_set_header  X-Real-IP  $remote_addr;
            proxy_set_header  X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_http_version 1.1; //核心语句
            proxy_set_header Upgrade $http_upgrade; //核心语句
            proxy_set_header Connection "upgrade"; //核心语句
        }
    }
}

网页端实现


<!doctype html>
  <html>
    <head>
      <link rel="stylesheet" href="node_modules/xterm/dist/xterm.css" />
      <script src="node_modules/xterm/dist/xterm.js"></script>
      <script src="node_modules/xterm/dist/addons/attach/attach.js"></script>
      <script src="node_modules/xterm/dist/addons/fit/fit.js"></script>
      <style>
      body {font-family: Arial, Helvetica, sans-serif;}

      input[type=text], input[type=password], input[type=number] {
          width: 100%;
          padding: 12px 20px;
          margin: 8px 0;
          display: inline-block;
          border: 1px solid #ccc;
          box-sizing: border-box;
      }

      button {
          background-color: #4CAF50;
          color: white;
          padding: 14px 20px;
          margin: 8px 0;
          border: none;
          cursor: pointer;
          width: 100%;
      }

      button:hover {
          opacity: 0.8;
      }

      .serverbox {
          padding: 16px;
          border: 3px solid #f1f1f1;
          width: 25%;
          position: absolute;
          top: 15%;
          left: 37%;
      }
      </style>
    </head>
    <body>
      <div id="serverbox" class="serverbox">
        <label for="psw"><b>Server</b></label><br>
        <input type="text" id="server" name="server" title="server" placeholder="server" /><br>
        <label for="psw"><b>Port</b></label><br>
        <input type="number" min="1" id="port" name="port" title="port" placeholder="port" /><br>
        <label for="psw"><b>User</b></label><br>
        <input type="text" id="user" name="user" title="user" placeholder="user" /><br>
        <label for="psw"><b>Password</b></label><br>
        <input type="password" id="password" name="password" title="password" placeholder="password" /><br>
        <button type="button" onclick="ConnectServer()">Connect</button><br>
      </div>
      <div id="terminal" style="width:100%; height:90vh;visibility:hidden"></div>
      <script>
        var resizeInterval;
        var wSocket = new WebSocket("ws:127.0.0.1:8080");
        Terminal.applyAddon(attach);  // Apply the `attach` addon
        Terminal.applyAddon(fit);  //Apply the `fit` addon
        var term = new Terminal({
                  cols: 80,
                  rows: 24
        });
        term.open(document.getElementById('terminal'));


        function ConnectServer(){
          document.getElementById("serverbox").style.visibility="hidden";
          document.getElementById("terminal").style.visibility="visible";
          var dataSend = {"auth":
                            {
                            "server":document.getElementById("server").value,
                            "port":document.getElementById("port").value,
                            "user":document.getElementById("user").value,
                            "password":document.getElementById("password").value
                            }
                          };
          wSocket.send(JSON.stringify(dataSend));

          term.fit();
          term.focus();
        }       

        wSocket.onopen = function (event) {
          console.log("Socket Open");
          term.attach(wSocket,false,false);
          window.setInterval(function(){
            wSocket.send(JSON.stringify({"refresh":""}));
          }, 700);
        };

        wSocket.onerror = function (event){
          term.detach(wSocket);
          alert("Connection Closed");
        }        

        term.on('data', function (data) {
          var dataSend = {"data":{"data":data}};
          wSocket.send(JSON.stringify(dataSend));
          //Xtermjs with attach dont print zero, so i force. Need to fix it :(
          if (data=="0"){
            term.write(data);
          }
        })

        //Execute resize with a timeout
        window.onresize = function() {
          clearTimeout(resizeInterval);
          resizeInterval = setTimeout(resize, 400);
        }
        // Recalculates the terminal Columns / Rows and sends new size to SSH server + xtermjs
        function resize() {
          if (term) {
            term.fit()
          }
        }
      </script>
    </body>
  </html>


注:以上示例代码摘录于以下仓库:
github.com/roke22/PHP-SSH2-Web-Client


站内搜索