Linux 的伪终端的基本原理 及其在远程登陆(SSH,telnet等)中的应用

本文介绍了linux中伪终端的建立,介绍了终端的回显、行缓存、控制字符等特性,并在此基础上解释和模拟了telnet、SSH开启远程会话的过程。linux

1、轻量级远程登陆

以前制做的一块嵌入式板子,安装了嵌入式linux操做系统,能够经过串口(Console)登陆。为了方便使用,须要寻找经过网线远程登陆的方法。最初的想法是SSH,不过板子的ROM过小,存不了体积庞大庞大的OpenSSH套装。后来换用了telnet,直接拿busybox的telnetd作服务器,效果很好。ios

后来有一天,发现了Linux中有一个直接创建TCP链接的工具:nc 。在服务端使用nc -l 端口号 来进行监听,在客户端使用nc IP地址 端口号来创建链接。创建链接后,nc会把从stdin读入的字节流发送给另外一方,把接收到的字节流写入stdout中。配合方便的管道操做,不正能够将shell的输入/输出传送到远端机器上吗?因而在Ubuntu中实验操做以下(以后发现这种操做叫作“反弹shell”):shell

打开一个终端A,输入命令缓存

mkfifo /tmp/p  # 建立临时管道
sh -i </tmp/p |& nc -l 2333 >/tmp/p

该命令将bash的标准输入输出与nc的标准输出输入链接起来,并由nc将其与socket链接起来。同时,nc监听2333端口(若是使用小于1024的端口,须要root权限),等待远程链接。如今打开另外一个终端B,准备链接:bash

nc localhost 2333

这时,在终端B中出现了sh的提示符。输入通常的shell命令后能够执行并获得结果。看来linux自带的工具已经灵活、强大到足够搭建一个小型的远程登陆系统。这个过程可使用下面的图来描述:服务器

经过tty命令,咱们看到,此时的shell并无一个tty终端。确实,它的标准输入输出都是管道。这会带来一个问题,须要操纵tty的一些命令,好比vi、less、sudo等都没法正常使用(能够动手试试效果怎么样)。更为要命的是,在终端B中按下Ctrl+C这样的控制键,内核把结束信号发送给了客户端nc,而不是远程的程序!网络

Ctrl+C直接杀死nc,结束了会话。对比telnet,咱们的登陆系统还缺乏什么东西。这就是伪终端(pseudoterminal)。session

2、了解伪终端

1. 终端和它的做用

终端(terminal)这个词拥有不少含义,这里尽可能将其分开说明。less

历史上,终端(有时被成为tty,tele typewriter)是用户访问计算机主机的硬件设备,能够理解为一个显示器和一个键盘的组合。socket

  • 现代Linux里面比较接近此概念的是(一系列)虚拟控制台(virtual console)。在Ubuntu等发行版本中按下Ctrl+Alt+F1(或F2, F3, ...)便可切换到相应控制台下。/dev/tty1等文件是这些硬件在linux下的设备文件。程序经过这些文件的读写实现对控制台的读写,经过ioctl实现对硬件参数的设置。

终端还能够指代设备文件,实现软件接口。好比常见的/dev/tty1文件,还有/dev/pts目录下的全部文件。

  • 对终端设备文件进行读写,可以从键盘读取输入,从显示器进行输出,实现交互式的输入输出
  • linux中的每一个进程有一个“控制终端(control terminal)”的属性(取值为设备文件),用于实现做业控制。在终端上输入Ctrl+C、Ctrl+Z,则以该终端为控制终端的前台进程组会收到终止、暂停的信号。
  • 对终端设备进行ioctl操做,能够实现终端相关的硬件参数设置。login、sudo的不显示密码,都离不开对终端设备的操做。

终端还能够指代“终端模拟器”。终端模拟器是应用程序,用于模拟一个终端。它通常是GUI程序,带有窗口。从窗口输入的字符做为模拟键盘的输入,在窗口上打印的字符做为模拟显示器的输出。终端模拟器还须要建立模拟的终端设备(如/dev/pts/1),用于当作命令行进程(CLI进程)的输入输出、控制终端。当键盘键入一个字符,它要让CLI进程从终端设备中读到这个字符,当CLI进程写入终端设备时,终端模拟器要读到并显示出来。

终端模拟器的这个需求,偏偏和telnet这种远程登陆服务器的需求类似。telnet服务器也要建立模拟的终端设备,用于当作命令行进程(CLI进程)的输入输出、控制终端。当从网络收到一个字符,它要让CLI进程从终端设备中读到这个字符,当CLI进程写入终端设备时,telnet要把输出发送到网络。

