網頁SSH客戶端的實現

xuanziDy發表於2022-02-28

1、功能要求:

使用web網頁充當ssh客戶端,達到在網頁端輸入linux命令可以正常返回ssh服務端結果的效果。

2、技術選擇:

2.1 傳輸協議的選擇

根據以上功能描述,如果用http協議則只能期待瀏覽器傳送請求才能得到服務端的結果響應。但是有些linux命令的結果是持續輸出,再考慮到網路開銷,所以需要一個客戶端與服務端能持續互動的工具,而websocket協議的特性正符合要求。

若網頁採用http, 則websocket採用ws;網頁採用https, 則websocket採用wss.

2.2 前端介面模擬與互動

解決了傳輸協議問題, 考慮到在網頁需要呈現一個與ssh終端相同的樣式,又要能捕捉鍵盤輸入事件, 方便與websocket配合把資料傳送給服務端。瞭解到xtem.js早已整合完成了這個使命。
Xterm.js官網

GitHub - xtermjs/xterm.js倉庫

2.3 服務端語言的選擇

服務端接收websocket傳輸來的資料,與ssh互動。這裡使用一個php輪子,用於websocket資料的接收與傳送。

Ratchet原始碼倉庫:
github.com/ratchetphp/Ratchet

Ratchet 官網介紹:
socketo.me/docs/websocket

3.如何實現

3.1 服務端實現

第一步:安裝依賴 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"; //核心語句
        }
    }
}

3.2 網頁端實現

index.html

<!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-Cli...


以上程式碼是功能實現的完整程式碼,而非部分示例。 均是親身實踐過可行的方案, 細節之處根據各專案的差異自行細微調整即可。

本作品採用《CC 協議》,轉載必須註明作者和本文連結
學過的東西能說出來那是最妙的,能覆盤寫下來那也不錯

相關文章