首頁 > 軟體

Linux 偽終端的基本原理 及其在遠端登入(SSH,telnet等)中的應用

2020-06-16 17:07:07

本文介紹了Linux中偽終端的建立,介紹了終端的回顯、行快取、控制字元等特性,並在此基礎上解釋和模擬了telnet、SSH開啟遠端對談的過程。

輕量級遠端登入

之前製作的一塊嵌入式板子,安裝了嵌入式Linux作業系統,可以通過串列埠(Console)登入。為了方便使用,需要尋找通過網線遠端登入的方法。最初的想法是SSH,不過板子的ROM太小,存不了體積龐大龐大的OpenSSH套裝。後來換用了telnet,直接拿busybox的telnetd做伺服器,效果很好。

後來有一天,發現了Linux中有一個直接建立TCP連線的工具:nc 。在伺服器端使用nc -l 埠號 來進行監聽,在用戶端使用nc IP地址 埠號來建立連線。建立連線後,nc會把從stdin讀入的位元組流傳送給另一方,把接收到的位元組流寫入stdout中。配合方便的管道操作,不正可以將shell的輸入/輸出傳送到遠端機器上嗎?於是在Ubuntu中實驗操作如下(之後發現這種操作叫做“反彈shell”):

開啟一個終端A,輸入命令

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

該命令將bash的標準輸入輸出與nc的標準輸出輸入連線起來,並由nc將其與socket連線起來。同時,nc監聽2333埠(如果使用小於1024的埠,需要root許可權),等待遠端連線。現在開啟另一個終端B,準備連線:

nc localhost 2333

這時,在終端B中出現了sh的提示符。輸入一般的shell命令後可以執行並得到結果。看來linux自帶的工具已經靈活、強大到足夠搭建一個小型的遠端登入系統。這個過程可以使用下面的圖來描述:

通過tty命令,我們看到,此時的shell並沒有一個tty終端。確實,它的標準輸入輸出都是管道。這會帶來一個問題,需要操縱tty的一些命令,比如vi、less、sudo等都無法正常使用(可以動手試試效果怎麼樣)。更為要命的是,在終端B中按下Ctrl+C這樣的控制鍵,核心把結束信號傳送給了用戶端nc,而不是遠端的程式!

Ctrl+C直接殺死nc,結束了對談。對比telnet,我們的登入系統還缺少什麼東西。這就是偽終端(pseudoterminal)。

了解偽終端

終端和它的作用

終端(terminal)是使用者存取計算機主機的裝置,可以理解為一個顯示器和一個鍵盤的組合。Linux裡面比較接近原始型別的一類終端裝置是(一系列)控制台。在Ubuntu等發行版本中按下Ctrl+Alt+F1(或F2, F3, ...)即可切換到相應控制台下。程式通過存取/dev/tty1等檔案可以對這些控制台讀寫。

除此以外,還有一種廣泛使用的虛擬裝置——偽終端(pseudoterminal)。每次在圖形介面使用“終端”應用,“終端”應用都會建立一個偽終端裝置,名字類似/dev/pts/23。終端中執行的程式,預設以此為標准輸入輸出。

那終端有什麼用呢?簡單地說,無論是使用Ctrl+C、Ctrl+Z來終止、暫停前台任務,還是login、sudo的不顯示密碼,都是終端的功勞。(事實上,終端和linux的進程管理密切相關。Shell的作業排程、前後台行程群組都是在終端的配合下完成的)

偽終端的介紹

通過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端是模擬使用者一端的互動。之所以稱為虛擬化的終端,它除了轉發資料流外,還要有點終端的樣子。

作為終端的偽終端

最為一個虛擬的終端,每一個偽終端裡面封裝了一個終端驅動,讓它能做到這些事情:

  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,並且被原樣返回。(可以試試上下左右、確認鍵的效果)

實驗:利用偽終端實現遠端登入

理解偽終端的基本原理後,我們就可以嘗試解釋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.  */
externint grantpt (int __fd) __THROW;

/* Release an internal lock so the slave can be opened.   Call after grantpt().  */
externint 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.  */
externchar *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,"%sn",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 就像一個包羅萬象、又自成體統的小宇宙,它採用獨特的虛擬化技術,靈活的模組化和重用機制,虛擬出各種裝置,實現了驅動程式的隨意拼插。在這裡,所有模組都得到了充分的利用,並能夠像變形金剛那樣對各類需求提出面面俱到的解決方案。

本文永久更新連結地址http://www.linuxidc.com/Linux/2017-09/146949.htm


IT145.com E-mail:sddin#qq.com