这种共同的需求在linux中有一个统一实现——伪终端(pseudoterminal)。没错,上面的/dev/pts/文件夹里的以数字命名的文件就是伪终端的设备文件。

2. 伪终端的介绍

经过man pts能够查阅linux对伪终端的介绍。伪终端是伪终端master和伪终端slave(终端设备文件)这一对字符设备。/dev/ptmx是用于建立一对master、slave的文件。当一个进程打开它时,得到了一个master的文件描述符(file descriptor),同时在/dev/pts下建立了一个slave设备文件。

master端是更接近用户显示器、键盘的一端,slave端是在虚拟终端上运行的CLI(Command Line Interface,命令行接口)程序。Linux的伪终端驱动程序,会把“master端(如键盘)写入的数据”转发给slave端供程序输入,把“程序写入slave端的数据”转发给master端供(显示器驱动等)读取。

咱们打开的“终端”桌面程序,实际上是一种终端模拟器。当终端模拟器运行时,它经过/dev/ptmx打开master端,建立了一个伪终端对,并让shell运行在slave端。当用户在终端模拟器中按下键盘按键时,它产生字节流并写入master中,shell即可从slave中读取输入;shell和它的子程序,将输出内容写入slave中,由终端模拟器负责将字符打印到窗口中。

(终端模拟器的显示原理就不在这里展开了,这里认为键盘按键造成一列字节流、向显示器输出字节流后便打印到屏幕上)

linux中为何要提出伪终端这个概念呢?shell等命令行程序不能够直接从显示器和键盘读取数据吗?为了同屏运行多个终端模拟器、并实现远程登陆,还真不能让bash直接跨过伪终端这一层。在操做系统的一大思想——虚拟化的指导下,为多个终端模拟器、远程用户分配多个虚拟的终端是有必要的。上图中的shell使用的slave端就是一个虚拟化的终端。master端是模拟用户一端的交互。之因此称为虚拟化的终端,它除了转发数据流外,还要有点终端的样子。

3. 做为终端的伪终端

最为一个虚拟的终端,每个伪终端里面封装了一个终端驱动,让它能作到这些事情:

  1. 为程序提供一些输入输出模式的帮助,好比输入密码时隐藏字符
  2. 为用户提供对进程的控制,好比按下Ctrl+C结束前台进程

对,这些就是转发数据以外的控制。

终端的属性:回显控制和行控制

当用户按下一个按键时,字符会出如今屏幕上。这可不是CLI进程写回来的。不信的话能够在终端里运行cat,随便输入些什么按回车。第二行是cat返回来的,第一行正是终端的特性。

终端驱动里存储了一个状态——回显控制:是否将写入master的字符再次送回master的读端(显示器)。默认状况下这个是启用的。在命令行里可使用stty来更改终端的状态。好比在终端中运行

stty -echo

则会关掉当前终端的回显。这时按下按键,已经没有字符显示出来了。输入ls等命令,可以看到shell正常接收到咱们的命令(此时回车并无显示出来)。这时cat后,盲打一些文字,按下回车后看到只有一条文字了。

除了用户经过命令行方式,CLI的程序还能经过系统调用来设置终端的回显,好比loginsudo等程序就是经过暂时关闭回显来隐藏密码的。具体方式是在slave的文件描述符上调用ioctl函数(参考man tty_ioctl),不过推荐使用更友好的tcsetattr函数。详细设置可查阅man tcsetattr

另外,终端驱动还提供有行缓冲功能。仍是以cat为例:当咱们输入文字,在键入回车以前,cat并不能读取到咱们输入的字符。这里的cat的行为能够理解为逐字符读写:

while(read(0, &c, 1) > 0) //read from stdin, while not EOF
    write(1, &c, 1);  //write to stdout

是谁阻止cat及时读入字符了呢?实际上是终端驱动。它默认开启了一个行缓冲区,这样等程序要调用read系统调用时,先让程序阻塞着(blocked),等用户输入一整行后,才解除阻塞。咱们可使用下列命令将行缓存大小设置为1:

stty min 1 -icanon

这时,运行cat,尝试输入文字。每输入一个字符,可以当即返回一个字符。(把min改成time,还能设置输入字符最长被阻塞1秒)

这些终端的状态属性信息还有不少,好比设置终端的宽度、高度等。具体能够参考man stty

特殊控制字符

