终端(命令行)作为本地IDE的常用功能,对git操作和项目的文件操作有很强的支持。对于webIDE来说,如果没有web伪终端,仅仅提供封装的命令行界面是不能满足开发者需求的。因此,为了更好的用户体验,Web伪终端的开发被提上日程。
研究
终端,在我们的认知范围内,与命令行工具略有相似,是一个可以执行shell的进程。每当您在命令行中输入一系列命令并键入enter时,终端进程将分叉一个子进程来执行输入的命令。终端进程通过系统调用wait4()监视子进程退出,并通过公开的stdout输出子进程执行信息。
如果在web端实现类似本地化的终端功能,还需要做更多的工作:网络延迟和可靠性保证、外壳用户体验尽可能接近本地化、web终端UI宽度和高度自适应输出信息、安全准入控制和权限管理等。在实现web终端之前,需要评估这些功能中哪些是核心,这一点非常明确:shell的功能实现、用户体验和安全性(web终端是在线服务器提供的功能,所以安全性必须得到保证)。只有在保证这两个功能的前提下,web伪终端才能正式上线。
首先考虑这两个功能的技术实现(服务器技术采用nodejs):
节点原生模块提供repl模块,可实现交互输入输出,同时提供tab完成功能、自定义输出样式等功能。但是,它只能执行节点的相关命令,因此无法实现我们想要执行系统外壳的目标。node原生模块child_porcess提供了spawn,一个封装底层libuv的uv_spawn函数,底层执行系统调用fork和execvp执行shell命令。但是,它没有提供伪终端的其他特性,例如选项卡自动完成、显示历史命令的箭头键等
因此,服务器不可能使用节点的原生模块来实现伪终端,因此有必要继续探索伪终端的原理和节点的实现方向。
伪终端
伪终端不是真正的终端,而是内核提供的“服务”。终端服务通常包括三层:
最顶层提供字符设备的输入/输出接口的中间层的线路规程的底层的硬件驱动器
其中,顶层接口往往通过系统调用函数来实现,如(读、写);底层硬件驱动负责伪终端的主从设备通信,由内核提供;行规程看起来很抽象,但实际上它负责处理输入和输出信息,例如处理输入过程中的中断字符(ctrl c)和一些退格和删除字符,并将输出的换行符N转换为r N。
伪终端分为两部分:主设备和从设备,它们的底层通过实现默认线路程序的双向管道(硬件驱动程序)连接。来自伪终端主设备的任何输入都将反映到从设备,反之亦然。从设备的输出信息也通过管道发送给主设备,使得外壳可以在伪终端的从设备中执行,完成终端的功能。
伪终端的从设备可以模拟终端的tab完成和其他特殊的shell命令。因此,在节点原生模块无法满足需求的前提下,我们需要看看底层,看看OS提供了哪些功能。目前,glibc库提供posix_openpt接口,但过程有些繁琐:
使用posix_openpt打开一个伪终端主设备grantpt,设置从设备的权限unlockpt以解锁相应的从设备,从而获得从设备名称(类似于/dev/pts/123)。主(从)设备读写并执行操作
于是,一个更好的打包pty库出现了,上述所有功能只需要一个forkpty函数就可以实现。通过编写节点的C扩展模块和匹配pty库,实现了一个从伪终端设备执行命令行的终端。
在文章的最后,我们讨论了pse的安全性
根据伪终端主从设备的特点,在主设备所在的父进程中管理伪终端的生命周期和资源,在从设备所在的子进程中执行shell,并通过双向管道将执行过程中的信息和结果传输给主设备,主设备所在的进程向外部提供stdout。
这里借鉴一下pty.js的实现思路:
pid_t pid=pty_forkpty(master,name,NULL,winp);开关(pid) { case -1:返回nan :错误(' forkpty(3)失败。');案例0: if(strlen(CWD))chdir(CWD);if (uid!=-1 gid!=-1){ if(setgid(GID)=-1){ perror(' setgid(2)失败。);_退出(1);} if(setuid(uid)==-1){ perror(' setuid(2)失败。);_退出(1);} } pty_execvpe(argv[0],argv,env);perror('execvp(3)失败。);_退出(1);default : if(pty _ nonblock(master)==-1){ return nan : throwerror('无法将master fd设置为非阻塞。');} local object obj=nan :3360 newobject();Nan:Set(obj,nan 3360: newstring(' FD ')。ToLocalChecked(),nan :3360 newnumber(master));Nan:Set(obj,nan 3360: newstring(' PID ')。ToLocalChecked(),nan :3360 newnumber(PID));Nan:Set(obj,nan 3360: newstring(' pty ')。ToLocalChecked(),Nan:NewString(名称)。tolocalcheckd());pty _ baton * baton=new pty _ baton();baton-exit _ code=0;指挥棒信号码=0;巴吞-cb。重置(本地功能:Cast(信息[8]);巴吞-PID=PID;baton-async . data=baton;uv_async_init(uv_default_loop(),baton-async,pty _ after _ wait PID);uv_thread_create(baton-tid,pty_waitpid,static _ cast void *(baton));返回信息。GetReturnValue()。设置(obj);}
首先通过pty _ fork pty(fork pty(fork pty的posix实现,兼容sunOS、unix等系统)创建主从设备,然后在子进程中设置权限(setuid、setgid)后,执行系统调用pty_execvpe(execvpe的封装),然后在这里执行主设备的输入信息(子进程执行的文件是sh,会监听stdin);
父进程将相关对象暴露给节点层,例如主设备的fd(通过哪个网络。可以创建Socket对象进行数据双向传输),同时注册libuv的消息队列baton-async,在子进程退出时触发baton-async消息,执行pty_after_waitpid函数;
最后,父进程通过调用uv_thread_create创建一个子进程,用于监听前一个子进程的退出消息(通过执行系统调用wait4,监听特定pid的进程被阻塞,退出信息存储在第三个参数中)。pty_waitpid函数封装了wait4函数,同时在函数结束时执行uv_async_send(baton-async)触发消息。
在底层实现pty模型后,一些stdio操作需要在节点层完成。由于伪终端的主设备是通过执行父进程中的系统调用来创建的,并且主设备的file描述符通过fd暴露给节点层,因此伪终端的输入输出可以通过读写对应的文件类型来完成,如PIPE和根据fd创建的FILE。实际上,在操作系统级别,伪终端主设备被视为双向通信的管道。在节点层,通过网络创建一个套接字。套接字(fd)实现数据流的双向io。伪终端的从设备也与主设备有相同的输入,从而在子进程中执行相应的命令,子进程的输出也通过PIPE反映到主设备中,从而触发节点层socket对象的数据事件。
在这里,父进程、主设备、子进程和从设备的输入和输出描述有些混乱,这里解释一下。父进程与主设备的关系是父进程通过系统调用创建主设备(可视为PIPE),并获取主设备的fd。父进程通过创建fd的连接套接字实现对子进程(从设备)的输入和输出。另一方面,由forkpty创建并执行login_tty操作的子流程,重置子流程的stdin、stderr和stderr,并将其全部复制为从设备(管道另一端)的FD。因此,子进程的输入和输出都与从设备的fd相关联,子进程通过PIPE输出数据,并从PIPE读取父进程的命令。有关详细信息,请参见参考资料中的forkpty实现
此外,pty库提供了伪终端的大小设置,所以我们可以通过参数来调整伪终端输出信息的布局信息,所以它还提供了在web端调整命令行的宽度和高度的功能,只需要在pty层设置伪终端窗口的大小,以字符为单位。
网络终端的安全保障
基于glibc提供的pty库实现伪终端后台没有安全保障。我们想通过web终端直接在服务器上操作一个目录,但是可以通过伪终端后台直接获取根权限,这对于服务来说是无法容忍的,因为直接影响到服务器的安全性,所以需要实现一个“系统”:多个用户可以同时在线,每个用户的访问权限可以配置,具体目录可以访问,bash命令可以选择性配置,用户之间相互隔离,用户不知道当前环境,环境简单易部署。
Docker是最合适的技术选择。作为内核级隔离,可以充分利用硬件资源,映射主机的相关文件非常方便。然而,码头工人并不是万能的。如果程序在docker容器中运行,为每个用户分配另一个容器会变得复杂得多,并且不受操作和维护人员的控制。这叫做Dood(docker Out of Docker)——通过Volume/usr/local/bin/Docker等二进制文件,使用主机的Docker命令打开兄弟映像运行构建服务。然而,行业中经常讨论的docker-in-docker模式有许多缺点,尤其是在文件系统级别,这可以在参考资料中找到。因此,docker技术不适合已经在容器中运行的服务来解决用户访问安全问题。
接下来,我们需要考虑单台机器上的解决方案。目前笔者只想到两个方案:
命令ACL,通过命令白名单实现受限bash chroot,为每个用户创建一个系统用户,禁锢用户的访问范围
首先,应该排除命令白名单的方式。首先,无法保证不同版本的linux的bash是一样的;其次,不可能有效穷尽所有命令;最后,由于伪终端提供的tab命令完成功能以及delete等特殊字符的存在,导致当前输入命令无法有效匹配。所以白名单法漏洞太多,放弃吧。
受限bash由/bin/bash -r触发,可以限制用户的显式“cd目录”,但它有很多缺点:
不足以允许执行完全不可信的软件。当执行一个被发现是shell脚本的命令时,rbash会关闭shell中生成的所有限制来执行该脚本。当用户从rbash运行bash或dash时,他们会得到无限的shell。打破受限bash外壳的方法很多,不容易预测。
最终,似乎只有一个解决方案,那就是chroot。Chroot修改用户的根目录,并在已建立的根目录下运行指令。无法跳出指定的根目录,所以无法访问原系统的所有目录;同时,chroot会创建一个与原系统隔离的系统目录结构,所以原系统的命令不能在“新系统”中使用,因为它是全新的、空的;最后,当多个用户使用它们时,它们是隔离和透明的,这完全满足了我们的需求。
因此,我们最终选择了chroot作为web终端的安全解决方案。然而,使用chroot需要大量的额外处理,不仅包括创建新用户,还包括命令的初始化。如上所述,“新系统”是空的,没有可执行的二进制文件,比如“ls,pmd”,所以需要初始化“新系统”。然而,许多二进制文件不仅静态链接到许多库,而且在运行时依赖于动态链接库。因此,需要找到每个命令所依赖的许多dll,这是极其麻烦的。为了帮助用户摆脱这个枯燥的过程,jailkit应运而生。
狱卒工具包,很容易使用
顾名思义,Jailkit就是用来囚禁用户的。Jailkit使用chroot创建用户根目录,并提供了一系列初始化和复制二进制文件以及所有dll的指令,这些功能都可以通过配置文件进行操作。因此,在实际开发中,使用了jailkit和初始化shell脚本来实现文件系统隔离。
这里的初始化shell指的是预处理脚本。因为chroot需要为每个用户设置根目录,所以它会在shell中为每个有命令行权限的用户创建一个对应的用户,并通过jailkit配置文件复制基本的二进制文件及其DLl,如基本shell指令、git、vim、ruby等。最后,会额外处理一些命令,并重置权限。
在“新系统”与原系统的文件映射过程中,还需要一些技巧。作者曾经以软链接的形式映射了chroot设置的用户根目录之外的其他目录,但在监狱监狱访问软链接时,仍然报错,找不到文件,这也是由chroot的特性造成的,他无权访问根目录之外的文件系统。如果通过硬链接建立映射,可以修改chroot设置的用户根目录中的硬链接文件,但删除、创建等操作无法正确映射到原系统的目录,硬链接无法连接到目录,因此硬链接无法满足要求;最后,通过挂载绑定来实现,比如挂载绑定/home/ttt/abc /usr/local/abc。它屏蔽已装载目录(/usr/local/abc)的目录信息(块),并维护已装载目录和内存中已装载目录之间的映射关系。对/usr/local/ABC的访问将通过转移到内存的映射表进行查询。/.
最后,初始化“新系统”后,需要通过伪终端执行监狱相关命令:
sudo JK _ chrotlaunch-j/usr/local/Jill user/$ { creator }-u $ { creator }-x/bin/bashr
打开bash程序,然后通过PIPE与主设备接收到的web终端输入(通过websocket)进行通信。
结局
整体设计图(只列出单个服务流程的处理图,忽略服务器前端节点):