当前位置: 首页 > 后端技术 > Node.js

支持多用户web终端实现和安全(nodejs)

时间:2023-04-03 23:19:14 Node.js

后台Terminal(命令行)是本地IDE的常用功能,对项目git操作和文件操作有非常强大的支持。对于WebIDE来说,在没有web伪终端的情况下,仅仅提供一个封装好的命令行接口是不够开发者使用的。因此,为了更好的用户体验,开发网页伪终端也提上了日程。关于终端(tty)和伪终端(pty)的区别,可以参考Whatdoptyandttymean?调查一下终端,它类似于我们理解中的命令行工具。通俗地说,就是可以执行shell的进程。每次在命令行输入一系列命令并回车,终端进程都会fork一个子进程来执行输入的命令。终端进程通过系统调用wait4()监听子进程的退出,同时通过暴露的stdout输出子进程执行信息。如果在web端实现类似本地化的终端功能,还需要做更多的工作:网络延迟和可靠性保证,shell用户体验尽可能接近本地化,web端UI宽高和输出信息适配,安全访问控制以及权限管理等。在实现web端之前,需要评估哪些功能是最核心的。明确:shell的功能实现,用户体验,安全(web端是线上服务器提供的功能,所以安全性是必须要保证的)。只有在保证这两个功能的前提下,网页伪终端才能正式上线。我们先来看这两个功能的技术实现(服务端技术采用nodejs):nodenative模块提供了一个repl模块,可以用来实现交互输入输出,并提供tab补全功能和自定义输出样式等功能,但是它只能执行node相关的命令,所以不能达到执行系统shell的目的。node原生模块child_porcess提供了spawn,一个uv_spawn函数,封装了底层libuv,底层执行系统调用fork和execvp。执行外壳命令。但是,它不提供伪终端的其他特性,如tab自动补全、方向键显示历史命令等操作。所以在server端使用node的native模块是不可能实现一个伪终端的。需要继续探索伪终端和节点端的原理。实现方向。伪终端伪终端不是真正的终端,而是内核提供的“服务”。终端服务通常包括三层:顶层提供字符设备的输入输出接口,中间层线路规程(linediscipline),底层硬件驱动,其中顶层接口往往通过系统调用函数实现,例如(读,写);底层硬件驱动负责伪终端的主从设备通信,由内核提供;linediscipline看似比较抽象,但实际上它在功能上负责输入输出信息的“处理”,比如处理输入过程中的中断字符(ctrl+c)和一些退格字符(backspace和delete),等,并将输出的换行符n转换为rn等。一个伪终端分为主设备和从设备两部分,它们的底层通过实现默认线路规程的双向管道(硬件驱动)相连。伪终端主机的任何输入都会反映在从机上,反之亦然。从设备的输出信息也通过管道发送给主设备,这样shell就可以在伪终端的从设备中执行,完成终端的功能。伪终端的slave设备可以真实模拟终端的tab补全等特殊的shell命令。所以,在nativenode模块不能满足需求的前提下,我们需要从底层看OS提供了哪些功能。.目前glibc库提供了posix_openpt接口,但是过程有些繁琐:使用posix_openpt打开一个伪终端主设备grantpt设置从设备的权限unlockpt解锁对应的从设备获取从设备名称(类似对/dev/pts/123)主(从)设备进行读写,执行操作。因此,出现了一个封装更好的pty库,仅通过一个forkpty函数就可以实现以上所有功能。通过编写节点C++扩展模块并使用pty库实现一个终端,该终端在伪终端上从设备执行命令行。关于伪终端的安全性,我们会在文末讨论。伪终端实现思路根据伪终端主从设备的特点,我们在master设备所在的父进程中管理伪终端的生命周期和资源,在子进程中执行shell从设备所在的进程,执行过程中的信息和信息结果通过双向管道传输给主设备,主设备所在的进程对外提供stdout。在此处借用pty.js的现实思路:pid_tpid=pty_forkpty(&master,name,NULL,&winp);switch(pid){case-1:returnNan::ThrowError("forkpty(3)failed.");案例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);默认值:if(pty_nonblock(master)==-1){returnNan::ThrowError("Couldnotsetmasterfdtononblocking.");}Localobj=Nan::New();Nan::Set(obj,Nan::New("fd").ToLocalChecked(),Nan::New(master));Nan::Set(obj,Nan::New("pid").ToLocalChecked(),Nan::New(pid));楠::Set(obj,Nan::New("pty").ToLocalChecked(),Nan::New(name).ToLocalChecked());pty_baton*baton=newpty_baton();接力棒->退出代码=0;接力棒->signal_code=0;接力棒->cb.Reset(Local::Cast(info[8]));接力棒->pid=pid;接力棒->async.data=接力棒;uv_async_init(uv_default_loop(),&baton->async,pty_after_waitpid);uv_thread_create(&baton->tid,pty_waitpid,static_cast(baton));返回信息.GetReturnValue().Set(obj);}首先通过pty_forkpty(forkptyposix实现,兼容sunOS和unix系统)创建主从设备,然后在子进程中设置权限(setuid,setgid),执行系统调用pty_execvpe(对execvpe的封装),然后这里会执行master设备的输入信息(子进程执行的文件是sh,监听stdin);父进程将相关对象暴露给node层,比如主设备的fd(通过这个fd可以创建一个net.Socket对象,用于双向数据传输),同时注册libuv的Messagequeue&baton->async,子进程退出时,触发&baton->async消息,执行pty_after_waitpid函数;最后父进程调用uv_thread_create创建子进程,用于监听上一个子进程的退出消息(通过执行系统调用wait4,阻塞监听特定pid的进程,退出信息存放在第三个参数),pty_waitpid函数封装了wait4函数,在函数最后执行uv_async_send(&baton->async)触发消息。在底层实现pty模型后,还需要在node层做一些stdio操作。由于伪终端的主设备是在父进程中执行系统调用创建的,而主设备的文件描述符是通过fd暴露给节点层的,因此伪终端的输入输出也被创建通过读写根据fd对应的文件类型,如PIPE、FILE来完成。实际上,在OS层面,伪终端master设备被看作是一个双向通信的PIPE。在node层通过net.Socket(fd)创建socket,实现数据流的双向IO。伪终端的slave设备也有和master设备一样的输入,从而在子进程中执行相应的命令,而子进程的输出也将通过PIPE反映到master设备中,并且然后在节点层触发Socket对象的数据事件。这里对父进程、master设备、子进程、slave设备的输入输出的描述有些混乱,这里解释一下。父进程与master设备的关系是:父进程通过系统调用创建master设备(可以看做是一个PIPE),并获得master设备的fd。父进程通过创建fd的connectsocket实现对子进程(slave设备)的输入输出。forkpty创建子进程后,执行login_tty操作,重置子进程的stdin、stderr、stderr,全部复制到slave设备的fd(PIPE的另一端)。因此,子进程的输入输出与slave设备的fd相关联,子进程的输出数据经过PIPE,父进程的命令从PIPE中读取。具体可以参考参考中forkpty的实现。另外pty库提供了伪终端的大小设置,所以我们可以通过参数调整伪终端输出信息的布局信息,所以这里也提供了调整命令宽高的功能线在网页端。只需要在pty层设置伪终端窗口的大小,窗口以字符为单位。web端安全保障是基于glibc提供的pty库实现伪终端后台,无需任何安全保障。我们想通过web终端直接操作服务器上的某个目录,但是通过伪终端后台可以直接获取到root权限,这对于服务来说是不能容忍的,因为它直接影响到服务器的安全,所以我们需要实现一个:多个用户同时在线,可以配置每个用户的访问权限,可以访问特定的目录,可以随意配置bash命令,用户之间相互隔离,用户不知道当前环境,环境简单易部署。最合适的技术选择是docker,作为一种内核级别的隔离,可以充分利用硬件资源,映射宿主机相关文件也很方便。但是docker并不是万能的。如果程序运行在docker容器中,为每个用户分配一个容器就会复杂很多,而且不受运维人员的控制。这就是所谓的DooD(dockeroutofdocker))——通过“/usr/local/bin/docker”卷等二进制文件,使用宿主机的docker命令启动兄弟镜像运行构建服务。但是,业界经常讨论的docker-in-docker模式会有很多缺点,尤其是在文件系统层面,可以在参考资料中找到。因此,docker技术并不适用于已经运行在容器中的服务来解决用户访问安全问题。接下来,我们需要考虑单机上的解决方案。目前我只想到两种解决方案:命令ACL,通过命令白名单实现受限bashchroot,为每个用户创建一个系统用户,禁锢用户的访问范围首先要排除命令白名单的方式,首先,有不保证不同版本的linux的bash是一样的;其次,无法有效穷尽所有命令;最后,由于伪终端提供的tab命令补全功能以及delete等特殊字符的存在,无法有效匹配当前输入的命令。所以白名单方式漏洞太多,放弃。受限的bash,由/bin/bash-r触发,可以明确地限制用户进入“cd目录”,但它有很多缺点:它不足以允许执行完全不受信任的软件。当执行发现是shell脚本的命令时,rbash会关闭shell中生成的任何限制以执行脚本。当用户从rbash运行bash或dash时,他们将获得一个无限的shell。突破受限制的bashshell的方法有很多,但并不容易预测。最后,似乎只有一种解决方案,chroot。chroot修改用户的根目录并在指定的根目录中运行命令。在指定的根目录下,无法跳出该目录,因此无法访问原系统的所有目录;同时,chroot会创建一个与原系统隔离的系统目录结构,所以原系统的各种命令不能在“新系统”中使用,因为它是全新的、空的;最后,在多个用户使用时,它们是隔离透明的,完全满足我们的需求。因此,我们最终选择了chroot作为web终端的安全解决方案。然而,使用chroot需要很多额外的处理,不仅包括新用户的创建,还包括命令的初始化。上面也提到“新系统”是空的,没有“ls、pmd”等可执行的二进制文件,所以需要对“新系统”进行初始化。然而,许多二进制文件不仅静态链接了很多库,而且在运行时还依赖于动态链接库(dll)。为此,需要找到很多每个命令所依赖的dll,极其繁琐。为了帮助用户摆脱这个枯燥的过程,jailkit应运而生。Jailkit,jailkit就是这么好用,顾名思义就是用来关押用户的。Jailkit内部使用chroot创建用户根目录,并提供了一系列初始化和复制二进制文件和所有dll的指令,这些功能可以通过配置文件来操作。所以在实际开发中,jailkit配合初始化shell脚本来实现文件系统隔离。这里的初始化shell指的是预处理脚本。由于chroot需要为每个用户设置根目录,因此在shell中为每个开启命令行权限的用户创建一个对应的用户,以及基本的Binary文件及其dll,如基本的shell命令、git、vim、红宝石等;最后,对某些命令执行额外的处理和权限重置。在处理“新系统”与原系统的文件映射过程中,还是需要一些技巧的。笔者曾经以软链接的形式映射了chroot设置的用户根目录以外的其他目录,但是在jail中访问软链接时,仍然会报错,找不到文件。这是由于chroot的特性,没有权限访问根目录以外的文件系统;如果通过硬链接建立映射,可以修改chroot设置的用户根目录下的硬链接文件,但是删除、创建等操作不能正确映射到原系统的目录,而硬链接无法连接到目录,所以硬链接不符合要求;最后通过mount--bind实现,比如mount--bind/home/ttt/abc/usr/local/abc,通过屏蔽挂载目录(/usr/local/)的目录信息(block)abc),并维护挂载目录与内存中挂载目录的映射关系,访问/usr/local/abc会通过内存映射表查询/home/ttt/abc的block,然后操作到实现目录的映射。最后,初始化“新系统”后,需要通过伪终端执行jail相关命令:sudojk_chrootlaunch-j/usr/local/jailuser/${creater}-u${creater}-x/bin/bashr启动bash程序然后通过PIPE与主设备接收到的web终端输入(通过websocket)通信。文末整体设计示意图(仅列出单机单个服务进程的处理图,忽略服务端前端节点):在线展示:参考forkptyimplementspty和tty是什么意思?linediscipline使用Docker-in-Docker运行CI或集成测试环境?三思而后行!mount--bind和硬链接的区别