特殊控制字符,是指Ctrl和其余键的组合。如Ctrl+C、Ctrl+Z等等。用户按下这些按键,终端模拟器(键盘)会在master端写入一个字节。规则是:Ctrl+字母获得的字节是(大写)字母的ascii码减去0x40。好比Ctrl+C是0x03,Ctrl+Z是0x1A。参见下表:

驱动收到这些特殊字符,并不会像收到正常字节那样处理。在echo的时候,它返回两个可见字符。好比键入Ctrl+C(0x03),就会回显^和C(0x5E 0x03)两个字符。更重要的是,驱动将会拦截某些控制字符,他们不会被转发给slave端,而是触发做业控制(job control)的规则:向前台进程组发送SIGINT信号。

要想绕过这一机制,咱们可使用stty的一些设置。下面的命令可以同时关闭控制字符的特殊语义、设置行缓冲大小为1:

stty raw

而后,运行cat命令,咱们键入的全部字符,包括控制字符Ctrl+C(0x03),都会成功传递给cat,而且被原样返回。(能够试试上下左右、回车键的效果)

3、实验:利用伪终端实现远程登陆

理解伪终端的基本原理后,咱们就能够尝试解释telnet和SSH等远程登陆的原理了。每次用户经过客户端链接服务端的时候,服务端建立一个伪终端master、slave字符设备对,在slave端运行login程序,将master端的输入输出经过网络传送至客户端。至于客户端,则将从网络收到的信息直接关联到键盘/显示器上。咱们将这个过程描述为下图:

说了这么多,其实这个结构相比本文第一张图而言,只多了一个伪终端。下面具体描述各部分的实现细节。

服务端②:建立伪终端,并将master重定向至nc

按照man pts中的介绍,要建立master、slave对,只须要用open系统调用打开/dev/ptmx文件,便可获得master的文件描述符。同时,在/dev/pts中已经建立了一个设备文件,表示slave端。可是,为了能让其余进程(login,shell)打开slave端,须要按照手册介绍来调用两个函数:

Before opening the pseudoterminal slave, you must pass the master's file descriptor to grantpt(3) and unlockpt(3).

具体信息能够查阅man 3 grantpt,man 3 unlockpt文档。

咱们能够直接关闭(man 2 close)终端建立进程的0和1号文件描述符,把master端的文件描述符拷贝(man 2 dup)到0和1号,而后把当前进程刷成ncman 3 exec)。这虽然是比较优雅的作法,但比较复杂。并且当没有进程打开slave的时候,nc从master处读不到数据(read返回0),会认为是EOF而结束链接。因此这里用一个笨办法:将全部从master读到的数据经过管道送给nc,将全部从nc获得的数据写入master。咱们须要两个线程完成这件事。

此小节代码总结以下:

//ptmxtest.c

//先是一些头文件和函数声明
#include<stdio.h>
#define _XOPEN_SOURCE
#include<stdlib.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<sys/ioctl.h>

/* Chown the slave to the calling user.  */
extern int grantpt (int __fd) __THROW;

/* Release an internal lock so the slave can be opened.
   Call after grantpt().  */
extern int unlockpt (int __fd) __THROW;

/* Return the pathname of the pseudo terminal slave associated with
   the master FD is open on, or NULL on errors.
   The returned storage is good until the next call to this function.  */
extern char *ptsname (int __fd) __THROW __wur;

char buf[1]={'\0'};  //建立缓冲区,这里只须要大小为1字节
int main()
{
    //建立master、slave对并解锁slave字符设备文件
    int mfd = open("/dev/ptmx", O_RDWR);
    grantpt(mfd);
    unlockpt(mfd);
    //查询并在控制台打印slave文件位置
    fprintf(stderr,"%s\n",ptsname(mfd));

    int pid=fork();//分为两个进程
    if(pid)//父进程从master读字节,并写入标准输出中
    {
        while(1)
        {
            if(read(mfd,buf,1)>0)
                write(1,buf,1);
            else
                sleep(1);
        }
    }
    else//子进程从标准输入读字节,并写入master中
    {
        while(1)
        {
            if(read(0,buf,1)>0)
                write(mfd,buf,1);
            else
                sleep(1);
        }
    }

    return 0;
}

将文件保存后,打开一个终端(称为终端A),运行下列命令,在命令行中创建此程序与nc的通道:

gcc -o ptmxtest ptmxtest.c
mkfifo /tmp/p
nc -l 2333 </tmp/p | ./ptmxtest >/tmp/p

至此,图中的②构建完毕,已经有一个nc在监听2333端口,它的输入输出经过管道送到ptmxtest程序中,ptmxtest又将这些信息搬运给master端。

