這篇文章主要介紹了node.js支援多用戶web終端實現方案以及web終端安全性保證的解決方法,一起學習參考下。
terminal(命令列)作為本機IDE普遍擁有的功能,對專案的git操作以及檔案操作有著非常強大的支援。對於WebIDE,在沒有web偽終端的情況下,僅僅提供封裝的命令列介面是完全不能滿足開發者使用,因此為了更好的使用者體驗,web偽終端的開發也就提上行程。
研究
終端,在我們認知範圍內略同於命令列工具,通俗點說就是可以執行shell的進程。每次在命令列中輸入一串命令,敲入回車,終端進程都會fork一個子進程,用來執行輸入的命令,終端進程透過系統呼叫wait4()監聽子進程退出,同時透過暴露的stdout輸出子進程執行資訊。
如果在web端實現一個類似本地化的終端功能,需要做的可能會更多:網路延遲可靠度保證、shell使用者體驗盡量接近本地化、web終端UI寬高與輸出資訊適配、安全存取控制與權限管理等。在具體實現web終端之前,需要評估這些功能那些是最核心的,很明確:shell的功能實現及用戶體驗、安全性(web終端是在線伺服器中提供的一個功能,因此安全性是必須要保證的)。只有在保證這兩個功能的前提下,web偽終端才可以正式上線。
以下首先針對這兩個功能考慮下技術實作(服務端技術採用nodejs):
node原生模組提供了repl模組,它可以用來實作互動式輸入並執行輸出,同時提供tab補全功能,自訂輸出樣式等功能,可是它只能執行node相關指令,因此無法達到我們想要執行系統shell的目的node原生模組child_porcess,它提供了spawn這種封裝了底層libuv的uv_spawn函數,底層執行系統呼叫fork和execvp,執行shell指令。但它未提供偽終端的其它特點,如tab自動補全、方向鍵顯示歷史命令等操作
因此,服務端採用node的原生模組是無法實現一個偽終端的,需要繼續探索偽終端的原理和node端的實現方向。
偽終端
偽終端不是真正的終端,而是核心提供的一個「服務」。終端服務通常包括三層:
最頂層提供給字元設備的輸入輸出介面中間層的線路規程(line discipline)底層的硬體驅動
其中,最頂層的介面往往透過系統呼叫函數實現,如(read,write);而底層的硬體驅動程式則負責偽終端的主從設備通信,它由核心提供;線路規程看起來則比較抽象,但是實際上從功能上說它負責輸入輸出資訊的“加工”,如處理輸入過程中的中斷字元(ctrl c)以及一些回退字元(backspace 和delete)等,同時轉換輸出的換行符n為rn等。
一個偽終端分為兩部分:主設備和從設備,他們底層透過實現預設線路規程的雙向管道連接(硬體驅動)。偽終端主設備的任何輸入都會反映到從設備上,反之亦然。從設備的輸出資訊也透過管道傳送給主設備,這樣可以在偽終端機的從設備執行shell,完成終端的功能。
偽終端的從設備中,可以真實的模擬終端的tab補全和其他的shell特殊命令,因此在node原生模組不能滿足需求的前提下,我們需要把目光放到底層,看看OS提供了什麼功能。目前,glibc庫提供了posix_openpt接口,不過流程有些繁瑣:
使用posix_openpt打開一個偽終端主設備grantpt設置從設備的權限unlockpt解鎖對應的從設備獲取從設備名稱(類似/dev/pts /123)主(從)設備讀寫,執行操作
因此出現了封裝更好的pty庫,僅透過一個forkpty函數便可以實現上述所有功能。透過寫一個node的c 擴充模組,搭配pty函式庫實作一個在偽終端從裝置執行指令行的terminal。
關於偽終端安全性的問題,我們在文章的最後在進行討論。
偽終端實現想法
根據偽終端機的主從裝置的特性,我們在主裝置所在的父行程中管理偽終端的生命週期及其資源,在從設備所在的子進程中執行shell,執行過程中的信息及結果透過雙向管道傳輸給主設備,由主設備所在的進程向外提供stdout。
在此處借鏡pty.js的實作想法:
pid_t pid = pty_forkpty(&master, name, NULL, &winp); switch (pid) { case -1: return Nan::ThrowError("forkpty(3) failed."); case 0: if (strlen(cwd)) chdir(cwd); if (uid != -1 && gid != -1) { if (setgid(gid) == -1) { perror("setgid(2) failed."); _exit(1); } if (setuid(uid) == -1) { perror("setuid(2) failed."); _exit(1); } } pty_execvpe(argv[0], argv, env); perror("execvp(3) failed."); _exit(1); default: if (pty_nonblock(master) == -1) { return Nan::ThrowError("Could not set master fd to nonblocking."); } Local<Object> obj = Nan::New<Object>(); Nan::Set(obj, Nan::New<String>("fd").ToLocalChecked(), Nan::New<Number>(master)); Nan::Set(obj, Nan::New<String>("pid").ToLocalChecked(), Nan::New<Number>(pid)); Nan::Set(obj, Nan::New<String>("pty").ToLocalChecked(), Nan::New<String>(name).ToLocalChecked()); pty_baton *baton = new pty_baton(); baton->exit_code = 0; baton->signal_code = 0; baton->cb.Reset(Local<Function>::Cast(info[8])); baton->pid = pid; baton->async.data = baton; uv_async_init(uv_default_loop(), &baton->async, pty_after_waitpid); uv_thread_create(&baton->tid, pty_waitpid, static_cast<void*>(baton)); return info.GetReturnValue().Set(obj); }
首先透過pty_forkpty(forkpty的posix實現,相容於sunOS和unix等系統)建立主從設備,然後在子進程中設定權限之後(setuid、setgid),執行系統呼叫pty_execvpe(execvpe的封裝),之後主設備的輸入資訊都會在此得到執行(子程序執行的文件為sh,會偵聽stdin);
父程序則向node層暴露相關對象,如主設備的fd(透過該fd可以建立net.Socket物件進行資料雙向傳輸),同時註冊libuv的訊息佇列&baton->async,當子行程退出時觸發&baton->async訊息,執行pty_after_waitpid函式;
最後父程式透過呼叫uv_thread_create建立一個子進程,用於偵聽上一個子進程的退出訊息(透過執行系統呼叫wait4,阻塞偵聽特定pid的進程,退出訊息存放在第三個參數),pty_waitpid函數封裝了wait4函數,同時在函數末尾執行uv_async_send(&baton->async)觸發訊息。
在底層實作pty模型後,在node層需要做一些stdio的操作。由於偽終端主設備是在父進程中執行系統呼叫的創建的,而且主設備的文件描述符透過fd暴露給node層,那麼偽終端的輸入輸出也就透過讀寫根據fd創建對應的文件類型如PIPE、FILE來完成。其實,在OS層面就是把偽終端主設備看為一個PIPE,雙向通訊。在node層透過net.Socket(fd)創建一個套接字實現資料流的雙向IO,偽終端的從設備也有著主設備相同的輸入,從而在子進程中執行對應的命令,子進程的輸出也會通PIPE反應在主設備中,進而觸發node層Socket物件的data事件。
此處關於父程序、主設備、子程序、從設備的輸入輸出描述有些讓人迷惑,在此解釋。父行程與主裝置的關係是:父行程透過系統呼叫建立主裝置(可看做是一個PIPE),並取得主裝置的fd。父進程透過建立該fd的connect socket實現向子進程(從裝置)的輸入輸出。而子程序透過forkpty 建立後執行login_tty操作,重置了子程序的stdin、stderr和stderr,全部複製為從裝置的fd(PIPE的另一端)。因此子行程輸入輸出都是與從裝置的fd相關聯的,子行程輸出資料走的是PIPE,並從PIPE讀入父行程的指令。詳情請看參考文獻之forkpty實現
另外,pty庫提供了偽終端的大小設置,因此我們透過參數可以調整偽終端輸出信息的佈局信息,因此這也提供了在web端調整命令行寬高的功能,只需在pty層設定偽終端視窗大小即可,該視窗是以字元為單位。
web終端機安全性保證
基於glibc提供的pty庫實作偽終端後台,是沒有任何安全性保證的。我們想要透過web終端直接操作服務端的某個目錄,但是透過偽終端後台可以直接取得root權限,這對服務而言是不可容忍的,因為它直接影響伺服器的安全,所有需要實現一個:可多使用者同時線上、可設定每個使用者存取權限、可存取特定目錄的、可選擇配置bash指令、使用者間相互隔離、使用者無感知當前環境且環境簡單易部署的「系統」。
最適合的技術選型是docker,作為一種核心層面的隔離,它可以充分利用硬體資源,並且十分方便地映射宿主機的相關檔案。但是docker並不是萬能的,如果程式運行在docker容器中,那麼為每個用戶再分配一個容器就會變得複雜得多,而且不受運維人員掌控,這就是所謂的DooD(docker out of docker )-- 透過volume “/usr/local/bin/docker”等二進位文件,使用宿主機的docker指令,開啟兄弟鏡像運行建置服務。而採用業界常討論的docker-in-docker模式會存在諸多缺點,特別是檔案系統層面的,這在參考文獻中可以找到。因此,docker技術並不適合已經運行在容器中的服務解決使用者存取安全問題。
接下來需要考慮單機上的解決方案。目前筆者只想到兩種方案:
命令ACL,透過命令白名單的方式實現restricted bash chroot,針對每個用戶創建一個系統用戶,監禁用戶訪問範圍
首先,命令白名單的方式是最應該排除的,首先無法保證不同release的linux的bash是相同的;其次無法有效窮舉所有的命令;最後由於偽終端提供的tab命令補全功能以及特殊字符如delete的存在,無法有效匹配當前輸入的命令。因此白名單方式漏洞太多,放棄。
restricted bash,透過/bin/bash -r觸發,可以限制使用者顯式“cd directory”,但有這許多缺點:
不足以允許執行完全不受信任的軟體。當一個被發現是shell腳本的命令被執行時,rbash會關閉在shell中產生的任何限制來執行腳本。當使用者從rbash運行bash或dash,那麼他們獲得了無限的shell。有很多方法來打破受限的bash shell,這是不容易預測的。
最後,似乎只有一個解決方案了,即chroot。 chroot修改了使用者的根目錄,在製定的根目錄下執行指令。在指定根目錄下無法跳出該目錄,因此無法存取原始系統的所有目錄;同時chroot會建立一個與原始系統隔離的系統目錄結構,因此原始系統的各種命令無法在「新系統」中使用,因為它是全新的、空的;最後,多個使用者使用時他們是隔離的、透明的,完全滿足我們的需求。
因此,我們最後選擇chroot作為web終端機的安全性解決方案。但是,使用chroot需要做非常多的額外處理,不僅包括新使用者的創建,還包括指令的初始化。上文也提到「新系統」是空的,所有可執行二進位檔案都沒有,如「ls,pmd」等,因此初始化「新系統」是必須的。但許多二進位檔案不只靜態連結了許多函式庫,還在執行時依賴動態連結函式庫(dll),為此還需要找到每個指令所依賴的許多dll,異常繁瑣。為了幫助使用者從這種無趣的過程中解脫出來,jailkit應運而生。
jailkit,真好用
jailkit,顧名思義用來監禁用戶。 jailkit內部使用chroot實作建立使用者根目錄,同時提供了一系列指令來初始化、拷貝二進位檔案及其所有的dll,而這些功能都可以透過設定檔進行操作。因此,在實際開發中採用jailkit搭配初始化shell腳本來實現檔案系統隔離。
此處的初始化shell指的是預處理腳本,由於chroot需要針對每個使用者設定根目錄,因此在shell中為每個開通命令列權限的使用者建立對應的user,並透過jailkit設定檔拷貝基本的二進位檔案及其dll,如基本的shell指令、git、vim、ruby等;最後再針對某些指令做額外的處理,以及權限重設。
在處理「新系統」與原始系統的檔案映射過程中,還是需要一些技巧。筆者曾經將chroot設定的用戶根目錄之外的其他目錄通過軟鏈接的形式建立映射,可是在jail監獄中訪問軟鏈接時仍會報錯,找不到該文件,這還是由於chroot的特性導致的,沒有權限存取根目錄之外的檔案系統;如果透過硬連結建立映射,則針對chroot設定的使用者根目錄中的硬連結檔案做修改是可以的,但是涉及到刪除、建立等操作是無法正確映射到原系統的目錄的,而且硬鏈接無法連接目錄,因此硬鏈接不滿足需求;最後通過mount --bind實現,如mount --bind /home/ttt/abc /usr/local/abc它通過屏蔽被掛載的目錄(/usr/local/abc)的目錄資訊(block),並在記憶體中維護被掛載目錄與掛載目錄的映射關係,對/usr/local/abc的存取都會透過傳內存的映射表查詢/home/ttt/abc的block,然後進行操作,實現目錄的對應。
最後,初始化「新系統」完成後,就需要透過偽終端機執行jail相關指令:
sudo jk_chrootlaunch -j /usr/local/jailuser/${creater} -u $ {creater} -x /bin/bashr
開啟bash程式之後便透過PIPE與主設備接收到的web終端輸入(透過websocket)進行通訊即可。
上面是我整理給大家的,希望今後對大家有幫助。
相關文章:
以上是使用node.js如何實現多用戶web終端顯示的詳細內容。更多資訊請關注PHP中文網其他相關文章!