大家可能见过类似这样嵌入到网页中的终端,可以在页面上与远程服务器交互,就像 ssh 到远程服务器一样。实现这样一个基于 web 的终端,具有跨平台、易审计、限制用户行为等优点。
本文将介绍如何构建一个最简单的 web 远程终端服务程序。
1. 基本概念
首先明确几个相关概念:
终端
终端是一种字符型输入输出设备,通过它用户才能与计算机进行 IO。在 linux 系统中,终端设备文件一般位于 /dev/ 下。
每打开一个终端,就会产生一个新的 tty 设备文件。使用命令 tty
可以查看当前使用的终端设备。
终端大致分为:
- 串行端口终端( /dev/ttySX )。是使用计算机串行端口连接的终端,串行端口所对应的设备名称是/dev/ttyS0、/dev/ttyS1 等等。
- 控制台终端( /dev/ttyn, /dev/console )。通常在 Linux 系统中,把计算机显示器称为控制台终端,与之相连的设备文件有:tty0, tty1, tty2 等。
- 控制终端( /dev/tty )。并不面向设备,而是面向进程组的,在 Linux 系统中,一个控制终端控制一个会话。
通常情况下,用户通过终端输入的指令经由shell解释和执行,从而与系统内核进行交互。
系统启动以后,在指定的波特率上打开串行端口终端(ttyS0), 并将 STDIN、 STDOUT、STDERR 都绑定到该设备上,然后启动 login 程序等待用户完成登陆 。若用户登陆成功,则启动一个 shell 程序为用户服务,这样用户就拥有一个 shell 终端了。
伪终端
对于远程网络用户来说,上节描述的 Terminal 登录过程并不适用,网络用户既不能远程使用串行端口设备,也不能远程控制显示器设备。因此需要创建一个虚拟的终端设备为其服务 —— 伪终端。
伪终端,顾名思义,不是真正的终端,不能操作某个物理设备。它是虚拟的终端驱动设备,用来模拟串行终端的行为。
当使用 ssh、telnet 等程序连接到某台服务器上时进行操作时,底层使用的就是伪终端技术。
伪终端是成对的逻辑终端设备,分为“主设备”(master)和“从设备”(slave),例如/dev/ptyp3和/dev/ttyp3。
其中,“从设备”提供了与真正终端无异的接口,可以与系统进行 IO,规范终端行输入。; 而“主设备”与管道文件类似,可以进行读写操作。往“主设备”写入的数据会传输到“从设备”,而“从设备”从系统获取到的数据也会同样的传输到“主设备”。因此,也可以说,伪终端是一个双向管道。
2. 构建远程终端服务
上面已经介绍过,想要与系统进行交互,除了有终端设备,还需要 shell 程序。两者结合才能完成用户的指令。
因此,一个远程终端服务程序由两个部分构成:伪终端和 shell 进程。通常构建如下:
- 1 创建伪终端设备。
- 2 fork 创建子进程,并将该子进程的标准输入、输出和错误输出均 dup 为伪终端的"从设备"。
- 3 在子进程中 exec 执行 /bin/bash 命令,启动 shell 进程。由于上一步的操作,该子进程(也就是 shell 进程)的 stdin、stdout 和 stderr 已与伪终端进行了绑定。如此,shell 子进程的输出、输出、错误输出均是通过伪终端的“从设备”进行的。
经过上述操作,可以说这个子进程就是一个“终端进程“了:既能够完成终端的输入输出操作,又能解释执行用户输入与系统内核交互。
由于伪终端“双向管道”的特性:对伪终端“主设备”的写操作,将传输到“从设备”,也就是传输给”终端进程“;而”终端进程“执行命令后的输出,将通过“从设备”传输返回至“主设备”。如此一来,对 ”终端进程“ 的 IO 操作完全可以通过操作伪终端的“主设备”来完成。
对“主设备”进行读写操作,就等同于在对一个终端 shell 进行操作。因此,如果在父进程中将该伪终端“主设备”与网络 socket 绑定,就能够实现远程终端操作了。(当然也可以将该“主设备”与其他文件描述符绑定,例如与另一进程通信的管道 fd 绑定等等,这些就取决于功能需求了)
数据传输可见下图:
3. 代码实现
下面给出实现一个 Remote Terminal 服务的关键代码。
主干框架
代码逻辑与上一节所描述的实现流程一致。
int startShell(int socketFd) // socketFd 为已连接状态可进行数据 IO 的 socket 描述符
{
int master = -1;
int slave = -1;
// 捕获子进程退出的信息,处理函数为 wait4child
if (signal(SIGCHLD, wait4child) == SIG_ERR)
{
oops("signal error", 0);
}
// 创建伪终端,得到 “主从设备” 文件描述符: master, slave
if(OpenSystemPtmx(&master, &slave) < 0)
{
oops("open OpenSystemPtmx error", errno);
}
// 创建子进程
int pid = fork();
if(pid == 0)
{
/* 子进程处理逻辑:将值为 0、1、2 的 fd 都变成伪终端“从设备” slave 的复制品。也就是说子进程的 stdin、stdout、stderr 都指向了 slave */
close(master);
setsid();
dup2(slave, 0);
dup2(slave, 1);
dup2(slave, 2);
// 执行 shell
execlp("sh", NULL);
}
else if(pid < 0)
{
close(master);
close(slave);
oops("fork err", 0);
}
else
{
// 主进程处理逻辑
int ret = 0;
while(ret == 0)
{
// 将从伪终端“主设备” master 读到的数据 echo 到 socket fd
ret = echoData(master, socketFd);
// 将从 socket fd 读到的数据 echo 到伪终端“主设备” master
ret = echoData(socketFd, master);
}
return ret;
}
}
创建伪终端
下面给出创建伪终端设备所需的最简单的代码。当然,还可以添加更复杂的代码来实现更多终端设置,例如屏蔽回显等等。
int OpenSystemPtmx(int *pMaster, int *pSlave)
{
int master = open("/dev/ptmx", O_RDWR | O_NOCTTY);
if (master == -1) return -1;
if (grantpt(master) == -1)
{
return -1;
}
if (unlockpt(master) == -1)
{
return -1;
}
char* slaveName = ptsname(master);
if (slaveName == NULL)
{
return -1;
}
int slave = open(slaveName, O_RDWR | O_NOCTTY);
if (slave == -1)
{
return -1;
}
*pMaster = master;
*pSlave = slave;
return 0;
}
子进程退出处理逻辑
子进程就是 shell 进程。在 shell 中输入 exit
将会退出该进程,为了保证主进程的正常退出,这里在捕获到子进程的退出信号后,直接退出。
void wait4child(int signo)
{
int status;
while(waitpid(-1, &status, WNOHANG) > 0);
exit(1);
}
数据处理
这里给出的只是最简单的示例代码,同步且阻塞的读写。可以看到,在主干代码中,是先从 master echo 数据到 socket的。这是因为 shell 程序启动后,会立即有数据输出到 stdout,也就是 master 了。
例如下图中的输出: sh-3.2$
下面代码的实现是同步阻塞的读写,建议使用更高效的方式,例如 IO 复用等。
// 从 inFd 读取数据,并写入到 outFd
int echoData(int inFd, int outFd)
{
char buffer[MAX_SIZE];
bzero(buffer, MAX_SIZE);
int nred = read(inFd, buffer, MAX_SIZE);
if (nred <= 0)
{
return -1;
}
int nwrite = write(outFd, buffer, nred);
if (nwrite <= 0)
{
return -1;
}
return 0;
}
4. Tips
1 终端默认是具有回显功能的,且终端是字符设备
Remote Terminal 在用户展示层需要格外注意,因为从 socket 写入到 master 的数据,socket 还会从 master 中读到。
因此 Remote Termial 最简单省事的实现是 在显示层捕获用户输入的每一个字符,并立即通过网络传输该单个字符 。这种方式,保留了 Terminal 最原始的功能,并不用处理回显等设置。(当然你也可以采用行数据网络传输的方式,只是要 care more ^.^)
注: Linux 系统中有 stty
命令,用于查看和更改终端行设置。stty -echo
命令会关闭回显,通常用于输入密码等场景。当然,也有相关的接口来实现屏蔽回显的功能。
2 终端操作通常是 IO 密集的,尤其是上述的单字符传输方式
上述代码中 echoData 的实现(同步阻塞 IO),最好改成 IO 复用的方式。可以使用select、poll、epoll 等框架, 监听 master fd 和 socket fd,提高 IO 效率。
3 开源组件