在个人Ubuntu中运行命令后显示,建立的slave设备文件是/dev/pts/20。

服务端①:将login程序与终端关联起来

在图中①处的地方,须要将login与伪终端的输入输出关联起来。这一点经过输入输出重定向便可完成。不过,想要实现Ctrl+C等做业控制,还须要更多的设置。这涉及到一些Linux的进程管理的知识(感兴趣的能够去搜索“进程、进程组、会话、控制终端”等关键字)。

一个进程与终端的联系,不只取决于它的输入输出,还有它的控制终端(Controlling terminal,可经过tty命令查询,经过/dev/tty打开)。简单地说,进程控制终端是谁,谁才能向进程发送控制信号。这里要将login的控制终端设为伪终端,具体说是slave设备文件才行。

设置控制终端须要使用终端设备的ioctl来实现。查看man tty_ioctl,能够找到相关信息:

Controlling terminal

TIOCSCTTY int arg
Make the given terminal the controlling terminal of the calling process. The calling process must be a session leader and not have a controlling terminal already. For this case, arg should be specified as zero.

...

TIOCNOTTY void
If the given terminal was the controlling terminal of the calling process, give up this controlling terminal. ...

比较重要的信息是,咱们能够指定TIOCSCTTY参数来设置控制终端,但它要求调用者是没有控制终端的会话组长(Session leader)。因此要先指定TIOCNOTTY参数来放弃当前控制终端,并用setsid函数(man 2 setsid)建立新的会话并设置本身为组长。

咱们将login包装一层,完成上面的操做,获得新的程序mylogin:

//mylogin.c

#include<stdio.h>
#define _XOPEN_SOURCE
#include<stdlib.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<termios.h>
#include<sys/ioctl.h>

int main(int argc, char *argv[])
{
    int old=open("/dev/tty",O_RDWR);  //打开当前控制终端
    ioctl(old, TIOCNOTTY);  //放弃当前控制终端
  
    //根据"man 2 setsid"的说明,调用setsid的进程不能是进程组组长(从bash中运行的命令是组长),故fork出一个子进程,让组长结束,子进程脱离进程组成为新的会话组长
    int pid=fork();
    if(pid==0){
        setsid();  //子进程成为会话组长
        perror("setsid");  //显示setsid是否成功
        ioctl(0, TIOCSCTTY, 0);  //这时能够设置新的控制终端了,设置控制终端为stdin
        execv("/bin/login", argv);  //把当前进程刷成login
    }
    return 0;
}

保存文件后,打开一个终端(称为终端B),编译运行:

gcc -o mylogin mylogin.c
#假设这里的slave设备是/dev/pts/20
#由于login要读取密码文件,须要用root权限执行
sudo ./mylogin </dev/pts/20 >/dev/pts/20 2>&1

该命令将实验图中①处的slave设备,重定向至mylogin的stdin、stdout和stderr。在程序执行时,会将控制终端设置为伪终端,而后执行login。至此,服务端所有创建完毕。

客户端:链接远程机器,配置本地终端

客户端处于实验图的③处。打开新的终端(终端C),这里简单地使用nc链接远程socket,而且nc的输入输出重定向至键盘、显示器便可。可是要注意,nc是运行在终端C上的,而终端C的默认属性会拦截字符Ctrl+C、使用行缓冲区域。这样nc的输入输出其实并不直接是键盘、显示器。为此,咱们先设置终端C的属性,再运行nc:

stty raw -echo
nc localhost 2333  #该行没有回显,要摸黑输入

而后,在终端C中出现了咱们打印的setsid的信息,和login的提示符。在终端C中,使用键盘能够正常登陆,获得shell的提示符。使用tty命令可以看到当前shell使用的控制终端是/dev/pts/20,也就是咱们建立的伪终端。输入w命令能够看到系统中登陆的用户和登陆终端。

至此为止,咱们实现了相似telnet的远程登陆。

结语

linux中终端驱动自己有回显、行缓存、做业控制等丰富的属性,在此基础上实现的伪终端在终端模拟器、远程登陆等场合下可以获得多种应用。

在实验过程当中也牵扯到进程控制、输入输出重定向、网络通讯这么多的知识,更体现出linux的复杂精致的结构。我感受,linux 就像一个一应俱全、又自成体统的小宇宙,它采用独特的虚拟化技术,灵活的模块化和重用机制,虚拟出各类设备,实现了驱动程序的随意拼插。在这里,全部模块都获得了充分的利用,并可以像变形金刚那样对各种需求提出面面俱到的解决方案。

相关文章
相关标签/搜索