Implementing a Web Terminal

Notice

This is an old article and only available in Chinese. If you need a translation, please leave a comment and I will do my best to provide it as soon as possible.

说起来也是有趣,本来是研究一下 WebSocket 准备给论坛/博客增加实时更新之类的特性,结果看着看着就脑洞大开搞了这么个玩意儿((

首先明确一下,这里说的 Web Terminal 是指再网页中实现的,类似于终端模拟器的玩意儿。举例的话应该是类似于 Linode 的 LiSH 和 Visual Studio Code 中内置的那个终端,而不是 ConoHa 提供的 VNC 式的终端(其实那玩意儿是个远程桌面了)。最终目标的效果就是和 Secure Shell 类似:打开一个网页,就能启动一个网页所在服务器的 shell,比如到处都有的 bash 或者非常强大的 zsh,然后就可以与这个终端进行交互式的操作,比如使用 vim 编辑文件,或者查阅 man 中的手册。

让我们从最简单的一些需求开始,如果只是需要远程执行一些命令或者脚本,那么我们只需要任何一个能调用系统 shell 的编程语言就行了。这里以 node 为例,代码很简单:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
'use strict';
const express = require('express');
const child_process = require('child_process');
let server = express();

server.get('/eval', (req, res) => {
  if (req.query.cmd) {
    child_process.exec(req.query.cmd, (err, stdout, stderr) => {
      res.send({
        status: 'ok',
        stdout,
        stderr,
      });
    })
  } else {
    return res.send({
      status: 'ok';
    })
  }
});

server.listen(8123, 'localhost', () => {
 console.log(`Server running at http://localhost:8123`);
});

安装好 express 后,执行这段代码,打开另一个终端,执行代码:

1
curl 'http://localhost:8123/eval?command=ls'

我们就能看到 node 所在目录的文件列表了。看起来不错,但是如果需要执行面向终端的程序呢?

面向终端的程序,顾名思义,这种程序需要控制一个终端,同时有能力进行 job-control(fg 等) 和终端相关的信号(SIGINT 等)。典型的面向终端的程序就有:nano、vim、htop、less,等等。要让这些程序执行,我们需要通过 POSIX 的接口来创建一个伪终端(pseudoterminal,简称 pty)。

伪终端由两个虚拟的设备组成:一个 pseudoterminal master 和一个 pseudoterminal slave(pts)。这两个虚拟设备之间可以互相通讯,类似一个串口设备。两个进程可以打开这两个虚拟设备进行通讯,类似于一个管道。伪终端的关键就是 pts,这个设备在操作上和真实的终端设备(tty1, ttyS1, …)基本一致,不同之处在于 pts 没有速率之类的属性。所有能在 tty 上使用的操作都能在 pts 上使用,不支持的部分属性会被自动忽略,反正没什么卵用((

知道这些东西之后,终端模拟器的工作原理就很简单了:终端模拟器创建了一对 pty 设备,同时在 pty slave 上启动当前用户的默认 shell,比如 execlp("/usr/bin/zsh", [ “–login” ])。pts 会将程序所有的输出发送给 pty master,终端模拟器在拿到这些数据后,再按照指定终端的标准将其输出。同时,所有的键盘输入也会发送给 pty slave。大致就是如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
+----------+
| X Server |
+----+-----+
     |
+----+--------------+  +------------+
| Terminal Emulator +--+ pty master +
+-------------------+  +--+-----+---+
                          |     |
                       +--+-----+--+
                       + pty slave +
                       +--+-----+--+
                          |     |
        +-----------------+-----+---+
        + Terminal-oriented program |
        +---------------------------+

Secure Shell 的远程登录的原理同样类似:ssh 客户端首先和 sshd 协商加密,互相认证,然后建立一个 SSH channel,由服务端创建一对 pty,然后将 pty master 的输出放到 SSH channel 中。ssh 客户端与服务端之间通过 SSH channel 通讯,便实现了远程登陆。

那么,Web Terminal 的实现思路就很明确了:在浏览器上,我们需要找到一个比较好用的终端框架(或者自己撸一个),在服务器上,我们需要一个当前程序语言与 ptmx 的接口(或者自己撸一个)。而通讯方面,SSH 用的是 TCP,Web 上能用的也就是 WebSocket 了(除非你想 XMLHttpRequest 然后疯狂刷新),这里能找到框架最好,全都自己撸就太累了(

嘛。虽然 npm 上坑爹的包非常多,但是在这种时候基本上还是能做到想要啥就有啥的。这里我选择了 xterm.js 作 HTML5 中的终端组件,node-pty 做服务端的 pty 操作工具。这两个也正是 Visual Studio Code 中内置的终端所采用的依赖。WebSocket 方面,我选择了 Socket.IO 这个框架。当然,为了让 ES6 Module 正常工作,我们还需要用 webpack 来处理。依靠着强大的 xterm.js 和 node-pty,需要我们来完成的工作非常少。以下晒代码:

服务端 JavaScript :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
const express = require("express");
const site = express();
const http = require("http").Server(site);
const io = require("socket.io")(http);
const net = require("net");
const pty = require("node-pty");

site.use("/", express.static("."));

io.on("connection", function (socket) {
  let ptyProcess = pty.spawn("bash", ["--login"], {
    name: "xterm-color",
    cols: 80,
    rows: 24,
    cwd: process.env.HOME,
    env: process.env,
  });
  ptyProcess.on("data", (data) => socket.emit("output", data));
  socket.on("input", (data) => ptyProcess.write(data));
  socket.on("resize", (size) => {
    console.log(size);
    ptyProcess.resize(size[0], size[1]);
  });
});

http.listen(8123);

浏览器端 JavaScript:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import Terminal from "xterm";
import "xterm/src/xterm.css";
import io from "socket.io-client";

Terminal.loadAddon("fit");

const socket = io(window.location.href);

const term = new Terminal({
  cols: 80,
  rows: 24,
});
term.open(document.getElementById("#terminal"));
term.on("resize", (size) => {
  socket.emit("resize", [size.cols, size.rows]);
});

term.on("data", (data) => socket.emit("input", data));

socket.on("output", (arrayBuffer) => {
  term.write(arrayBuffer);
});

window.addEventListener("resize", () => {
  term.fit();
});
term.fit();

Webpack 配置:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
const path = require("path");

module.exports = {
  entry: "./src/entry.js",
  output: {
    path: path.join(__dirname, "dist"),
    filename: "bundle.js",
  },
  module: {
    loaders: [{ test: /\.css$/, loader: "style-loader!css-loader" }],
  },
};

运行效果: webterm.jpg

完整的代码可以参考 GitHub 上的 playground 仓库。

comments powered by Disqus
Except where otherwise noted, content on this blog is licensed under CC-BY 2.0.
Built with Hugo
Theme Stack designed by Jimmy