说起来也是有趣,本来是研究一下 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` );
});
Copy 安装好 express 后,执行这段代码,打开另一个终端,执行代码:
1
curl 'http://localhost:8123/eval?command=ls'
Copy 我们就能看到 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 |
+---------------------------+
Copy 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 );
Copy 浏览器端 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 ();
Copy 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" }],
},
};
Copy 运行效果:
完整的代码可以参考 GitHub 上的 playground 仓库。