说起会话,我们经常登录到linux系统,执行各种各样的程序,这都牵涉到会话。但是,一般情况下我们又很少会去关注到会话的存在,很少会去了解它的来龙去脉。本文就对linux会话相关的信息做一些整理,看看隐藏在我们日常使用的背后,都有些什么样的逻辑。
【会话的维系】
维系一个会话,最常见的有两种方式:
一是基于某种凭证,比如web网站的登录会话,在登录验证之后,服务器就会返回一个session id作为凭证。用户之后的请求总是会带上这个id,而服务器通过这个id也就能知道用户是谁。直到用户注销登录、或者登录超时,服务器会清洗掉对应的session id,这个id就失效了,会话也就随之而结束。
第二种方式是基于连接的,当用户和系统之间的连接启用时,系统会对用户进行验证,验证通过之后,来自这个连接的操作都是属于这个用户的。直到连接断开,则会话结束。
linux系统的会话就是以第二种方式来维系的。会话基于连接,那么连接的安全性就决定了会话的安全性。以最常见的两种连接为例:
1、本地连接,用户是直接通过键盘显示器来跟系统交互的,键盘显示器直接连接在主机上,连接被篡改基本上是不可能的;
2、远程连接,以ssh为例,其协议会进行加密,从而避免连接被篡改;
下面在讨论会话的时候就围绕两种连接来展开。
【连接的启用】
前面说到,会话是基于连接的。会话的源头,就是用户与系统之间连接的启用。
对于本地连接,连接是在系统初始化时建立的。linux会初始化若干个tty(/dev/tty?),形成若干个虚拟终端。本地连接的键盘和显示器通过对应的驱动程序,跟其中某个tty绑定上。用户可以通过ALT+F[1-12]键,将键盘和显示器切换到不同的tty上(也就是说,一套键盘显示器可以对应多个本地连接)。
在系统启动的时候,init程序会根据相应的配置(如:/etc/init/tty1.conf),启动相应的程序来监听这些tty(如:/sbin/getty -8 38400 tty1。下文说到这个监听程序时,就以getty为例)。当用户通过ALT+F?切换到对应的tty?、并有所输入时,在该tty上监听的getty就能读取到输入信息,然后通过exec启动login程序来进行登录验证。从getty得到输入的这一刻起,tty?上的这个连接就算是启用了。
对于远程连接,本文都以ssh为例。系统安装ssh server之后,在/etc/init.d/下会有它对应的启动脚本,这会使得sshd程序(ssh服务器)在系统启动的时候被启动(当然,root用户也可以在系统启动之后,手动启动sshd)。sshd监听网络上的相应端口(如:tcp:22),等待远程用户的连接。用户的连接始于TCP连接,然后sshd和ssh-client会打通ssh隧道。从这时起,连接启用,sshd会要求用户进行登录验证。
【登录验证】
不管是本地终端登录,还是远程登录,登录验证无非就是要让用户输入用户名和密码。用户输入的信息通过已经启用的连接到达对端程序(如:login、sshd),然后由其进行校验。
系统中的用户信息存放在/etc/passwd文件中,里面保存的系统中所有用户的信息。这些信息主要有:用户名、密码、组、home路径、以及登录后执行的程序、等。这些信息可以分两个层面来看:
一、用于验证的。包括用户名和密码两项。即用户在与系统建立连接后,需要提供用户名和密码来进行验证(注意,密码一般并不是明文保存在passwd文件中的);
二、登录后用于设置用户属性的。除密码外的所有项。下面会详细解释;
用户登录后会得到一个进程,关于这个进程,它有如下一些特征:
1、对于本地连接,它就是原本执行getty的那个进程;而对于ssh连接,它是由sshd fork出来的进程;
2、进程的用户名、组、当前路径、等都被按/etc/passwd文件中的描述进行设置;
3、进程的stdin、stdout、stderr连接到一个对应的终端。对于本地连接,这个终端就是getty监听的那个tty;对于ssh连接,它是由sshd打开的pty(伪终端,后面会详细解释);
4、这个进程会执行/etc/passwd中配置的"登录后执行的程序",这个程序一般就是/bin/sh,即登录后为用户提供一个shell控制台。于是用户可以使用自己的终端跟这个shell程序交互,干各种事情。这个"登录后执行的程序"也常被配置为/bin/nologin,表示对应用户是不允许登录的,因为nologin程序不会与用户进行交互,打印错误信息后就会退出了,所以登录只是白费劲。当然,如果你愿意,并且有root权限,也可以将"登录后执行的程序"配置成其他程序;
于是,用户在登录完成之后,系统中就存在这样一个用户名为该登录用户的进程。通常这个进程运行shell程序,并且其输入输出连接到用户的终端,所以用户可以用键盘来操作这个shell,并且用显示器接收shell的输出。
【关于终端】
前面讲到,登录后的shell其输入输出是连接到用户使用的终端的,不管是本地登录的tty,还是远程登录的pty。但是,为什么要有终端呢?shell的输入直接接到键盘、输出直接接到显示器,这样不行么?尤其是远程的情况,shell的输入输出为什么不能直接接到网络,而非要弄一个pty出来呢?
最容易想到的一点,终端能够使得上层不必关心输入输出设备本身的细节,只管对其读写就行了。不过这一点似乎并不是终端所特有的,因为vfs已经能够胜任了。应用程序open设备文件,得到fd,然后同样只用管对其读写就行了,而不用关心这个fd代表的是键盘、还是普通文件,具体的细节已经被隐藏在设备驱动程序之中。
不过,相比于普通的读写,终端还实现了很多可以通过ioctl系统调用进行配置的功能,能够完成一些针对输入输出的处理逻辑。如:
1、回车换行的转换:定义输入输出如何映射回车换行符。比如:回车键是\r、还是\n、还是\r\n;再如:\n应该如何打印到屏幕上,是回车+换行、还是只换行不回车、等等;
2、行编辑:允许让输入字符不是立马送到应用程序,而是在换行以后才能被读取到。未换行的输入字符可以通过退格键进行编辑(比如在你密码输错的时候,是可以用CTRL+退格来进行编辑的);
3、回显:可以让输入字符自动被回显到终端的输出上。于是,键盘每输入一个字符都能在显示器上看到它,而这些字符其实很可能是还没被应用程序读取到的(因为有行编辑);
4、功能键:允许定义功能键。比如最常用的Ctrl+C,杀死前台进程,就是由终端来触发的。终端检测到Ctrl+C输入,会向前台进程组发送SIGTERM信号。而谁是前台进程组呢?这是由shell通过ioctl系统调用对终端使用TIOCSPGRP命令来设置的,每当shell启动一个前台进程,它都需要这么设置一下;
5、输入输出流向控制,只有前台进程组能够从终端中读数据、而不管前台后台程序都能向终端写数据。这点也是必须的,跟用户进程交互的是前台进程,用户的输入当然不能被其他后台进程抢走。但是一个进程是前台还是后台,是它自己是所不知道的,没法靠进程自己来判断什么时候可以读终端、什么时候不能读。所以需要终端来提供支持,如果后台进程读这个终端,终端的驱动程序将向其发送SIGTTIN信号,从而将其挂起。直到shell将其重新置为前台进程时(通过fg命令),该进程才会继续执行;
6、等等;
终端提供的这些功能未必都会被打开它的程序使用到,但是如果要使用,则可以通过统一的ioctl接口来设置,而不需要关心终端具体接的是什么设备,是键盘显示器?还是网络。大多数应用程序则根本不关心终端,只当它是能够满足读写需求的文件。而像shell这样为人机交互而生的程序,则注定会跟终端打交道。
对于shell来说,像“行编辑”、“回显”这样的功能,其实是可以不需要依赖终端的,shell程序自己可以做这样的处理,因为要做处理的数据正是shell从终端里面读到的数据。但是像“功能键”、“输入输出流向控制”这样的功能,则又不得不依赖终端。比如“功能键”,因为这时在对终端进行读操作的是shell启动的前台程序,而不是shell自己,所以不可能由shell来读取功能键,然后触发信号。
可以说,终端是人机交互时,应用程序与用户之间的一个中间层。如果应用程序是在跟人交互,使用终端是其不二的选择;否则则没有必要使用终端。这一点在sshd上面有很好的体现。当用户使用ssh登录到远程机器remotehost,并执行一些操作时(比如执行cat test.txt操作),可以有两种方式:
1、ssh user:pass@remotehost,远程登录后,再在shell中执行cat test.txt命令;
2、ssh user:pass@remotehost 'cat test.txt',直接由sshd启动一个shell、自动执行'cat test.txt'命令;
第一种方式,是登录之后,再通过人机交互输入命令,这时sshd会为登录后得到的shell程序准备一个pty,以支持人机交互;而第二种方式,在登录之后并不需要交互,所以sshd就并不会使用pty,而是直接通过socket将shell的输入输出跟自己连接起来(再由sshd将其转发给ssh-client)。
下面再说一下pty。pty分master和slave两端,跟pipe的两端很像,写入到master端的数据可以原样从slave端读出,反之亦然。在上面所述的第一种方式下,各个进程的联系如下:
ssh-client <-> [socket] <-> sshd <-> [master] <-> [slave] <-> shell
pty的master和slave两端分别被sshd和shell打开,就像pipe一样,将它们的输入输出连接起来。而pty跟pipe的不同之处,则正是前面所说的终端的功能(pty的slave端对于shell来说就是一个终端)。
跟tty不同,tty是系统初始化时生成的,其数目是固定的(比如12个)。而pty是系统启动后动态创建的。其创建的方法是:fd = open('/dev/ptms'),这样就得到了一个pty。open返回的fd代表master端,通过ptyname(fd)可以得到对应slave端的文件路径(比如'/dev/pts/2'),这个设备文件是master端被open之后动态生成的。
【权限控制】
用户登录验证成功后,getty、sshd这样的程序就会为用户启动他所对应的"登录后执行的程序",并且在此之前会通过setuid()、setgid()这样的系统调用,设置好进程的uid和gid(用户和组)。之后,这个进程对文件的读、写、执行,以及对系统调用的使用,等都会受到进程uid和gid的限制。
用户对系统的各种操作都是通过进程来完成的。而用户的输入输出都被定向到他登录后所得到的那个进程上,于是用户能够控制这个进程,来干他想干的事情(如果这个进程接受控制的话,比如进程运行着一个shell程序)。
用户的操作都受限于进程的uid和gid,比如文件访问、向进程发送信号、使用系统调用、监听网络端口、等都需要对其做检查。
除非是root用户,否则没有权限使用setuid()、setgid()这样的系统调用来修改进程的uid和gid。而login时,login、sshd这样的进程之所以能够设置登录后进程的uid和gid,正是因为它们都是root用户的进程。
如果想要改变登录用户,则必须利用属于root用户的进程。一种办法是退出登录,然后走老路,重新跟getty、sshd这样的进程打交道,而使用其他用户名登录。另一种办法是执行一个setuid/setgid程序(参见《记一个linux内核内存提权问题》中的说明),临时获得root权限,再实现用户的切换(例如su就是这样一个能实现用户切换的setuid命令)。
为了实现进程权限控制中的各种功能,进程的uid和gid其实并不止一组,主要有如下三组:
1、ruid/rgid,代表实际的用户和组id;
2、euid/egid,代表当前生效的用户和组id;
3、suid/sgid,代表保留的用户和组id;
什么意思呢?一般情况下,在用户登录得到的进程中,这三组id都是相同的值,与登录用户相对应(假设为aaa)。而用户新创建的进程也会全部继承这三组id。
当用户执行一个setuid/setgid程序时,执行程序的进程将得到可执行程序owner(假设为bbb)的用户属性,euid/egid和suid/sgid会更新为bbb(而ruid/rgid不会被更新)。
这些id在进程操作别的对象时(比如写文件、发信号、等)或被别的进程操作时(比如其他进程向其发信号、等)会被用做校验。ruid/rgid和euid/egid在进程被操作的时候会使用到,euid/egid在进程发起操作的时候会用到。比如有一个进程,其ruid是aaa、euid是bbb,则euid为aaa或bbb的其他进程都可以向其发送信号。而该进程在进行读写文件、创建文件、等操作时,使用的则是用户bbb的权限:它创建的文件owner是bbb、它在访问owner是aaa的文件时以other权限进行校验。
那么第三组id,suid/sgid又是干什么用的呢?之前说普通用户没有权限使用setuid()这样的系统调用来改变进程的uid和gid,其实这个说法并不确切。进程其实是有权限将自己的ruid/rgid、euid/egid、suid/sgid的值修改成与这三者之一相等的值。比如ruid/rgid为aaa、suid/sgid为bbb,则用户可以任意将euid/egid设置为aaa或bbb。因为大多数情况下这三组id是等值的,所以一般说用户进程不能修改自己的uid和gid(只能从aaa修改为aaa,相当于不能修改)。但是执行setuid的程序后,进程的这三组id就有了两种不同的取值,ruid/rgid等于aaa、euid/egid和suid/sgid等于bbb,于是进程的uid就有了一定的选择余地。比如此时进程的euid/egid被更新成了bbb,那就不能再以owner身份去操作aaa的文件了(注意,这个进程原本是以aaa用户登录而得到的)。不过没关系,进程是有权限将euid/egid改回aaa的,因为ruid/rgid的值是aaa。但是改回来之后,ruid/rgid和euid/egid都是aaa了,要再想把euid/egid改为bbb怎么办呢?suid/sgid就是为此而生的,作为一个备份,它的值是bbb,这使得euid/egid还能够修改回bbb。当然,进程也有权限将euid/egid和suid/sgid都改回aaa,这将使得它们不再能修改成bbb了。
【会话退出】
用户登录是一个会话的开始。登录之后,用户会得到一个跟用户使用的终端相连的进程,这个进程被称作是这个会话的leader,会话的id就等于该进程的pid。由该进程fork出来的子进程都是这个会话的成员(进程的sid等于该会话id)。
leader进程的退出,将导致它所连接的终端被hangup,这意味着会话结束。反过来,像ssh这样的远程连接也可以通过断开连接的方式来使终端hangup,这将使得leader进程收到SIGHUP信号而退出。
如果会话使用的是pty,其本身是随会话的建立而创建出来的,会话结束,则pty被销毁;而如果会话使用的是tty,其本身是在系统初始化时创建的,并不依赖于会话的建立,则会话结束时,tty依然存在。init进程检测到使用该tty的会话已经结束,便会重新启动一个getty来监听该tty。
不过,会话结束,并不意味着在该会话中创建的所有进程都结束了。所谓的daemon进程,正是在某个会话中创建,但是却不依赖该会话,而常驻后台的进程。
具体来说,当终端hangup时,内核会有如下两个动作:
1、向对应会话的leader进程发送SIGHUP信号。而一般来说,会话的leader进程很可能是一个shell,它在收到SIGHUP信号后,并不是马上退出,而是会向它所启动的子进程都各自发送一个SIGHUP信号,将它们都杀死,然后自己才退出。不过,如果是这个作为leader进程shell自己退出,而导致终端hangup的话,向其子进程发送SIGHUP信号的事情就不会发生了,因为shell退出在先,它再也不会收到SIGHUP信号;
2、修改所有打开该终端的文件句柄,改成一个不可读不可写的实现;
所以,在会话退出之后还常驻后台的进程肯定是没法跟终端交互的。而要想让进程常驻后台,一般有如下几种方法:
1、避免shell发SIGHUP信号;
主要有两种办法:1)主动exit,而不是直接断开终端;2)两次fork。因为shell只认识它自己fork出来的子进程,并不知道"子又生孙"的事情,也就不会给孙子进程发送SIGHUP信号了;
2、忽略SIGHUP信号;
终端hangup时进程可能收到shell发送的SIGHUP信号。信号的默认处理动作是退出进程,但是该信号是可以忽略的。忽略信号,就可以使得后台进程不会随会话退出而退出。
nohup命令就是做这件事情的,而且它做得更完整一些,不仅忽略SIGHUP,还会将进程的标准输入重定向为/dev/null、输出重定向到nohup.out文件;
3、使用setsid()系统调用,为进程开启一个新的会话;
从一个会话中fork出来的进程,默认都是属于这个会话的。但是进程可以调用setsid(),使自己脱离原先的会话,而成为一个新会话的leader。于是,原先的会话退出,就不会影响到新建的会话了。
setsid命令包装了setsid()系统调用,可以为进程创建新的会话。不过它并不对新进程的输入输出进行重定向,这就意味着新进程的输入输出还是连接到原先的那个tty的,这可能跟原先的会话争抢输入。所以,对新会话的进程进行输入输出的重定向也是一件很重要的事情。
一个进程成为daemon进程,可以不随会话的退出而退出,但是进程的uid/gid并不会因此而改变。对应的用户还可以在其他会话中,通过发信号等方式,操作那些原来由他所启动的daemon进程(因为权限控制是以用户为准的,而并不考虑会话)。