远程终端服务的简单实现

大家可能见过类似这样嵌入到网页中的终端,可以在页面上与远程服务器交互,就像 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 开源组件

  • term.js 有完整的 web terminal 示例,同时提供了可参考的 terminal 前端库;
  • termlib 是一个具有配色、text wrapping、远程通信等功能的Javascript库。
上一篇:MaxCompute Console 实用小命令


下一篇:zabbix监控误报的问题