说起来也是有趣,本来是研究一下 WebSocket 准备给论坛/博客增加实时更新之类的特性,结果看着看着就脑洞大开搞了这么个玩意儿((
首先明确一下,这里说的 Web Terminal 是指再网页中实现的,类似于终端模拟器的玩意儿。举例的话应该是类似于 Linode 的 LiSH 和 Visual Studio Code 中内置的那个终端,而不是 ConoHa 提供的 VNC 式的终端(其实那玩意儿是个远程桌面了)。最终目标的效果就是和 Secure Shell 类似:打开一个网页,就能启动一个网页所在服务器的 shell,比如到处都有的 bash 或者非常强大的 zsh,然后就可以与这个终端进行交互式的操作,比如使用 vim 编辑文件,或者查阅 man 中的手册。
让我们从最简单的一些需求开始,如果只是需要远程执行一些命令或者脚本,那么我们只需要任何一个能调用系统 shell 的编程语言就行了。这里以 node 为例,代码很简单:
'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 后,执行这段代码,打开另一个终端,执行代码:
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。大致就是如下:
+----------+ | 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 :
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:
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 配置:
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" } ] } };
运行效果:
完整的代码可以参考 GitHub 上的 playground 仓库。