node.js支持多用戶web終端實現(xiàn)及安全方案
terminal(命令行)作為本地IDE普遍擁有的功能,對項目的git操作以及文件操作有著非常強大的支持。對于WebIDE,在沒有web偽終端的情況下,僅僅提供封裝的命令行接口是完全不能滿足開發(fā)者使用,因此為了更好的用戶體驗,web偽終端的開發(fā)也就提上日程。
調(diào)研
終端,在我們認(rèn)知范圍內(nèi)略同于命令行工具,通俗點說就是可以執(zhí)行shell的進程。每次在命令行中輸入一串命令,敲入回車,終端進程都會fork一個子進程,用來執(zhí)行輸入的命令,終端進程通過系統(tǒng)調(diào)用wait4()監(jiān)聽子進程退出,同時通過暴露的stdout輸出子進程執(zhí)行信息。
如果在web端實現(xiàn)一個類似于本地化的終端功能,需要做的可能會更多:網(wǎng)絡(luò)時延及可靠性保證、shell用戶體驗盡量接近本地化、web終端UI寬高與輸出信息適配、安全準(zhǔn)入控制與權(quán)限管理等。在具體實現(xiàn)web終端之前,需要評估這些功能那些是最核心的,很明確:shell的功能實現(xiàn)及用戶體驗、安全性(web終端是在線上服務(wù)器中提供的一個功能,因此安全性是必須要保證的)。只有在保證這兩個功能的前提下,web偽終端才可以正式上線。
下面首先針對這兩個功能考慮下技術(shù)實現(xiàn)(服務(wù)端技術(shù)采用nodejs):
node原生模塊提供了repl模塊,它可以用來實現(xiàn)交互式輸入并執(zhí)行輸出,同時提供tab補全功能,自定義輸出樣式等功能,可是它只能執(zhí)行node相關(guān)命令,因此無法達到我們想要執(zhí)行系統(tǒng)shell的目的 node原生模塊child_porcess,它提供了spawn這種封裝了底層libuv的uv_spawn函數(shù),底層執(zhí)行系統(tǒng)調(diào)用fork和execvp,執(zhí)行shell命令。但是它未提供偽終端的其它特點,如tab自動補全、方向鍵顯示歷史命令等操作
因此,服務(wù)端采用node的原生模塊是無法實現(xiàn)一個偽終端的,需要繼續(xù)探索偽終端的原理和node端的實現(xiàn)方向。
偽終端
偽終端不是真正的終端,而是內(nèi)核提供的一個“服務(wù)”。終端服務(wù)通常包括三層:
最頂層提供給字符設(shè)備的輸入輸出接口中間層的線路規(guī)程(line discipline)底層的硬件驅(qū)動
其中,最頂層的接口往往通過系統(tǒng)調(diào)用函數(shù)實現(xiàn),如(read,write);而底層的硬件驅(qū)動程序則負(fù)責(zé)偽終端的主從設(shè)備通信,它由內(nèi)核提供;線路規(guī)程看起來則比較抽象,但是實際上從功能上說它負(fù)責(zé)輸入輸出信息的“加工”,如處理輸入過程中的中斷字符(ctrl + c)以及一些回退字符(backspace 和 delete)等,同時轉(zhuǎn)換輸出的換行符n為rn等。
一個偽終端分為兩部分:主設(shè)備和從設(shè)備,他們底層通過實現(xiàn)默認(rèn)線路規(guī)程的雙向管道連接(硬件驅(qū)動)。偽終端主設(shè)備的任何輸入都會反映到從設(shè)備上,反之亦然。從設(shè)備的輸出信息也通過管道發(fā)送給主設(shè)備,這樣可以在偽終端的從設(shè)備中執(zhí)行shell,完成終端的功能。
偽終端的從設(shè)備中,可以真實的模擬終端的tab補全和其他的shell特殊命令,因此在node原生模塊不能滿足需求的前提下,我們需要把目光放到底層,看看OS提供了什么功能。目前,glibc庫提供了posix_openpt接口,不過流程有些繁瑣:
使用posix_openpt打開一個偽終端主設(shè)備 grantpt設(shè)置從設(shè)備的權(quán)限 unlockpt解鎖對應(yīng)的從設(shè)備獲取從設(shè)備名稱(類似 /dev/pts/123)主(從)設(shè)備讀寫,執(zhí)行操作
因此出現(xiàn)了封裝更好的pty庫,僅僅通過一個forkpty函數(shù)便可以實現(xiàn)上述所有功能。通過編寫一個node的c++擴展模塊,搭配pty庫實現(xiàn)一個在偽終端從設(shè)備執(zhí)行命令行的terminal。
關(guān)于偽終端安全性的問題,我們在文章的最后在進行討論。
偽終端實現(xiàn)思路
根據(jù)偽終端的主從設(shè)備的特性,我們在主設(shè)備所在的父進程中管理偽終端的生命周期及其資源,在從設(shè)備所在的子進程中執(zhí)行shell,執(zhí)行過程中的信息及結(jié)果通過雙向管道傳輸給主設(shè)備,由主設(shè)備所在的進程向外提供stdout。
在此處借鑒pty.js的實現(xiàn)思路:
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實現(xiàn),兼容 sunOS和 unix等系統(tǒng))創(chuàng)建主從設(shè)備,然后在子進程中設(shè)置權(quán)限之后(setuid、setgid),執(zhí)行系統(tǒng)調(diào)用pty_execvpe(execvpe的封裝),此后主設(shè)備的輸入信息都會在此得到執(zhí)行(子進程執(zhí)行的文件為sh,會偵聽stdin);
父進程則向node層暴露相關(guān)對象,如主設(shè)備的fd(通過該fd可以創(chuàng)建net.Socket對象進行數(shù)據(jù)雙向傳輸),同時注冊libuv的消息隊列&baton->async,當(dāng)子進程退出時觸發(fā)&baton->async消息,執(zhí)行pty_after_waitpid函數(shù);
最后父進程通過調(diào)用uv_thread_create創(chuàng)建一個子進程,用于偵聽上一個子進程的退出消息(通過執(zhí)行系統(tǒng)調(diào)用wait4,阻塞偵聽特定pid的進程,退出信息存放在第三個參數(shù)中),pty_waitpid函數(shù)封裝了wait4函數(shù),同時在函數(shù)末尾執(zhí)行uv_async_send(&baton->async)觸發(fā)消息。
在底層實現(xiàn)pty模型后,在node層需要做一些stdio的操作。由于偽終端主設(shè)備是在父進程中執(zhí)行系統(tǒng)調(diào)用的創(chuàng)建的,而且主設(shè)備的文件描述符通過fd暴露給node層,那么偽終端的輸入輸出也就通過讀寫根據(jù)fd創(chuàng)建對應(yīng)的文件類型如PIPE、FILE來完成。其實,在OS層面就是把偽終端主設(shè)備看為一個PIPE,雙向通信。在node層通過net.Socket(fd)創(chuàng)建一個套接字實現(xiàn)數(shù)據(jù)流的雙向IO,偽終端的從設(shè)備也有著主設(shè)備相同的輸入,從而在子進程中執(zhí)行對應(yīng)的命令,子進程的輸出也會通PIPE反應(yīng)在主設(shè)備中,進而觸發(fā)node層Socket對象的data事件。
此處關(guān)于父進程、主設(shè)備、子進程、從設(shè)備的輸入輸出描述有些讓人迷惑,在此解釋。父進程與主設(shè)備的關(guān)系是:父進程通過系統(tǒng)調(diào)用創(chuàng)建主設(shè)備(可看做是一個PIPE),并獲取主設(shè)備的fd。父進程通過創(chuàng)建該fd的connect socket實現(xiàn)向子進程(從設(shè)備)的輸入輸出。 而子進程通過forkpty 創(chuàng)建后執(zhí)行l(wèi)ogin_tty操作,重置了子進程的stdin、stderr和stderr,全部復(fù)制為從設(shè)備的fd(PIPE的另一端)。因此子進程輸入輸出都是與從設(shè)備的fd相關(guān)聯(lián)的,子進程輸出數(shù)據(jù)走的是PIPE,并從PIPE中讀入父進程的命令。詳情請看參考文獻之forkpty實現(xiàn)
另外,pty庫提供了偽終端的大小設(shè)置,因此我們通過參數(shù)可以調(diào)整偽終端輸出信息的布局信息,因此這也提供了在web端調(diào)整命令行寬高的功能,只需在pty層設(shè)置偽終端窗口大小即可,該窗口是以字符為單位。
web終端安全性保證
基于glibc提供的pty庫實現(xiàn)偽終端后臺,是沒有任何安全性保證的。我們想通過web終端直接操作服務(wù)端的某個目錄,但是通過偽終端后臺可以直接獲取root權(quán)限,這對服務(wù)而言是不可容忍的,因為它直接影響著服務(wù)器的安全,所有需要實現(xiàn)一個:可多用戶同時在線、可配置每個用戶訪問權(quán)限、可訪問特定目錄的、可選擇配置bash命令、用戶間相互隔離、用戶無感知當(dāng)前環(huán)境且環(huán)境簡單易部署的“系統(tǒng)”。
最為適合的技術(shù)選型是docker,作為一種內(nèi)核層面的隔離,它可以充分利用硬件資源,且十分方便映射宿主機的相關(guān)文件。但是docker并不是萬能的,如果程序運行在docker容器中,那么為每個用戶再分配一個容器就會變得復(fù)雜得多,而且不受運維人員掌控,這就是所謂的DooD(docker out of docker)-- 通過volume “/usr/local/bin/docker”等二進制文件,使用宿主機的docker命令,開啟兄弟鏡像運行構(gòu)建服務(wù)。而采用業(yè)界經(jīng)常討論的docker-in-docker模式會存在諸多缺點,特別是文件系統(tǒng)層面的,這在參考文獻中可以找到。因此,docker技術(shù)并不適合已經(jīng)運行在容器中的服務(wù)解決用戶訪問安全問題。
接下來需要考慮單機上的解決方案。目前筆者只想到兩種方案:
命令A(yù)CL,通過命令白名單的方式實現(xiàn) restricted bash chroot,針對每個用戶創(chuàng)建一個系統(tǒng)用戶,監(jiān)禁用戶訪問范圍
首先,命令白名單的方式是最應(yīng)該排除的,首先無法保證不同release的linux的bash是相同的;其次無法有效窮舉所有的命令;最后由于偽終端提供的tab命令補全功能以及特殊字符如delete的存在,無法有效匹配當(dāng)前輸入的命令。因此白名單方式漏洞太多,放棄。
restricted bash,通過/bin/bash -r觸發(fā),可以限制使用者顯式“cd directory”,但有這諸多缺點:
不足以允許執(zhí)行完全不受信任的軟件。當(dāng)一個被發(fā)現(xiàn)是shell腳本的命令被執(zhí)行時,rbash會關(guān)閉在shell中生成的任何限制來執(zhí)行腳本。當(dāng)用戶從rbash運行bash或dash,那么他們獲得了無限制的shell。有很多方法來打破受限制的bash shell,這是不容易預(yù)測的。
最后,貌似只有一個解決方案了,即chroot。chroot修改了用戶的根目錄,在制定的根目錄下運行指令。在指定根目錄下無法跳出該目錄,因此無法訪問原系統(tǒng)的所有目錄;同時chroot會創(chuàng)建一個與原系統(tǒng)隔離的系統(tǒng)目錄結(jié)構(gòu),因此原系統(tǒng)的各種命令無法在“新系統(tǒng)”中使用,因為它是全新的、空的;最后,多個用戶使用時他們是隔離的、透明的,完全滿足我們的需求。
因此,我們最終選擇chroot作為web終端的安全性解決方案。但是,使用chroot需要做非常多的額外處理,不僅包括新用戶的創(chuàng)建,還包括命令的初始化。上文也提到“新系統(tǒng)”是空的,所有可執(zhí)行二進制文件都沒有,如“l(fā)s,pmd”等,因此初始化“新系統(tǒng)”是必須的??墒窃S多二進制文件不僅僅靜態(tài)鏈接了許多庫,還在運行時依賴動態(tài)鏈接庫(dll),為此還需要找到每個命令依賴的諸多dll,異常繁瑣。為了幫助使用者從這種無趣的過程中解脫出來,jailkit應(yīng)運而生。
jailkit,真好用
jailkit,顧名思義用來監(jiān)禁用戶。jailkit內(nèi)部使用chroot實現(xiàn)創(chuàng)建用戶根目錄,同時提供了一系列指令來初始化、拷貝二進制文件及其所有的dll,而這些功能都可以通過配置文件進行操作。因此,在實際開發(fā)中采用jailkit搭配初始化shell腳本來實現(xiàn)文件系統(tǒng)隔離。
此處的初始化shell指的是預(yù)處理腳本,由于chroot需要針對每個用戶設(shè)置根目錄,因此在shell中為每個開通命令行權(quán)限的使用者創(chuàng)建對應(yīng)的user,并通過jailkit配置文件拷貝基本的二進制文件及其dll,如基本的shell指令、git、vim、ruby等;最后再針對某些命令做額外的處理,以及權(quán)限重置。
在處理“新系統(tǒng)”與原系統(tǒng)的文件映射過程中,還是需要一些技巧。筆者曾經(jīng)將chroot設(shè)定的用戶根目錄之外的其他目錄通過軟鏈接的形式建立映射,可是在jail監(jiān)獄中訪問軟鏈接時仍會報錯,找不到該文件,這還是由于chroot的特性導(dǎo)致的,沒有權(quán)限訪問根目錄之外的文件系統(tǒng);如果通過硬鏈接建立映射,則針對chroot設(shè)定的用戶根目錄中的硬鏈接文件做修改是可以的,但是涉及到刪除、創(chuàng)建等操作是無法正確映射到原系統(tǒng)的目錄的,而且硬鏈接無法連接目錄,因此硬鏈接不滿足需求;最后通過mount --bind實現(xiàn),如 mount --bind /home/ttt/abc /usr/local/abc它通過屏蔽被掛載的目錄(/usr/local/abc)的目錄信息(block),并在內(nèi)存中維護被掛載目錄與掛載目錄的映射關(guān)系,對/usr/local/abc的訪問都會通過傳內(nèi)存的映射表查詢/home/ttt/abc的block,然后進行操作,實現(xiàn)目錄的映射。
最后,初始化“新系統(tǒng)”完畢后,就需要通過偽終端執(zhí)行jail相關(guān)命令:
sudo jk_chrootlaunch -j /usr/local/jailuser/${creater} -u ${creater} -x /bin/bashr
開啟bash程序之后便通過PIPE與主設(shè)備接收到的web終端輸入(通過websocket)進行通信即可。
結(jié)尾
總體的設(shè)計示意圖(只列出單機單個服務(wù)進程的處理圖,并忽略服務(wù)器前端節(jié)點):
相關(guān)文章
使用Node.js創(chuàng)建HTTP服務(wù)器并實現(xiàn)公網(wǎng)訪問本地Server的步驟
Node.js含有一系列內(nèi)置模塊,使得程序可以脫離 Apache HTTP Server 或 IIS,作為獨立服務(wù)器運,下面將介紹如何簡單幾步實現(xiàn)遠程公共網(wǎng)絡(luò)下訪問windwos node.js的服務(wù)端,感興趣的朋友一起看看吧2023-11-11nodejs使用PassThrough流進行數(shù)據(jù)傳遞合并示例詳解
這篇文章主要為大家介紹了nodejs使用PassThrough流進行數(shù)據(jù)傳遞合并示例詳解,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進步,早日升職加薪2023-09-09Node.js同時安裝多個版本及相關(guān)配置指南(簡單易操作)
在實際開發(fā)過程中我們可能需要安裝多個版本的 nodejs,下面這篇文章主要給大家介紹了關(guān)于Node.js同時安裝多個版本及相關(guān)配置的相關(guān)資料,文中通過圖文介紹的非常詳細,需要的朋友可以參考下2023-11-11Node.js結(jié)合FFmpeg構(gòu)建自動化腳本實現(xiàn)轉(zhuǎn)碼視頻
傳統(tǒng)的視頻轉(zhuǎn)碼中,很常用的工具是FFmpeg,本文主要介紹了Node.js結(jié)合FFmpeg構(gòu)建自動化腳本實現(xiàn)轉(zhuǎn)碼視頻,具有一定的參考價值,感興趣的可以了解一下2024-04-04用Nodejs實現(xiàn)在終端中炒股的實現(xiàn)
這篇文章主要介紹了用Nodejs實現(xiàn)在終端中炒股的實現(xiàn),文中通過示例代碼介紹的非常詳細,對大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價值,需要的朋友們下面隨著小編來一起學(xué)習(xí)學(xué)習(xí)吧2020-10-10使用pify實現(xiàn)Node.js回調(diào)函數(shù)的Promise化
使用pify庫,你可以非常便利地將任何遵循Node.js回調(diào)風(fēng)格的函數(shù)轉(zhuǎn)換為返回Promise對象的函數(shù),從而使得你的異步代碼更加清晰和易于維護,本文通過豐富的代碼示例引導(dǎo)你如何有效地利用pify,需要的朋友可以參考下2024-06-06