node.js支持多用户web终端实现及安全方案


Posted in Javascript onNovember 29, 2017

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。

node.js支持多用户web终端实现及安全方案

在此处借鉴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终端实现及安全方案

Javascript 相关文章推荐
js 对联广告、漂浮广告封装类(IE,FF,Opera,Safari,Chrome
Nov 26 Javascript
Javascript学习笔记6 prototype的提出
Jan 11 Javascript
Node.js入门教程:在windows和Linux上安装配置Node.js图文教程
Aug 14 Javascript
在JavaScript中构建ArrayList示例代码
Sep 17 Javascript
在JS方法中返回多个值的方法汇总
May 20 Javascript
jquery+json实现数据二级联动的方法
Nov 28 Javascript
Angular中响应式表单的三种更新值方法详析
Aug 22 Javascript
Vue基于NUXT的SSR详解
Oct 24 Javascript
写一个移动端惯性滑动&amp;回弹Vue导航栏组件 ly-tab
Mar 06 Javascript
vue 使某个组件不被 keep-alive 缓存的方法
Sep 21 Javascript
小程序文字跑马灯效果
Dec 28 Javascript
前端使用crypto.js进行加密的函数代码
Aug 16 Javascript
Angular4的输入属性与输出属性实例详解
Nov 29 #Javascript
node.js中axios使用心得总结
Nov 29 #Javascript
Vue2.0用户权限控制解决方案
Nov 29 #Javascript
vue.js项目中实用的小技巧汇总
Nov 29 #Javascript
关于 angularJS的一些用法
Nov 29 #Javascript
ReactNative之FlatList的具体使用方法
Nov 29 #Javascript
ReactNative 之FlatList使用及踩坑封装总结
Nov 29 #Javascript
You might like
smtp邮件发送一例
2006/10/09 PHP
连接到txt文本的超链接,不直接打开而是点击后下载的处理方法
2009/07/01 PHP
php根据操作系统转换文件名大小写的方法
2014/02/24 PHP
PHP的Yii框架中Model模型的学习教程
2016/03/29 PHP
js过滤数组重复元素的方法
2010/09/05 Javascript
用jQuery中的ajax分页实现代码
2011/09/20 Javascript
JavaScript获取路径设计源码
2014/05/22 Javascript
浅谈javascript 函数内部属性
2015/01/21 Javascript
js生成验证码并直接在前端判断
2015/05/15 Javascript
jquery实现的Accordion折叠面板效果代码
2015/09/02 Javascript
百度多文件异步上传控件webuploader基本用法解析
2016/11/07 Javascript
Angular指令封装jQuery日期时间插件datetimepicker实现双向绑定示例
2017/01/22 Javascript
Vue监听数组变化源码解析
2017/03/09 Javascript
vue与bootstrap实现简单用户信息添加删除功能
2019/02/15 Javascript
layui 选择列表,打勾,点击确定返回数据的例子
2019/09/02 Javascript
layui添加动态菜单与选项卡 AJAX请求的例子
2019/09/25 Javascript
[36:52]DOTA2真视界:基辅特锦赛总决赛
2017/05/21 DOTA
[01:11:08]Winstrike vs NB 2018国际邀请赛淘汰赛BO1 8.21
2018/08/22 DOTA
[05:49]DOTA2-DPC中国联赛 正赛 Elephant vs LBZS 选手采访
2021/03/11 DOTA
Python3处理文件中每个词的方法
2015/05/22 Python
Python实现Sqlite将字段当做索引进行查询的方法
2016/07/21 Python
Python IDLE清空窗口的实例
2018/06/25 Python
python实现共轭梯度法
2019/07/03 Python
python导入不同目录下的自定义模块过程解析
2019/11/18 Python
python 二维矩阵转三维矩阵示例
2019/11/30 Python
python 中不同包 类 方法 之间的调用详解
2020/03/09 Python
python安装dlib库报错问题及解决方法
2020/03/16 Python
儿科护士自我鉴定
2013/10/14 职场文书
中专生自我鉴定范文
2013/12/19 职场文书
小学家长评语大全
2014/04/16 职场文书
知识改变命运演讲稿
2014/05/21 职场文书
敬老院献爱心活动总结
2014/07/08 职场文书
通知的格式范文
2015/04/27 职场文书
总经理致辞
2015/07/29 职场文书
公司周年庆寄语
2019/06/21 职场文书
浅谈sql_@SelectProvider及使用注意说明
2021/08/04 Java/Android