遠端終端服務的簡單實現

貞巨集發表於2016-06-30

大家可能見過類似這樣嵌入到網頁中的終端,可以在頁面上與遠端伺服器互動,就像 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 繫結等等,這些就取決於功能需求了)

資料傳輸可見下圖:
ww1

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$

ww2

下面程式碼的實現是同步阻塞的讀寫,建議使用更高效的方式,例如 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庫。


相關文章